diff --git a/.github/workflows/flutter-linux.yml b/.github/workflows/flutter-linux.yml index b59d8bd6..ff0cd727 100644 --- a/.github/workflows/flutter-linux.yml +++ b/.github/workflows/flutter-linux.yml @@ -32,7 +32,9 @@ jobs: libsecret-1-dev \ libjsoncpp-dev \ liblzma-dev \ + librsvg2-common \ locales \ + yaru-theme-icon \ xvfb sudo locale-gen en_US.UTF-8 en_GB.UTF-8 @@ -89,6 +91,16 @@ jobs: - name: Test run: '"$BUSYMAX_FLUTTER_EXECUTABLE" test' + - name: Test GTK header-icon bridge + run: | + gtk_config="$RUNNER_TEMP/busymax-gtk-config" + mkdir -p "$gtk_config/gtk-3.0" + printf '[Settings]\ngtk-icon-theme-name=Yaru\n' \ + > "$gtk_config/gtk-3.0/settings.ini" + XDG_CONFIG_HOME="$gtk_config" xvfb-run -a \ + "$BUSYMAX_FLUTTER_EXECUTABLE" test \ + integration_test/gtk_header_icons_integration_test.dart -d linux + - name: Test native clock patterns, weekday locale, and GTK time picker run: | mkdir -p build/linux/native-tests @@ -124,6 +136,26 @@ jobs: -o build/linux/native-tests/time_picker_test \ $(pkg-config --cflags --libs gtk+-3.0) xvfb-run -a build/linux/native-tests/time_picker_test + g++ -std=c++17 -Wall -Wextra -Werror \ + linux/runner/tests/gtk_window_preferences_test.cc \ + linux/runner/gtk_window_preferences.cc \ + -o build/linux/native-tests/gtk_window_preferences_test \ + $(pkg-config --cflags --libs gtk+-3.0) + xvfb-run -a build/linux/native-tests/gtk_window_preferences_test + g++ -std=c++17 -Wall -Wextra -Werror \ + linux/runner/tests/gtk_header_icons_test.cc \ + linux/runner/gtk_header_icons.cc \ + -o build/linux/native-tests/gtk_header_icons_test \ + $(pkg-config --cflags --libs gtk+-3.0) + XDG_CONFIG_HOME="$RUNNER_TEMP/busymax-gtk-config" \ + xvfb-run -a sh -c 'build/linux/native-tests/gtk_header_icons_test' + g++ -std=c++17 -Wall -Wextra -Werror \ + linux/runner/tests/gtk_search_entry_theme_test.cc \ + linux/runner/gtk_search_entry_theme.cc \ + -o build/linux/native-tests/gtk_search_entry_theme_test \ + $(pkg-config --cflags --libs gtk+-3.0) + xvfb-run -a env GDK_BACKEND=x11 \ + build/linux/native-tests/gtk_search_entry_theme_test - name: Validate release provider configuration if: github.event_name == 'push' diff --git a/README.md b/README.md index a70f60e4..158c0e8c 100644 --- a/README.md +++ b/README.md @@ -116,6 +116,7 @@ Account, provider, and data information: Development and maintenance: - [Development setup for Linux and Windows](docs/development.md) +- [Linux window and rendering architecture](docs/linux_architecture.md) - [Android release](docs/android_release.md), [verification](docs/android_verification.md), and [implementation status](docs/android_implementation_status.md) - [Google developer OAuth registration](docs/google_setup.md) and diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index d801cc0a..4d0adc57 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -58,11 +58,13 @@ android { applicationId = "io.busystack.busymax" minSdk = 24 targetSdk = 37 - // Uses the version code from pubspec.yaml. When using split APKs, 1000 * ABI_VERSION - // is added automatically by Flutter. (https://developer.android.com/studio/build/configure-apk-splits#configure-APK-versions) + // Keep Android's required monotonic package code separate from the + // public 0.2.3 version declared in pubspec.yaml. When using split APKs, + // 1000 * ABI_VERSION is added automatically by Flutter. + // (https://developer.android.com/studio/build/configure-apk-splits#configure-APK-versions) // You can force using the value of versionCode by specifying the `-P force-version-code-ignoring-abi=true` // flag during build. - versionCode = flutter.versionCode + versionCode = 3 versionName = flutter.versionName manifestPlaceholders["busymaxMsalRedirectHost"] = applicationId!! manifestPlaceholders["busymaxMsalSignatureHash"] = msalSignatureHash diff --git a/docs/android_setup.md b/docs/android_setup.md index 37b12bd1..50f685e3 100644 --- a/docs/android_setup.md +++ b/docs/android_setup.md @@ -1,7 +1,7 @@ # Android setup and provider registration BusyMax Android is a beta application with package ID -`io.busystack.busymax`, version `0.2.2+2`, minimum API 24, and compile/target +`io.busystack.busymax`, version `0.2.3`, minimum API 24, and compile/target API 37. The supported toolchain is Flutter 3.47.4, its bundled Dart 3.13.3, JDK 17, Android platform 37, and Build Tools 37.x. diff --git a/docs/linux_architecture.md b/docs/linux_architecture.md new file mode 100644 index 00000000..16a8884e --- /dev/null +++ b/docs/linux_architecture.md @@ -0,0 +1,27 @@ +# Linux window and rendering ownership + +BusyMax uses one Flutter engine and one `FlView` in its main Linux window. The +native runner creates and owns the `HdyApplicationWindow`, while Flutter owns +the visible main application chrome: the header, sidebar, calendar/task +workspace, settings header, onboarding header, and startup header. + +`LinuxWindowHost` is the Linux-only boundary around the existing router. It +tracks native window state and GTK titlebar preferences and presents only the +configured system-window controls above route dialog barriers. It does not +own page widgets or sidebar geometry. `LinuxPageFrame` is the reusable route +frame. Its leading child is one clipped, full-height allocation containing +both the sidebar header and body; one controller changes that allocation, and +the remaining column receives the actual remaining width. + +The GTK runner retains platform responsibilities that do not belong in the +Flutter layout: application identity and lifecycle, close-versus-quit policy, +deferred first-frame presentation, plugin registration, native menus and +pickers, retained native dialogs, external file/URI activation, GTK desktop +settings, and native-surface styling. Native surface styling is independent +of main-header existence. GTK window-decoration layout and titlebar-action +preferences are exposed as settings events, not as per-frame geometry +synchronization. + +Windows and Android keep their existing platform-specific application roots +and window-management compositions. Linux-only imports and Yaru window +initialization remain in the Linux entrypoint and Linux application root. diff --git a/docs/linux_button_reference.md b/docs/linux_button_reference.md new file mode 100644 index 00000000..b906a14b --- /dev/null +++ b/docs/linux_button_reference.md @@ -0,0 +1,83 @@ +# Linux button visual reference + +This reference is fixed for the Ubuntu button-alignment work. Do not combine +measurements from a different Yaru or libadwaita generation when reviewing the +corresponding BusyMax screenshots. + +## Recorded environment + +- Ubuntu 26.04.1 LTS (Resolute Raccoon), GNOME Wayland session +- GTK 3.24.52 (the BusyMax native host) +- libadwaita 1.9.1 (the contemporary GNOME control reference) +- Yaru GTK and GNOME Shell 26.04.5.1 +- Active GTK theme: `Yaru-magenta-dark` +- Active color scheme: `prefer-dark` +- Active accent preference: `pink` +- UI font: Ubuntu Sans 11 pt +- Text scale: 1.0 +- Display scale: 1x +- Flutter 3.47.4 / Dart 3.13.3, with Yaru Flutter 10.2.0 + +The environment was recorded on 2026-09-19 before changing the BusyMax button +theme. The installed libadwaita stylesheet embedded in +`libadwaita-1.so.0` was inspected directly. Its normal button rule uses a +24-pixel content-box minimum, 5-pixel vertical padding, bold labels, explicit +hover and keyboard-activation states, and a 2-pixel `:focus-visible` outline. +Text-only buttons add horizontal padding to the common button rule. BusyMax +uses the requested 17-pixel text-button padding and retains Yaru Flutter's +34-pixel overall minimum and 8-pixel radius. + +The pinned stylesheet gives ordinary buttons an outline offset of `-2px` and +gives `button.suggested-action` (through its opaque-button treatment) an outer +outline offset of `1px`. Both use a 2-pixel accent outline at 50% opacity, or +80% in high-contrast mode. These offsets, rather than the GTK 3 host's focus +rendering, are the focus-placement reference used by the Flutter fixture and +rendering tests. + +The GTK fixture at `tool/linux/native_button_reference.cc` captures the real +GTK 3 controls used by the BusyMax host, including its standard, suggested, +destructive, focused, and disabled variants. The separate +`tool/linux/native_libadwaita_button_reference.js` fixture renders real GTK 4 +buttons through the installed libadwaita 1.9.1 runtime. Its focused row uses +GTK's native `FOCUSED | FOCUS_VISIBLE` state so the inset standard outline and +offset suggested-action outline can be compared directly. The Flutter fixture +at `tool/linux_button_comparison.dart` renders the production BusyMax theme +and `BusyMaxPushButton` factories; it contains no fixture-only button styling. + +The GTK 3 capture is retained as the pre-migration comparison for the former +native onboarding actions and the current Flutter actions. It is not used as a substitute for +the libadwaita 1.9 measurements above. + +## Review procedure + +1. Run both native fixtures under the recorded desktop theme and capture the + GTK 3 host controls and libadwaita target separately. +2. Run the Flutter comparison fixture with the same theme, font, accent, and + scale. +3. Compare overall height, horizontal padding, radius, label weight/baseline, + semantic role colors, hover/pressed changes, and focus-outline placement. +4. Only update regression images after that comparison. + +## Reviewed artifacts + +- `docs/screenshots/linux_buttons_native_reference.png`: GTK 3/Yaru host + controls from the recorded environment. +- `docs/screenshots/linux_buttons_libadwaita_reference.png`: GTK 4/libadwaita + 1.9.1 standard and suggested controls, including native focus-visible rings. +- `docs/screenshots/linux_buttons_before.png`: the unmodified production + theme rendered by the Linux desktop engine from an isolated baseline + checkout. +- `docs/screenshots/linux_buttons_after_desktop.png`: the corrected production + theme and components rendered by the same Linux desktop engine. +- `test/app/goldens/linux_buttons_reviewed.png`: the post-review widget-test + regression image. + +The visual review confirmed a common 34-pixel baseline and compact rounded +geometry, bold and baseline-aligned action labels, distinct whole-control +hover/pressed states, semantic neutral/accent/destructive colors, stable +disabled sizing, and role-aware keyboard focus outlines. Neutral and +destructive actions use the ordinary inset outline; suggested actions use the +1-pixel-offset outer outline from libadwaita's opaque-button rule. Actual mouse +focus remains quiet, while Tab and Shift+Tab focus displays the appropriate +outline without changing layout or being clipped on the window, dialog, and +popover surfaces. diff --git a/docs/screenshots/linux_buttons_after_desktop.png b/docs/screenshots/linux_buttons_after_desktop.png new file mode 100644 index 00000000..d3950a40 Binary files /dev/null and b/docs/screenshots/linux_buttons_after_desktop.png differ diff --git a/docs/screenshots/linux_buttons_before.png b/docs/screenshots/linux_buttons_before.png new file mode 100644 index 00000000..d9cd9ce9 Binary files /dev/null and b/docs/screenshots/linux_buttons_before.png differ diff --git a/docs/screenshots/linux_buttons_libadwaita_reference.png b/docs/screenshots/linux_buttons_libadwaita_reference.png new file mode 100644 index 00000000..d2631a7c Binary files /dev/null and b/docs/screenshots/linux_buttons_libadwaita_reference.png differ diff --git a/docs/screenshots/linux_buttons_native_reference.png b/docs/screenshots/linux_buttons_native_reference.png new file mode 100644 index 00000000..cbb1d62d Binary files /dev/null and b/docs/screenshots/linux_buttons_native_reference.png differ diff --git a/integration_test/gtk_header_icons_integration_test.dart b/integration_test/gtk_header_icons_integration_test.dart new file mode 100644 index 00000000..d8980f3d --- /dev/null +++ b/integration_test/gtk_header_icons_integration_test.dart @@ -0,0 +1,113 @@ +import 'dart:io'; + +import 'package:busymax/src/app/linux/linux_header_style.dart'; +import 'package:busymax/src/platform/gtk_header_icon_service.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + testWidgets('Linux resolves the live GTK header-icon catalog', ( + tester, + ) async { + if (!Platform.isLinux) return; + final service = GtkHeaderIconService(); + addTearDown(service.dispose); + + expect(await service.initialize(), isTrue); + final catalog = service.catalog; + expect(catalog.scale, greaterThanOrEqualTo(1)); + for (final icon in const [ + BusyMaxLinuxHeaderIcon.back, + BusyMaxLinuxHeaderIcon.previous, + BusyMaxLinuxHeaderIcon.next, + BusyMaxLinuxHeaderIcon.search, + BusyMaxLinuxHeaderIcon.searchEntryFind, + BusyMaxLinuxHeaderIcon.searchClear, + BusyMaxLinuxHeaderIcon.mainMenu, + BusyMaxLinuxHeaderIcon.create, + BusyMaxLinuxHeaderIcon.refresh, + BusyMaxLinuxHeaderIcon.viewDay, + BusyMaxLinuxHeaderIcon.viewWeek, + BusyMaxLinuxHeaderIcon.viewMonth, + BusyMaxLinuxHeaderIcon.viewYear, + BusyMaxLinuxHeaderIcon.viewAgenda, + BusyMaxLinuxHeaderIcon.viewMenuArrow, + BusyMaxLinuxHeaderIcon.close, + ]) { + final asset = catalog.assetFor(icon, TextDirection.ltr); + expect(asset, isNotNull, reason: 'GTK did not resolve ${icon.gtkNames}'); + expect(asset!.bytes, isNotEmpty); + expect(asset.pixelWidth, greaterThan(0)); + expect(asset.pixelHeight, greaterThan(0)); + expect(asset.scale, catalog.scale); + } + for (final role in const { + BusyMaxLinuxHeaderIcon.search: 'system-search-symbolic', + BusyMaxLinuxHeaderIcon.searchEntryFind: 'edit-find-symbolic', + BusyMaxLinuxHeaderIcon.searchClear: 'edit-clear-symbolic', + }.entries) { + expect( + catalog.assetFor(role.key, TextDirection.ltr)!.resolvedName, + role.value, + ); + } + expect( + catalog + .assetFor(BusyMaxLinuxHeaderIcon.today, TextDirection.ltr)! + .resolvedName, + anyOf('today-symbolic', 'x-office-calendar-symbolic'), + ); + for (final icon in const [ + BusyMaxLinuxHeaderIcon.viewWeek, + BusyMaxLinuxHeaderIcon.viewMonth, + ]) { + expect( + catalog.assetFor(icon, TextDirection.ltr)!.resolvedName, + isIn(icon.gtkNames), + ); + } + debugPrint( + 'GTK header icon catalog: ' + '${{ + for (final icon in const [BusyMaxLinuxHeaderIcon.search, BusyMaxLinuxHeaderIcon.searchEntryFind, BusyMaxLinuxHeaderIcon.searchClear, BusyMaxLinuxHeaderIcon.viewDay, BusyMaxLinuxHeaderIcon.viewWeek, BusyMaxLinuxHeaderIcon.viewMonth, BusyMaxLinuxHeaderIcon.viewYear, BusyMaxLinuxHeaderIcon.viewAgenda, BusyMaxLinuxHeaderIcon.filter]) icon.name: catalog.assetFor(icon, TextDirection.ltr)?.resolvedName ?? 'missing-optional', + }}', + ); + + await tester.pumpWidget( + GtkHeaderIconScope( + service: service, + child: const MaterialApp( + home: Center( + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + BusyMaxGtkHeaderIcon(BusyMaxLinuxHeaderIcon.search), + BusyMaxGtkHeaderIcon(BusyMaxLinuxHeaderIcon.searchEntryFind), + BusyMaxGtkHeaderIcon(BusyMaxLinuxHeaderIcon.searchClear), + ], + ), + ), + ), + ), + ); + for (final icon in tester.widgetList( + find.byType(BusyMaxGtkHeaderIcon), + )) { + expect( + tester.getSize(find.byWidget(icon)), + const Size.square(BusyMaxLinuxHeaderStyle.symbolicIconSize), + ); + } + expect(find.byType(Image), findsNWidgets(3)); + expect( + find.descendant( + of: find.byType(BusyMaxGtkHeaderIcon), + matching: find.byType(CustomPaint), + ), + findsNothing, + ); + }); +} diff --git a/integration_test/linux_window_close_acceptance_test.dart b/integration_test/linux_window_close_acceptance_test.dart new file mode 100644 index 00000000..87c3cb57 --- /dev/null +++ b/integration_test/linux_window_close_acceptance_test.dart @@ -0,0 +1,118 @@ +import 'dart:async'; + +import 'package:busymax/l10n/generated/app_localizations.dart'; +import 'package:busymax/src/app/busymax_design.dart'; +import 'package:busymax/src/app/busymax_dialogs.dart'; +import 'package:busymax/src/app/busymax_window_close.dart'; +import 'package:busymax/src/app/linux/linux_window_host.dart'; +import 'package:busymax/src/platform/gtk_window_preferences_service.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; +import 'package:yaru/yaru.dart'; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + setUpAll(YaruWindowTitleBar.ensureInitialized); + + testWidgets('native Linux close request can be cancelled by an editor', ( + tester, + ) async { + final coordinator = BusyMaxWindowCloseCoordinator(); + await tester.pumpWidget( + ProviderScope( + overrides: [ + gtkWindowPreferencesProvider.overrideWith( + (ref) => Stream.value(_closePreferences), + ), + ], + child: MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + builder: (context, child) => LinuxWindowHost( + closeCoordinator: coordinator, + child: child ?? const SizedBox.shrink(), + ), + home: const _CloseProbe(), + ), + ), + ); + await tester.pumpAndSettle(); + + final closeControl = find.byWidgetPredicate( + (widget) => + widget is YaruWindowControl && + widget.type == YaruWindowControlType.close, + ); + expect(closeControl, findsOneWidget); + + await tester.tap(closeControl); + await tester.pumpAndSettle(); + + expect(find.text('Discard changes?'), findsOneWidget); + expect(find.text('Close requests: 1'), findsOneWidget); + + final cancelButton = find.descendant( + of: find.byType(BusyMaxConfirmDialog), + matching: find.byType(FilledButton), + ); + expect(cancelButton, findsOneWidget); + await tester.tap(cancelButton); + await tester.pumpAndSettle(); + + expect(find.text('Discard changes?'), findsNothing); + expect(find.text('Editor remains mounted'), findsOneWidget); + }); +} + +const _closePreferences = GtkWindowPreferences( + decorationLayout: GtkDecorationLayout( + left: [], + right: [GtkWindowDecorationElement.close], + ), + doubleClick: GtkTitlebarAction.toggleMaximize, + middleClick: GtkTitlebarAction.none, + rightClick: GtkTitlebarAction.menu, +); + +class _CloseProbe extends StatefulWidget { + const _CloseProbe(); + + @override + State<_CloseProbe> createState() => _CloseProbeState(); +} + +class _CloseProbeState extends State<_CloseProbe> { + var _closeRequests = 0; + + Future _confirmClose() async { + setState(() => _closeRequests += 1); + return showBusyMaxConfirm( + context, + title: 'Discard changes?', + message: 'The editor contains an unsaved draft.', + confirmLabel: 'Discard', + destructive: true, + ); + } + + @override + Widget build(BuildContext context) { + return BusyMaxWindowCloseGuard( + onCloseRequested: _confirmClose, + child: Scaffold( + body: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Text('Editor remains mounted'), + Text('Close requests: $_closeRequests'), + ], + ), + ), + ), + ); + } +} diff --git a/lib/l10n/app_ar.arb b/lib/l10n/app_ar.arb index 7bba1cef..208a5bb6 100644 --- a/lib/l10n/app_ar.arb +++ b/lib/l10n/app_ar.arb @@ -246,6 +246,10 @@ "viewAgenda": "جدول الأعمال", "scheduleSettings": "الجدول", "scheduleDisplaySettings": "عرض الجدول", + "newEventsAndTasks": "الأحداث والمهام الجديدة", + "defaultCalendar": "التقويم الافتراضي", + "defaultTaskList": "قائمة المهام الافتراضية", + "lastUsed": "آخر استخدام", "scheduleDisplayHoursDescription": "تفتح طريقتا عرض اليوم والأسبوع ضمن هذه الساعات. توسّع العناصر المبكرة والمتأخرة النطاق عند الحاجة.", "scheduleDayStartsAt": "يبدأ اليوم في", "scheduleDayEndsAt": "ينتهي اليوم في", @@ -308,6 +312,7 @@ "shortcutPreviousPeriodDescription": "الأسبوع السابق في عرض الأسبوع، والشهر السابق في عرض الشهر، وهكذا", "shortcutJumpToToday": "الانتقال إلى اليوم", "shortcutGroupView": "العرض", + "viewSelector": "العرض", "shortcutDayView": "عرض اليوم", "shortcutWeekView": "عرض الأسبوع", "shortcutMonthView": "عرض الشهر", @@ -589,7 +594,6 @@ "connectAccountAction": "ربط", "cancelAccountConnection": "إلغاء الربط", "nextcloudAccountRemovedRevokeFailed": "تمت إزالة الحساب محليًا، لكن تعذّر إبطال كلمة مرور تطبيق Nextcloud.", - "davCachedOfflineNotice": "تُخزّن بيانات التقويم والمهام محليًا للاستخدام دون اتصال.", "davReauthenticationRequired": "أعد ربط هذا الحساب لاستئناف المزامنة.", "davTemporarilyUnavailable": "هذا الحساب غير متاح مؤقتًا.", "davPermissionChanged": "تغيّرت أذونات الخادم. تم إيقاف التعديلات المعلقة مؤقتًا.", @@ -1044,5 +1048,8 @@ "searchClearFilters": "إعادة ضبط عوامل التصفية", "searchFiltersAction": "عوامل التصفية", "searchNoSources": "لم يتم تحديد مصادر للبحث", - "searchClearText": "مسح النص" + "searchClearText": "مسح النص", + "windowMinimize": "تصغير", + "windowMaximize": "تكبير", + "windowRestore": "استعادة" } diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index f50e4bd9..85005dff 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -246,6 +246,10 @@ "viewAgenda": "Tagesübersicht", "scheduleSettings": "Zeitplan", "scheduleDisplaySettings": "Zeitplananzeige", + "newEventsAndTasks": "Neue Ereignisse und Aufgaben", + "defaultCalendar": "Standardkalender", + "defaultTaskList": "Standardaufgabenliste", + "lastUsed": "Zuletzt verwendet", "scheduleDisplayHoursDescription": "In der Tages- und Wochenansicht wird zunächst dieser Zeitraum angezeigt. Frühere oder spätere Einträge erweitern ihn bei Bedarf.", "scheduleDayStartsAt": "Tag beginnt um", "scheduleDayEndsAt": "Tag endet um", @@ -329,6 +333,7 @@ "shortcutPreviousPeriodDescription": "Vorherige Woche in der Wochenansicht, vorheriger Monat in der Monatsansicht usw.", "shortcutJumpToToday": "Zum heutigen Tag springen", "shortcutGroupView": "Ansicht", + "viewSelector": "Ansicht", "shortcutDayView": "Tagesansicht", "shortcutWeekView": "Wochenansicht", "shortcutMonthView": "Monatsansicht", @@ -645,7 +650,6 @@ "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.", @@ -1107,5 +1111,8 @@ "searchClearFilters": "Filter zurücksetzen", "searchFiltersAction": "Suchfilter öffnen", "searchNoSources": "Keine Suchquellen ausgewählt", - "searchClearText": "Text löschen" + "searchClearText": "Text löschen", + "windowMinimize": "Minimieren", + "windowMaximize": "Maximieren", + "windowRestore": "Wiederherstellen" } diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index a44eabbf..45cee748 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -150,6 +150,9 @@ "microsoftSignInNotConfigured": "Microsoft sign-in is not configured. Set MICROSOFT_OAUTH_CLIENT_ID.", "cancel": "Cancel", "close": "Close", + "windowMinimize": "Minimize", + "windowMaximize": "Maximize", + "windowRestore": "Restore", "exit": "Exit", "options": "Options", "hide": "Hide", @@ -289,6 +292,10 @@ "viewAgenda": "Agenda", "scheduleSettings": "Schedule", "scheduleDisplaySettings": "Schedule display", + "newEventsAndTasks": "New events and tasks", + "defaultCalendar": "Default calendar", + "defaultTaskList": "Default task list", + "lastUsed": "Last used", "scheduleDisplayHoursDescription": "Day and Week views open within these hours. Early and late items expand the range when needed.", "scheduleDayStartsAt": "Day starts at", "scheduleDayEndsAt": "Day ends at", @@ -427,6 +434,7 @@ "shortcutPreviousPeriodDescription": "Previous week in week view, previous month in month view, and so on", "shortcutJumpToToday": "Jump to today", "shortcutGroupView": "View", + "viewSelector": "View", "shortcutDayView": "Day view", "shortcutWeekView": "Week view", "shortcutMonthView": "Month view", @@ -1150,7 +1158,6 @@ "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.", diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index e70e9026..3d1711bd 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -246,6 +246,10 @@ "viewAgenda": "Vista de agenda", "scheduleSettings": "Agenda", "scheduleDisplaySettings": "Visualización de agenda", + "newEventsAndTasks": "Nuevos eventos y tareas", + "defaultCalendar": "Calendario predeterminado", + "defaultTaskList": "Lista de tareas predeterminada", + "lastUsed": "Último utilizado", "scheduleDisplayHoursDescription": "Las vistas de día y semana muestran inicialmente este intervalo horario. Los elementos anteriores o posteriores lo amplían cuando es necesario.", "scheduleDayStartsAt": "El día empieza a las", "scheduleDayEndsAt": "El día termina a las", @@ -329,6 +333,7 @@ "shortcutPreviousPeriodDescription": "Semana anterior en la vista semanal, mes anterior en la vista mensual, etc.", "shortcutJumpToToday": "Ir a la fecha de hoy", "shortcutGroupView": "Vista", + "viewSelector": "Vista", "shortcutDayView": "Vista de día", "shortcutWeekView": "Vista de semana", "shortcutMonthView": "Vista de mes", @@ -645,7 +650,6 @@ "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.", @@ -1107,5 +1111,8 @@ "searchClearFilters": "Restablecer filtros", "searchFiltersAction": "Filtros", "searchNoSources": "No hay fuentes de búsqueda seleccionadas", - "searchClearText": "Borrar texto" + "searchClearText": "Borrar texto", + "windowMinimize": "Minimizar", + "windowMaximize": "Maximizar", + "windowRestore": "Restaurar" } diff --git a/lib/l10n/app_et.arb b/lib/l10n/app_et.arb index c454497b..39578833 100644 --- a/lib/l10n/app_et.arb +++ b/lib/l10n/app_et.arb @@ -253,6 +253,10 @@ "viewAgenda": "Päevakava", "scheduleSettings": "Ajakava", "scheduleDisplaySettings": "Ajakava kuvamine", + "newEventsAndTasks": "Uued sündmused ja ülesanded", + "defaultCalendar": "Vaikekalender", + "defaultTaskList": "Vaikimisi ülesannete loend", + "lastUsed": "Viimati kasutatud", "scheduleDisplayHoursDescription": "Päeva- ja nädalavaade avanevad nende kellaaegade piires. Vajaduse korral laiendavad varasemad ja hilisemad kirjed vahemikku.", "scheduleDayStartsAt": "Päev algab", "scheduleDayEndsAt": "Päev lõpeb", @@ -343,6 +347,7 @@ "shortcutPreviousPeriodDescription": "Nädalavaates eelmine nädal, kuuvaates eelmine kuu jne", "shortcutJumpToToday": "Mine tänasele kuupäevale", "shortcutGroupView": "Vaade", + "viewSelector": "Vaade", "shortcutDayView": "Päevavaade", "shortcutWeekView": "Nädalavaade", "shortcutMonthView": "Kuuvaade", @@ -760,7 +765,6 @@ "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.", @@ -1222,5 +1226,8 @@ "searchClearFilters": "Lähtesta filtrid", "searchFiltersAction": "Filtrid", "searchNoSources": "Otsinguallikaid pole valitud", - "searchClearText": "Tühjenda tekst" + "searchClearText": "Tühjenda tekst", + "windowMinimize": "Minimeeri", + "windowMaximize": "Maksimeeri", + "windowRestore": "Taasta" } diff --git a/lib/l10n/app_fa.arb b/lib/l10n/app_fa.arb index 2d2b1e71..d94aaff0 100644 --- a/lib/l10n/app_fa.arb +++ b/lib/l10n/app_fa.arb @@ -251,6 +251,10 @@ "viewAgenda": "برنامه", "scheduleSettings": "برنامه", "scheduleDisplaySettings": "نمایش برنامه", + "newEventsAndTasks": "رویدادها و کارهای جدید", + "defaultCalendar": "تقویم پیش‌فرض", + "defaultTaskList": "فهرست کارهای پیش‌فرض", + "lastUsed": "آخرین استفاده", "scheduleDisplayHoursDescription": "نماهای روز و هفته ابتدا این بازهٔ زمانی را نشان می‌دهند. موارد زودتر یا دیرتر در صورت نیاز این بازه را گسترش می‌دهند.", "scheduleDayStartsAt": "شروع روز از", "scheduleDayEndsAt": "پایان روز در", @@ -313,6 +317,7 @@ "shortcutPreviousPeriodDescription": "هفتهٔ قبل در نمای هفته، ماه قبل در نمای ماه و به همین ترتیب", "shortcutJumpToToday": "رفتن به امروز", "shortcutGroupView": "نما", + "viewSelector": "نما", "shortcutDayView": "نمای روز", "shortcutWeekView": "نمای هفته", "shortcutMonthView": "نمای ماه", @@ -594,7 +599,6 @@ "connectAccountAction": "اتصال", "cancelAccountConnection": "لغو اتصال", "nextcloudAccountRemovedRevokeFailed": "حساب به‌صورت محلی حذف شد، اما ابطال گذرواژهٔ برنامهٔ Nextcloud ممکن نیست.", - "davCachedOfflineNotice": "داده‌های تقویم و کار برای استفادهٔ آفلاین به‌صورت محلی ذخیره می‌شوند.", "davReauthenticationRequired": "برای ادامهٔ همگام‌سازی، این حساب را دوباره متصل کنید.", "davTemporarilyUnavailable": "این حساب موقتاً در دسترس نیست.", "davPermissionChanged": "مجوزهای سرور تغییر کرده‌اند. ویرایش‌های در انتظار متوقف شده‌اند.", @@ -1118,5 +1122,8 @@ "searchClearFilters": "بازنشانی فیلترها", "searchFiltersAction": "فیلترها", "searchNoSources": "هیچ منبع جستجویی انتخاب نشده است", - "searchClearText": "پاک کردن متن" + "searchClearText": "پاک کردن متن", + "windowMinimize": "کوچک کردن", + "windowMaximize": "بزرگ کردن", + "windowRestore": "بازیابی" } diff --git a/lib/l10n/app_fi.arb b/lib/l10n/app_fi.arb index ce86ccdd..192022a9 100644 --- a/lib/l10n/app_fi.arb +++ b/lib/l10n/app_fi.arb @@ -246,6 +246,10 @@ "viewAgenda": "Agendanäkymä", "scheduleSettings": "Aikataulu", "scheduleDisplaySettings": "Aikataulun näyttö", + "newEventsAndTasks": "Uudet tapahtumat ja tehtävät", + "defaultCalendar": "Oletuskalenteri", + "defaultTaskList": "Oletustehtävälista", + "lastUsed": "Viimeksi käytetty", "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.", "scheduleDayStartsAt": "Päivä alkaa", "scheduleDayEndsAt": "Päivä päättyy", @@ -308,6 +312,7 @@ "shortcutPreviousPeriodDescription": "Edellinen viikko viikkonäkymässä, edellinen kuukausi kuukausinäkymässä ja niin edelleen", "shortcutJumpToToday": "Siirry tähän päivään", "shortcutGroupView": "Näkymä", + "viewSelector": "Näkymä", "shortcutDayView": "Päivänäkymä", "shortcutWeekView": "Viikkonäkymä", "shortcutMonthView": "Kuukausinäkymä", @@ -589,7 +594,6 @@ "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.", @@ -1044,5 +1048,8 @@ "searchClearFilters": "Nollaa suodattimet", "searchFiltersAction": "Suodattimet", "searchNoSources": "Hakulähteitä ei ole valittu", - "searchClearText": "Tyhjennä teksti" + "searchClearText": "Tyhjennä teksti", + "windowMinimize": "Pienennä", + "windowMaximize": "Suurenna", + "windowRestore": "Palauta" } diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index ba1f2146..c4309433 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -246,6 +246,10 @@ "viewAgenda": "Vue agenda", "scheduleSettings": "Planning", "scheduleDisplaySettings": "Affichage du planning", + "newEventsAndTasks": "Nouveaux événements et tâches", + "defaultCalendar": "Calendrier par défaut", + "defaultTaskList": "Liste de tâches par défaut", + "lastUsed": "Dernier utilisé", "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.", "scheduleDayStartsAt": "La journée commence à", "scheduleDayEndsAt": "La journée se termine à", @@ -329,6 +333,7 @@ "shortcutPreviousPeriodDescription": "Semaine précédente en vue semaine, mois précédent en vue mois, etc.", "shortcutJumpToToday": "Aller à la date du jour", "shortcutGroupView": "Affichage", + "viewSelector": "Affichage", "shortcutDayView": "Vue jour", "shortcutWeekView": "Vue semaine", "shortcutMonthView": "Vue mois", @@ -645,7 +650,6 @@ "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.", @@ -1107,5 +1111,8 @@ "searchClearFilters": "Réinitialiser les filtres", "searchFiltersAction": "Filtres", "searchNoSources": "Aucune source de recherche sélectionnée", - "searchClearText": "Effacer le texte" + "searchClearText": "Effacer le texte", + "windowMinimize": "Réduire", + "windowMaximize": "Agrandir", + "windowRestore": "Restaurer" } diff --git a/lib/l10n/app_hi.arb b/lib/l10n/app_hi.arb index a6ae8cd3..19585036 100644 --- a/lib/l10n/app_hi.arb +++ b/lib/l10n/app_hi.arb @@ -246,6 +246,10 @@ "viewAgenda": "कार्यसूची", "scheduleSettings": "शेड्यूल", "scheduleDisplaySettings": "शेड्यूल प्रदर्शन", + "newEventsAndTasks": "नए इवेंट और कार्य", + "defaultCalendar": "डिफ़ॉल्ट कैलेंडर", + "defaultTaskList": "डिफ़ॉल्ट कार्य सूची", + "lastUsed": "पिछली बार उपयोग किया गया", "scheduleDisplayHoursDescription": "दिन और सप्ताह दृश्य शुरू में यह समयावधि दिखाते हैं। आवश्यकता होने पर पहले या बाद के आइटम इस सीमा को बढ़ाते हैं।", "scheduleDayStartsAt": "दिन शुरू होता है", "scheduleDayEndsAt": "दिन समाप्त होता है", @@ -308,6 +312,7 @@ "shortcutPreviousPeriodDescription": "सप्ताह दृश्य में पिछला सप्ताह, महीने के दृश्य में पिछला महीना, इत्यादि", "shortcutJumpToToday": "आज की तारीख पर जाएँ", "shortcutGroupView": "दृश्य", + "viewSelector": "दृश्य", "shortcutDayView": "दिन का दृश्य", "shortcutWeekView": "सप्ताह का दृश्य", "shortcutMonthView": "महीने का दृश्य", @@ -589,7 +594,6 @@ "connectAccountAction": "कनेक्ट करें", "cancelAccountConnection": "कनेक्शन रद्द करें", "nextcloudAccountRemovedRevokeFailed": "खाता स्थानीय रूप से हटाया गया, लेकिन Nextcloud ऐप पासवर्ड रद्द नहीं किया जा सका।", - "davCachedOfflineNotice": "ऑफ़लाइन उपयोग के लिए कैलेंडर और कार्य डेटा स्थानीय रूप से कैश किया जाता है।", "davReauthenticationRequired": "सिंक फिर से शुरू करने के लिए इस खाते को दोबारा कनेक्ट करें।", "davTemporarilyUnavailable": "यह खाता अस्थायी रूप से उपलब्ध नहीं है।", "davPermissionChanged": "सर्वर अनुमतियाँ बदल गई हैं। लंबित संपादन रोक दिए गए हैं।", @@ -1044,5 +1048,8 @@ "searchClearFilters": "फ़िल्टर रीसेट करें", "searchFiltersAction": "फ़िल्टर", "searchNoSources": "कोई खोज स्रोत नहीं चुना गया", - "searchClearText": "पाठ साफ़ करें" + "searchClearText": "पाठ साफ़ करें", + "windowMinimize": "छोटा करें", + "windowMaximize": "अधिकतम करें", + "windowRestore": "पुनर्स्थापित करें" } diff --git a/lib/l10n/app_it.arb b/lib/l10n/app_it.arb index 2c9b75cb..c2f9194a 100644 --- a/lib/l10n/app_it.arb +++ b/lib/l10n/app_it.arb @@ -246,6 +246,10 @@ "viewAgenda": "Vista agenda", "scheduleSettings": "Agenda", "scheduleDisplaySettings": "Visualizzazione agenda", + "newEventsAndTasks": "Nuovi eventi e attività", + "defaultCalendar": "Calendario predefinito", + "defaultTaskList": "Elenco attività predefinito", + "lastUsed": "Ultimo utilizzato", "scheduleDisplayHoursDescription": "Le viste Giorno e Settimana mostrano inizialmente questo intervallo orario. Gli elementi precedenti o successivi lo estendono quando necessario.", "scheduleDayStartsAt": "Inizio giornata", "scheduleDayEndsAt": "Fine giornata", @@ -308,6 +312,7 @@ "shortcutPreviousPeriodDescription": "Settimana precedente nella vista settimanale, mese precedente nella vista mensile e così via", "shortcutJumpToToday": "Vai alla data odierna", "shortcutGroupView": "Vista", + "viewSelector": "Vista", "shortcutDayView": "Vista giornaliera", "shortcutWeekView": "Vista settimanale", "shortcutMonthView": "Vista mensile", @@ -589,7 +594,6 @@ "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.", @@ -1044,5 +1048,8 @@ "searchClearFilters": "Reimposta filtri", "searchFiltersAction": "Filtri", "searchNoSources": "Nessuna origine di ricerca selezionata", - "searchClearText": "Cancella testo" + "searchClearText": "Cancella testo", + "windowMinimize": "Riduci", + "windowMaximize": "Ingrandisci", + "windowRestore": "Ripristina" } diff --git a/lib/l10n/app_ja.arb b/lib/l10n/app_ja.arb index 2cdeaca7..35fc5fee 100644 --- a/lib/l10n/app_ja.arb +++ b/lib/l10n/app_ja.arb @@ -246,6 +246,10 @@ "viewAgenda": "予定一覧", "scheduleSettings": "スケジュール", "scheduleDisplaySettings": "スケジュール表示", + "newEventsAndTasks": "新しい予定とタスク", + "defaultCalendar": "既定のカレンダー", + "defaultTaskList": "既定のタスクリスト", + "lastUsed": "前回使用", "scheduleDisplayHoursDescription": "日表示と週表示では、最初にこの時間範囲が表示されます。必要に応じて、この範囲より前または後の項目も表示されるように範囲が広がります。", "scheduleDayStartsAt": "一日の開始時刻", "scheduleDayEndsAt": "一日の終了時刻", @@ -308,6 +312,7 @@ "shortcutPreviousPeriodDescription": "週表示では前の週、月表示では前の月というように移動します", "shortcutJumpToToday": "今日に移動", "shortcutGroupView": "表示", + "viewSelector": "表示", "shortcutDayView": "日表示", "shortcutWeekView": "週表示", "shortcutMonthView": "月表示", @@ -589,7 +594,6 @@ "connectAccountAction": "接続", "cancelAccountConnection": "接続をキャンセル", "nextcloudAccountRemovedRevokeFailed": "アカウントはローカルから削除されましたが、Nextcloud の App パスワードを無効にできませんでした。", - "davCachedOfflineNotice": "オフラインで使用できるよう、カレンダーとタスクのデータはローカルにキャッシュされます。", "davReauthenticationRequired": "同期を再開するには、このアカウントを再接続してください。", "davTemporarilyUnavailable": "このアカウントは一時的に利用できません。", "davPermissionChanged": "サーバーの権限が変更されました。保留中の編集は一時停止されています。", @@ -1044,5 +1048,8 @@ "searchClearFilters": "フィルターをリセット", "searchFiltersAction": "フィルター", "searchNoSources": "検索元が選択されていません", - "searchClearText": "テキストを消去" + "searchClearText": "テキストを消去", + "windowMinimize": "最小化", + "windowMaximize": "最大化", + "windowRestore": "元に戻す" } diff --git a/lib/l10n/app_ko.arb b/lib/l10n/app_ko.arb index f25807ee..5ac3f372 100644 --- a/lib/l10n/app_ko.arb +++ b/lib/l10n/app_ko.arb @@ -246,6 +246,10 @@ "viewAgenda": "일정 목록", "scheduleSettings": "일정", "scheduleDisplaySettings": "일정 표시", + "newEventsAndTasks": "새 일정 및 작업", + "defaultCalendar": "기본 캘린더", + "defaultTaskList": "기본 작업 목록", + "lastUsed": "마지막으로 사용", "scheduleDisplayHoursDescription": "일간 및 주간 보기는 처음에 이 시간 범위를 표시합니다. 필요한 경우 더 이르거나 늦은 항목에 맞춰 범위가 확장됩니다.", "scheduleDayStartsAt": "하루 시작 시간", "scheduleDayEndsAt": "하루 종료 시간", @@ -308,6 +312,7 @@ "shortcutPreviousPeriodDescription": "주간 보기에서는 이전 주, 월간 보기에서는 이전 달로 이동하는 식입니다", "shortcutJumpToToday": "오늘로 이동", "shortcutGroupView": "보기", + "viewSelector": "보기", "shortcutDayView": "일간 보기", "shortcutWeekView": "주간 보기", "shortcutMonthView": "월간 보기", @@ -589,7 +594,6 @@ "connectAccountAction": "연결", "cancelAccountConnection": "연결 취소", "nextcloudAccountRemovedRevokeFailed": "계정은 로컬에서 삭제되었지만 Nextcloud 앱 암호를 취소할 수 없습니다.", - "davCachedOfflineNotice": "오프라인 사용을 위해 캘린더 및 할 일 데이터가 로컬에 캐시됩니다.", "davReauthenticationRequired": "동기화를 재개하려면 이 계정을 다시 연결하세요.", "davTemporarilyUnavailable": "이 계정은 일시적으로 사용할 수 없습니다.", "davPermissionChanged": "서버 권한이 변경되었습니다. 보류 중인 편집이 일시 중지되었습니다.", @@ -1044,5 +1048,8 @@ "searchClearFilters": "필터 초기화", "searchFiltersAction": "필터", "searchNoSources": "선택한 검색 소스가 없습니다", - "searchClearText": "텍스트 지우기" + "searchClearText": "텍스트 지우기", + "windowMinimize": "최소화", + "windowMaximize": "최대화", + "windowRestore": "복원" } diff --git a/lib/l10n/app_pl.arb b/lib/l10n/app_pl.arb index edc8996f..90e31104 100644 --- a/lib/l10n/app_pl.arb +++ b/lib/l10n/app_pl.arb @@ -381,6 +381,10 @@ "viewAgenda": "Plan dnia", "scheduleSettings": "Harmonogram", "scheduleDisplaySettings": "Wyświetlanie harmonogramu", + "newEventsAndTasks": "Nowe wydarzenia i zadania", + "defaultCalendar": "Domyślny kalendarz", + "defaultTaskList": "Domyślna lista zadań", + "lastUsed": "Ostatnio używane", "scheduleDisplayHoursDescription": "Widoki dnia i tygodnia otwierają się w tym zakresie godzin. Wcześniejsze i późniejsze elementy rozszerzają zakres w razie potrzeby.", "scheduleDayStartsAt": "Początek dnia", "scheduleDayEndsAt": "Koniec dnia", @@ -519,6 +523,7 @@ "shortcutPreviousPeriodDescription": "Poprzedni tydzień w widoku tygodnia, poprzedni miesiąc w widoku miesiąca itd.", "shortcutJumpToToday": "Przejdź do dzisiaj", "shortcutGroupView": "Widok", + "viewSelector": "Widok", "shortcutDayView": "Widok dnia", "shortcutWeekView": "Widok tygodnia", "shortcutMonthView": "Widok miesiąca", @@ -1243,7 +1248,6 @@ "connectAccountAction": "Połącz", "cancelAccountConnection": "Anuluj łączenie", "nextcloudAccountRemovedRevokeFailed": "Konto usunięto lokalnie, ale nie udało się unieważnić jego hasła aplikacji Nextcloud.", - "davCachedOfflineNotice": "Dane kalendarzy i zadań są zapisywane lokalnie, aby można było korzystać z nich offline.", "davReauthenticationRequired": "Połącz to konto ponownie, aby wznowić synchronizację.", "davTemporarilyUnavailable": "To konto jest tymczasowo niedostępne.", "davPermissionChanged": "Uprawnienia na serwerze uległy zmianie. Oczekujące zmiany zostały wstrzymane.", @@ -1754,5 +1758,8 @@ "searchClearFilters": "Resetuj filtry", "searchFiltersAction": "Filtry", "searchNoSources": "Nie wybrano źródeł wyszukiwania", - "searchClearText": "Wyczyść tekst" + "searchClearText": "Wyczyść tekst", + "windowMinimize": "Minimalizuj", + "windowMaximize": "Maksymalizuj", + "windowRestore": "Przywróć" } diff --git a/lib/l10n/app_pt_PT.arb b/lib/l10n/app_pt_PT.arb index ad3b62b7..02acedbe 100644 --- a/lib/l10n/app_pt_PT.arb +++ b/lib/l10n/app_pt_PT.arb @@ -246,6 +246,10 @@ "viewAgenda": "Vista de agenda", "scheduleSettings": "Agenda", "scheduleDisplaySettings": "Apresentação da agenda", + "newEventsAndTasks": "Novos eventos e tarefas", + "defaultCalendar": "Calendário predefinido", + "defaultTaskList": "Lista de tarefas predefinida", + "lastUsed": "Utilizado recentemente", "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", @@ -308,6 +312,7 @@ "shortcutPreviousPeriodDescription": "Semana anterior na vista semanal, mês anterior na vista mensal e assim por diante", "shortcutJumpToToday": "Ir para hoje", "shortcutGroupView": "Vista", + "viewSelector": "Vista", "shortcutDayView": "Vista diária", "shortcutWeekView": "Vista semanal", "shortcutMonthView": "Vista mensal", @@ -589,7 +594,6 @@ "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.", @@ -1044,5 +1048,8 @@ "searchClearFilters": "Repor filtros", "searchFiltersAction": "Filtros", "searchNoSources": "Nenhuma origem de pesquisa selecionada", - "searchClearText": "Limpar texto" + "searchClearText": "Limpar texto", + "windowMinimize": "Minimizar", + "windowMaximize": "Maximizar", + "windowRestore": "Restaurar" } diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index b4f4cfa5..134e17c7 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -246,6 +246,10 @@ "viewAgenda": "Расписание", "scheduleSettings": "Расписание", "scheduleDisplaySettings": "Отображение расписания", + "newEventsAndTasks": "Новые события и задачи", + "defaultCalendar": "Календарь по умолчанию", + "defaultTaskList": "Список задач по умолчанию", + "lastUsed": "Последний использованный", "scheduleDisplayHoursDescription": "В представлениях дня и недели изначально отображается этот период. Более ранние или поздние записи при необходимости расширяют его.", "scheduleDayStartsAt": "Начало дня", "scheduleDayEndsAt": "Конец дня", @@ -308,6 +312,7 @@ "shortcutPreviousPeriodDescription": "Предыдущая неделя в представлении недели, предыдущий месяц в представлении месяца и так далее", "shortcutJumpToToday": "Перейти к сегодняшнему дню", "shortcutGroupView": "Представление", + "viewSelector": "Представление", "shortcutDayView": "Представление дня", "shortcutWeekView": "Представление недели", "shortcutMonthView": "Представление месяца", @@ -589,7 +594,6 @@ "connectAccountAction": "Подключить", "cancelAccountConnection": "Отменить подключение", "nextcloudAccountRemovedRevokeFailed": "Аккаунт удалён локально, но пароль приложения Nextcloud не удалось отозвать.", - "davCachedOfflineNotice": "Данные календаря и задач кэшируются локально для работы без подключения.", "davReauthenticationRequired": "Подключите аккаунт заново, чтобы возобновить синхронизацию.", "davTemporarilyUnavailable": "Этот аккаунт временно недоступен.", "davPermissionChanged": "Разрешения сервера изменились. Ожидающие изменения приостановлены.", @@ -1044,5 +1048,8 @@ "searchClearFilters": "Сбросить фильтры", "searchFiltersAction": "Фильтры", "searchNoSources": "Источники поиска не выбраны", - "searchClearText": "Очистить текст" + "searchClearText": "Очистить текст", + "windowMinimize": "Свернуть", + "windowMaximize": "Развернуть", + "windowRestore": "Восстановить" } diff --git a/lib/l10n/app_vi.arb b/lib/l10n/app_vi.arb index 2cba9cd8..64ddaf90 100644 --- a/lib/l10n/app_vi.arb +++ b/lib/l10n/app_vi.arb @@ -246,6 +246,10 @@ "viewAgenda": "Lịch biểu", "scheduleSettings": "Lịch biểu", "scheduleDisplaySettings": "Hiển thị lịch biểu", + "newEventsAndTasks": "Sự kiện và công việc mới", + "defaultCalendar": "Lịch mặc định", + "defaultTaskList": "Danh sách công việc mặc định", + "lastUsed": "Dùng gần nhất", "scheduleDisplayHoursDescription": "Chế độ xem Ngày và Tuần ban đầu hiển thị khoảng thời gian này. Các mục sớm hơn hoặc muộn hơn sẽ mở rộng khoảng hiển thị khi cần.", "scheduleDayStartsAt": "Ngày bắt đầu lúc", "scheduleDayEndsAt": "Ngày kết thúc lúc", @@ -308,6 +312,7 @@ "shortcutPreviousPeriodDescription": "Tuần trước trong chế độ xem tuần, tháng trước trong chế độ xem tháng, v.v.", "shortcutJumpToToday": "Chuyển đến hôm nay", "shortcutGroupView": "Chế độ xem", + "viewSelector": "Chế độ xem", "shortcutDayView": "Chế độ xem ngày", "shortcutWeekView": "Chế độ xem tuần", "shortcutMonthView": "Chế độ xem tháng", @@ -589,7 +594,6 @@ "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.", @@ -1044,5 +1048,8 @@ "searchClearFilters": "Đặt lại bộ lọc", "searchFiltersAction": "Bộ lọc", "searchNoSources": "Chưa chọn nguồn tìm kiếm", - "searchClearText": "Xóa văn bản" + "searchClearText": "Xóa văn bản", + "windowMinimize": "Thu nhỏ", + "windowMaximize": "Phóng to", + "windowRestore": "Khôi phục" } diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index f87c01fe..cd068b7f 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -246,6 +246,10 @@ "viewAgenda": "日程", "scheduleSettings": "日程", "scheduleDisplaySettings": "日程显示", + "newEventsAndTasks": "新建日程和任务", + "defaultCalendar": "默认日历", + "defaultTaskList": "默认任务列表", + "lastUsed": "最近使用", "scheduleDisplayHoursDescription": "日视图和周视图最初显示此时间范围。需要时,更早或更晚的项目会扩展该范围。", "scheduleDayStartsAt": "每日开始时间", "scheduleDayEndsAt": "每日结束时间", @@ -308,6 +312,7 @@ "shortcutPreviousPeriodDescription": "在周视图中前往上一周,在月视图中前往上个月,依此类推", "shortcutJumpToToday": "跳转到今天", "shortcutGroupView": "视图", + "viewSelector": "视图", "shortcutDayView": "日视图", "shortcutWeekView": "周视图", "shortcutMonthView": "月视图", @@ -589,7 +594,6 @@ "connectAccountAction": "连接", "cancelAccountConnection": "取消连接", "nextcloudAccountRemovedRevokeFailed": "账户已在本地移除,但无法撤销 Nextcloud App 密码。", - "davCachedOfflineNotice": "日历和任务数据会缓存在本地,以供离线使用。", "davReauthenticationRequired": "重新连接此账户以恢复同步。", "davTemporarilyUnavailable": "此账户暂时不可用。", "davPermissionChanged": "服务器权限已更改。待处理的编辑已暂停。", @@ -1044,5 +1048,8 @@ "searchClearFilters": "重置筛选条件", "searchFiltersAction": "筛选", "searchNoSources": "未选择搜索来源", - "searchClearText": "清除文字" + "searchClearText": "清除文字", + "windowMinimize": "最小化", + "windowMaximize": "最大化", + "windowRestore": "还原" } diff --git a/lib/l10n/app_zh_Hans.arb b/lib/l10n/app_zh_Hans.arb index 124fcb11..147d9b68 100644 --- a/lib/l10n/app_zh_Hans.arb +++ b/lib/l10n/app_zh_Hans.arb @@ -246,6 +246,10 @@ "viewAgenda": "日程", "scheduleSettings": "日程", "scheduleDisplaySettings": "日程显示", + "newEventsAndTasks": "新建日程和任务", + "defaultCalendar": "默认日历", + "defaultTaskList": "默认任务列表", + "lastUsed": "最近使用", "scheduleDisplayHoursDescription": "日视图和周视图最初显示此时间范围。需要时,更早或更晚的项目会扩展该范围。", "scheduleDayStartsAt": "每日开始时间", "scheduleDayEndsAt": "每日结束时间", @@ -308,6 +312,7 @@ "shortcutPreviousPeriodDescription": "在周视图中前往上一周,在月视图中前往上个月,依此类推", "shortcutJumpToToday": "跳转到今天", "shortcutGroupView": "视图", + "viewSelector": "视图", "shortcutDayView": "日视图", "shortcutWeekView": "周视图", "shortcutMonthView": "月视图", @@ -589,7 +594,6 @@ "connectAccountAction": "连接", "cancelAccountConnection": "取消连接", "nextcloudAccountRemovedRevokeFailed": "账户已在本地移除,但无法撤销 Nextcloud App 密码。", - "davCachedOfflineNotice": "日历和任务数据会缓存在本地,以供离线使用。", "davReauthenticationRequired": "重新连接此账户以恢复同步。", "davTemporarilyUnavailable": "此账户暂时不可用。", "davPermissionChanged": "服务器权限已更改。待处理的编辑已暂停。", @@ -1044,5 +1048,8 @@ "searchClearFilters": "重置筛选条件", "searchFiltersAction": "筛选", "searchNoSources": "未选择搜索来源", - "searchClearText": "清除文字" + "searchClearText": "清除文字", + "windowMinimize": "最小化", + "windowMaximize": "最大化", + "windowRestore": "还原" } diff --git a/lib/l10n/app_zh_Hant.arb b/lib/l10n/app_zh_Hant.arb index d548d43a..ab4b9a02 100644 --- a/lib/l10n/app_zh_Hant.arb +++ b/lib/l10n/app_zh_Hant.arb @@ -246,6 +246,10 @@ "viewAgenda": "行程", "scheduleSettings": "行程", "scheduleDisplaySettings": "行程顯示", + "newEventsAndTasks": "新增行程和工作", + "defaultCalendar": "預設行事曆", + "defaultTaskList": "預設工作清單", + "lastUsed": "最近使用", "scheduleDisplayHoursDescription": "日檢視和週檢視一開始會顯示此時間範圍。必要時,較早或較晚的項目會擴大此範圍。", "scheduleDayStartsAt": "每日開始時間", "scheduleDayEndsAt": "每日結束時間", @@ -308,6 +312,7 @@ "shortcutPreviousPeriodDescription": "在週檢視中前往上一週,在月檢視中前往上個月,依此類推", "shortcutJumpToToday": "跳至今天", "shortcutGroupView": "檢視", + "viewSelector": "檢視", "shortcutDayView": "日檢視", "shortcutWeekView": "週檢視", "shortcutMonthView": "月檢視", @@ -589,7 +594,6 @@ "connectAccountAction": "連接", "cancelAccountConnection": "取消連接", "nextcloudAccountRemovedRevokeFailed": "賬戶已在本地移除,但無法撤銷 Nextcloud App 密碼。", - "davCachedOfflineNotice": "日曆和任務數據會緩存在本地,以供離線使用。", "davReauthenticationRequired": "重新連接此賬戶以恢復同步。", "davTemporarilyUnavailable": "此賬戶暫時不可用。", "davPermissionChanged": "服務器權限已更改。待處理的編輯已暫停。", @@ -1044,5 +1048,8 @@ "searchClearFilters": "重設篩選條件", "searchFiltersAction": "篩選", "searchNoSources": "未選取搜尋來源", - "searchClearText": "清除文字" + "searchClearText": "清除文字", + "windowMinimize": "最小化", + "windowMaximize": "最大化", + "windowRestore": "還原" } diff --git a/lib/l10n/generated/app_localizations.dart b/lib/l10n/generated/app_localizations.dart index f98fc773..8fea3128 100644 --- a/lib/l10n/generated/app_localizations.dart +++ b/lib/l10n/generated/app_localizations.dart @@ -852,6 +852,24 @@ abstract class AppLocalizations { /// **'Close'** String get close; + /// No description provided for @windowMinimize. + /// + /// In en, this message translates to: + /// **'Minimize'** + String get windowMinimize; + + /// No description provided for @windowMaximize. + /// + /// In en, this message translates to: + /// **'Maximize'** + String get windowMaximize; + + /// No description provided for @windowRestore. + /// + /// In en, this message translates to: + /// **'Restore'** + String get windowRestore; + /// No description provided for @exit. /// /// In en, this message translates to: @@ -1272,6 +1290,30 @@ abstract class AppLocalizations { /// **'Schedule display'** String get scheduleDisplaySettings; + /// No description provided for @newEventsAndTasks. + /// + /// In en, this message translates to: + /// **'New events and tasks'** + String get newEventsAndTasks; + + /// No description provided for @defaultCalendar. + /// + /// In en, this message translates to: + /// **'Default calendar'** + String get defaultCalendar; + + /// No description provided for @defaultTaskList. + /// + /// In en, this message translates to: + /// **'Default task list'** + String get defaultTaskList; + + /// No description provided for @lastUsed. + /// + /// In en, this message translates to: + /// **'Last used'** + String get lastUsed; + /// No description provided for @scheduleDisplayHoursDescription. /// /// In en, this message translates to: @@ -1872,6 +1914,12 @@ abstract class AppLocalizations { /// **'View'** String get shortcutGroupView; + /// No description provided for @viewSelector. + /// + /// In en, this message translates to: + /// **'View'** + String get viewSelector; + /// No description provided for @shortcutDayView. /// /// In en, this message translates to: @@ -4128,12 +4176,6 @@ abstract class AppLocalizations { /// **'The account was removed locally, but its Nextcloud app password could not be revoked.'** String get nextcloudAccountRemovedRevokeFailed; - /// No description provided for @davCachedOfflineNotice. - /// - /// In en, this message translates to: - /// **'Calendar and task data is cached locally for offline use.'** - String get davCachedOfflineNotice; - /// No description provided for @davReauthenticationRequired. /// /// In en, this message translates to: diff --git a/lib/l10n/generated/app_localizations_ar.dart b/lib/l10n/generated/app_localizations_ar.dart index f93994a1..26e99347 100644 --- a/lib/l10n/generated/app_localizations_ar.dart +++ b/lib/l10n/generated/app_localizations_ar.dart @@ -449,6 +449,15 @@ class AppLocalizationsAr extends AppLocalizations { @override String get close => 'إغلاق'; + @override + String get windowMinimize => 'تصغير'; + + @override + String get windowMaximize => 'تكبير'; + + @override + String get windowRestore => 'استعادة'; + @override String get exit => 'خروج'; @@ -732,6 +741,18 @@ class AppLocalizationsAr extends AppLocalizations { @override String get scheduleDisplaySettings => 'عرض الجدول'; + @override + String get newEventsAndTasks => 'الأحداث والمهام الجديدة'; + + @override + String get defaultCalendar => 'التقويم الافتراضي'; + + @override + String get defaultTaskList => 'قائمة المهام الافتراضية'; + + @override + String get lastUsed => 'آخر استخدام'; + @override String get scheduleDisplayHoursDescription => 'تفتح طريقتا عرض اليوم والأسبوع ضمن هذه الساعات. توسّع العناصر المبكرة والمتأخرة النطاق عند الحاجة.'; @@ -1095,6 +1116,9 @@ class AppLocalizationsAr extends AppLocalizations { @override String get shortcutGroupView => 'العرض'; + @override + String get viewSelector => 'العرض'; + @override String get shortcutDayView => 'عرض اليوم'; @@ -2491,10 +2515,6 @@ class AppLocalizationsAr extends AppLocalizations { String get nextcloudAccountRemovedRevokeFailed => 'تمت إزالة الحساب محليًا، لكن تعذّر إبطال كلمة مرور تطبيق Nextcloud.'; - @override - String get davCachedOfflineNotice => - 'تُخزّن بيانات التقويم والمهام محليًا للاستخدام دون اتصال.'; - @override String get davReauthenticationRequired => 'أعد ربط هذا الحساب لاستئناف المزامنة.'; diff --git a/lib/l10n/generated/app_localizations_de.dart b/lib/l10n/generated/app_localizations_de.dart index f67bb9a1..758cfbdb 100644 --- a/lib/l10n/generated/app_localizations_de.dart +++ b/lib/l10n/generated/app_localizations_de.dart @@ -454,6 +454,15 @@ class AppLocalizationsDe extends AppLocalizations { @override String get close => 'Schließen'; + @override + String get windowMinimize => 'Minimieren'; + + @override + String get windowMaximize => 'Maximieren'; + + @override + String get windowRestore => 'Wiederherstellen'; + @override String get exit => 'Beenden'; @@ -717,6 +726,18 @@ class AppLocalizationsDe extends AppLocalizations { @override String get scheduleDisplaySettings => 'Zeitplananzeige'; + @override + String get newEventsAndTasks => 'Neue Ereignisse und Aufgaben'; + + @override + String get defaultCalendar => 'Standardkalender'; + + @override + String get defaultTaskList => 'Standardaufgabenliste'; + + @override + String get lastUsed => 'Zuletzt verwendet'; + @override String get scheduleDisplayHoursDescription => 'In der Tages- und Wochenansicht wird zunächst dieser Zeitraum angezeigt. Frühere oder spätere Einträge erweitern ihn bei Bedarf.'; @@ -1057,6 +1078,9 @@ class AppLocalizationsDe extends AppLocalizations { @override String get shortcutGroupView => 'Ansicht'; + @override + String get viewSelector => 'Ansicht'; + @override String get shortcutDayView => 'Tagesansicht'; @@ -2420,10 +2444,6 @@ class AppLocalizationsDe extends AppLocalizations { String get nextcloudAccountRemovedRevokeFailed => 'Das Konto wurde lokal entfernt, aber das Nextcloud-App-Passwort konnte nicht widerrufen werden.'; - @override - String get davCachedOfflineNotice => - 'Kalender- und Aufgabendaten werden für die Offline-Nutzung lokal zwischengespeichert.'; - @override String get davReauthenticationRequired => 'Verbinden Sie dieses Konto erneut, um die Synchronisierung fortzusetzen.'; diff --git a/lib/l10n/generated/app_localizations_en.dart b/lib/l10n/generated/app_localizations_en.dart index 429ac2b8..a3c49c42 100644 --- a/lib/l10n/generated/app_localizations_en.dart +++ b/lib/l10n/generated/app_localizations_en.dart @@ -452,6 +452,15 @@ class AppLocalizationsEn extends AppLocalizations { @override String get close => 'Close'; + @override + String get windowMinimize => 'Minimize'; + + @override + String get windowMaximize => 'Maximize'; + + @override + String get windowRestore => 'Restore'; + @override String get exit => 'Exit'; @@ -734,6 +743,18 @@ class AppLocalizationsEn extends AppLocalizations { @override String get scheduleDisplaySettings => 'Schedule display'; + @override + String get newEventsAndTasks => 'New events and tasks'; + + @override + String get defaultCalendar => 'Default calendar'; + + @override + String get defaultTaskList => 'Default task list'; + + @override + String get lastUsed => 'Last used'; + @override String get scheduleDisplayHoursDescription => 'Day and Week views open within these hours. Early and late items expand the range when needed.'; @@ -1086,6 +1107,9 @@ class AppLocalizationsEn extends AppLocalizations { @override String get shortcutGroupView => 'View'; + @override + String get viewSelector => 'View'; + @override String get shortcutDayView => 'Day view'; @@ -2438,10 +2462,6 @@ class AppLocalizationsEn extends AppLocalizations { 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.'; diff --git a/lib/l10n/generated/app_localizations_es.dart b/lib/l10n/generated/app_localizations_es.dart index bcfc3595..686c07da 100644 --- a/lib/l10n/generated/app_localizations_es.dart +++ b/lib/l10n/generated/app_localizations_es.dart @@ -458,6 +458,15 @@ class AppLocalizationsEs extends AppLocalizations { @override String get close => 'Cerrar'; + @override + String get windowMinimize => 'Minimizar'; + + @override + String get windowMaximize => 'Maximizar'; + + @override + String get windowRestore => 'Restaurar'; + @override String get exit => 'Salir'; @@ -721,6 +730,18 @@ class AppLocalizationsEs extends AppLocalizations { @override String get scheduleDisplaySettings => 'Visualización de agenda'; + @override + String get newEventsAndTasks => 'Nuevos eventos y tareas'; + + @override + String get defaultCalendar => 'Calendario predeterminado'; + + @override + String get defaultTaskList => 'Lista de tareas predeterminada'; + + @override + String get lastUsed => 'Último utilizado'; + @override String get scheduleDisplayHoursDescription => 'Las vistas de día y semana muestran inicialmente este intervalo horario. Los elementos anteriores o posteriores lo amplían cuando es necesario.'; @@ -1060,6 +1081,9 @@ class AppLocalizationsEs extends AppLocalizations { @override String get shortcutGroupView => 'Vista'; + @override + String get viewSelector => 'Vista'; + @override String get shortcutDayView => 'Vista de día'; @@ -2426,10 +2450,6 @@ class AppLocalizationsEs extends AppLocalizations { String get nextcloudAccountRemovedRevokeFailed => 'La cuenta se quitó localmente, pero no se pudo revocar la contraseña de app de Nextcloud.'; - @override - String get davCachedOfflineNotice => - 'Los datos del calendario y las tareas se almacenan localmente para usarlos sin conexión.'; - @override String get davReauthenticationRequired => 'Vuelve a conectar esta cuenta para reanudar la sincronización.'; diff --git a/lib/l10n/generated/app_localizations_et.dart b/lib/l10n/generated/app_localizations_et.dart index 59d2fe70..c3719749 100644 --- a/lib/l10n/generated/app_localizations_et.dart +++ b/lib/l10n/generated/app_localizations_et.dart @@ -451,6 +451,15 @@ class AppLocalizationsEt extends AppLocalizations { @override String get close => 'Sulge'; + @override + String get windowMinimize => 'Minimeeri'; + + @override + String get windowMaximize => 'Maksimeeri'; + + @override + String get windowRestore => 'Taasta'; + @override String get exit => 'Välju'; @@ -710,6 +719,18 @@ class AppLocalizationsEt extends AppLocalizations { @override String get scheduleDisplaySettings => 'Ajakava kuvamine'; + @override + String get newEventsAndTasks => 'Uued sündmused ja ülesanded'; + + @override + String get defaultCalendar => 'Vaikekalender'; + + @override + String get defaultTaskList => 'Vaikimisi ülesannete loend'; + + @override + String get lastUsed => 'Viimati kasutatud'; + @override String get scheduleDisplayHoursDescription => 'Päeva- ja nädalavaade avanevad nende kellaaegade piires. Vajaduse korral laiendavad varasemad ja hilisemad kirjed vahemikku.'; @@ -1048,6 +1069,9 @@ class AppLocalizationsEt extends AppLocalizations { @override String get shortcutGroupView => 'Vaade'; + @override + String get viewSelector => 'Vaade'; + @override String get shortcutDayView => 'Päevavaade'; @@ -2389,10 +2413,6 @@ class AppLocalizationsEt extends AppLocalizations { String get nextcloudAccountRemovedRevokeFailed => 'Konto eemaldati kohalikult, kuid Nextcloudi rakenduse parooli ei saanud tühistada.'; - @override - String get davCachedOfflineNotice => - 'Kalendri- ja ülesandeandmed salvestatakse võrguühenduseta kasutamiseks kohapeal.'; - @override String get davReauthenticationRequired => 'Sünkroonimise jätkamiseks ühenda see konto uuesti.'; diff --git a/lib/l10n/generated/app_localizations_fa.dart b/lib/l10n/generated/app_localizations_fa.dart index d12a8570..fd07dd47 100644 --- a/lib/l10n/generated/app_localizations_fa.dart +++ b/lib/l10n/generated/app_localizations_fa.dart @@ -448,6 +448,15 @@ class AppLocalizationsFa extends AppLocalizations { @override String get close => 'بستن'; + @override + String get windowMinimize => 'کوچک کردن'; + + @override + String get windowMaximize => 'بزرگ کردن'; + + @override + String get windowRestore => 'بازیابی'; + @override String get exit => 'خروج'; @@ -730,6 +739,18 @@ class AppLocalizationsFa extends AppLocalizations { @override String get scheduleDisplaySettings => 'نمایش برنامه'; + @override + String get newEventsAndTasks => 'رویدادها و کارهای جدید'; + + @override + String get defaultCalendar => 'تقویم پیش‌فرض'; + + @override + String get defaultTaskList => 'فهرست کارهای پیش‌فرض'; + + @override + String get lastUsed => 'آخرین استفاده'; + @override String get scheduleDisplayHoursDescription => 'نماهای روز و هفته ابتدا این بازهٔ زمانی را نشان می‌دهند. موارد زودتر یا دیرتر در صورت نیاز این بازه را گسترش می‌دهند.'; @@ -1085,6 +1106,9 @@ class AppLocalizationsFa extends AppLocalizations { @override String get shortcutGroupView => 'نما'; + @override + String get viewSelector => 'نما'; + @override String get shortcutDayView => 'نمای روز'; @@ -2442,10 +2466,6 @@ class AppLocalizationsFa extends AppLocalizations { String get nextcloudAccountRemovedRevokeFailed => 'حساب به‌صورت محلی حذف شد، اما ابطال گذرواژهٔ برنامهٔ Nextcloud ممکن نیست.'; - @override - String get davCachedOfflineNotice => - 'داده‌های تقویم و کار برای استفادهٔ آفلاین به‌صورت محلی ذخیره می‌شوند.'; - @override String get davReauthenticationRequired => 'برای ادامهٔ همگام‌سازی، این حساب را دوباره متصل کنید.'; diff --git a/lib/l10n/generated/app_localizations_fi.dart b/lib/l10n/generated/app_localizations_fi.dart index 77de90c6..7640c252 100644 --- a/lib/l10n/generated/app_localizations_fi.dart +++ b/lib/l10n/generated/app_localizations_fi.dart @@ -454,6 +454,15 @@ class AppLocalizationsFi extends AppLocalizations { @override String get close => 'Sulje'; + @override + String get windowMinimize => 'Pienennä'; + + @override + String get windowMaximize => 'Suurenna'; + + @override + String get windowRestore => 'Palauta'; + @override String get exit => 'Lopeta'; @@ -723,6 +732,18 @@ class AppLocalizationsFi extends AppLocalizations { @override String get scheduleDisplaySettings => 'Aikataulun näyttö'; + @override + String get newEventsAndTasks => 'Uudet tapahtumat ja tehtävät'; + + @override + String get defaultCalendar => 'Oletuskalenteri'; + + @override + String get defaultTaskList => 'Oletustehtävälista'; + + @override + String get lastUsed => 'Viimeksi käytetty'; + @override String get 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.'; @@ -1075,6 +1096,9 @@ class AppLocalizationsFi extends AppLocalizations { @override String get shortcutGroupView => 'Näkymä'; + @override + String get viewSelector => 'Näkymä'; + @override String get shortcutDayView => 'Päivänäkymä'; @@ -2436,10 +2460,6 @@ class AppLocalizationsFi extends AppLocalizations { String get nextcloudAccountRemovedRevokeFailed => 'Tili poistettiin paikallisesti, mutta Nextcloudin sovellussalasanaa ei voitu kumota.'; - @override - String get davCachedOfflineNotice => - 'Kalenteri- ja tehtävätiedot tallennetaan paikallisesti offline-käyttöä varten.'; - @override String get davReauthenticationRequired => 'Yhdistä tämä tili uudelleen jatkaaksesi synkronointia.'; diff --git a/lib/l10n/generated/app_localizations_fr.dart b/lib/l10n/generated/app_localizations_fr.dart index b3ea7fc3..cd886a3a 100644 --- a/lib/l10n/generated/app_localizations_fr.dart +++ b/lib/l10n/generated/app_localizations_fr.dart @@ -459,6 +459,15 @@ class AppLocalizationsFr extends AppLocalizations { @override String get close => 'Fermer'; + @override + String get windowMinimize => 'Réduire'; + + @override + String get windowMaximize => 'Agrandir'; + + @override + String get windowRestore => 'Restaurer'; + @override String get exit => 'Quitter'; @@ -722,6 +731,18 @@ class AppLocalizationsFr extends AppLocalizations { @override String get scheduleDisplaySettings => 'Affichage du planning'; + @override + String get newEventsAndTasks => 'Nouveaux événements et tâches'; + + @override + String get defaultCalendar => 'Calendrier par défaut'; + + @override + String get defaultTaskList => 'Liste de tâches par défaut'; + + @override + String get lastUsed => 'Dernier utilisé'; + @override String get 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.'; @@ -1062,6 +1083,9 @@ class AppLocalizationsFr extends AppLocalizations { @override String get shortcutGroupView => 'Affichage'; + @override + String get viewSelector => 'Affichage'; + @override String get shortcutDayView => 'Vue jour'; @@ -2425,10 +2449,6 @@ class AppLocalizationsFr extends AppLocalizations { String get nextcloudAccountRemovedRevokeFailed => '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 => - 'Les données des calendriers et des tâches sont mises en cache localement pour une utilisation hors connexion.'; - @override String get davReauthenticationRequired => 'Reconnectez ce compte pour reprendre la synchronisation.'; diff --git a/lib/l10n/generated/app_localizations_hi.dart b/lib/l10n/generated/app_localizations_hi.dart index 194ad5ab..9d8eb84e 100644 --- a/lib/l10n/generated/app_localizations_hi.dart +++ b/lib/l10n/generated/app_localizations_hi.dart @@ -453,6 +453,15 @@ class AppLocalizationsHi extends AppLocalizations { @override String get close => 'बंद करें'; + @override + String get windowMinimize => 'छोटा करें'; + + @override + String get windowMaximize => 'अधिकतम करें'; + + @override + String get windowRestore => 'पुनर्स्थापित करें'; + @override String get exit => 'बाहर निकलें'; @@ -717,6 +726,18 @@ class AppLocalizationsHi extends AppLocalizations { @override String get scheduleDisplaySettings => 'शेड्यूल प्रदर्शन'; + @override + String get newEventsAndTasks => 'नए इवेंट और कार्य'; + + @override + String get defaultCalendar => 'डिफ़ॉल्ट कैलेंडर'; + + @override + String get defaultTaskList => 'डिफ़ॉल्ट कार्य सूची'; + + @override + String get lastUsed => 'पिछली बार उपयोग किया गया'; + @override String get scheduleDisplayHoursDescription => 'दिन और सप्ताह दृश्य शुरू में यह समयावधि दिखाते हैं। आवश्यकता होने पर पहले या बाद के आइटम इस सीमा को बढ़ाते हैं।'; @@ -1068,6 +1089,9 @@ class AppLocalizationsHi extends AppLocalizations { @override String get shortcutGroupView => 'दृश्य'; + @override + String get viewSelector => 'दृश्य'; + @override String get shortcutDayView => 'दिन का दृश्य'; @@ -2423,10 +2447,6 @@ class AppLocalizationsHi extends AppLocalizations { String get nextcloudAccountRemovedRevokeFailed => 'खाता स्थानीय रूप से हटाया गया, लेकिन Nextcloud ऐप पासवर्ड रद्द नहीं किया जा सका।'; - @override - String get davCachedOfflineNotice => - 'ऑफ़लाइन उपयोग के लिए कैलेंडर और कार्य डेटा स्थानीय रूप से कैश किया जाता है।'; - @override String get davReauthenticationRequired => 'सिंक फिर से शुरू करने के लिए इस खाते को दोबारा कनेक्ट करें।'; diff --git a/lib/l10n/generated/app_localizations_it.dart b/lib/l10n/generated/app_localizations_it.dart index d7d4996b..5e8c0190 100644 --- a/lib/l10n/generated/app_localizations_it.dart +++ b/lib/l10n/generated/app_localizations_it.dart @@ -457,6 +457,15 @@ class AppLocalizationsIt extends AppLocalizations { @override String get close => 'Chiudi'; + @override + String get windowMinimize => 'Riduci'; + + @override + String get windowMaximize => 'Ingrandisci'; + + @override + String get windowRestore => 'Ripristina'; + @override String get exit => 'Esci'; @@ -721,6 +730,18 @@ class AppLocalizationsIt extends AppLocalizations { @override String get scheduleDisplaySettings => 'Visualizzazione agenda'; + @override + String get newEventsAndTasks => 'Nuovi eventi e attività'; + + @override + String get defaultCalendar => 'Calendario predefinito'; + + @override + String get defaultTaskList => 'Elenco attività predefinito'; + + @override + String get lastUsed => 'Ultimo utilizzato'; + @override String get scheduleDisplayHoursDescription => 'Le viste Giorno e Settimana mostrano inizialmente questo intervallo orario. Gli elementi precedenti o successivi lo estendono quando necessario.'; @@ -1073,6 +1094,9 @@ class AppLocalizationsIt extends AppLocalizations { @override String get shortcutGroupView => 'Vista'; + @override + String get viewSelector => 'Vista'; + @override String get shortcutDayView => 'Vista giornaliera'; @@ -2445,10 +2469,6 @@ class AppLocalizationsIt extends AppLocalizations { String get nextcloudAccountRemovedRevokeFailed => 'L’account è stato rimosso localmente, ma non è stato possibile revocare la password dell’app Nextcloud.'; - @override - String get davCachedOfflineNotice => - 'I dati di calendari e attività vengono memorizzati localmente per l’uso offline.'; - @override String get davReauthenticationRequired => 'Ricollega questo account per riprendere la sincronizzazione.'; diff --git a/lib/l10n/generated/app_localizations_ja.dart b/lib/l10n/generated/app_localizations_ja.dart index 89af0ce3..a4fb64d3 100644 --- a/lib/l10n/generated/app_localizations_ja.dart +++ b/lib/l10n/generated/app_localizations_ja.dart @@ -437,6 +437,15 @@ class AppLocalizationsJa extends AppLocalizations { @override String get close => '閉じる'; + @override + String get windowMinimize => '最小化'; + + @override + String get windowMaximize => '最大化'; + + @override + String get windowRestore => '元に戻す'; + @override String get exit => '終了'; @@ -696,6 +705,18 @@ class AppLocalizationsJa extends AppLocalizations { @override String get scheduleDisplaySettings => 'スケジュール表示'; + @override + String get newEventsAndTasks => '新しい予定とタスク'; + + @override + String get defaultCalendar => '既定のカレンダー'; + + @override + String get defaultTaskList => '既定のタスクリスト'; + + @override + String get lastUsed => '前回使用'; + @override String get scheduleDisplayHoursDescription => '日表示と週表示では、最初にこの時間範囲が表示されます。必要に応じて、この範囲より前または後の項目も表示されるように範囲が広がります。'; @@ -1041,6 +1062,9 @@ class AppLocalizationsJa extends AppLocalizations { @override String get shortcutGroupView => '表示'; + @override + String get viewSelector => '表示'; + @override String get shortcutDayView => '日表示'; @@ -2372,10 +2396,6 @@ class AppLocalizationsJa extends AppLocalizations { String get nextcloudAccountRemovedRevokeFailed => 'アカウントはローカルから削除されましたが、Nextcloud の App パスワードを無効にできませんでした。'; - @override - String get davCachedOfflineNotice => - 'オフラインで使用できるよう、カレンダーとタスクのデータはローカルにキャッシュされます。'; - @override String get davReauthenticationRequired => '同期を再開するには、このアカウントを再接続してください。'; diff --git a/lib/l10n/generated/app_localizations_ko.dart b/lib/l10n/generated/app_localizations_ko.dart index 93698eed..b968135f 100644 --- a/lib/l10n/generated/app_localizations_ko.dart +++ b/lib/l10n/generated/app_localizations_ko.dart @@ -439,6 +439,15 @@ class AppLocalizationsKo extends AppLocalizations { @override String get close => '닫기'; + @override + String get windowMinimize => '최소화'; + + @override + String get windowMaximize => '최대화'; + + @override + String get windowRestore => '복원'; + @override String get exit => '종료'; @@ -697,6 +706,18 @@ class AppLocalizationsKo extends AppLocalizations { @override String get scheduleDisplaySettings => '일정 표시'; + @override + String get newEventsAndTasks => '새 일정 및 작업'; + + @override + String get defaultCalendar => '기본 캘린더'; + + @override + String get defaultTaskList => '기본 작업 목록'; + + @override + String get lastUsed => '마지막으로 사용'; + @override String get scheduleDisplayHoursDescription => '일간 및 주간 보기는 처음에 이 시간 범위를 표시합니다. 필요한 경우 더 이르거나 늦은 항목에 맞춰 범위가 확장됩니다.'; @@ -1044,6 +1065,9 @@ class AppLocalizationsKo extends AppLocalizations { @override String get shortcutGroupView => '보기'; + @override + String get viewSelector => '보기'; + @override String get shortcutDayView => '일간 보기'; @@ -2376,9 +2400,6 @@ class AppLocalizationsKo extends AppLocalizations { String get nextcloudAccountRemovedRevokeFailed => '계정은 로컬에서 삭제되었지만 Nextcloud 앱 암호를 취소할 수 없습니다.'; - @override - String get davCachedOfflineNotice => '오프라인 사용을 위해 캘린더 및 할 일 데이터가 로컬에 캐시됩니다.'; - @override String get davReauthenticationRequired => '동기화를 재개하려면 이 계정을 다시 연결하세요.'; diff --git a/lib/l10n/generated/app_localizations_pl.dart b/lib/l10n/generated/app_localizations_pl.dart index 6aad0970..01493364 100644 --- a/lib/l10n/generated/app_localizations_pl.dart +++ b/lib/l10n/generated/app_localizations_pl.dart @@ -463,6 +463,15 @@ class AppLocalizationsPl extends AppLocalizations { @override String get close => 'Zamknij'; + @override + String get windowMinimize => 'Minimalizuj'; + + @override + String get windowMaximize => 'Maksymalizuj'; + + @override + String get windowRestore => 'Przywróć'; + @override String get exit => 'Zakończ'; @@ -754,6 +763,18 @@ class AppLocalizationsPl extends AppLocalizations { @override String get scheduleDisplaySettings => 'Wyświetlanie harmonogramu'; + @override + String get newEventsAndTasks => 'Nowe wydarzenia i zadania'; + + @override + String get defaultCalendar => 'Domyślny kalendarz'; + + @override + String get defaultTaskList => 'Domyślna lista zadań'; + + @override + String get lastUsed => 'Ostatnio używane'; + @override String get scheduleDisplayHoursDescription => 'Widoki dnia i tygodnia otwierają się w tym zakresie godzin. Wcześniejsze i późniejsze elementy rozszerzają zakres w razie potrzeby.'; @@ -1111,6 +1132,9 @@ class AppLocalizationsPl extends AppLocalizations { @override String get shortcutGroupView => 'Widok'; + @override + String get viewSelector => 'Widok'; + @override String get shortcutDayView => 'Widok dnia'; @@ -2575,10 +2599,6 @@ class AppLocalizationsPl extends AppLocalizations { String get nextcloudAccountRemovedRevokeFailed => 'Konto usunięto lokalnie, ale nie udało się unieważnić jego hasła aplikacji Nextcloud.'; - @override - String get davCachedOfflineNotice => - 'Dane kalendarzy i zadań są zapisywane lokalnie, aby można było korzystać z nich offline.'; - @override String get davReauthenticationRequired => 'Połącz to konto ponownie, aby wznowić synchronizację.'; diff --git a/lib/l10n/generated/app_localizations_pt.dart b/lib/l10n/generated/app_localizations_pt.dart index 437533b5..d35c96a3 100644 --- a/lib/l10n/generated/app_localizations_pt.dart +++ b/lib/l10n/generated/app_localizations_pt.dart @@ -452,6 +452,15 @@ class AppLocalizationsPt extends AppLocalizations { @override String get close => 'Close'; + @override + String get windowMinimize => 'Minimize'; + + @override + String get windowMaximize => 'Maximize'; + + @override + String get windowRestore => 'Restore'; + @override String get exit => 'Exit'; @@ -734,6 +743,18 @@ class AppLocalizationsPt extends AppLocalizations { @override String get scheduleDisplaySettings => 'Schedule display'; + @override + String get newEventsAndTasks => 'New events and tasks'; + + @override + String get defaultCalendar => 'Default calendar'; + + @override + String get defaultTaskList => 'Default task list'; + + @override + String get lastUsed => 'Last used'; + @override String get scheduleDisplayHoursDescription => 'Day and Week views open within these hours. Early and late items expand the range when needed.'; @@ -1086,6 +1107,9 @@ class AppLocalizationsPt extends AppLocalizations { @override String get shortcutGroupView => 'View'; + @override + String get viewSelector => 'View'; + @override String get shortcutDayView => 'Day view'; @@ -2438,10 +2462,6 @@ class AppLocalizationsPt extends AppLocalizations { 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.'; @@ -3466,6 +3486,15 @@ class AppLocalizationsPtPt extends AppLocalizationsPt { @override String get close => 'Fechar'; + @override + String get windowMinimize => 'Minimizar'; + + @override + String get windowMaximize => 'Maximizar'; + + @override + String get windowRestore => 'Restaurar'; + @override String get exit => 'Sair'; @@ -3730,6 +3759,18 @@ class AppLocalizationsPtPt extends AppLocalizationsPt { @override String get scheduleDisplaySettings => 'Apresentação da agenda'; + @override + String get newEventsAndTasks => 'Novos eventos e tarefas'; + + @override + String get defaultCalendar => 'Calendário predefinido'; + + @override + String get defaultTaskList => 'Lista de tarefas predefinida'; + + @override + String get lastUsed => 'Utilizado recentemente'; + @override String get scheduleDisplayHoursDescription => 'As vistas de dia e semana mostram inicialmente este intervalo horário. Os itens anteriores ou posteriores alargam-no quando necessário.'; @@ -4083,6 +4124,9 @@ class AppLocalizationsPtPt extends AppLocalizationsPt { @override String get shortcutGroupView => 'Vista'; + @override + String get viewSelector => 'Vista'; + @override String get shortcutDayView => 'Vista diária'; @@ -5452,10 +5496,6 @@ class AppLocalizationsPtPt extends AppLocalizationsPt { String get nextcloudAccountRemovedRevokeFailed => 'A conta foi removida localmente, mas não foi possível revogar a palavra-passe da aplicação Nextcloud.'; - @override - String get davCachedOfflineNotice => - 'Os dados de calendários e tarefas são colocados em cache localmente para utilização offline.'; - @override String get davReauthenticationRequired => 'Volte a ligar esta conta para retomar a sincronização.'; diff --git a/lib/l10n/generated/app_localizations_ru.dart b/lib/l10n/generated/app_localizations_ru.dart index 11ad24bc..704f6dc3 100644 --- a/lib/l10n/generated/app_localizations_ru.dart +++ b/lib/l10n/generated/app_localizations_ru.dart @@ -451,6 +451,15 @@ class AppLocalizationsRu extends AppLocalizations { @override String get close => 'Закрыть'; + @override + String get windowMinimize => 'Свернуть'; + + @override + String get windowMaximize => 'Развернуть'; + + @override + String get windowRestore => 'Восстановить'; + @override String get exit => 'Выйти'; @@ -721,6 +730,18 @@ class AppLocalizationsRu extends AppLocalizations { @override String get scheduleDisplaySettings => 'Отображение расписания'; + @override + String get newEventsAndTasks => 'Новые события и задачи'; + + @override + String get defaultCalendar => 'Календарь по умолчанию'; + + @override + String get defaultTaskList => 'Список задач по умолчанию'; + + @override + String get lastUsed => 'Последний использованный'; + @override String get scheduleDisplayHoursDescription => 'В представлениях дня и недели изначально отображается этот период. Более ранние или поздние записи при необходимости расширяют его.'; @@ -1078,6 +1099,9 @@ class AppLocalizationsRu extends AppLocalizations { @override String get shortcutGroupView => 'Представление'; + @override + String get viewSelector => 'Представление'; + @override String get shortcutDayView => 'Представление дня'; @@ -2447,10 +2471,6 @@ class AppLocalizationsRu extends AppLocalizations { String get nextcloudAccountRemovedRevokeFailed => 'Аккаунт удалён локально, но пароль приложения Nextcloud не удалось отозвать.'; - @override - String get davCachedOfflineNotice => - 'Данные календаря и задач кэшируются локально для работы без подключения.'; - @override String get davReauthenticationRequired => 'Подключите аккаунт заново, чтобы возобновить синхронизацию.'; diff --git a/lib/l10n/generated/app_localizations_vi.dart b/lib/l10n/generated/app_localizations_vi.dart index 2d8f1e7c..a069e749 100644 --- a/lib/l10n/generated/app_localizations_vi.dart +++ b/lib/l10n/generated/app_localizations_vi.dart @@ -452,6 +452,15 @@ class AppLocalizationsVi extends AppLocalizations { @override String get close => 'Đóng'; + @override + String get windowMinimize => 'Thu nhỏ'; + + @override + String get windowMaximize => 'Phóng to'; + + @override + String get windowRestore => 'Khôi phục'; + @override String get exit => 'Thoát'; @@ -715,6 +724,18 @@ class AppLocalizationsVi extends AppLocalizations { @override String get scheduleDisplaySettings => 'Hiển thị lịch biểu'; + @override + String get newEventsAndTasks => 'Sự kiện và công việc mới'; + + @override + String get defaultCalendar => 'Lịch mặc định'; + + @override + String get defaultTaskList => 'Danh sách công việc mặc định'; + + @override + String get lastUsed => 'Dùng gần nhất'; + @override String get scheduleDisplayHoursDescription => 'Chế độ xem Ngày và Tuần ban đầu hiển thị khoảng thời gian này. Các mục sớm hơn hoặc muộn hơn sẽ mở rộng khoảng hiển thị khi cần.'; @@ -1066,6 +1087,9 @@ class AppLocalizationsVi extends AppLocalizations { @override String get shortcutGroupView => 'Chế độ xem'; + @override + String get viewSelector => 'Chế độ xem'; + @override String get shortcutDayView => 'Chế độ xem ngày'; @@ -2423,10 +2447,6 @@ class AppLocalizationsVi extends AppLocalizations { String get 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.'; - @override - String get 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.'; - @override String get davReauthenticationRequired => 'Kết nối lại tài khoản này để tiếp tục đồng bộ.'; diff --git a/lib/l10n/generated/app_localizations_zh.dart b/lib/l10n/generated/app_localizations_zh.dart index f2f6e30f..35d44729 100644 --- a/lib/l10n/generated/app_localizations_zh.dart +++ b/lib/l10n/generated/app_localizations_zh.dart @@ -423,6 +423,15 @@ class AppLocalizationsZh extends AppLocalizations { @override String get close => '关闭'; + @override + String get windowMinimize => '最小化'; + + @override + String get windowMaximize => '最大化'; + + @override + String get windowRestore => '还原'; + @override String get exit => '退出'; @@ -680,6 +689,18 @@ class AppLocalizationsZh extends AppLocalizations { @override String get scheduleDisplaySettings => '日程显示'; + @override + String get newEventsAndTasks => '新建日程和任务'; + + @override + String get defaultCalendar => '默认日历'; + + @override + String get defaultTaskList => '默认任务列表'; + + @override + String get lastUsed => '最近使用'; + @override String get scheduleDisplayHoursDescription => '日视图和周视图最初显示此时间范围。需要时,更早或更晚的项目会扩展该范围。'; @@ -1022,6 +1043,9 @@ class AppLocalizationsZh extends AppLocalizations { @override String get shortcutGroupView => '视图'; + @override + String get viewSelector => '视图'; + @override String get shortcutDayView => '日视图'; @@ -2344,9 +2368,6 @@ class AppLocalizationsZh extends AppLocalizations { String get nextcloudAccountRemovedRevokeFailed => '账户已在本地移除,但无法撤销 Nextcloud App 密码。'; - @override - String get davCachedOfflineNotice => '日历和任务数据会缓存在本地,以供离线使用。'; - @override String get davReauthenticationRequired => '重新连接此账户以恢复同步。'; @@ -3301,6 +3322,15 @@ class AppLocalizationsZhHans extends AppLocalizationsZh { @override String get close => '关闭'; + @override + String get windowMinimize => '最小化'; + + @override + String get windowMaximize => '最大化'; + + @override + String get windowRestore => '还原'; + @override String get exit => '退出'; @@ -3558,6 +3588,18 @@ class AppLocalizationsZhHans extends AppLocalizationsZh { @override String get scheduleDisplaySettings => '日程显示'; + @override + String get newEventsAndTasks => '新建日程和任务'; + + @override + String get defaultCalendar => '默认日历'; + + @override + String get defaultTaskList => '默认任务列表'; + + @override + String get lastUsed => '最近使用'; + @override String get scheduleDisplayHoursDescription => '日视图和周视图最初显示此时间范围。需要时,更早或更晚的项目会扩展该范围。'; @@ -3900,6 +3942,9 @@ class AppLocalizationsZhHans extends AppLocalizationsZh { @override String get shortcutGroupView => '视图'; + @override + String get viewSelector => '视图'; + @override String get shortcutDayView => '日视图'; @@ -5222,9 +5267,6 @@ class AppLocalizationsZhHans extends AppLocalizationsZh { String get nextcloudAccountRemovedRevokeFailed => '账户已在本地移除,但无法撤销 Nextcloud App 密码。'; - @override - String get davCachedOfflineNotice => '日历和任务数据会缓存在本地,以供离线使用。'; - @override String get davReauthenticationRequired => '重新连接此账户以恢复同步。'; @@ -6179,6 +6221,15 @@ class AppLocalizationsZhHant extends AppLocalizationsZh { @override String get close => '關閉'; + @override + String get windowMinimize => '最小化'; + + @override + String get windowMaximize => '最大化'; + + @override + String get windowRestore => '還原'; + @override String get exit => '結束'; @@ -6436,6 +6487,18 @@ class AppLocalizationsZhHant extends AppLocalizationsZh { @override String get scheduleDisplaySettings => '行程顯示'; + @override + String get newEventsAndTasks => '新增行程和工作'; + + @override + String get defaultCalendar => '預設行事曆'; + + @override + String get defaultTaskList => '預設工作清單'; + + @override + String get lastUsed => '最近使用'; + @override String get scheduleDisplayHoursDescription => '日檢視和週檢視一開始會顯示此時間範圍。必要時,較早或較晚的項目會擴大此範圍。'; @@ -6778,6 +6841,9 @@ class AppLocalizationsZhHant extends AppLocalizationsZh { @override String get shortcutGroupView => '檢視'; + @override + String get viewSelector => '檢視'; + @override String get shortcutDayView => '日檢視'; @@ -8101,9 +8167,6 @@ class AppLocalizationsZhHant extends AppLocalizationsZh { String get nextcloudAccountRemovedRevokeFailed => '賬戶已在本地移除,但無法撤銷 Nextcloud App 密碼。'; - @override - String get davCachedOfflineNotice => '日曆和任務數據會緩存在本地,以供離線使用。'; - @override String get davReauthenticationRequired => '重新連接此賬戶以恢復同步。'; diff --git a/lib/main_linux.dart b/lib/main_linux.dart index 555a5a5c..89e81f91 100644 --- a/lib/main_linux.dart +++ b/lib/main_linux.dart @@ -4,6 +4,7 @@ import 'dart:io'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:system_theme/system_theme.dart'; +import 'package:yaru/yaru.dart'; import 'src/app/app_bootstrap.dart'; import 'src/app/app_theme.dart'; @@ -20,16 +21,20 @@ 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/gtk_header_icon_service.dart'; +import 'src/platform/gtk_animation_settings_service.dart'; +import 'src/platform/gtk_window_preferences_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'; +import 'src/platform/native_style.dart'; Future main(List arguments) async { final binding = WidgetsFlutterBinding.ensureInitialized(); binding.deferFirstFrame(); + await YaruWindowTitleBar.ensureInitialized(); final buildConfig = BuildConfig.fromEnvironment(); final LocalSettingsStore settingsStore; if (buildConfig.useFakeProviderData) { @@ -48,7 +53,13 @@ Future main(List arguments) async { await activationService.initialize(); final systemAccentFuture = SystemTheme.accentColor.load(); + final gtkHeaderIconService = GtkHeaderIconService(); + final initialGtkHeaderIconsFuture = gtkHeaderIconService.initialize(); final initialGtkFontFuture = const GtkFontService().getGtkFont(); + final initialGtkAnimationsFuture = const GtkAnimationSettingsService() + .getAnimationsEnabled(); + final initialGtkWindowPreferencesFuture = const GtkWindowPreferencesService() + .load(); final initialAppSettingsFuture = loadInitialAppSettings(settingsStore); final initialAppSettings = await initialAppSettingsFuture; final gtkThemeService = const GtkThemeService(); @@ -63,12 +74,18 @@ Future main(List arguments) async { systemAccentFuture, initialGtkFontFuture, gtkThemeService.getGtkThemeColors(), + initialGtkAnimationsFuture, + initialGtkWindowPreferencesFuture, + initialGtkHeaderIconsFuture, ]); configureLogging(); final initialGtkFont = desktopSettings[1] as GtkFontSettings?; final initialGtkThemeColors = desktopSettings[2] as GtkThemeColors?; - await _applyInitialNativeHeaderBarTheme( + final initialGtkAnimationsEnabled = desktopSettings[3] as bool?; + final initialGtkWindowPreferences = + desktopSettings[4] as GtkWindowPreferences?; + await _applyInitialNativeSurfaceTheme( settings: initialAppSettings, gtkFont: initialGtkFont, gtkThemeColors: initialGtkThemeColors, @@ -80,6 +97,16 @@ Future main(List arguments) async { initialAppSettingsProvider.overrideWithValue(initialAppSettings), initialGtkFontSettingsProvider.overrideWithValue(initialGtkFont), initialGtkThemeColorsProvider.overrideWithValue(initialGtkThemeColors), + initialGtkAnimationsEnabledProvider.overrideWithValue( + initialGtkAnimationsEnabled, + ), + initialGtkWindowPreferencesProvider.overrideWithValue( + initialGtkWindowPreferences, + ), + gtkHeaderIconServiceProvider.overrideWith((ref) { + ref.onDispose(gtkHeaderIconService.dispose); + return gtkHeaderIconService; + }), networkConnectivityMonitorProvider.overrideWith((ref) { final monitor = createLinuxNetworkConnectivityMonitor(); ref.onDispose(() => unawaited(monitor.dispose())); @@ -132,7 +159,7 @@ Future main(List arguments) async { binding.allowFirstFrame(); } -Future _applyInitialNativeHeaderBarTheme({ +Future _applyInitialNativeSurfaceTheme({ required AppSettings settings, required GtkFontSettings? gtkFont, required GtkThemeColors? gtkThemeColors, @@ -153,13 +180,7 @@ Future _applyInitialNativeHeaderBarTheme({ gtkThemeColors: gtkThemeColors, highContrast: highContrast, ); - final headerBarService = LinuxHeaderBarService(); - try { - await headerBarService.initialize(); - await headerBarService.setTheme( - busyMaxHeaderBarThemeFor(theme, highContrast: highContrast), - ); - } finally { - headerBarService.dispose(); - } + await const NativeSurfaceStyleService().setTheme( + busyMaxNativeSurfaceThemeFor(theme, highContrast: highContrast), + ); } diff --git a/lib/src/android/android_app.dart b/lib/src/android/android_app.dart index a858a28e..d55be8cc 100644 --- a/lib/src/android/android_app.dart +++ b/lib/src/android/android_app.dart @@ -8,6 +8,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../l10n/generated/app_localizations.dart'; import '../app/app_bootstrap.dart'; +import '../app/common/busymax_motion_widgets.dart'; import '../l10n/app_locale.dart'; import '../l10n/locale_resolution.dart'; import '../l10n/l10n.dart'; @@ -284,7 +285,10 @@ class _AndroidHomeShellState extends ConsumerState return LayoutBuilder( builder: (context, constraints) { final useRail = constraints.maxWidth >= 840; - final content = IndexedStack(index: selected, children: pages); + final content = BusyMaxRetainedCrossfade( + index: selected, + children: pages, + ); if (useRail) { return Scaffold( body: SafeArea( diff --git a/lib/src/android/android_notifications.dart b/lib/src/android/android_notifications.dart index 1ea0122c..c6c41c8c 100644 --- a/lib/src/android/android_notifications.dart +++ b/lib/src/android/android_notifications.dart @@ -700,6 +700,7 @@ final class AndroidNotificationService (table) => table.accountId.isIn(eligibleAccounts) & table.pendingDelete.equals(false) & + table.serverMissing.equals(false) & (table.deleted.isNull() | table.deleted.equals(false)) & (table.hidden.isNull() | table.hidden.equals(false)), )) diff --git a/lib/src/android/presentation/android_diagnostics_screen.dart b/lib/src/android/presentation/android_diagnostics_screen.dart index 71b17b5f..ffd17365 100644 --- a/lib/src/android/presentation/android_diagnostics_screen.dart +++ b/lib/src/android/presentation/android_diagnostics_screen.dart @@ -43,7 +43,6 @@ class AndroidDiagnosticsScreen extends ConsumerWidget { ListTile( leading: const Icon(Icons.sync), title: Text(context.l10n.sync), - subtitle: Text(context.l10n.davCachedOfflineNotice), onTap: () => unawaited(enqueueImmediateBusyMaxSync()), ), ], diff --git a/lib/src/android/presentation/android_schedule_screen.dart b/lib/src/android/presentation/android_schedule_screen.dart index 8cba5a2a..b33c9dc2 100644 --- a/lib/src/android/presentation/android_schedule_screen.dart +++ b/lib/src/android/presentation/android_schedule_screen.dart @@ -8,6 +8,8 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:intl/intl.dart'; import '../../app/app_bootstrap.dart'; +import '../../app/common/busymax_motion_widgets.dart'; +import '../../app/common/busymax_mutation_list.dart'; import '../../calendar_providers/calendar_mutation.dart'; import '../../core/logging/redacting_logger.dart'; import '../../features/accounts/data/accounts_repository.dart'; @@ -28,6 +30,7 @@ import '../../schedule/schedule_projection.dart'; import '../../schedule/schedule_range.dart'; import '../../schedule/schedule_view_mode.dart'; import '../../schedule/schedule_source_visibility.dart'; +import '../../schedule/task_list_mutation_intent.dart'; import '../../schedule/schedule_search_criteria.dart'; import 'android_schedule_search_filters.dart'; import '../../features/schedule/presentation/schedule_search_result_text.dart'; @@ -36,6 +39,7 @@ import 'android_availability_dialog.dart'; import 'android_date_picker.dart'; import 'android_settings_screen.dart'; import 'android_tasks_screen.dart'; +import 'android_task_motion.dart'; final _androidScheduleItemsProvider = FutureProvider.autoDispose .family< @@ -98,9 +102,15 @@ class _AndroidScheduleScreenState extends ConsumerState { bool _searching = false; String _query = ''; final _searchController = TextEditingController(); + final _searchFocusNode = FocusNode(); ScheduleSearchCriteria? _searchCriteria; ScheduleSearchCriteria? _initialSearchCriteria; var _searchFirstWeekday = DateTime.monday; + var _searchRequestGeneration = 0; + var _navigationGeneration = 0; + var _navigationDirection = 0; + var _taskMutationGeneration = 0; + TaskListMutationIntent? _taskMutationIntent; @override void didChangeDependencies() { @@ -116,6 +126,7 @@ class _AndroidScheduleScreenState extends ConsumerState { @override void dispose() { + _searchFocusNode.dispose(); _searchController.dispose(); super.dispose(); } @@ -133,42 +144,53 @@ class _AndroidScheduleScreenState extends ConsumerState { firstWeekday: _firstWeekday(context), )), ); + final searchCriteria = _searchCriteria; + final presentation = _searching && searchCriteria != null + ? _buildSearchPresentation( + items: items, + criteria: searchCriteria, + query: _query, + ) + : _buildCalendarPresentation(items: items, mode: mode); return Scaffold( appBar: AppBar( - title: _searching - ? TextField( - controller: _searchController, - autofocus: true, - decoration: InputDecoration( - hintText: context.l10n.windowsSearch, - border: InputBorder.none, - suffixIcon: IconButton( - tooltip: MaterialLocalizations.of( - context, - ).clearButtonTooltip, - icon: const Icon(Icons.clear), - onPressed: () { - _searchController.clear(); - setState(() => _query = ''); - }, + title: BusyMaxBinaryPresentation( + alternateActive: _searching, + child: _searching + ? TextField( + controller: _searchController, + focusNode: _searchFocusNode, + decoration: InputDecoration( + hintText: context.l10n.windowsSearch, + border: InputBorder.none, + suffixIcon: IconButton( + tooltip: MaterialLocalizations.of( + context, + ).clearButtonTooltip, + icon: const Icon(Icons.clear), + onPressed: () { + _searchController.clear(); + setState(() => _query = ''); + }, + ), ), - ), - onChanged: (value) => setState(() => _query = value), - ) - : InkWell( - borderRadius: BorderRadius.circular(12), - onTap: _selectDate, - child: ConstrainedBox( - constraints: const BoxConstraints(minHeight: 48), - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 4), - child: Align( - alignment: AlignmentDirectional.centerStart, - child: Text(_periodLabel(context, _anchor, mode)), + onChanged: (value) => setState(() => _query = value), + ) + : InkWell( + borderRadius: BorderRadius.circular(12), + onTap: _selectDate, + child: ConstrainedBox( + constraints: const BoxConstraints(minHeight: 48), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 4), + child: Align( + alignment: AlignmentDirectional.centerStart, + child: Text(_periodLabel(context, _anchor, mode)), + ), ), ), ), - ), + ), actions: [ IconButton( tooltip: context.l10n.windowsSearch, @@ -199,86 +221,20 @@ class _AndroidScheduleScreenState extends ConsumerState { ], ), ], - bottom: _searching - ? null - : PreferredSize( - preferredSize: const Size.fromHeight(48), - child: Padding( - padding: const EdgeInsets.fromLTRB(8, 0, 8, 8), - child: Row( - children: [ - IconButton( - tooltip: context.l10n.shortcutPreviousPeriod, - onPressed: () => - setState(() => _anchor = _move(_anchor, mode, -1)), - icon: const Icon(Icons.chevron_left), - ), - FilledButton.tonal( - onPressed: () => - setState(() => _anchor = DateTime.now()), - child: Text(context.l10n.today), - ), - IconButton( - tooltip: context.l10n.shortcutNextPeriod, - onPressed: () => - setState(() => _anchor = _move(_anchor, mode, 1)), - icon: const Icon(Icons.chevron_right), - ), - const Spacer(), - Flexible( - child: Text( - _modeLabel(context, mode), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ), - ], - ), - ), - ), ), - body: items.when( - loading: () => const Center(child: CircularProgressIndicator()), - error: (error, _) => _Message( - icon: Icons.error_outline, - title: context.l10n.scheduleUnavailable, - detail: '$error', - ), - data: (value) => - value.isEmpty && (_searching || mode == ScheduleViewMode.agenda) - ? _Message( - icon: !_searching ? Icons.event_busy : Icons.search_off, - title: !_searching - ? context.l10n.noEventsOrTasks - : _searchCriteria?.hasSources == false - ? context.l10n.searchNoSources - : context.l10n.scheduleNoSearchResults, - detail: !_searching - ? context.l10n.noEventsOrTasks - : context.l10n.scheduleNoSearchResultsDescription, - ) - : _searching - ? _AgendaList( - key: const ValueKey('android-search-results'), - items: value, - onOpen: (item) => _showItem(context, item), - onToggleTask: _toggleTask, - searchCriteria: _searchCriteria, - searchQuery: _query, - ) - : _ScheduleBody( - anchor: _anchor, - mode: mode, - items: value, - onSelectDate: (date) => setState(() => _anchor = date), - onModeChanged: (value) => unawaited( - ref - .read(appSettingsControllerProvider.notifier) - .setAndroidScheduleViewMode(value), - ), - onOpen: (item) => _showItem(context, item), - onToggleTask: _toggleTask, - ), + body: Column( + children: [ + BusyMaxVerticalReveal( + visible: !_searching, + child: _navigationRow(context, mode), + ), + Expanded( + child: BusyMaxBinaryPresentation( + alternateActive: _searching, + child: presentation, + ), + ), + ], ), floatingActionButton: FloatingActionButton( heroTag: 'android-schedule-add', @@ -289,10 +245,99 @@ class _AndroidScheduleScreenState extends ConsumerState { ); } + Widget _buildCalendarPresentation({ + required AsyncValue> items, + required ScheduleViewMode mode, + }) { + // A provider-family key change may be loading for longer than the period + // motion. Keep the destination calendar mounted with an empty, coherent + // projection instead of replacing it with the progress indicator. + final displayedItems = items.isLoading || items.hasError + ? const [] + : items.valueOrNull ?? const []; + return Stack( + fit: StackFit.expand, + children: [ + BusyMaxDirectionalEntrance( + generation: _navigationGeneration, + direction: _navigationDirection, + child: _ScheduleBody( + anchor: _anchor, + mode: mode, + items: displayedItems, + onSelectDate: (date) => setState(() => _anchor = date), + onModeChanged: (value) => unawaited( + ref + .read(appSettingsControllerProvider.notifier) + .setAndroidScheduleViewMode(value), + ), + onOpen: (item) => _showItem(context, item), + onToggleTask: _toggleTask, + taskMutationIntent: _taskMutationIntent, + onTaskMutationConsumed: _consumeTaskMutation, + agendaEmptyBuilder: items.isLoading + ? (context) => const SizedBox.shrink() + : null, + ), + ), + if (items.isLoading) + const IgnorePointer( + child: Center(child: CircularProgressIndicator()), + ), + if (items.hasError) + Positioned.fill( + child: ColoredBox( + color: Theme.of(context).scaffoldBackgroundColor, + child: _Message( + icon: Icons.error_outline, + title: context.l10n.scheduleUnavailable, + detail: '${items.error}', + ), + ), + ), + ], + ); + } + + Widget _buildSearchPresentation({ + required AsyncValue> items, + required ScheduleSearchCriteria criteria, + required String query, + }) { + if (items.isLoading && items.valueOrNull == null) { + return const Center(child: CircularProgressIndicator()); + } + if (items.hasError) { + return _Message( + icon: Icons.error_outline, + title: context.l10n.scheduleUnavailable, + detail: '${items.error}', + ); + } + return _AgendaList( + key: const ValueKey('android-search-results'), + items: items.valueOrNull ?? const [], + onOpen: (item) => _showItem(context, item), + onToggleTask: _toggleTask, + searchCriteria: criteria, + searchQuery: query, + taskMutationIntent: _taskMutationIntent, + onTaskMutationConsumed: _consumeTaskMutation, + emptyBuilder: (context) => _Message( + icon: Icons.search_off, + title: !criteria.hasSources + ? context.l10n.searchNoSources + : context.l10n.scheduleNoSearchResults, + detail: context.l10n.scheduleNoSearchResultsDescription, + ), + ); + } + Future _openSearch() async { + final generation = ++_searchRequestGeneration; final sources = await ref.read(calendarSourcesStreamProvider.future); final lists = await ref.read(scheduleTaskListsProvider.future); - if (!mounted) return; + if (!mounted || generation != _searchRequestGeneration) return; final visibility = ScheduleSourceVisibility.fromSources( calendarSources: sources, taskLists: lists, @@ -309,9 +354,17 @@ class _AndroidScheduleScreenState extends ConsumerState { _searchCriteria = _initialSearchCriteria; _searching = true; }); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted || !_searching || generation != _searchRequestGeneration) { + return; + } + _searchFocusNode.requestFocus(); + }); } void _closeSearch() { + _searchRequestGeneration += 1; + _searchFocusNode.unfocus(); setState(() { _searching = false; _searchCriteria = null; @@ -321,6 +374,49 @@ class _AndroidScheduleScreenState extends ConsumerState { }); } + Widget _navigationRow(BuildContext context, ScheduleViewMode mode) { + return Padding( + padding: const EdgeInsets.fromLTRB(8, 0, 8, 8), + child: Row( + children: [ + IconButton( + tooltip: context.l10n.shortcutPreviousPeriod, + onPressed: () => _movePeriod(mode, -1), + icon: const Icon(Icons.chevron_left), + ), + FilledButton.tonal( + onPressed: () => setState(() { + _navigationDirection = 0; + _anchor = DateTime.now(); + }), + child: Text(context.l10n.today), + ), + IconButton( + tooltip: context.l10n.shortcutNextPeriod, + onPressed: () => _movePeriod(mode, 1), + icon: const Icon(Icons.chevron_right), + ), + const Spacer(), + Flexible( + child: Text( + _modeLabel(context, mode), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ); + } + + void _movePeriod(ScheduleViewMode mode, int direction) { + setState(() { + _anchor = _move(_anchor, mode, direction); + _navigationDirection = direction; + _navigationGeneration += 1; + }); + } + Future _showSearchFilters() async { if (_searchCriteria == null) return; await showModalBottomSheet( @@ -500,26 +596,39 @@ class _AndroidScheduleScreenState extends ConsumerState { } final accounts = ref.read(accountsStreamProvider).valueOrNull ?? const []; - final list = await selectAndroidTaskList( - context, - lists, - title: context.l10n.newTask, - accountLabels: { - for (final account in accounts) account.id: account.displayLabel, - }, - ); + final settings = ref.read(appSettingsControllerProvider); + final list = + preferredCreationDestination( + lists, + selected: settings.defaultTaskList, + lastUsed: settings.lastUsedTaskList, + destinationOf: (list) => + CreationDestination(accountId: list.accountId, id: list.id), + ) ?? + await selectAndroidTaskList( + context, + lists, + title: context.l10n.newTask, + accountLabels: { + for (final account in accounts) account.id: account.displayLabel, + }, + ); if (list == null || !context.mounted) return; final account = accounts .where((value) => value.id == list.accountId) .firstOrNull; if (account == null) return; - await showAndroidTaskEditor( + final result = await showAndroidTaskEditor( context, ref, creationList: list, creationProvider: account.provider, initialDue: DateTime(_anchor.year, _anchor.month, _anchor.day), + onMutationStarted: _consumeEditorResult, + onMutationCommitted: _consumeEditorResult, + onMutationFailed: _clearEditorMutation, ); + if (result == null) return; } Future _showItem(BuildContext context, ScheduleItem item) async { @@ -628,7 +737,15 @@ class _AndroidScheduleScreenState extends ConsumerState { if (item is CalendarScheduleItem) { await _editEvent(context, item); } else if (item is TaskScheduleItem) { - await showAndroidTaskEditor(context, ref, task: item); + final result = await showAndroidTaskEditor( + context, + ref, + task: item, + onMutationStarted: _consumeEditorResult, + onMutationCommitted: _consumeEditorResult, + onMutationFailed: _clearEditorMutation, + ); + if (result == null) return; } }, icon: const Icon(Icons.edit), @@ -697,30 +814,39 @@ class _AndroidScheduleScreenState extends ConsumerState { ).showSnackBar(SnackBar(content: Text(context.l10n.noWritableCalendars))); return; } - final source = sources.length == 1 - ? sources.single - : await showModalBottomSheet( - context: context, - showDragHandle: true, - builder: (sheetContext) => SafeArea( - child: ListView( - shrinkWrap: true, - children: [ - ListTile(title: Text(context.l10n.newEvent)), - for (final candidate in sources) - ListTile( - leading: const Icon(Icons.calendar_today_outlined), - title: Text(candidate.summary), - subtitle: Text( - '${candidate.provider.displayName} · ' - '${candidate.authenticatedAccountEmail ?? candidate.accountId}', - ), - onTap: () => Navigator.pop(sheetContext, candidate), - ), - ], - ), - ), - ); + final settings = ref.read(appSettingsControllerProvider); + final source = + preferredCreationDestination( + sources, + selected: settings.defaultCalendar, + lastUsed: settings.lastUsedCalendar, + destinationOf: (source) => + CreationDestination(accountId: source.accountId, id: source.id), + ) ?? + (sources.length == 1 + ? sources.single + : await showModalBottomSheet( + context: context, + showDragHandle: true, + builder: (sheetContext) => SafeArea( + child: ListView( + shrinkWrap: true, + children: [ + ListTile(title: Text(context.l10n.newEvent)), + for (final candidate in sources) + ListTile( + leading: const Icon(Icons.calendar_today_outlined), + title: Text(candidate.summary), + subtitle: Text( + '${candidate.provider.displayName} · ' + '${candidate.authenticatedAccountEmail ?? candidate.accountId}', + ), + onTap: () => Navigator.pop(sheetContext, candidate), + ), + ], + ), + ), + )); if (source == null || !context.mounted) return; final start = DateTime(_anchor.year, _anchor.month, _anchor.day, 9); await Navigator.of(context).push( @@ -798,6 +924,15 @@ class _AndroidScheduleScreenState extends ConsumerState { Future _toggleTask(TaskScheduleItem task) async { if (!task.capabilities.canEdit) return; + final intent = TaskListMutationIntent( + presentation: TaskListMutationPresentation.completion, + accountId: task.accountId, + taskListId: task.sourceId, + taskId: task.id, + completed: !task.completed, + generation: ++_taskMutationGeneration, + ); + setState(() => _taskMutationIntent = intent); try { await ref .read(tasksRepositoryForAccountProvider(task.accountId)) @@ -810,12 +945,56 @@ class _AndroidScheduleScreenState extends ConsumerState { ); } on Object catch (error) { if (mounted) { + if (_taskMutationIntent?.generation == intent.generation) { + setState(() => _taskMutationIntent = null); + } ScaffoldMessenger.of( context, ).showSnackBar(SnackBar(content: Text('$error'))); } } } + + void _consumeTaskMutation(TaskListMutationIntent intent) { + if (!mounted) return; + if (_taskMutationIntent?.generation == intent.generation) { + setState(() => _taskMutationIntent = null); + } + } + + void _consumeEditorResult(AndroidTaskEditorResult result) { + if (!mounted) return; + final presentation = switch (result.action) { + AndroidTaskEditorAction.created => TaskListMutationPresentation.insertion, + AndroidTaskEditorAction.deleted || + AndroidTaskEditorAction.moved => TaskListMutationPresentation.removal, + AndroidTaskEditorAction.updated => + TaskListMutationPresentation.completion, + }; + final taskListId = result.previousTaskListId ?? result.taskListId; + final current = _taskMutationIntent; + if (current?.presentation == presentation && + current?.accountId == result.accountId && + current?.taskListId == taskListId && + current?.taskId == result.taskId && + current?.completed == result.completed) { + return; + } + setState(() { + _taskMutationIntent = TaskListMutationIntent( + presentation: presentation, + accountId: result.accountId, + taskListId: taskListId, + taskId: result.taskId, + completed: result.completed, + generation: ++_taskMutationGeneration, + ); + }); + } + + void _clearEditorMutation() { + if (mounted) setState(() => _taskMutationIntent = null); + } } Future showAndroidEventEditor( @@ -852,6 +1031,9 @@ class _ScheduleBody extends StatelessWidget { required this.onModeChanged, required this.onOpen, required this.onToggleTask, + this.taskMutationIntent, + this.onTaskMutationConsumed, + this.agendaEmptyBuilder, }); final DateTime anchor; final ScheduleViewMode mode; @@ -860,36 +1042,45 @@ class _ScheduleBody extends StatelessWidget { final ValueChanged onModeChanged; final ValueChanged onOpen; final ValueChanged onToggleTask; + final TaskListMutationIntent? taskMutationIntent; + final ValueChanged? onTaskMutationConsumed; + final WidgetBuilder? agendaEmptyBuilder; @override - Widget build(BuildContext context) => switch (mode) { - ScheduleViewMode.month => _MonthView( - anchor: anchor, - items: items, - onSelectDate: onSelectDate, - onOpen: onOpen, - onToggleTask: onToggleTask, - ), - ScheduleViewMode.year => _YearView( - anchor: anchor, - items: items, - onSelectDate: onSelectDate, - onModeChanged: onModeChanged, - ), - ScheduleViewMode.week || ScheduleViewMode.day => _AndroidTimeGrid( - key: ValueKey('android-${mode.name}-time-grid'), - anchor: anchor, - days: mode == ScheduleViewMode.day ? 1 : 7, - firstWeekday: _firstWeekday(context), - items: items, - onOpen: onOpen, - ), - ScheduleViewMode.agenda => _AgendaList( - items: items, - onOpen: onOpen, - onToggleTask: onToggleTask, - ), - }; + Widget build(BuildContext context) => BusyMaxKeyedCrossfade( + transitionKey: mode, + child: switch (mode) { + ScheduleViewMode.month => _MonthView( + anchor: anchor, + items: items, + onSelectDate: onSelectDate, + onOpen: onOpen, + onToggleTask: onToggleTask, + ), + ScheduleViewMode.year => _YearView( + anchor: anchor, + items: items, + onSelectDate: onSelectDate, + onModeChanged: onModeChanged, + ), + ScheduleViewMode.week || ScheduleViewMode.day => _AndroidTimeGrid( + key: ValueKey('android-${mode.name}-time-grid'), + anchor: anchor, + days: mode == ScheduleViewMode.day ? 1 : 7, + firstWeekday: _firstWeekday(context), + items: items, + onOpen: onOpen, + ), + ScheduleViewMode.agenda => _AgendaList( + items: items, + onOpen: onOpen, + onToggleTask: onToggleTask, + taskMutationIntent: taskMutationIntent, + onTaskMutationConsumed: onTaskMutationConsumed, + emptyBuilder: agendaEmptyBuilder, + ), + }, + ); } class _AgendaList extends StatelessWidget { @@ -900,27 +1091,44 @@ class _AgendaList extends StatelessWidget { required this.items, required this.onOpen, required this.onToggleTask, + this.taskMutationIntent, + this.onTaskMutationConsumed, + this.emptyBuilder, }); final ScheduleSearchCriteria? searchCriteria; final String searchQuery; final List items; final ValueChanged onOpen; final ValueChanged onToggleTask; + final TaskListMutationIntent? taskMutationIntent; + final ValueChanged? onTaskMutationConsumed; + final WidgetBuilder? emptyBuilder; @override Widget build(BuildContext context) { - if (items.isEmpty) { - return Center(child: Text(context.l10n.noEventsOrTasks)); - } final agendaItems = searchCriteria == null ? items : (List.of(items) ..sort(compareScheduleSearchResultPresentation)); - return ListView.separated( + return BusyMaxMutationList( + items: agendaItems, + mutation: taskMutationIntent, + identityOf: (item) => item is TaskScheduleItem + ? '${item.accountId}\u0000${item.sourceId}\u0000${item.id}' + : 'event\u0000${item.accountId}\u0000${item.sourceId}\u0000${item.id}', + mutationApplied: (item, mutation) => + item is! TaskScheduleItem || + mutation.completed == null || + item.completed == mutation.completed, + onMutationConsumed: onTaskMutationConsumed, + emptyBuilder: + emptyBuilder ?? + (context) => Center(child: Text(context.l10n.noEventsOrTasks)), padding: const EdgeInsets.fromLTRB(12, 8, 12, 96), - itemCount: agendaItems.length, separatorBuilder: (_, _) => const SizedBox(height: 6), - itemBuilder: (context, index) { - final item = agendaItems[index]; + itemBuilder: (context, item, index) { + final displayedCompleted = item is TaskScheduleItem + ? _displayedCompletion(item) + : false; final displayDate = searchCriteria == null ? item.start : scheduleSearchResultDisplayDate(item); @@ -963,24 +1171,29 @@ class _AgendaList extends StatelessWidget { minVerticalPadding: 12, leading: item is TaskScheduleItem ? IconButton( - tooltip: item.completed + tooltip: displayedCompleted ? context.l10n.taskStatusCompleted : context.l10n.taskStatusInProcess, onPressed: item.capabilities.canEdit ? () => onToggleTask(item) : null, - icon: Icon( - item.completed - ? Icons.check_circle - : Icons.radio_button_unchecked, + icon: AndroidTaskCompletionIcon( + completed: displayedCompleted, + animate: _animatesTaskCompletion(item), ), ) : const Icon(Icons.event), - title: Text( - item.title, - maxLines: 2, - overflow: TextOverflow.ellipsis, - ), + title: item is TaskScheduleItem + ? AndroidTaskTitle( + title: item.title, + completed: displayedCompleted, + animate: _animatesTaskCompletion(item), + ) + : Text( + item.title, + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), subtitle: Text( searchCriteria != null ? scheduleSearchResultText( @@ -1001,6 +1214,25 @@ class _AgendaList extends StatelessWidget { }, ); } + + bool _animatesTaskCompletion(TaskScheduleItem task) { + final intent = taskMutationIntent; + return intent?.presentation == TaskListMutationPresentation.completion && + intent?.taskKey == + '${task.accountId}\u0000${task.sourceId}\u0000${task.id}' && + intent?.completed != null; + } + + bool _displayedCompletion(TaskScheduleItem task) { + final intent = taskMutationIntent; + if (intent?.presentation == TaskListMutationPresentation.completion && + intent?.taskKey == + '${task.accountId}\u0000${task.sourceId}\u0000${task.id}' && + intent?.completed != null) { + return intent!.completed!; + } + return task.completed; + } } class _AndroidTimeGrid extends StatelessWidget { @@ -2147,6 +2379,16 @@ class _AndroidEventEditorState extends ConsumerState { } if (draft.eventId == null) { await ref.read(calendarRepositoryProvider).createLocalEvent(draft); + unawaited( + ref + .read(appSettingsControllerProvider.notifier) + .rememberCalendar( + CreationDestination( + accountId: draft.accountId, + id: draft.sourceId, + ), + ), + ); } else { await ref.read(calendarRepositoryProvider).updateLocalEvent(draft); } diff --git a/lib/src/android/presentation/android_settings_screen.dart b/lib/src/android/presentation/android_settings_screen.dart index 6da02e64..f836f1c6 100644 --- a/lib/src/android/presentation/android_settings_screen.dart +++ b/lib/src/android/presentation/android_settings_screen.dart @@ -19,6 +19,7 @@ import '../../dav/nextcloud/nextcloud_trash_service.dart'; import '../../features/accounts/data/accounts_repository.dart'; import '../../features/accounts/domain/account_collection_creation_capabilities.dart'; import '../../features/calendar/data/calendar_repository.dart'; +import '../../features/sync/sync_auth_error.dart'; import '../../features/task_lists/data/task_lists_repository.dart'; import '../../features/tasks/domain/task_capabilities.dart'; import '../../ical/ical_import_service.dart'; @@ -94,10 +95,11 @@ class _AndroidSettingsScreenState extends ConsumerState { } }, itemBuilder: (context) => [ - PopupMenuItem( - value: 'sync', - child: Text(context.l10n.sync), - ), + if (account.isSyncEligible) + PopupMenuItem( + value: 'sync', + child: Text(context.l10n.sync), + ), if (account.needsReconnect) PopupMenuItem( value: 'reconnect', @@ -517,7 +519,6 @@ class _AndroidSettingsScreenState extends ConsumerState { ) : const Icon(Icons.sync), title: Text(context.l10n.sync), - subtitle: Text(context.l10n.davCachedOfflineNotice), enabled: !_syncing, onTap: _syncing ? null : () => unawaited(_syncAll()), ), @@ -787,7 +788,14 @@ class _AndroidSettingsScreenState extends ConsumerState { await ref.read(notificationReconcilerProvider).reconcile(); _message(l10n.syncComplete); } on Object catch (error) { - _message(l10n.syncFailed('$error')); + _message( + l10n.syncFailed( + syncFailureMessage( + error, + networkUnavailableMessage: l10n.networkOfflineTryAgain, + ), + ), + ); } finally { if (mounted) setState(() => _syncing = false); } @@ -796,11 +804,27 @@ class _AndroidSettingsScreenState extends ConsumerState { Future _syncAccount(String accountId) async { final l10n = context.l10n; try { + final account = await ref + .read(accountsRepositoryProvider) + .accountById(accountId); + if (account?.isSyncEligible != true) { + if (account?.needsReconnect == true) { + _message(l10n.syncFailed(accountReconnectRequiredSyncMessage)); + } + return; + } await ref.read(signedInSyncRunnerProvider)(accountId, true); await ref.read(notificationReconcilerProvider).reconcile(); _message(l10n.syncComplete); } on Object catch (error) { - _message(l10n.syncFailed('$error')); + _message( + l10n.syncFailed( + syncFailureMessage( + error, + networkUnavailableMessage: l10n.networkOfflineTryAgain, + ), + ), + ); } } @@ -933,7 +957,14 @@ class _AndroidSettingsScreenState extends ConsumerState { _requestQueuedCalendarSync(source); } } on Object catch (error) { - _message(l10n.calendarUpdateFailed('$error')); + _message( + l10n.calendarUpdateFailed( + syncFailureMessage( + error, + networkUnavailableMessage: l10n.networkOfflineTryAgain, + ), + ), + ); } } @@ -1059,7 +1090,14 @@ class _AndroidSettingsScreenState extends ConsumerState { .deleteTaskList(list.id); } } on Object catch (error) { - _message(l10n.taskListRenameFailed('$error')); + _message( + l10n.taskListRenameFailed( + syncFailureMessage( + error, + networkUnavailableMessage: l10n.networkOfflineTryAgain, + ), + ), + ); } } @@ -1125,10 +1163,14 @@ class _AndroidSettingsScreenState extends ConsumerState { .createTaskList(title); } } on Object catch (error) { + final message = syncFailureMessage( + error, + networkUnavailableMessage: l10n.networkOfflineTryAgain, + ); _message( calendar - ? l10n.calendarCreateFailed('$error') - : l10n.taskListCreateFailed('$error'), + ? l10n.calendarCreateFailed(message) + : l10n.taskListCreateFailed(message), ); } } @@ -1303,9 +1345,7 @@ class _AndroidSettingsScreenState extends ConsumerState { await ref .read(davConflictResolutionServiceProvider) .resolve(conflict.id, resolution); - await ref - .read(accountSyncOperationsProvider) - .syncAccount(conflict.accountId, full: false); + await ref.read(signedInSyncRunnerProvider)(conflict.accountId, false); } on Object { _message(l10n.conflictResolutionFailed); } @@ -1373,13 +1413,10 @@ class _AndroidSettingsScreenState extends ConsumerState { Future _showAbout() async { final info = await PackageInfo.fromPlatform(); if (!mounted) return; - final build = info.buildNumber.trim(); showAboutDialog( context: context, applicationName: info.appName.isEmpty ? 'BusyMax' : info.appName, - applicationVersion: build.isEmpty - ? info.version - : '${info.version}+$build', + applicationVersion: info.version, applicationLegalese: '© BusyStack', ); } diff --git a/lib/src/android/presentation/android_task_motion.dart b/lib/src/android/presentation/android_task_motion.dart new file mode 100644 index 00000000..7b344245 --- /dev/null +++ b/lib/src/android/presentation/android_task_motion.dart @@ -0,0 +1,121 @@ +import 'package:flutter/material.dart'; + +import '../../app/common/busymax_design_values.dart'; + +class AndroidTaskCompletionIcon extends StatefulWidget { + const AndroidTaskCompletionIcon({ + super.key, + required this.completed, + this.size, + this.animate = false, + }); + + final bool completed; + final double? size; + final bool animate; + + @override + State createState() => + _AndroidTaskCompletionIconState(); +} + +class _AndroidTaskCompletionIconState extends State + with SingleTickerProviderStateMixin { + late final AnimationController _controller = AnimationController( + vsync: this, + animationBehavior: AnimationBehavior.preserve, + value: widget.completed ? 1 : 0, + ); + bool _disableAnimations = false; + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + final disabled = MediaQuery.disableAnimationsOf(context); + if (disabled != _disableAnimations) { + _disableAnimations = disabled; + if (disabled) _controller.value = widget.completed ? 1 : 0; + } + } + + @override + void didUpdateWidget(covariant AndroidTaskCompletionIcon oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.completed == widget.completed) return; + final target = widget.completed ? 1.0 : 0.0; + if (_disableAnimations || !widget.animate) { + _controller.value = target; + return; + } + final distance = (target - _controller.value).abs(); + if (distance == 0) return; + _controller.animateTo( + target, + duration: BusyMaxMotion.taskCompletion * distance, + curve: BusyMaxMotion.presentationCurve, + ); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) => SizedBox.square( + dimension: widget.size ?? IconTheme.of(context).size ?? 24, + child: AnimatedBuilder( + animation: _controller, + builder: (context, _) { + final progress = _controller.value; + final scheme = Theme.of(context).colorScheme; + return Transform.scale( + scale: .92 + .08 * (progress <= .5 ? 1 - progress : progress), + child: Icon( + progress >= .5 ? Icons.check_circle : Icons.radio_button_unchecked, + size: widget.size, + color: Color.lerp( + scheme.onSurfaceVariant, + scheme.primary, + progress, + ), + ), + ); + }, + ), + ); +} + +class AndroidTaskTitle extends StatelessWidget { + const AndroidTaskTitle({ + super.key, + required this.title, + required this.completed, + this.maxLines = 2, + this.animate = false, + }); + + final String title; + final bool completed; + final int maxLines; + final bool animate; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return AnimatedDefaultTextStyle( + duration: MediaQuery.disableAnimationsOf(context) || !animate + ? Duration.zero + : BusyMaxMotion.taskCompletion, + curve: BusyMaxMotion.presentationCurve, + style: (theme.textTheme.bodyLarge ?? const TextStyle()).copyWith( + decoration: completed ? TextDecoration.lineThrough : null, + color: completed + ? theme.colorScheme.onSurfaceVariant + : theme.colorScheme.onSurface, + ), + child: Text(title, maxLines: maxLines, overflow: TextOverflow.ellipsis), + ); + } +} diff --git a/lib/src/android/presentation/android_tasks_screen.dart b/lib/src/android/presentation/android_tasks_screen.dart index 95813d7d..7ee9a7a3 100644 --- a/lib/src/android/presentation/android_tasks_screen.dart +++ b/lib/src/android/presentation/android_tasks_screen.dart @@ -8,6 +8,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:intl/intl.dart'; import '../../app/app_bootstrap.dart'; +import '../../app/common/busymax_mutation_list.dart'; import '../../features/accounts/data/accounts_repository.dart'; import '../../dav/ical/ical_task_alarm.dart'; import '../../features/task_lists/data/task_lists_repository.dart'; @@ -20,8 +21,10 @@ import '../../l10n/l10n.dart'; import '../../providers/busy_provider.dart'; import '../../schedule/schedule_filters.dart'; import '../../schedule/schedule_item.dart'; +import '../../schedule/task_list_mutation_intent.dart'; import '../android_notifications.dart'; import 'android_date_picker.dart'; +import 'android_task_motion.dart'; final _androidTasksProvider = FutureProvider.autoDispose .family< @@ -71,12 +74,34 @@ class AndroidTasksScreen extends ConsumerStatefulWidget { ConsumerState createState() => _AndroidTasksScreenState(); } +enum AndroidTaskEditorAction { created, updated, deleted, moved } + +class AndroidTaskEditorResult { + const AndroidTaskEditorResult({ + required this.action, + required this.accountId, + required this.taskListId, + required this.taskId, + this.previousTaskListId, + this.completed, + }); + + final AndroidTaskEditorAction action; + final String accountId; + final String taskListId; + final String taskId; + final String? previousTaskListId; + final bool? completed; +} + class _AndroidTasksScreenState extends ConsumerState { String _query = ''; String? _accountId; String? _listId; bool _showCompleted = true; bool _showNoDate = true; + var _mutationGeneration = 0; + TaskListMutationIntent? _mutationIntent; @override Widget build(BuildContext context) { @@ -118,6 +143,7 @@ class _AndroidTasksScreenState extends ConsumerState { return; } setState(() { + _mutationIntent = null; if (value == 'completed') _showCompleted = !_showCompleted; if (value == 'no-date') _showNoDate = !_showNoDate; }); @@ -152,7 +178,10 @@ class _AndroidTasksScreenState extends ConsumerState { SearchBar( hintText: context.l10n.windowsSearch, leading: const Icon(Icons.search), - onChanged: (value) => setState(() => _query = value), + onChanged: (value) => setState(() { + _mutationIntent = null; + _query = value; + }), ), const SizedBox(height: 8), DropdownButtonFormField( @@ -179,6 +208,7 @@ class _AndroidTasksScreenState extends ConsumerState { ), ], onChanged: (value) => setState(() { + _mutationIntent = null; if (value == null) { _accountId = null; _listId = null; @@ -197,57 +227,64 @@ class _AndroidTasksScreenState extends ConsumerState { body: tasks.when( loading: () => const Center(child: CircularProgressIndicator()), error: (error, _) => Center(child: Text('$error')), - data: (items) => items.isEmpty - ? Center(child: Text(context.l10n.noTasks)) - : ListView.separated( - padding: const EdgeInsets.fromLTRB(8, 8, 8, 96), - itemCount: items.length, - separatorBuilder: (_, _) => const Divider(height: 1), - itemBuilder: (context, index) { - final task = items[index]; - return ListTile( - contentPadding: EdgeInsets.only( - left: 8.0 + 16 * task.hierarchyDepth.clamp(0, 3), - right: 12, - ), - leading: IconButton( - iconSize: 30, - tooltip: task.completed - ? context.l10n.taskStatusCompleted - : context.l10n.taskStatusInProcess, - onPressed: task.capabilities.canEdit - ? () => _toggle(task) - : null, - icon: Icon( - task.completed - ? Icons.check_circle - : Icons.radio_button_unchecked, - ), - ), - title: Text( - task.title, - style: task.completed - ? const TextStyle( - decoration: TextDecoration.lineThrough, - ) - : null, - ), - subtitle: Text( - [ - task.sourceName ?? task.provider.displayName, - if (task.start != null) - DateFormat.yMMMd().format(task.start!), - if (task.parentTitle != null) task.parentTitle!, - ].join(' · '), - ), - trailing: task.hasSubtasks - ? const Icon(Icons.account_tree_outlined) - : null, - onTap: () => - showAndroidTaskEditor(context, ref, task: task), - ); - }, + data: (items) => BusyMaxMutationList( + items: items, + mutation: _mutationIntent, + identityOf: (task) => + '${task.accountId}\u0000${task.sourceId}\u0000${task.id}', + mutationApplied: (task, mutation) => + mutation.completed == null || + task.completed == mutation.completed, + onMutationConsumed: (mutation) { + if (!mounted) return; + if (_mutationIntent?.generation == mutation.generation) { + setState(() => _mutationIntent = null); + } + }, + emptyBuilder: (context) => Center(child: Text(context.l10n.noTasks)), + padding: const EdgeInsets.fromLTRB(8, 8, 8, 96), + separatorBuilder: (_, _) => const Divider(height: 1), + itemBuilder: (context, task, index) { + final displayedCompleted = _displayedCompletion(task); + return ListTile( + contentPadding: EdgeInsets.only( + left: 8.0 + 16 * task.hierarchyDepth.clamp(0, 3), + right: 12, + ), + leading: IconButton( + iconSize: 30, + tooltip: displayedCompleted + ? context.l10n.taskStatusCompleted + : context.l10n.taskStatusInProcess, + onPressed: task.capabilities.canEdit + ? () => _toggle(task) + : null, + icon: AndroidTaskCompletionIcon( + completed: displayedCompleted, + size: 30, + animate: _animatesCompletion(task), + ), ), + title: AndroidTaskTitle( + title: task.title, + completed: displayedCompleted, + animate: _animatesCompletion(task), + ), + subtitle: Text( + [ + task.sourceName ?? task.provider.displayName, + if (task.start != null) + DateFormat.yMMMd().format(task.start!), + if (task.parentTitle != null) task.parentTitle!, + ].join(' · '), + ), + trailing: task.hasSubtasks + ? const Icon(Icons.account_tree_outlined) + : null, + onTap: () => _edit(task), + ); + }, + ), ), floatingActionButton: FloatingActionButton( heroTag: 'android-tasks-add', @@ -259,6 +296,16 @@ class _AndroidTasksScreenState extends ConsumerState { } Future _toggle(TaskScheduleItem task) async { + setState(() { + _mutationIntent = TaskListMutationIntent( + presentation: TaskListMutationPresentation.completion, + accountId: task.accountId, + taskListId: task.sourceId, + taskId: task.id, + completed: !task.completed, + generation: ++_mutationGeneration, + ); + }); try { await ref .read(tasksRepositoryForAccountProvider(task.accountId)) @@ -271,6 +318,7 @@ class _AndroidTasksScreenState extends ConsumerState { ); } on Object catch (error) { if (mounted) { + setState(() => _mutationIntent = null); ScaffoldMessenger.of( context, ).showSnackBar(SnackBar(content: Text('$error'))); @@ -278,6 +326,25 @@ class _AndroidTasksScreenState extends ConsumerState { } } + bool _animatesCompletion(TaskScheduleItem task) { + final intent = _mutationIntent; + return intent?.presentation == TaskListMutationPresentation.completion && + intent?.taskKey == + '${task.accountId}\u0000${task.sourceId}\u0000${task.id}' && + intent?.completed != null; + } + + bool _displayedCompletion(TaskScheduleItem task) { + final intent = _mutationIntent; + if (intent?.presentation == TaskListMutationPresentation.completion && + intent?.taskKey == + '${task.accountId}\u0000${task.sourceId}\u0000${task.id}' && + intent?.completed != null) { + return intent!.completed!; + } + return task.completed; + } + Future _clearCompleted() async { final accountId = _accountId; final listId = _listId; @@ -299,8 +366,16 @@ class _AndroidTasksScreenState extends ConsumerState { final current = lists .where((list) => list.id == _listId && list.accountId == _accountId) .firstOrNull; + final settings = ref.read(appSettingsControllerProvider); final selected = current ?? + preferredCreationDestination( + lists, + selected: settings.defaultTaskList, + lastUsed: settings.lastUsedTaskList, + destinationOf: (list) => + CreationDestination(accountId: list.accountId, id: list.id), + ) ?? await selectAndroidTaskList( context, lists, @@ -319,13 +394,62 @@ class _AndroidTasksScreenState extends ConsumerState { .where((value) => value.id == selected.accountId) .firstOrNull; if (account == null) return; - await showAndroidTaskEditor( + final result = await showAndroidTaskEditor( context, ref, creationList: selected, creationProvider: account.provider, + onMutationStarted: _consumeEditorResult, + onMutationCommitted: _consumeEditorResult, + onMutationFailed: _clearEditorMutation, + ); + // The callbacks report the mutation before route cleanup can delay it. + if (result == null) return; + } + + Future _edit(TaskScheduleItem task) async { + await showAndroidTaskEditor( + context, + ref, + task: task, + onMutationStarted: _consumeEditorResult, + onMutationCommitted: _consumeEditorResult, + onMutationFailed: _clearEditorMutation, ); } + + void _consumeEditorResult(AndroidTaskEditorResult result) { + final presentation = switch (result.action) { + AndroidTaskEditorAction.created => TaskListMutationPresentation.insertion, + AndroidTaskEditorAction.deleted || + AndroidTaskEditorAction.moved => TaskListMutationPresentation.removal, + AndroidTaskEditorAction.updated => + TaskListMutationPresentation.completion, + }; + final taskListId = result.previousTaskListId ?? result.taskListId; + final current = _mutationIntent; + if (current?.presentation == presentation && + current?.accountId == result.accountId && + current?.taskListId == taskListId && + current?.taskId == result.taskId && + current?.completed == result.completed) { + return; + } + setState(() { + _mutationIntent = TaskListMutationIntent( + presentation: presentation, + accountId: result.accountId, + taskListId: taskListId, + taskId: result.taskId, + completed: result.completed, + generation: ++_mutationGeneration, + ); + }); + } + + void _clearEditorMutation() { + if (mounted) setState(() => _mutationIntent = null); + } } Future selectAndroidTaskList( @@ -356,13 +480,16 @@ Future selectAndroidTaskList( ); } -Future showAndroidTaskEditor( +Future showAndroidTaskEditor( BuildContext context, WidgetRef ref, { TaskScheduleItem? task, TaskListEntity? creationList, BusyProvider? creationProvider, DateTime? initialDue, + ValueChanged? onMutationStarted, + ValueChanged? onMutationCommitted, + VoidCallback? onMutationFailed, }) async { TaskEntity? entity; TaskListEntity? selectedList = creationList; @@ -371,7 +498,7 @@ Future showAndroidTaskEditor( .read(tasksRepositoryForAccountProvider(task.accountId)) .watchTask(task.sourceId, task.id) .first; - if (!context.mounted || entity == null) return; + if (!context.mounted || entity == null) return null; selectedList = (await ref .read(taskListsRepositoryForAccountProvider(task.accountId)) @@ -382,9 +509,9 @@ Future showAndroidTaskEditor( final account = await ref .read(accountsRepositoryProvider) .accountById(task?.accountId ?? creationList!.accountId); - if (!context.mounted) return; - await Navigator.of(context).push( - MaterialPageRoute( + if (!context.mounted) return null; + return Navigator.of(context).push( + MaterialPageRoute( fullscreenDialog: true, builder: (_) => AndroidTaskEditor( accountId: task?.accountId ?? creationList!.accountId, @@ -394,6 +521,9 @@ Future showAndroidTaskEditor( initialDue: initialDue, accountLabel: account?.displayLabel, listLabel: selectedList?.title, + onMutationStarted: onMutationStarted, + onMutationCommitted: onMutationCommitted, + onMutationFailed: onMutationFailed, ), ), ); @@ -409,6 +539,9 @@ class AndroidTaskEditor extends ConsumerStatefulWidget { this.initialDue, this.accountLabel, this.listLabel, + this.onMutationStarted, + this.onMutationCommitted, + this.onMutationFailed, }); final String accountId; final BusyProvider provider; @@ -417,6 +550,9 @@ class AndroidTaskEditor extends ConsumerStatefulWidget { final DateTime? initialDue; final String? accountLabel; final String? listLabel; + final ValueChanged? onMutationStarted; + final ValueChanged? onMutationCommitted; + final VoidCallback? onMutationFailed; @override ConsumerState createState() => _AndroidTaskEditorState(); } @@ -1067,11 +1203,7 @@ class _AndroidTaskEditorState extends ConsumerState { onPressed: capabilities.canUpdateTasks ? () => _toggleSubtask(subtask) : null, - icon: Icon( - subtask.completed - ? Icons.check_circle - : Icons.radio_button_unchecked, - ), + icon: AndroidTaskCompletionIcon(completed: subtask.completed), ), title: Text(subtask.title), trailing: subtask.hasChildren @@ -1090,8 +1222,8 @@ class _AndroidTaskEditorState extends ConsumerState { ); Future _openHierarchyTask(TaskEntity task) async { - await Navigator.of(context).push( - MaterialPageRoute( + await Navigator.of(context).push( + MaterialPageRoute( fullscreenDialog: true, builder: (_) => AndroidTaskEditor( accountId: widget.accountId, @@ -1099,6 +1231,9 @@ class _AndroidTaskEditorState extends ConsumerState { task: task, accountLabel: widget.accountLabel, listLabel: widget.listLabel, + onMutationStarted: widget.onMutationStarted, + onMutationCommitted: widget.onMutationCommitted, + onMutationFailed: widget.onMutationFailed, ), ), ); @@ -1462,15 +1597,42 @@ class _AndroidTaskEditorState extends ConsumerState { setState(_syncDraftFromControllers); if (!_draftIsValid) return; setState(() => _saving = true); + final existing = widget.task; + final pendingResult = existing == null + ? null + : AndroidTaskEditorResult( + action: AndroidTaskEditorAction.updated, + accountId: widget.accountId, + taskListId: _draft.taskListId, + taskId: existing.id, + completed: _draftCompletion, + ); + if (pendingResult != null) { + widget.onMutationStarted?.call(pendingResult); + } + var mutationPersisted = false; try { - await _persistTaskDraft(); + final taskId = await _persistTaskDraft(); + mutationPersisted = true; + final result = + pendingResult ?? + AndroidTaskEditorResult( + action: AndroidTaskEditorAction.created, + accountId: widget.accountId, + taskListId: _draft.taskListId, + taskId: taskId, + ); + widget.onMutationCommitted?.call(result); if (mounted) { await _clearRecovery(finalCleanup: true); if (!mounted) return; setState(() => _allowPop = true); - Navigator.pop(context); + Navigator.pop(context, result); } } on Object catch (error) { + if (pendingResult != null && !mutationPersisted) { + widget.onMutationFailed?.call(); + } if (mounted) { ScaffoldMessenger.of( context, @@ -1481,6 +1643,19 @@ class _AndroidTaskEditorState extends ConsumerState { } } + bool? get _draftCompletion { + final original = widget.task; + if (original == null) return null; + final originalCompleted = + original.status?.toLowerCase() == 'completed' || + original.status?.toUpperCase() == 'COMPLETED' || + original.percentComplete == 100; + final completed = + _draft.taskStatus?.toUpperCase() == 'COMPLETED' || + _draft.percentComplete == 100; + return completed == originalCompleted ? null : completed; + } + void _syncDraftFromControllers() { _draft = _draft.copyWith( title: _title.text.trim(), @@ -1500,7 +1675,7 @@ class _AndroidTaskEditorState extends ConsumerState { _draft.hasValidTaskUrlFor(_capabilities) && _draft.scheduleIssueFor(_capabilities) == TaskScheduleIssue.none; - Future _persistTaskDraft() async { + Future _persistTaskDraft() async { if (_draft.microsoftReminderEnabled || _draft.alarms.isNotEmpty) { await ref .read(androidNotificationServiceProvider) @@ -1510,14 +1685,24 @@ class _AndroidTaskEditorState extends ConsumerState { tasksRepositoryForAccountProvider(widget.accountId), ); if (widget.task == null) { - await repository.createTask( + final id = await repository.createTask( _draft.taskListId, _draft.toCreateInput( _capabilities, localTimeZone: ref.read(localTimeZoneProvider), ), ); - return; + unawaited( + ref + .read(appSettingsControllerProvider.notifier) + .rememberTaskList( + CreationDestination( + accountId: widget.accountId, + id: _draft.taskListId, + ), + ), + ); + return id; } await repository.patchTask( _draft.taskListId, @@ -1530,6 +1715,7 @@ class _AndroidTaskEditorState extends ConsumerState { ), ), ); + return widget.task!.id; } RecurrenceRule _taskRecurrence() { @@ -1623,17 +1809,28 @@ class _AndroidTaskEditorState extends ConsumerState { ); if (confirmed != true) return; setState(() => _saving = true); + final result = AndroidTaskEditorResult( + action: AndroidTaskEditorAction.deleted, + accountId: widget.accountId, + taskListId: widget.task!.taskListId, + taskId: widget.task!.id, + ); + widget.onMutationStarted?.call(result); + var mutationPersisted = false; try { await ref .read(tasksRepositoryForAccountProvider(widget.accountId)) .deleteTask(widget.task!.taskListId, widget.task!.id); + mutationPersisted = true; + widget.onMutationCommitted?.call(result); if (mounted) { await _clearRecovery(finalCleanup: true); if (!mounted) return; setState(() => _allowPop = true); - Navigator.pop(context); + Navigator.pop(context, result); } } on Object catch (error) { + if (!mutationPersisted) widget.onMutationFailed?.call(); if (mounted) { ScaffoldMessenger.of( context, @@ -1647,9 +1844,17 @@ class _AndroidTaskEditorState extends ConsumerState { Future _duplicate() async { setState(() => _saving = true); try { - await ref + final duplicateId = await ref .read(tasksRepositoryForAccountProvider(widget.accountId)) .duplicateTask(_draft.taskListId, widget.task!.id); + widget.onMutationCommitted?.call( + AndroidTaskEditorResult( + action: AndroidTaskEditorAction.created, + accountId: widget.accountId, + taskListId: _draft.taskListId, + taskId: duplicateId, + ), + ); if (mounted) { ScaffoldMessenger.of( context, @@ -1723,6 +1928,15 @@ class _AndroidTaskEditorState extends ConsumerState { if (saveEdits && !_draftIsValid) return; } setState(() => _saving = true); + final result = AndroidTaskEditorResult( + action: AndroidTaskEditorAction.moved, + accountId: widget.accountId, + taskListId: destination.id, + taskId: widget.task!.id, + previousTaskListId: _draft.taskListId, + ); + widget.onMutationStarted?.call(result); + var mutationPersisted = false; try { final repository = ref.read( tasksRepositoryForAccountProvider(widget.accountId), @@ -1735,11 +1949,14 @@ class _AndroidTaskEditorState extends ConsumerState { taskId: widget.task!.id, ), ); + mutationPersisted = true; + widget.onMutationCommitted?.call(result); await _clearRecovery(finalCleanup: true); if (!mounted) return; setState(() => _allowPop = true); - Navigator.pop(context); + Navigator.pop(context, result); } on Object catch (error) { + if (!mutationPersisted) widget.onMutationFailed?.call(); if (mounted) { ScaffoldMessenger.of( context, @@ -2439,8 +2656,8 @@ Future showAndroidTaskById( .where((value) => value.id == row.taskListId) .firstOrNull; if (!context.mounted || row == null || account == null) return; - await Navigator.of(context).push( - MaterialPageRoute( + await Navigator.of(context).push( + MaterialPageRoute( fullscreenDialog: true, builder: (_) => AndroidTaskEditor( accountId: accountId, diff --git a/lib/src/app/app_bootstrap.dart b/lib/src/app/app_bootstrap.dart index 55631383..6a690a55 100644 --- a/lib/src/app/app_bootstrap.dart +++ b/lib/src/app/app_bootstrap.dart @@ -788,9 +788,11 @@ typedef SignedInSyncRunner = final signedInSyncRunnerProvider = Provider((ref) { return (accountId, initial) async { try { - await ref - .read(accountSyncOperationsProvider) - .syncAccount(accountId, full: initial); + await _runAccountSyncIfEligible( + ref, + accountId, + (operations) => operations.syncAccount(accountId, full: initial), + ); } on Object catch (error) { await _markAccountReconnectRequiredForSyncError(ref, accountId, error); rethrow; @@ -802,9 +804,7 @@ typedef AllAccountsSyncRunner = Future Function(); final allAccountsSyncRunnerProvider = Provider((ref) { Future syncAccount(String accountId) async { - await ref - .read(accountSyncOperationsProvider) - .syncAccount(accountId, full: false); + await ref.read(signedInSyncRunnerProvider)(accountId, false); } return () async { @@ -814,6 +814,7 @@ final allAccountsSyncRunnerProvider = Provider((ref) { .read(accountsRepositoryProvider) .listSyncEligibleAccounts, syncAccount: syncAccount, + rethrowFirstFailure: true, onSyncFailure: ref .read(operationalNotificationReporterProvider) .notifySyncFailure, @@ -970,9 +971,8 @@ final davTaskListMutationClientForAccountProvider = secretStore: ref.watch(secretStoreProvider), httpClient: ref.watch(baseHttpClientProvider), accountId: accountId, - refreshAfterMutation: () => ref - .read(accountSyncOperationsProvider) - .syncAccount(accountId, full: true), + refreshAfterMutation: () => + ref.read(signedInSyncRunnerProvider)(accountId, true), requireNetwork: ref .watch(networkConnectivityMonitorProvider) .requireNetwork, @@ -989,9 +989,7 @@ final nextcloudCollectionServiceProvider = requireNetwork: ref .watch(networkConnectivityMonitorProvider) .requireNetwork, - refresh: () => ref - .read(accountSyncOperationsProvider) - .syncAccount(accountId, full: true), + refresh: () => ref.read(signedInSyncRunnerProvider)(accountId, true), ), ); final nextcloudNativeExportServiceProvider = @@ -1028,9 +1026,8 @@ final davCalendarCollectionMutationClientForAccountProvider = secretStore: ref.watch(secretStoreProvider), httpClient: ref.watch(baseHttpClientProvider), accountId: accountId, - refreshAfterMutation: () => ref - .read(accountSyncOperationsProvider) - .syncAccount(accountId, full: true), + refreshAfterMutation: () => + ref.read(signedInSyncRunnerProvider)(accountId, true), requireNetwork: ref .watch(networkConnectivityMonitorProvider) .requireNetwork, @@ -1125,9 +1122,11 @@ final pendingMutationSyncRequesterProvider = } final requester = PendingMutationSyncRequester( - sync: () => ref - .read(accountSyncOperationsProvider) - .syncTasks(accountId, full: false), + sync: () => _runAccountSyncIfEligible( + ref, + accountId, + (operations) => operations.syncTasks(accountId, full: false), + ), onSyncFailure: ref .watch(operationalNotificationReporterProvider) .notifySyncFailure, @@ -1142,9 +1141,11 @@ final pendingMutationSyncRequesterProvider = final pendingMutationSyncRequesterForAccountProvider = Provider.family((ref, accountId) { final requester = PendingMutationSyncRequester( - sync: () => ref - .read(accountSyncOperationsProvider) - .syncTasks(accountId, full: false), + sync: () => _runAccountSyncIfEligible( + ref, + accountId, + (operations) => operations.syncTasks(accountId, full: false), + ), onSyncFailure: ref .watch(operationalNotificationReporterProvider) .notifySyncFailure, @@ -1159,9 +1160,11 @@ final pendingMutationSyncRequesterForAccountProvider = final pendingCalendarMutationSyncRequesterForAccountProvider = Provider.family((ref, accountId) { final requester = PendingMutationSyncRequester( - sync: () => ref - .read(accountSyncOperationsProvider) - .syncCalendar(accountId, full: false), + sync: () => _runAccountSyncIfEligible( + ref, + accountId, + (operations) => operations.syncCalendar(accountId, full: false), + ), onSyncFailure: ref .watch(operationalNotificationReporterProvider) .notifySyncFailure, @@ -1182,12 +1185,16 @@ final pendingOpResolutionServiceForAccountProvider = calendarRemoteApiClientForAccountProvider(accountId), ), accountId: accountId, - syncTasks: () => ref - .read(accountSyncOperationsProvider) - .syncTasks(accountId, full: false), - syncCalendar: () => ref - .read(accountSyncOperationsProvider) - .syncCalendar(accountId, full: false), + syncTasks: () => _runAccountSyncIfEligible( + ref, + accountId, + (operations) => operations.syncTasks(accountId, full: false), + ), + syncCalendar: () => _runAccountSyncIfEligible( + ref, + accountId, + (operations) => operations.syncCalendar(accountId, full: false), + ), onNotificationScheduleChanged: () => ref.read(notificationReconcilerProvider).reconcile(), ); @@ -1204,9 +1211,7 @@ final pendingOpResolutionServiceProvider = final syncSchedulerProvider = Provider((ref) { Future syncAccount(String accountId) async { - await ref - .read(accountSyncOperationsProvider) - .syncAccount(accountId, full: false); + await ref.read(signedInSyncRunnerProvider)(accountId, false); } final scheduler = AllAccountsSyncScheduler( @@ -1282,6 +1287,20 @@ Future _markAccountReconnectRequiredForSyncError( } } +Future _runAccountSyncIfEligible( + Ref ref, + String accountId, + Future Function(AccountSyncOperations operations) synchronize, +) async { + final account = await ref + .read(accountsRepositoryProvider) + .accountById(accountId); + if (account?.isSyncEligible != true) { + return; + } + await synchronize(ref.read(accountSyncOperationsProvider)); +} + // This handler outlives individual schedulers, so a visible Linux notification // can route to the current scheduler even after an explicit invalidation. final _reminderActionHandlerProvider = Provider( diff --git a/lib/src/app/app_router.dart b/lib/src/app/app_router.dart index 2b28b828..c9245ebd 100644 --- a/lib/src/app/app_router.dart +++ b/lib/src/app/app_router.dart @@ -12,25 +12,44 @@ import 'app_bootstrap.dart'; import 'busymax_design.dart'; import 'busymax_layout.dart'; import 'busymax_surface_colors.dart'; +import 'linux/linux_page_frame.dart'; +import 'linux/linux_window_host.dart'; final rootNavigatorKey = GlobalKey(); +class _RouterRefreshNotifier extends ChangeNotifier { + void refresh() => notifyListeners(); +} + final appRouterProvider = Provider((ref) { - final session = ref.watch(authSessionControllerProvider); + final refreshNotifier = _RouterRefreshNotifier(); + ref.listen(authSessionControllerProvider, (_, _) { + refreshNotifier.refresh(); + }); + ref.listen(webCalSubscriptionsProvider, (_, _) { + refreshNotifier.refresh(); + }); + + final session = ref.read(authSessionControllerProvider); final hasSubscriptions = - ref.watch(webCalSubscriptionsProvider).valueOrNull?.isNotEmpty == true; + ref.read(webCalSubscriptionsProvider).valueOrNull?.isNotEmpty == true; final canOpenSchedule = session.isSignedIn || hasSubscriptions; - return GoRouter( + final router = GoRouter( navigatorKey: rootNavigatorKey, + refreshListenable: refreshNotifier, initialLocation: session.status == AuthSessionStatus.loading ? '/' : canOpenSchedule ? '/schedule' : '/sign-in', redirect: (context, state) { + final session = ref.read(authSessionControllerProvider); + final hasSubscriptions = + ref.read(webCalSubscriptionsProvider).valueOrNull?.isNotEmpty == true; + final canOpenSchedule = session.isSignedIn || hasSubscriptions; if (session.status == AuthSessionStatus.loading) { - return state.matchedLocation == '/' ? null : '/'; + return null; } if (state.matchedLocation == '/') { @@ -93,6 +112,11 @@ final appRouterProvider = Provider((ref) { ), ], ); + ref.onDispose(() { + router.dispose(); + refreshNotifier.dispose(); + }); + return router; }); const _tasksWorkspacePageKey = ValueKey('tasks-workspace'); @@ -128,18 +152,19 @@ class BusyMaxStartupView extends StatelessWidget { color: colors.window, child: const Center(child: YaruCircularProgressIndicator()), ); - if (!BusyMaxLayoutRules.showSidebar(constraints.maxWidth)) { - return content; - } - return Row( - children: [ - const SizedBox( - key: ValueKey('startup-sidebar'), - width: BusyMaxSizes.sidebarWidth, - child: BusyMaxSidebarSurface(child: SizedBox.expand()), - ), - Expanded(child: content), - ], + return LinuxPageFrame( + header: const LinuxTitlebarGestureRegion(child: SizedBox.expand()), + body: content, + sidebarHeader: const BusyMaxLinuxBrandHeader(), + sidebarBody: const BusyMaxSidebarSurface( + key: ValueKey('startup-sidebar'), + showEndBorder: false, + child: SizedBox.expand(), + ), + sidebarAvailable: BusyMaxLayoutRules.showSidebar( + constraints.maxWidth, + ), + sidebarExpanded: true, ); }, ), diff --git a/lib/src/app/app_settings.dart b/lib/src/app/app_settings.dart index f0c40526..1c69e82f 100644 --- a/lib/src/app/app_settings.dart +++ b/lib/src/app/app_settings.dart @@ -25,6 +25,61 @@ enum BusyMaxThemeModePreference { system, light, dark } enum NotificationDetailLevel { private, normal } +class CreationDestination { + const CreationDestination({required this.accountId, required this.id}); + + final String accountId; + final String id; + + factory CreationDestination.fromJson(Object? value) { + if (value is! Map) throw const FormatException('Invalid destination'); + final accountId = value['accountId']; + final id = value['id']; + if (accountId is! String || + accountId.isEmpty || + id is! String || + id.isEmpty) { + throw const FormatException('Invalid destination'); + } + return CreationDestination(accountId: accountId, id: id); + } + + Map toJson() => {'accountId': accountId, 'id': id}; + + @override + bool operator ==(Object other) => + other is CreationDestination && + other.accountId == accountId && + other.id == id; + + @override + int get hashCode => Object.hash(accountId, id); +} + +CreationDestination? _destinationFromJson(Object? value) { + try { + return CreationDestination.fromJson(value); + } on FormatException { + return null; + } +} + +T? preferredCreationDestination( + Iterable candidates, { + required CreationDestination? selected, + required CreationDestination? lastUsed, + required CreationDestination Function(T) destinationOf, +}) { + final available = candidates.toList(); + for (final preferred in [selected, lastUsed]) { + if (preferred == null) continue; + for (final candidate in available) { + if (destinationOf(candidate) == preferred) return candidate; + } + } + return null; +} + const defaultScheduleDayStartMinute = 7 * 60; const defaultScheduleDayEndMinute = 22 * 60; const Object _unset = Object(); @@ -66,6 +121,10 @@ class AppSettings { this.androidScheduleViewMode, required this.scheduleDayStartMinute, required this.scheduleDayEndMinute, + this.defaultCalendar, + this.defaultTaskList, + this.lastUsedCalendar, + this.lastUsedTaskList, this.sidebarOrder = const ScheduleSidebarOrder.empty(), }); @@ -211,6 +270,10 @@ class AppSettings { ), scheduleDayStartMinute: scheduleDayStartMinute, scheduleDayEndMinute: scheduleDayEndMinute, + defaultCalendar: _destinationFromJson(json['defaultCalendar']), + defaultTaskList: _destinationFromJson(json['defaultTaskList']), + lastUsedCalendar: _destinationFromJson(json['lastUsedCalendar']), + lastUsedTaskList: _destinationFromJson(json['lastUsedTaskList']), ); } @@ -240,6 +303,10 @@ class AppSettings { final ScheduleViewMode? androidScheduleViewMode; final int scheduleDayStartMinute; final int scheduleDayEndMinute; + final CreationDestination? defaultCalendar; + final CreationDestination? defaultTaskList; + final CreationDestination? lastUsedCalendar; + final CreationDestination? lastUsedTaskList; ThemeMode get themeMode => themeModePreference.themeMode; @@ -273,6 +340,10 @@ class AppSettings { 'androidScheduleViewMode': androidScheduleViewMode?.name, 'scheduleDayStartMinute': scheduleDayStartMinute, 'scheduleDayEndMinute': scheduleDayEndMinute, + 'defaultCalendar': defaultCalendar?.toJson(), + 'defaultTaskList': defaultTaskList?.toJson(), + 'lastUsedCalendar': lastUsedCalendar?.toJson(), + 'lastUsedTaskList': lastUsedTaskList?.toJson(), }; } @@ -303,6 +374,10 @@ class AppSettings { Object? androidScheduleViewMode = _unset, int? scheduleDayStartMinute, int? scheduleDayEndMinute, + Object? defaultCalendar = _unset, + Object? defaultTaskList = _unset, + Object? lastUsedCalendar = _unset, + Object? lastUsedTaskList = _unset, bool clearLastDueTodayNotificationDate = false, }) { final ( @@ -352,6 +427,18 @@ class AppSettings { : androidScheduleViewMode as ScheduleViewMode?, scheduleDayStartMinute: resolvedScheduleDayStartMinute, scheduleDayEndMinute: resolvedScheduleDayEndMinute, + defaultCalendar: identical(defaultCalendar, _unset) + ? this.defaultCalendar + : defaultCalendar as CreationDestination?, + defaultTaskList: identical(defaultTaskList, _unset) + ? this.defaultTaskList + : defaultTaskList as CreationDestination?, + lastUsedCalendar: identical(lastUsedCalendar, _unset) + ? this.lastUsedCalendar + : lastUsedCalendar as CreationDestination?, + lastUsedTaskList: identical(lastUsedTaskList, _unset) + ? this.lastUsedTaskList + : lastUsedTaskList as CreationDestination?, ); } @@ -440,6 +527,18 @@ class AppSettingsController extends StateNotifier { BusyMaxFirstDayOfWeekPreference value, ) => _mutate((current) => current.copyWith(firstDayOfWeekPreference: value)); + Future setDefaultCalendar(CreationDestination? value) => + _mutate((current) => current.copyWith(defaultCalendar: value)); + + Future setDefaultTaskList(CreationDestination? value) => + _mutate((current) => current.copyWith(defaultTaskList: value)); + + Future rememberCalendar(CreationDestination value) => + _mutate((current) => current.copyWith(lastUsedCalendar: value)); + + Future rememberTaskList(CreationDestination value) => + _mutate((current) => current.copyWith(lastUsedTaskList: value)); + Future registerSidebarIds( SidebarOrderSection section, Iterable ids, { diff --git a/lib/src/app/busymax_about_dialog.dart b/lib/src/app/busymax_about_dialog.dart index 5edb7cdc..ebf2ac82 100644 --- a/lib/src/app/busymax_about_dialog.dart +++ b/lib/src/app/busymax_about_dialog.dart @@ -6,7 +6,6 @@ import 'package:url_launcher/url_launcher.dart'; import 'package:yaru/yaru.dart'; import '../l10n/l10n.dart'; -import '../platform/linux_header_bar_service.dart'; import 'busymax_design.dart'; import 'busymax_dialog_identity.dart'; import 'busymax_dialogs.dart'; @@ -15,13 +14,9 @@ const _busyMaxWebsiteUrl = 'https://busystack.org'; const _busyMaxRepositoryUrl = 'https://github.com/busystack/busymax/'; const _apacheLicenseUrl = 'https://www.apache.org/licenses/LICENSE-2.0'; -Future showBusyMaxAboutDialog( - BuildContext context, { - LinuxHeaderBarService? headerBarService, -}) { +Future showBusyMaxAboutDialog(BuildContext context) { return showBusyMaxModalDialog( context, - headerBarService: headerBarService, builder: (dialogContext) => const BusyMaxAboutDialog(), ); } @@ -116,18 +111,10 @@ class _BusyMaxLogo extends StatelessWidget { String _formatVersion(PackageInfo info) { final version = info.version.trim(); - final buildNumber = info.buildNumber.trim(); - if (version.isEmpty && buildNumber.isEmpty) { + if (version.isEmpty) { return ''; } - final versionWithBuild = switch ((version, buildNumber)) { - ('', final build) => build, - (final release, '') => release, - (final release, final build) => '$release+$build', - }; - return versionWithBuild.startsWith('v') - ? versionWithBuild - : 'v$versionWithBuild'; + return version.startsWith('v') ? version : 'v$version'; } class _VersionTag extends StatelessWidget { diff --git a/lib/src/app/busymax_app.dart b/lib/src/app/busymax_app.dart index 0e615fc0..d0670a4c 100644 --- a/lib/src/app/busymax_app.dart +++ b/lib/src/app/busymax_app.dart @@ -10,10 +10,10 @@ import '../platform/busymax_tray_service.dart'; import '../features/tray/domain/tray_presentation.dart'; import '../features/tray/domain/tray_presentation_formatter.dart'; import '../platform/gtk_font_service.dart'; +import '../platform/gtk_header_icon_service.dart'; +import '../platform/gtk_animation_settings_service.dart'; import '../platform/linux_first_weekday_source.dart'; -import '../platform/linux_header_bar_configuration_synchronizer.dart'; -import '../platform/linux_header_bar_provider.dart'; -import '../platform/linux_header_bar_service.dart'; +import '../platform/native_style.dart'; import '../platform/common/desktop_services.dart'; import '../l10n/locale_resolution.dart'; import '../l10n/time_format_scope.dart'; @@ -24,11 +24,13 @@ import 'desktop_startup_policy.dart'; import 'app_router.dart'; import 'busymax_keyboard_shortcuts_dialog.dart'; import 'busymax_shortcuts.dart'; +import 'busymax_window_close.dart'; import '../../l10n/generated/app_localizations.dart'; import 'busymax_yaru_theme.dart'; import 'busymax_design.dart'; import 'system_accent.dart'; import 'app_theme.dart'; +import 'linux/linux_window_host.dart'; import '../features/settings/presentation/settings_screen.dart'; import '../features/calendar/presentation/ical_import_flow.dart'; import '../core/logging/redacting_logger.dart'; @@ -37,34 +39,24 @@ import 'common/desktop_calendar_open_readiness.dart'; typedef BusyMaxTrayServiceFactory = BusyMaxTrayService Function(BusyMaxTrayServiceConfiguration configuration); -BusyMaxHeaderBarTheme busyMaxHeaderBarThemeFor( +BusyMaxNativeSurfaceTheme busyMaxNativeSurfaceThemeFor( ThemeData theme, { required bool highContrast, }) { final colors = theme.extension()!; - return BusyMaxHeaderBarTheme( - preferDark: theme.brightness == Brightness.dark, + return BusyMaxNativeSurfaceTheme( highContrast: highContrast, windowBackgroundColor: colors.window, - // This header is deliberately borderless and visually continuous with - // the main workspace, so both use the window surface role. - backgroundColor: colors.window, - sidebarBackgroundColor: colors.sidebar, - foregroundColor: colors.foreground, - sidebarBorderColor: colors.sidebarBorder, dialogBackgroundColor: colors.dialog, dialogOutlineColor: colors.dialogOutline, - modalBarrierColor: colors.shade, - tooltip: BusyMaxHeaderBarTooltipTheme( - backgroundColor: BusyMaxTooltipStyle.background, - foregroundColor: BusyMaxTooltipStyle.foreground, - borderColor: BusyMaxTooltipStyle.border, - borderRadius: BusyMaxRadius.tooltip, - fontSize: theme.tooltipTheme.textStyle?.fontSize ?? 14, - horizontalPadding: BusyMaxSpacing.tooltipHorizontal, - verticalPadding: BusyMaxSpacing.tooltipVertical, - minimumHeight: BusyMaxSizes.tooltipMinHeight, - ), + tooltipBackgroundColor: BusyMaxTooltipStyle.background, + tooltipForegroundColor: BusyMaxTooltipStyle.foreground, + tooltipBorderColor: BusyMaxTooltipStyle.border, + tooltipRadius: BusyMaxRadius.tooltip, + tooltipFontSize: theme.tooltipTheme.textStyle?.fontSize ?? 14, + tooltipHorizontalPadding: BusyMaxSpacing.tooltipHorizontal, + tooltipVerticalPadding: BusyMaxSpacing.tooltipVertical, + tooltipMinimumHeight: BusyMaxSizes.tooltipMinHeight, ); } @@ -94,6 +86,7 @@ class BusyMaxApp extends LinuxBusyMaxApp { class _BusyMaxAppState extends ConsumerState { final _logger = RedactingLogger(Logger('LinuxBusyMaxApp')); final _calendarOpenReadiness = DesktopCalendarOpenReadiness(); + final _windowCloseCoordinator = BusyMaxWindowCloseCoordinator(); late final BusyMaxSystemFirstWeekdayController _firstWeekdayController; BusyMaxTrayService? _trayService; bool? _lastHideOnClose; @@ -109,8 +102,9 @@ class _BusyMaxAppState extends ConsumerState { StreamSubscription? _externalOpenSubscription; StreamSubscription? _navigationSubscription; Future _externalOpenTail = Future.value(); - late final BusyMaxHeaderBarConfigurationSynchronizer - _headerBarConfigurationSynchronizer; + BusyMaxNativeSurfaceTheme? _nativeSurfaceTheme; + var _nativeSurfaceThemeRevision = 0; + Future? _quitRequest; @override void initState() { @@ -118,10 +112,6 @@ class _BusyMaxAppState extends ConsumerState { _firstWeekdayController = BusyMaxSystemFirstWeekdayController( const LinuxFirstWeekdaySource(), )..addListener(_weekPreferenceChanged); - _headerBarConfigurationSynchronizer = - BusyMaxHeaderBarConfigurationSynchronizer( - ref.read(linuxHeaderBarServiceProvider), - ); _externalOpenSubscription = ref .read(desktopActivationServiceProvider) .activations @@ -148,7 +138,7 @@ class _BusyMaxAppState extends ConsumerState { _firstWeekdayController ..removeListener(_weekPreferenceChanged) ..dispose(); - _headerBarConfigurationSynchronizer.dispose(); + _nativeSurfaceThemeRevision += 1; unawaited(_externalOpenSubscription?.cancel()); unawaited(_navigationSubscription?.cancel()); final tray = _trayService; @@ -254,6 +244,8 @@ class _BusyMaxAppState extends ConsumerState { .valueOrNull; final gtkFont = ref.watch(gtkFontSettingsProvider).valueOrNull; final gtkThemeColors = ref.watch(gtkThemeColorsProvider).valueOrNull; + final gtkAnimationsEnabled = + ref.watch(gtkAnimationsEnabledProvider).valueOrNull ?? true; ref.watch(networkAvailabilityProvider); ref.watch(syncSchedulerProvider); ref.watch(syncSchedulerRunningProvider); @@ -348,64 +340,67 @@ class _BusyMaxAppState extends ConsumerState { ), ); _trayPresentationFormatter = trayFormatter; - _configureNativeHeaderBarTheme(context); + _configureNativeSurfaceTheme(context); _configureBackgroundServices(ref, settings, trayFormatter); - return Shortcuts( - shortcuts: const { - BusyMaxShortcutActivators.keyboardShortcuts: - _KeyboardShortcutsIntent(), - BusyMaxShortcutActivators.settings: _OpenSettingsIntent(), - }, - child: Actions( - actions: { - _KeyboardShortcutsIntent: - CallbackAction<_KeyboardShortcutsIntent>( - onInvoke: (intent) { - final navigatorContext = - rootNavigatorKey.currentContext; - if (navigatorContext != null) { - unawaited( - showBusyMaxKeyboardShortcutsDialog( - navigatorContext, - headerBarService: ref.read( - linuxHeaderBarServiceProvider, - ), - ), - ); - } - return null; - }, - ), - _OpenSettingsIntent: CallbackAction<_OpenSettingsIntent>( - onInvoke: (intent) { - if (router.state.uri.path != '/settings') { - unawaited(router.push('/settings')); - } - return null; + return GtkHeaderIconScope( + service: ref.watch(gtkHeaderIconServiceProvider), + child: LinuxApplicationMediaQuery( + alwaysUse24HourFormat: clock.use24Hour, + gtkAnimationsEnabled: gtkAnimationsEnabled, + child: LinuxWindowHost( + closeCoordinator: _windowCloseCoordinator, + child: Shortcuts( + shortcuts: const { + BusyMaxShortcutActivators.keyboardShortcuts: + _KeyboardShortcutsIntent(), + BusyMaxShortcutActivators.settings: _OpenSettingsIntent(), }, - ), - }, - child: ColoredBox( - color: BusyMaxSurfaceColors.of(context).window, - child: BusyMaxTimeFormatScope( - formatter: clock, - child: BusyMaxWeekPreferencesScope( - preference: settings.firstDayOfWeekPreference, - systemWeekday: _firstWeekdayController.value, - platformLocaleTag: WidgetsBinding - .instance - .platformDispatcher - .locale - .toLanguageTag(), - child: BusyMaxWeekPreferencesStartupGate( - preference: settings.firstDayOfWeekPreference, - systemValueInitialized: - _firstWeekdayController.isInitialized, - child: MediaQuery( - data: MediaQuery.of( - context, - ).copyWith(alwaysUse24HourFormat: clock.use24Hour), - child: child ?? const SizedBox.shrink(), + child: Actions( + actions: { + _KeyboardShortcutsIntent: + CallbackAction<_KeyboardShortcutsIntent>( + onInvoke: (intent) { + final navigatorContext = + rootNavigatorKey.currentContext; + if (navigatorContext != null) { + unawaited( + showBusyMaxKeyboardShortcutsDialog( + navigatorContext, + ), + ); + } + return null; + }, + ), + _OpenSettingsIntent: + CallbackAction<_OpenSettingsIntent>( + onInvoke: (intent) { + if (router.state.uri.path != '/settings') { + unawaited(router.push('/settings')); + } + return null; + }, + ), + }, + child: ColoredBox( + color: BusyMaxSurfaceColors.of(context).window, + child: BusyMaxTimeFormatScope( + formatter: clock, + child: BusyMaxWeekPreferencesScope( + preference: settings.firstDayOfWeekPreference, + systemWeekday: _firstWeekdayController.value, + platformLocaleTag: WidgetsBinding + .instance + .platformDispatcher + .locale + .toLanguageTag(), + child: BusyMaxWeekPreferencesStartupGate( + preference: settings.firstDayOfWeekPreference, + systemValueInitialized: + _firstWeekdayController.isInitialized, + child: child ?? const SizedBox.shrink(), + ), + ), ), ), ), @@ -420,58 +415,19 @@ class _BusyMaxAppState extends ConsumerState { ); } - void _configureNativeHeaderBarTheme(BuildContext context) { - final l10n = AppLocalizations.of(context); - final materialL10n = MaterialLocalizations.of(context); + void _configureNativeSurfaceTheme(BuildContext context) { final theme = Theme.of(context); - final labels = BusyMaxHeaderBarLabels( - today: l10n.today, - day: l10n.viewDay, - week: l10n.viewWeek, - month: l10n.viewMonth, - year: l10n.viewYear, - agenda: l10n.viewAgenda, - search: materialL10n.searchFieldLabel, - create: l10n.create, - createEvent: l10n.createEventAtTime, - createTask: l10n.createTaskAtDate, - refresh: l10n.refreshAll, - menu: l10n.mainMenu, - previous: materialL10n.previousPageTooltip, - next: materialL10n.nextPageTooltip, - showSidebarPanel: l10n.showSidebar, - hideSidebarPanel: l10n.hideSidebar, - back: materialL10n.backButtonTooltip, - settings: l10n.settings, - keyboardShortcuts: l10n.keyboardShortcuts, - reportIssue: l10n.reportAnIssue, - aboutBusyMax: l10n.aboutBusyMax, - todayShortcut: BusyMaxShortcutLabels.today, - dayShortcut: BusyMaxShortcutLabels.dayView, - weekShortcut: BusyMaxShortcutLabels.weekView, - monthShortcut: BusyMaxShortcutLabels.monthView, - yearShortcut: BusyMaxShortcutLabels.yearView, - agendaShortcut: BusyMaxShortcutLabels.agendaView, - searchShortcut: BusyMaxShortcutLabels.search, - sidebarShortcut: BusyMaxShortcutLabels.sidebar, - createEventShortcut: BusyMaxShortcutLabels.newEvent, - createTaskShortcut: BusyMaxShortcutLabels.newTask, - previousShortcut: BusyMaxShortcutLabels.previousPeriod, - nextShortcut: BusyMaxShortcutLabels.nextPeriod, - settingsShortcut: BusyMaxShortcutLabels.settings, - keyboardShortcutsShortcut: BusyMaxShortcutLabels.keyboardShortcuts, - ); - _headerBarConfigurationSynchronizer.schedule( - BusyMaxHeaderBarConfiguration( - labels: labels, - sidebarWidth: BusyMaxSizes.sidebarWidth, - textDirection: Directionality.of(context), - theme: busyMaxHeaderBarThemeFor( - theme, - highContrast: MediaQuery.highContrastOf(context), - ), - ), + final value = busyMaxNativeSurfaceThemeFor( + theme, + highContrast: MediaQuery.highContrastOf(context), ); + if (_nativeSurfaceTheme == value) return; + _nativeSurfaceTheme = value; + final revision = ++_nativeSurfaceThemeRevision; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted || revision != _nativeSurfaceThemeRevision) return; + unawaited(const NativeSurfaceStyleService().setTheme(value)); + }); } void _configureBackgroundServices( @@ -574,7 +530,7 @@ class _BusyMaxAppState extends ConsumerState { openTodayAgenda: () => _openMainAgenda(windowService), synchronize: () => ref.read(syncSchedulerProvider).runNow(), openSettings: () => _openTraySettings(windowService), - quitBusyMax: windowService.quitApp, + quitBusyMax: () => _requestQuit(windowService), ), ); final factory = widget.trayServiceFactory; @@ -598,6 +554,27 @@ class _BusyMaxAppState extends ConsumerState { _issueScheduleCommand(ScheduleWorkspaceCommandKind.newEvent); } + Future _requestQuit(DesktopWindowService windowService) { + final pending = _quitRequest; + if (pending != null) return pending; + final request = _performQuitRequest(windowService); + _quitRequest = request; + return request.whenComplete(() { + if (identical(_quitRequest, request)) { + _quitRequest = null; + } + }); + } + + Future _performQuitRequest(DesktopWindowService windowService) async { + if (_windowCloseCoordinator.hasActiveHandler) { + await windowService.showWindow(); + } + if (await _windowCloseCoordinator.requestClose()) { + await windowService.quitApp(); + } + } + Future _openTrayNewTask(DesktopWindowService windowService) async { await windowService.showWindow(); ref.read(appRouterProvider).go('/tasks'); @@ -705,6 +682,34 @@ class _BusyMaxAppState extends ConsumerState { } } +/// Applies BusyMax's effective Linux platform policy without discarding any +/// inherited display or accessibility metrics. +@visibleForTesting +class LinuxApplicationMediaQuery extends StatelessWidget { + const LinuxApplicationMediaQuery({ + super.key, + required this.alwaysUse24HourFormat, + required this.gtkAnimationsEnabled, + required this.child, + }); + + final bool alwaysUse24HourFormat; + final bool gtkAnimationsEnabled; + final Widget child; + + @override + Widget build(BuildContext context) { + return MediaQuery( + data: MediaQuery.of(context).copyWith( + alwaysUse24HourFormat: alwaysUse24HourFormat, + disableAnimations: + MediaQuery.disableAnimationsOf(context) || !gtkAnimationsEnabled, + ), + child: child, + ); + } +} + class _KeyboardShortcutsIntent extends Intent { const _KeyboardShortcutsIntent(); } diff --git a/lib/src/app/busymax_design.dart b/lib/src/app/busymax_design.dart index 81dcddba..aaf8aaf6 100644 --- a/lib/src/app/busymax_design.dart +++ b/lib/src/app/busymax_design.dart @@ -11,6 +11,9 @@ import '../l10n/l10n.dart'; import '../platform/native_menu_service.dart'; import 'busymax_glyphs.dart'; import 'busymax_surface_colors.dart'; +import 'common/busymax_design_values.dart'; + +export 'common/busymax_design_values.dart' show BusyMaxMotion; abstract final class BusyMaxSpacing { static const double xxs = 2; @@ -41,6 +44,7 @@ abstract final class BusyMaxSizes { static const double compactDetailsWidth = 700; static const double comboWidth = 220; static const double toolbarHeight = kYaruTitleBarHeight; + static const double onboardingContentMaxWidth = 480; static const double sidebarRowHeight = 36; static const double taskRowMinHeight = 48; static const double iconSm = 16; @@ -87,15 +91,9 @@ abstract final class BusyMaxAlpha { static const double tooltipBorder = 0.10; } -abstract final class BusyMaxMotion { - static const Duration dialogInsets = Duration(milliseconds: 160); - static const Curve dialogInsetsCurve = Curves.easeOutCubic; - static const Duration tooltipWait = Duration(milliseconds: 500); -} - /// Cross-toolkit tooltip visuals. /// -/// Flutter and the native GTK header bar render separate tooltip widgets. +/// Flutter chrome and retained GTK surfaces render separate tooltip widgets. /// This contract keeps their surface geometry and palette identical while /// allowing both toolkits to retain native positioning and accessibility. abstract final class BusyMaxTooltipStyle { @@ -543,6 +541,10 @@ class BusyMaxHeaderIconButton extends StatelessWidget { this.fixedSize, this.shape, this.focusNode, + this.isSelected, + this.selectedIcon, + this.disabledForegroundColor, + this.focusBorderRadius, }); final Widget icon; @@ -555,6 +557,10 @@ class BusyMaxHeaderIconButton extends StatelessWidget { final Size? fixedSize; final OutlinedBorder? shape; final FocusNode? focusNode; + final bool? isSelected; + final Widget? selectedIcon; + final Color? disabledForegroundColor; + final double? focusBorderRadius; @override Widget build(BuildContext context) { @@ -564,10 +570,13 @@ class BusyMaxHeaderIconButton extends StatelessWidget { iconSize: iconSize, onPressed: onPressed, focusNode: focusNode, + isSelected: isSelected, + selectedIcon: selectedIcon, style: busyMaxHeaderIconButtonStyle( context, foregroundColor: foregroundColor, + disabledForegroundColor: disabledForegroundColor, backgroundColor: backgroundColor, overlayColor: overlayColor, ).copyWith( @@ -585,7 +594,10 @@ class BusyMaxHeaderIconButton extends StatelessWidget { ); return YaruTheme.maybeOf(context)?.focusBorders == true ? YaruFocusBorder.primary( - borderRadius: BorderRadius.circular(100), + borderRadius: BorderRadius.circular( + focusBorderRadius ?? + (shape is CircleBorder ? 100 : BusyMaxRadius.headerButton), + ), child: button, ) : button; @@ -595,12 +607,13 @@ class BusyMaxHeaderIconButton extends StatelessWidget { ButtonStyle busyMaxHeaderIconButtonStyle( BuildContext context, { Color? foregroundColor, + Color? disabledForegroundColor, WidgetStateProperty? backgroundColor, WidgetStateProperty? overlayColor, }) { - final disabledForeground = BusyMaxSurfaceColors.of( - context, - ).disabledForeground; + final disabledForeground = + disabledForegroundColor ?? + BusyMaxSurfaceColors.of(context).disabledForeground; return ButtonStyle( fixedSize: const WidgetStatePropertyAll( Size.square(BusyMaxSizes.headerIconButton), @@ -613,6 +626,8 @@ ButtonStyle busyMaxHeaderIconButtonStyle( ), padding: const WidgetStatePropertyAll(EdgeInsets.zero), tapTargetSize: MaterialTapTargetSize.shrinkWrap, + animationDuration: BusyMaxMotion.fast, + splashFactory: NoSplash.splashFactory, foregroundColor: WidgetStateProperty.resolveWith((states) { return states.contains(WidgetState.disabled) ? disabledForeground @@ -692,11 +707,139 @@ WidgetStateProperty busyMaxHeaderButtonBackground( }); } -/// BusyMax's cross-platform fallback for a native desktop search entry. +enum BusyMaxPushButtonFocusPlacement { + /// The ordinary libadwaita button outline: 2 px at offset -2. + inset, + + /// The opaque/suggested-action outline: 2 px at offset +1. + outside, +} + +/// Paints the role-aware Ubuntu keyboard-focus outline over the complete +/// button without changing its layout. /// -/// Linux header bars use `GtkSearchEntry`. Flutter-owned layouts delegate -/// geometry, icons, and interaction states to Yaru instead of restyling a raw -/// [TextField]. +/// A [ButtonStyle.backgroundBuilder] receives the overall button bounds; using +/// it here avoids the label-sized outline produced by a foreground builder. +/// Focus visibility is tracked from actual key and pointer input because +/// Flutter's [FocusHighlightMode.traditional] includes both mouse and keyboard. +ButtonLayerBuilder busyMaxPushButtonFocusBuilder( + Color focusColor, { + BusyMaxPushButtonFocusPlacement placement = + BusyMaxPushButtonFocusPlacement.inset, +}) { + return (context, states, child) { + final focused = + states.contains(WidgetState.focused) && + !states.contains(WidgetState.disabled); + final focusVisibility = _BusyMaxKeyboardFocusVisibility.instance + ..ensureRegistered(); + return ValueListenableBuilder( + valueListenable: focusVisibility, + builder: (context, keyboardInput, child) { + final opacity = Theme.of(context).colorScheme.isHighContrast + ? 0.80 + : 0.50; + return CustomPaint( + foregroundPainter: BusyMaxPushButtonFocusPainter( + color: focused && keyboardInput + ? focusColor.withValues(alpha: focusColor.a * opacity) + : Colors.transparent, + placement: placement, + ), + child: child, + ); + }, + child: child, + ); + }; +} + +/// The concrete focus geometry used by the shared button layer. +/// +/// This is public so rendering tests can verify the native outline offset +/// without inferring it from a fixture-only style. +class BusyMaxPushButtonFocusPainter extends CustomPainter { + const BusyMaxPushButtonFocusPainter({ + required this.color, + required this.placement, + }); + + static const double outlineWidth = 2; + + final Color color; + final BusyMaxPushButtonFocusPlacement placement; + + double get outlineOffset => switch (placement) { + BusyMaxPushButtonFocusPlacement.inset => -2, + BusyMaxPushButtonFocusPlacement.outside => 1, + }; + + @override + void paint(Canvas canvas, Size size) { + if (color.a == 0 || size.isEmpty) { + return; + } + final centerOffset = outlineOffset + outlineWidth / 2; + final bounds = (Offset.zero & size).inflate(centerOffset); + final radius = math.max(0.0, BusyMaxRadius.sm + centerOffset); + canvas.drawRRect( + RRect.fromRectAndRadius(bounds, Radius.circular(radius)), + Paint() + ..color = color + ..style = PaintingStyle.stroke + ..strokeWidth = outlineWidth, + ); + } + + @override + bool shouldRepaint(BusyMaxPushButtonFocusPainter oldDelegate) { + return color != oldDelegate.color || placement != oldDelegate.placement; + } +} + +/// Tracks focus-visible input modality for BusyMax push buttons. +/// +/// Desktop starts keyboard-visible so initial programmatic/autofocus remains +/// discoverable. A pointer press hides the ring before any focus request made +/// by that interaction; a hardware key restores it before traversal runs. +class _BusyMaxKeyboardFocusVisibility extends ValueNotifier { + _BusyMaxKeyboardFocusVisibility() : super(true) { + GestureBinding.instance.pointerRouter.addGlobalRoute(_handlePointerEvent); + } + + static final instance = _BusyMaxKeyboardFocusVisibility(); + + FocusManager? _registeredFocusManager; + + void ensureRegistered() { + final focusManager = FocusManager.instance; + if (identical(_registeredFocusManager, focusManager)) { + return; + } + _registeredFocusManager?.removeEarlyKeyEventHandler(_handleKeyEvent); + focusManager.addEarlyKeyEventHandler(_handleKeyEvent); + _registeredFocusManager = focusManager; + } + + void _handlePointerEvent(PointerEvent event) { + if (event is PointerDownEvent && value) { + value = false; + } + } + + KeyEventResult _handleKeyEvent(KeyEvent event) { + if ((event is KeyDownEvent || event is KeyRepeatEvent) && !value) { + value = true; + } + return KeyEventResult.ignored; + } +} + +/// BusyMax's shared Yaru-backed desktop search entry. +/// +/// Flutter-owned Linux header layouts delegate geometry and interaction states +/// to Yaru instead of restyling a raw [TextField]. Header callers may supply +/// GTK-themed artwork through the optional leading and clear slots. class BusyMaxSearchField extends StatefulWidget { const BusyMaxSearchField({ super.key, @@ -708,6 +851,8 @@ class BusyMaxSearchField extends StatefulWidget { this.onSubmitted, this.onClear, this.clearButtonSemanticLabel, + this.leadingIcon, + this.clearIcon, }); final TextEditingController? controller; @@ -721,6 +866,8 @@ class BusyMaxSearchField extends StatefulWidget { final ValueChanged? onSubmitted; final VoidCallback? onClear; final String? clearButtonSemanticLabel; + final Widget? leadingIcon; + final Widget? clearIcon; @override State createState() => _BusyMaxSearchFieldState(); @@ -816,14 +963,15 @@ class _BusyMaxSearchFieldState extends State { final clearLabel = widget.clearButtonSemanticLabel ?? MaterialLocalizations.of(context).clearButtonTooltip; + final hasLeadingIcon = widget.leadingIcon != null; final contentPadding = switch (Directionality.of(context)) { - TextDirection.ltr => const EdgeInsets.only( - left: BusyMaxSpacing.md, + TextDirection.ltr => EdgeInsets.only( + left: hasLeadingIcon ? kYaruTitleBarItemHeight : BusyMaxSpacing.md, right: kYaruTitleBarItemHeight, ), - TextDirection.rtl => const EdgeInsets.only( + TextDirection.rtl => EdgeInsets.only( left: kYaruTitleBarItemHeight, - right: BusyMaxSpacing.md, + right: hasLeadingIcon ? kYaruTitleBarItemHeight : BusyMaxSpacing.md, ), }; return FocusScope( @@ -844,6 +992,18 @@ class _BusyMaxSearchFieldState extends State { onSubmitted: widget.onSubmitted, clearIconSemanticLabel: clearLabel, ), + if (widget.leadingIcon != null) + Align( + alignment: AlignmentDirectional.centerStart, + child: IgnorePointer( + child: Padding( + padding: const EdgeInsetsDirectional.only( + start: BusyMaxSpacing.md, + ), + child: widget.leadingIcon, + ), + ), + ), if (widget.onClear != null && !_isEmpty) Align( alignment: AlignmentDirectional.centerEnd, @@ -853,7 +1013,7 @@ class _BusyMaxSearchFieldState extends State { ), child: BusyMaxHeaderIconButton( tooltip: clearLabel, - icon: const Icon(YaruIcons.edit_clear), + icon: widget.clearIcon ?? const Icon(YaruIcons.edit_clear), iconSize: BusyMaxSizes.iconSm, fixedSize: const Size.square( kYaruTitleBarItemHeight - BusyMaxSpacing.headerInset, @@ -955,17 +1115,52 @@ abstract final class BusyMaxPushButton { Key? key, }) { final colorScheme = Theme.of(context).colorScheme; + final colors = BusyMaxSurfaceColors.of(context); + final background = colorScheme.error; + final foreground = colorScheme.onError; + final destructiveStyle = ButtonStyle( + foregroundColor: WidgetStateProperty.resolveWith((states) { + return states.contains(WidgetState.disabled) + ? colors.disabledForeground + : foreground; + }), + iconColor: WidgetStateProperty.resolveWith((states) { + return states.contains(WidgetState.disabled) + ? colors.disabledForeground + : foreground; + }), + backgroundColor: WidgetStateProperty.resolveWith((states) { + if (states.contains(WidgetState.disabled)) { + return colors.disabledControl; + } + if (states.contains(WidgetState.pressed)) { + return Color.alphaBlend( + Colors.black.withValues(alpha: 0.20), + background, + ); + } + if (states.contains(WidgetState.hovered)) { + return Color.alphaBlend( + foreground.withValues(alpha: 0.10), + background, + ); + } + return background; + }), + overlayColor: const WidgetStatePropertyAll(Colors.transparent), + elevation: const WidgetStatePropertyAll(0), + shadowColor: const WidgetStatePropertyAll(Colors.transparent), + surfaceTintColor: const WidgetStatePropertyAll(Colors.transparent), + splashFactory: NoSplash.splashFactory, + backgroundBuilder: busyMaxPushButtonFocusBuilder(foreground), + ).merge(style); return ElevatedButton( key: key, onPressed: onPressed, onLongPress: onLongPress, onHover: onHover, onFocusChange: onFocusChange, - style: ElevatedButton.styleFrom( - backgroundColor: colorScheme.error, - foregroundColor: colorScheme.onError, - iconColor: colorScheme.onError, - ).merge(style), + style: destructiveStyle, focusNode: focusNode, autofocus: autofocus, clipBehavior: clipBehavior, @@ -1150,8 +1345,16 @@ TextStyle? busyMaxSectionHeaderStyle(BuildContext context) { /// Flutter fallback keeps the theme's title geometry and color, and mirrors /// only that semantic emphasis. TextStyle busyMaxHeaderTitleStyle(BuildContext context) { - return (Theme.of(context).textTheme.titleMedium ?? const TextStyle()) - .copyWith(fontWeight: FontWeight.bold); + return (Theme.of(context).textTheme.bodyMedium ?? const TextStyle()).copyWith( + fontWeight: FontWeight.bold, + ); +} + +/// The stronger GTK-sized wordmark used only by the Linux sidebar header. +TextStyle busyMaxHeaderBrandStyle(BuildContext context) { + return (Theme.of(context).textTheme.bodyMedium ?? const TextStyle()).copyWith( + fontWeight: FontWeight.w800, + ); } Widget _busyMaxGroupedRowSubtitle( @@ -1416,28 +1619,33 @@ class BusyMaxGroupedSurface extends StatelessWidget { } class BusyMaxSidebarSurface extends StatelessWidget { - const BusyMaxSidebarSurface({super.key, required this.child}); + const BusyMaxSidebarSurface({ + super.key, + required this.child, + this.showEndBorder = true, + }); final Widget child; + final bool showEndBorder; @override Widget build(BuildContext context) { final surfaceColors = BusyMaxSurfaceColors.of(context); - return Material( - color: surfaceColors.sidebar, - child: DecoratedBox( - position: DecorationPosition.foreground, - decoration: BoxDecoration( - border: BorderDirectional( - end: BorderSide( - color: surfaceColors.sidebarBorder, - width: BusyMaxStroke.outline, + final content = showEndBorder + ? DecoratedBox( + position: DecorationPosition.foreground, + decoration: BoxDecoration( + border: BorderDirectional( + end: BorderSide( + color: surfaceColors.sidebarBorder, + width: BusyMaxStroke.outline, + ), + ), ), - ), - ), - child: child, - ), - ); + child: child, + ) + : child; + return Material(color: surfaceColors.sidebar, child: content); } } @@ -2304,6 +2512,87 @@ class BusyMaxComboRow extends StatelessWidget { } } +typedef BusyMaxYaruFocusedControlBuilder = + Widget Function(BuildContext context, FocusNode focusNode); + +/// Presents Yaru's outline around the existing focus target during keyboard +/// navigation without adding another focus node or interaction handler. +/// +/// Yaru 10.2.0's selection controls and toggle list tiles do not propagate +/// their internal target's focus to the [YaruFocusBorder] installed by +/// `hasFocusBorder`. Driving the same outline from the target's [FocusNode] +/// keeps the supported presentation while preserving traversal and gestures. +class BusyMaxYaruFocusBorder extends StatefulWidget { + const BusyMaxYaruFocusBorder({ + super.key, + required this.builder, + this.focusNode, + this.borderStrokeAlign, + }); + + final BusyMaxYaruFocusedControlBuilder builder; + final FocusNode? focusNode; + final double? borderStrokeAlign; + + @override + State createState() => _BusyMaxYaruFocusBorderState(); +} + +class _BusyMaxYaruFocusBorderState extends State { + late FocusNode _focusNode; + + @override + void initState() { + super.initState(); + _updateFocusNode(); + } + + @override + void didUpdateWidget(covariant BusyMaxYaruFocusBorder oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.focusNode != widget.focusNode) { + _focusNode.removeListener(_handleFocusChange); + if (oldWidget.focusNode == null) _focusNode.dispose(); + _updateFocusNode(); + } + } + + @override + void dispose() { + _focusNode.removeListener(_handleFocusChange); + if (widget.focusNode == null) _focusNode.dispose(); + super.dispose(); + } + + void _updateFocusNode() { + _focusNode = widget.focusNode ?? FocusNode(); + _focusNode.addListener(_handleFocusChange); + } + + void _handleFocusChange() { + if (mounted) setState(() {}); + } + + @override + Widget build(BuildContext context) { + final focusVisibility = _BusyMaxKeyboardFocusVisibility.instance + ..ensureRegistered(); + return ValueListenableBuilder( + valueListenable: focusVisibility, + builder: (context, keyboardInput, child) => YaruFocusBorder.primary( + focused: true, + onFocusChange: (_) {}, + borderColor: _focusNode.hasPrimaryFocus && keyboardInput + ? null + : Colors.transparent, + borderStrokeAlign: widget.borderStrokeAlign, + child: child!, + ), + child: widget.builder(context, _focusNode), + ); + } +} + class BusyMaxSwitchRow extends StatelessWidget { const BusyMaxSwitchRow({ super.key, @@ -2324,20 +2613,34 @@ class BusyMaxSwitchRow extends StatelessWidget { @override Widget build(BuildContext context) { - return YaruSwitchListTile( - value: value, - onChanged: enabled ? onChanged : null, - secondary: leading, - title: Text(title), - subtitle: subtitle == null - ? null - : _busyMaxGroupedRowSubtitle( - context, - Text(subtitle!), - enabled: enabled, - ), - shape: const RoundedRectangleBorder(), - hoverColor: busyMaxRowHoverColor(context), + final effectiveOnChanged = enabled ? onChanged : null; + return BusyMaxYaruFocusBorder( + borderStrokeAlign: BorderSide.strokeAlignInside, + builder: (context, rowFocusNode) => YaruSwitchListTile( + value: value, + onChanged: effectiveOnChanged, + focusNode: rowFocusNode, + control: BusyMaxYaruFocusBorder( + builder: (context, controlFocusNode) => YaruSwitch( + value: value, + onChanged: effectiveOnChanged, + focusNode: controlFocusNode, + hasFocusBorder: false, + ), + ), + secondary: leading, + title: Text(title), + subtitle: subtitle == null + ? null + : _busyMaxGroupedRowSubtitle( + context, + Text(subtitle!), + enabled: enabled, + ), + shape: const RoundedRectangleBorder(), + hoverColor: busyMaxRowHoverColor(context), + hasFocusBorder: false, + ), ); } } @@ -2347,6 +2650,7 @@ class BusyMaxMenuEntry { required this.value, required this.label, this.icon, + this.nativeIconName, this.child, this.shortcut, this.enabled = true, @@ -2359,6 +2663,7 @@ class BusyMaxMenuEntry { final T value; final String label; final IconData? icon; + final String? nativeIconName; final Widget? child; final String? shortcut; final bool enabled; @@ -2566,7 +2871,9 @@ List _nativeMenuEntries(List> entries) { for (final entry in entries) NativeMenuEntry( label: entry.label, - iconName: BusyMaxGlyphs.nativeMenuIconName(entry.icon), + iconName: + entry.nativeIconName ?? + BusyMaxGlyphs.nativeMenuIconName(entry.icon), enabled: entry.enabled, role: switch (entry.role) { BusyMaxMenuEntryRole.command => NativeMenuEntryRole.command, @@ -3291,9 +3598,6 @@ class BusyMaxEditorHeader extends StatelessWidget { @override Widget build(BuildContext context) { - final actionStyle = ButtonStyle( - textStyle: WidgetStatePropertyAll(Theme.of(context).textTheme.titleSmall), - ); return Padding( padding: const EdgeInsets.fromLTRB( BusyMaxSpacing.headerInset, @@ -3310,8 +3614,7 @@ class BusyMaxEditorHeader extends StatelessWidget { heightFactor: 1, child: BusyMaxPushButton.standard( onPressed: cancelEnabled ? onCancel : null, - style: actionStyle, - child: Text(cancelLabel, overflow: TextOverflow.ellipsis), + child: Text(cancelLabel), ), ), ), @@ -3330,15 +3633,23 @@ class BusyMaxEditorHeader extends StatelessWidget { heightFactor: 1, child: BusyMaxPushButton.suggested( onPressed: onSave, - style: actionStyle, - child: saving - ? const ExcludeSemantics( + child: Stack( + alignment: Alignment.center, + children: [ + Opacity( + opacity: saving ? 0 : 1, + alwaysIncludeSemantics: true, + child: Text(saveLabel), + ), + if (saving) + const ExcludeSemantics( child: SizedBox.square( dimension: 16, child: CircularProgressIndicator(strokeWidth: 2), ), - ) - : Text(saveLabel, overflow: TextOverflow.ellipsis), + ), + ], + ), ), ), ), @@ -3503,7 +3814,7 @@ class BusyMaxEditorScrollBody extends StatelessWidget { BusyMaxSpacing.lg, BusyMaxSpacing.headerInset, BusyMaxSpacing.lg, - 0, + BusyMaxSpacing.lg, ), controller: controller, child: child, diff --git a/lib/src/app/busymax_dialogs.dart b/lib/src/app/busymax_dialogs.dart index 5d1fdef9..7eb37e06 100644 --- a/lib/src/app/busymax_dialogs.dart +++ b/lib/src/app/busymax_dialogs.dart @@ -1,15 +1,14 @@ import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import '../platform/linux_header_bar_service.dart'; -import '../platform/linux_header_bar_provider.dart'; import 'busymax_design.dart'; import 'busymax_shortcuts.dart'; +import 'busymax_window_close.dart'; const _modalShortcuts = { BusyMaxShortcutActivators.keyboardShortcuts: DoNothingAndStopPropagationIntent(), BusyMaxShortcutActivators.settings: DoNothingAndStopPropagationIntent(), + BusyMaxShortcutActivators.back: DoNothingAndStopPropagationIntent(), }; /// Prevents application-level navigation shortcuts from escaping a modal @@ -28,63 +27,24 @@ class BusyMaxModalShortcutBoundary extends StatelessWidget { } } -final _modalStates = - Map.identity(); -final _modalBarrierUpdateTails = - Map>.identity(); - Future showBusyMaxModalDialog( BuildContext context, { required WidgetBuilder builder, - LinuxHeaderBarService? headerBarService, Color? barrierColor, bool barrierDismissible = true, -}) async { - final effectiveHeaderBarService = - headerBarService ?? _headerBarServiceFrom(context); - return _coordinateBusyMaxModal( - context, - headerBarService: effectiveHeaderBarService, - shadesHeader: barrierColor == null || barrierColor.a > 0, - showSurface: () => _showBusyMaxFlutterDialog( - context, - builder: builder, - barrierColor: barrierColor, - barrierDismissible: barrierDismissible, - ), - ); -} - -Future _coordinateBusyMaxModal( - BuildContext context, { - required LinuxHeaderBarService? headerBarService, - required bool shadesHeader, - required Future Function() showSurface, }) async { final previousFocus = FocusManager.instance.primaryFocus; - await acquireBusyMaxModalBarrier( - headerBarService, - shadesHeader: shadesHeader, + final result = await _showBusyMaxFlutterDialog( + context, + builder: builder, + barrierColor: barrierColor, + barrierDismissible: barrierDismissible, ); - if (!context.mounted) { - await releaseBusyMaxModalBarrier( - headerBarService, - shadesHeader: shadesHeader, - ); - return null; - } - - try { - return await showSurface(); - } finally { - await releaseBusyMaxModalBarrier( - headerBarService, - shadesHeader: shadesHeader, - ); - if (previousFocus?.context != null && previousFocus!.canRequestFocus) { - previousFocus.requestFocus(); - } + if (previousFocus?.context?.mounted == true && + previousFocus!.canRequestFocus) { + previousFocus.requestFocus(); } + return result; } Future _showBusyMaxFlutterDialog( @@ -92,19 +52,22 @@ Future _showBusyMaxFlutterDialog( required WidgetBuilder builder, Color? barrierColor, bool barrierDismissible = true, -}) { +}) async { final navigator = Navigator.of(context, rootNavigator: true); final themes = InheritedTheme.capture(from: context, to: navigator.context); - return navigator.push( - _BusyMaxDialogRoute( - context: context, - builder: builder, - themes: themes, - fixedBarrierColor: barrierColor, - initialBarrierColor: barrierColor ?? busyMaxModalBarrierColor(context), - barrierDismissible: barrierDismissible, - ), + final closeCoordinator = BusyMaxWindowCloseScope.maybeOf(context); + final route = _BusyMaxDialogRoute( + context: context, + builder: builder, + themes: themes, + closeCoordinator: closeCoordinator, + fixedBarrierColor: barrierColor, + initialBarrierColor: barrierColor ?? busyMaxModalBarrierColor(context), + barrierDismissible: barrierDismissible, ); + final result = await navigator.push(route); + await route.completed; + return result; } class _BusyMaxDialogRoute extends DialogRoute { @@ -112,14 +75,25 @@ class _BusyMaxDialogRoute extends DialogRoute { required super.context, required WidgetBuilder builder, required CapturedThemes themes, + required BusyMaxWindowCloseCoordinator? closeCoordinator, required Color? fixedBarrierColor, required Color initialBarrierColor, required super.barrierDismissible, }) : _fixedBarrierColor = fixedBarrierColor, _initialBarrierColor = initialBarrierColor, super( - builder: (dialogContext) => - BusyMaxModalShortcutBoundary(child: builder(dialogContext)), + builder: (dialogContext) { + final dialog = BusyMaxWindowCloseGuard( + onCloseRequested: () => false, + child: BusyMaxModalShortcutBoundary(child: builder(dialogContext)), + ); + return closeCoordinator == null + ? dialog + : BusyMaxWindowCloseScope( + coordinator: closeCoordinator, + child: dialog, + ); + }, themes: themes, barrierColor: initialBarrierColor, traversalEdgeBehavior: TraversalEdgeBehavior.closedLoop, @@ -146,14 +120,12 @@ class _BusyMaxDialogRoute extends DialogRoute { Future showBusyMaxModalEditorDialog( BuildContext context, { required WidgetBuilder builder, - LinuxHeaderBarService? headerBarService, Color? barrierColor, double maxWidth = BusyMaxSizes.compactDetailsWidth, double? maxHeight = 760, }) async { return showBusyMaxModalDialog( context, - headerBarService: headerBarService, barrierColor: barrierColor, barrierDismissible: false, builder: (dialogContext) { @@ -175,11 +147,9 @@ Future showBusyMaxTextPrompt( String? initialValue, String? message, Color? barrierColor, - LinuxHeaderBarService? headerBarService, }) { return showBusyMaxModalDialog( context, - headerBarService: headerBarService, barrierColor: barrierColor, barrierDismissible: false, builder: (dialogContext) => BusyMaxPromptDialog( @@ -199,11 +169,9 @@ Future showBusyMaxConfirm( required String confirmLabel, bool destructive = false, Color? barrierColor, - LinuxHeaderBarService? headerBarService, }) async { final confirmed = await showBusyMaxModalDialog( context, - headerBarService: headerBarService, barrierColor: barrierColor, builder: (dialogContext) => BusyMaxConfirmDialog( title: title, @@ -214,152 +182,3 @@ Future showBusyMaxConfirm( ); return confirmed == true; } - -/// Acquires a reference-counted native header-bar modal barrier. -/// -/// Every call must be paired with [releaseBusyMaxModalBarrier]. In-page modal -/// surfaces should use this pair; route dialogs acquire it automatically. -/// Set [shadesHeader] to false when the matching Flutter barrier is fully -/// transparent but still blocks interaction. -Future acquireBusyMaxModalBarrier( - LinuxHeaderBarService? service, { - bool shadesHeader = true, -}) async { - if (service == null) { - return; - } - final state = _modalStates.putIfAbsent( - service, - _BusyMaxModalBarrierState.new, - ); - final previousVisible = state.modalDepth > 0; - final previousShadeDepth = state.shadeDepth; - state.modalDepth += 1; - if (shadesHeader) { - state.shadeDepth += 1; - } - final visible = state.modalDepth > 0; - final stateChanged = - visible != previousVisible || state.shadeDepth != previousShadeDepth; - final stateUpdate = stateChanged - ? _enqueueBusyMaxModalBarrierUpdate( - service, - visible: visible, - shadeDepth: state.shadeDepth, - ) - : _modalBarrierUpdateTails[service]; - if (stateUpdate == null) { - return; - } - - try { - // A nested transparent route shares the in-flight native state without - // adding a shade. A visibly shaded route increments only shadeDepth. - await stateUpdate; - } on Object catch (error, stackTrace) { - state.modalDepth -= 1; - if (shadesHeader) { - state.shadeDepth -= 1; - } - if (state.modalDepth == 0) { - _modalStates.remove(service); - } - try { - // The platform may have applied the state before its response failed. - // Restore the preceding native state while preserving the cause. - await _enqueueBusyMaxModalBarrierUpdate( - service, - visible: state.modalDepth > 0, - shadeDepth: state.shadeDepth, - ); - } on Object { - // Best-effort rollback cannot replace the causative exception. - } - Error.throwWithStackTrace(error, stackTrace); - } -} - -/// Releases a barrier acquired by [acquireBusyMaxModalBarrier]. -/// -/// [shadesHeader] must match the value used by the corresponding acquire. -Future releaseBusyMaxModalBarrier( - LinuxHeaderBarService? service, { - bool shadesHeader = true, -}) async { - if (service == null) { - return; - } - final state = _modalStates[service]; - if (state == null || state.modalDepth == 0) { - return; - } - final previousVisible = state.modalDepth > 0; - final previousShadeDepth = state.shadeDepth; - state.modalDepth -= 1; - if (shadesHeader && state.shadeDepth > 0) { - state.shadeDepth -= 1; - } - final visible = state.modalDepth > 0; - if (!visible) { - _modalStates.remove(service); - } - if (visible == previousVisible && state.shadeDepth == previousShadeDepth) { - final pending = _modalBarrierUpdateTails[service]; - if (pending != null) { - await pending; - } - return; - } - await _enqueueBusyMaxModalBarrierUpdate( - service, - visible: visible, - shadeDepth: state.shadeDepth, - ); -} - -Future _enqueueBusyMaxModalBarrierUpdate( - LinuxHeaderBarService service, { - required bool visible, - required int shadeDepth, -}) { - final previous = _modalBarrierUpdateTails[service] ?? Future.value(); - final ready = previous.then( - (_) {}, - // A failed update belongs to the caller that requested it. It must not - // poison the per-service queue and prevent a rollback or later retry. - onError: (Object _, StackTrace _) {}, - ); - late final Future update; - update = ready - .then( - (_) => service.setModalBarrierState( - visible: visible, - shadeDepth: shadeDepth, - ), - ) - .whenComplete(() { - if (identical(_modalBarrierUpdateTails[service], update)) { - _modalBarrierUpdateTails.remove(service); - } - }); - _modalBarrierUpdateTails[service] = update; - return update; -} - -final class _BusyMaxModalBarrierState { - int modalDepth = 0; - int shadeDepth = 0; -} - -LinuxHeaderBarService? _headerBarServiceFrom(BuildContext context) { - try { - return ProviderScope.containerOf( - context, - listen: false, - ).read(linuxHeaderBarServiceProvider); - } on StateError { - // Standalone widget hosts (including lightweight tests) do not - // necessarily install Riverpod. Explicit injection remains available. - return null; - } -} diff --git a/lib/src/app/busymax_header_actions.dart b/lib/src/app/busymax_header_actions.dart new file mode 100644 index 00000000..a500b589 --- /dev/null +++ b/lib/src/app/busymax_header_actions.dart @@ -0,0 +1,25 @@ +/// Commands exposed by BusyMax's Flutter-rendered application header. +/// +/// These are presentation commands, not platform-channel messages. A route +/// dispatches them directly to the state that owns the corresponding action. +enum BusyMaxHeaderAction { + back, + continueSetup, + sidebarToggle, + today, + previous, + next, + viewModeDay, + viewModeWeek, + viewModeMonth, + viewModeYear, + viewModeAgenda, + search, + createEvent, + createTask, + refresh, + settings, + keyboardShortcuts, + reportIssue, + aboutBusyMax, +} diff --git a/lib/src/app/busymax_keyboard_shortcuts_dialog.dart b/lib/src/app/busymax_keyboard_shortcuts_dialog.dart index 08583d45..a291dc4c 100644 --- a/lib/src/app/busymax_keyboard_shortcuts_dialog.dart +++ b/lib/src/app/busymax_keyboard_shortcuts_dialog.dart @@ -2,19 +2,14 @@ import 'package:flutter/material.dart'; import 'package:yaru/yaru.dart'; import '../l10n/l10n.dart'; -import '../platform/linux_header_bar_service.dart'; import 'busymax_design.dart'; import 'busymax_dialog_identity.dart'; import 'busymax_dialogs.dart'; import 'busymax_shortcuts.dart'; -Future showBusyMaxKeyboardShortcutsDialog( - BuildContext context, { - LinuxHeaderBarService? headerBarService, -}) async { +Future showBusyMaxKeyboardShortcutsDialog(BuildContext context) async { await showBusyMaxModalDialog( context, - headerBarService: headerBarService, builder: (context) => const BusyMaxKeyboardShortcutsDialog(), ); } @@ -75,6 +70,13 @@ class BusyMaxKeyboardShortcutsDialog extends StatelessWidget { title: l10n.shortcutGroupNavigation, filled: true, children: [ + BusyMaxActionRow( + title: MaterialLocalizations.of(context).backButtonTooltip, + leading: const Icon(Icons.arrow_back), + trailing: const _KeyboardShortcutBadge( + BusyMaxShortcutLabels.back, + ), + ), BusyMaxActionRow( title: l10n.toggleSidebar, leading: const Icon(Icons.vertical_split_outlined), diff --git a/lib/src/app/busymax_native_search_entry_theme.dart b/lib/src/app/busymax_native_search_entry_theme.dart new file mode 100644 index 00000000..de3b9ef5 --- /dev/null +++ b/lib/src/app/busymax_native_search_entry_theme.dart @@ -0,0 +1,212 @@ +import 'package:flutter/material.dart'; +import 'package:yaru/theme.dart'; + +import '../platform/gtk_font_service.dart'; +import 'busymax_surface_colors.dart'; + +@immutable +final class BusyMaxNativeSearchEntryTheme + extends ThemeExtension { + const BusyMaxNativeSearchEntryTheme({ + required this.normal, + required this.focused, + required this.backdrop, + required this.backdropFocused, + }); + + factory BusyMaxNativeSearchEntryTheme.fromGtk(GtkSearchEntryTheme theme) { + return BusyMaxNativeSearchEntryTheme( + normal: theme.normal, + focused: theme.focused, + backdrop: theme.backdrop, + backdropFocused: theme.backdropFocused, + ); + } + + factory BusyMaxNativeSearchEntryTheme.fallback({ + required BusyMaxSurfaceColors colors, + required Color accent, + required bool highContrast, + }) { + final radius = highContrast ? 6.0 : 9.0; + final backdropForeground = _withOpacity(colors.foreground, .5); + final backdropIcon = _withOpacity(colors.mutedForeground, .5); + return BusyMaxNativeSearchEntryTheme( + normal: _fallbackState( + colors: colors, + foreground: colors.foreground, + borderColor: colors.border, + iconForeground: colors.mutedForeground, + borderWidth: 1, + radius: radius, + ), + focused: _fallbackState( + colors: colors, + foreground: colors.foreground, + borderColor: accent, + iconForeground: colors.mutedForeground, + borderWidth: highContrast ? 2 : 1, + radius: radius, + hasInnerFocus: !highContrast, + innerFocusColor: accent, + innerFocusWidth: highContrast ? 0 : 1, + ), + backdrop: _fallbackState( + colors: colors, + foreground: backdropForeground, + borderColor: colors.border, + iconForeground: backdropIcon, + borderWidth: 1, + radius: radius, + ), + backdropFocused: _fallbackState( + colors: colors, + foreground: backdropForeground, + borderColor: colors.border, + iconForeground: backdropIcon, + borderWidth: 1, + radius: radius, + ), + ); + } + + final GtkSearchEntryStateStyle normal; + final GtkSearchEntryStateStyle focused; + final GtkSearchEntryStateStyle backdrop; + final GtkSearchEntryStateStyle backdropFocused; + + static BusyMaxNativeSearchEntryTheme of(BuildContext context) { + final theme = Theme.of(context); + return theme.extension() ?? + BusyMaxNativeSearchEntryTheme.fallback( + colors: BusyMaxSurfaceColors.of(context), + accent: theme.colorScheme.primary, + highContrast: theme.colorScheme.isHighContrast, + ); + } + + GtkSearchEntryStateStyle stateFor({ + required bool windowActive, + required bool focused, + }) { + return switch ((windowActive, focused)) { + (true, false) => normal, + (true, true) => this.focused, + (false, false) => backdrop, + (false, true) => backdropFocused, + }; + } + + @override + BusyMaxNativeSearchEntryTheme copyWith({ + GtkSearchEntryStateStyle? normal, + GtkSearchEntryStateStyle? focused, + GtkSearchEntryStateStyle? backdrop, + GtkSearchEntryStateStyle? backdropFocused, + }) { + return BusyMaxNativeSearchEntryTheme( + normal: normal ?? this.normal, + focused: focused ?? this.focused, + backdrop: backdrop ?? this.backdrop, + backdropFocused: backdropFocused ?? this.backdropFocused, + ); + } + + @override + BusyMaxNativeSearchEntryTheme lerp( + covariant BusyMaxNativeSearchEntryTheme? other, + double t, + ) { + if (other == null) return this; + return BusyMaxNativeSearchEntryTheme( + normal: _lerpState(normal, other.normal, t), + focused: _lerpState(focused, other.focused, t), + backdrop: _lerpState(backdrop, other.backdrop, t), + backdropFocused: _lerpState(backdropFocused, other.backdropFocused, t), + ); + } +} + +GtkSearchEntryStateStyle _fallbackState({ + required BusyMaxSurfaceColors colors, + required Color foreground, + required Color borderColor, + required Color iconForeground, + required double borderWidth, + required double radius, + bool hasInnerFocus = false, + Color innerFocusColor = Colors.transparent, + double innerFocusWidth = 0, +}) { + return GtkSearchEntryStateStyle( + background: colors.view, + foreground: foreground, + borderTop: borderWidth, + borderRight: borderWidth, + borderBottom: borderWidth, + borderLeft: borderWidth, + borderColor: borderColor, + primaryIconForeground: iconForeground, + primaryIconForegroundRtl: iconForeground, + secondaryIconForeground: iconForeground, + secondaryIconForegroundRtl: iconForeground, + radius: radius, + hasInnerFocus: hasInnerFocus, + innerFocusColor: innerFocusColor, + innerFocusWidth: innerFocusWidth, + ); +} + +GtkSearchEntryStateStyle _lerpState( + GtkSearchEntryStateStyle first, + GtkSearchEntryStateStyle second, + double t, +) { + return GtkSearchEntryStateStyle( + background: Color.lerp(first.background, second.background, t)!, + foreground: Color.lerp(first.foreground, second.foreground, t)!, + borderTop: _lerpDouble(first.borderTop, second.borderTop, t), + borderRight: _lerpDouble(first.borderRight, second.borderRight, t), + borderBottom: _lerpDouble(first.borderBottom, second.borderBottom, t), + borderLeft: _lerpDouble(first.borderLeft, second.borderLeft, t), + borderColor: Color.lerp(first.borderColor, second.borderColor, t)!, + primaryIconForeground: Color.lerp( + first.primaryIconForeground, + second.primaryIconForeground, + t, + )!, + primaryIconForegroundRtl: Color.lerp( + first.primaryIconForegroundRtl, + second.primaryIconForegroundRtl, + t, + )!, + secondaryIconForeground: Color.lerp( + first.secondaryIconForeground, + second.secondaryIconForeground, + t, + )!, + secondaryIconForegroundRtl: Color.lerp( + first.secondaryIconForegroundRtl, + second.secondaryIconForegroundRtl, + t, + )!, + radius: _lerpDouble(first.radius, second.radius, t), + hasInnerFocus: t >= .5 ? second.hasInnerFocus : first.hasInnerFocus, + innerFocusColor: Color.lerp( + first.innerFocusColor, + second.innerFocusColor, + t, + )!, + innerFocusWidth: _lerpDouble( + first.innerFocusWidth, + second.innerFocusWidth, + t, + ), + ); +} + +double _lerpDouble(double first, double second, double t) => + first + (second - first) * t; + +Color _withOpacity(Color color, double opacity) => + color.withValues(alpha: color.a * opacity); diff --git a/lib/src/app/busymax_shortcuts.dart b/lib/src/app/busymax_shortcuts.dart index 135461df..ce28fdc4 100644 --- a/lib/src/app/busymax_shortcuts.dart +++ b/lib/src/app/busymax_shortcuts.dart @@ -4,6 +4,7 @@ import 'package:flutter/widgets.dart'; import '../schedule/schedule_view_mode.dart'; abstract final class BusyMaxShortcutActivators { + static const back = SingleActivator(LogicalKeyboardKey.arrowLeft, alt: true); static const keyboardShortcuts = SingleActivator( LogicalKeyboardKey.keyK, control: true, @@ -20,6 +21,7 @@ abstract final class BusyMaxShortcutActivators { } abstract final class BusyMaxShortcutLabels { + static const back = 'Alt+Left'; static const keyboardShortcuts = 'Ctrl+Alt+K'; static const settings = 'Ctrl+Alt+S'; static const search = 'Ctrl+F'; diff --git a/lib/src/app/busymax_window_close.dart b/lib/src/app/busymax_window_close.dart new file mode 100644 index 00000000..2ff1911a --- /dev/null +++ b/lib/src/app/busymax_window_close.dart @@ -0,0 +1,135 @@ +import 'dart:async'; + +import 'package:flutter/widgets.dart'; + +typedef BusyMaxWindowCloseHandler = FutureOr Function(); + +/// Coordinates destructive top-level window close requests with the active +/// modal surface that owns any transient editor state. +/// +/// Handlers are ordered by presentation: the most recently mounted active +/// surface owns the decision. Concurrent close requests share one resolution +/// so they cannot open duplicate confirmation dialogs. +final class BusyMaxWindowCloseCoordinator { + final List<_BusyMaxWindowCloseRegistration> _registrations = []; + Future? _pendingRequest; + + bool get hasActiveHandler => _registrations.isNotEmpty; + + Future requestClose() { + final pending = _pendingRequest; + if (pending != null) return pending; + + final request = _resolveCloseRequest(); + _pendingRequest = request; + return request.whenComplete(() { + if (identical(_pendingRequest, request)) { + _pendingRequest = null; + } + }); + } + + _BusyMaxWindowCloseRegistration _register(BusyMaxWindowCloseHandler handler) { + final registration = _BusyMaxWindowCloseRegistration(this, handler); + _registrations.add(registration); + return registration; + } + + Future _resolveCloseRequest() async { + if (_registrations case [..., final active]) { + try { + return await active.handler(); + } on Object catch (error, stackTrace) { + FlutterError.reportError( + FlutterErrorDetails( + exception: error, + stack: stackTrace, + library: 'BusyMax window close coordinator', + context: ErrorDescription( + 'while asking the active surface to resolve a window close', + ), + ), + ); + return false; + } + } + return true; + } + + void _unregister(_BusyMaxWindowCloseRegistration registration) { + _registrations.remove(registration); + } +} + +final class _BusyMaxWindowCloseRegistration { + _BusyMaxWindowCloseRegistration(this._owner, this.handler); + + BusyMaxWindowCloseCoordinator? _owner; + final BusyMaxWindowCloseHandler handler; + + void dispose() { + _owner?._unregister(this); + _owner = null; + } +} + +class BusyMaxWindowCloseScope extends InheritedWidget { + const BusyMaxWindowCloseScope({ + super.key, + required this.coordinator, + required super.child, + }); + + final BusyMaxWindowCloseCoordinator coordinator; + + static BusyMaxWindowCloseCoordinator? maybeOf(BuildContext context) { + return context + .dependOnInheritedWidgetOfExactType() + ?.coordinator; + } + + @override + bool updateShouldNotify(BusyMaxWindowCloseScope oldWidget) => + !identical(coordinator, oldWidget.coordinator); +} + +/// Registers a close decision owned by an editor or modal surface while that +/// surface is mounted beneath [BusyMaxWindowCloseScope]. +class BusyMaxWindowCloseGuard extends StatefulWidget { + const BusyMaxWindowCloseGuard({ + super.key, + required this.onCloseRequested, + required this.child, + }); + + final BusyMaxWindowCloseHandler onCloseRequested; + final Widget child; + + @override + State createState() => + _BusyMaxWindowCloseGuardState(); +} + +class _BusyMaxWindowCloseGuardState extends State { + BusyMaxWindowCloseCoordinator? _coordinator; + _BusyMaxWindowCloseRegistration? _registration; + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + final coordinator = BusyMaxWindowCloseScope.maybeOf(context); + if (identical(coordinator, _coordinator)) return; + _registration?.dispose(); + _coordinator = coordinator; + _registration = coordinator?._register(() => widget.onCloseRequested()); + } + + @override + void dispose() { + _registration?.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) => widget.child; +} diff --git a/lib/src/app/busymax_yaru_theme.dart b/lib/src/app/busymax_yaru_theme.dart index 22c05fcd..a0e2025a 100644 --- a/lib/src/app/busymax_yaru_theme.dart +++ b/lib/src/app/busymax_yaru_theme.dart @@ -1,11 +1,15 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:yaru/theme.dart'; +import 'package:yaru/widgets.dart' + show YaruCheckboxThemeData, YaruRadioThemeData, YaruSwitchThemeData; import '../platform/gtk_font_service.dart'; import 'busymax_design.dart'; +import 'busymax_native_search_entry_theme.dart'; import 'busymax_surface_colors.dart'; +export 'busymax_native_search_entry_theme.dart'; export 'busymax_surface_colors.dart'; const _minimumControlSurfaceContrast = 1.02; @@ -43,6 +47,15 @@ class BusyMaxYaruTheme { final colors = highContrast ? _highContrastSurfaceColors(brightness) : resolvedColors; + final nativeSearchEntry = + gtkThemeColors?.brightness == brightness && + gtkThemeColors?.searchEntry != null + ? BusyMaxNativeSearchEntryTheme.fromGtk(gtkThemeColors!.searchEntry!) + : BusyMaxNativeSearchEntryTheme.fallback( + colors: colors, + accent: accentColor, + highContrast: highContrast, + ); final surfaceContainers = _surfaceContainerLadder(colors, brightness); final sampledAccentForeground = gtkThemeColors?.brightness == brightness && @@ -111,54 +124,55 @@ class BusyMaxYaruTheme { normalizer: normalizer, textTheme: textTheme, ); - final outlinedButtonStyle = _semanticButtonStyle( - _yaruDesktopButtonStyle(base.outlinedButtonTheme.style), + final pushButtonTextStyle = WidgetStatePropertyAll( + textTheme.labelLarge?.copyWith(fontWeight: FontWeight.bold), + ); + final standardFocusColor = accentColor; + final outlinedButtonStyle = _semanticPushButtonStyle( + base.outlinedButtonTheme.style, foreground: colors.foreground, background: Colors.transparent, + hoveredBackground: colors.controlHover, + pressedBackground: colors.controlActive, disabledForeground: colors.disabledForeground, disabledBackground: Colors.transparent, - textStyle: _normalizeTextStyleProperty( - base.outlinedButtonTheme.style?.textStyle, - normalizer: normalizer, - fallback: textTheme.labelLarge, - ), + textStyle: pushButtonTextStyle, + focusColor: standardFocusColor, ); - final filledButtonStyle = _semanticButtonStyle( - _yaruDesktopButtonStyle(base.filledButtonTheme.style), + final filledButtonStyle = _semanticPushButtonStyle( + base.filledButtonTheme.style, foreground: colors.foreground, background: colors.control, + hoveredBackground: colors.controlHover, + pressedBackground: colors.controlActive, selectedBackground: colors.controlActive, disabledForeground: colors.disabledForeground, disabledBackground: colors.disabledControl, - textStyle: _normalizeTextStyleProperty( - base.filledButtonTheme.style?.textStyle, - normalizer: normalizer, - fallback: textTheme.labelLarge, - ), + textStyle: pushButtonTextStyle, + focusColor: standardFocusColor, ); - final elevatedButtonStyle = _semanticButtonStyle( - _yaruDesktopButtonStyle(base.elevatedButtonTheme.style), + final elevatedButtonStyle = _semanticPushButtonStyle( + base.elevatedButtonTheme.style, foreground: onAccent, background: accentColor, + hoveredBackground: _opaqueButtonHover(accentColor, onAccent), + pressedBackground: _opaqueButtonPressed(accentColor), disabledForeground: colors.disabledForeground, disabledBackground: colors.disabledControl, - textStyle: _normalizeTextStyleProperty( - base.elevatedButtonTheme.style?.textStyle, - normalizer: normalizer, - fallback: textTheme.labelLarge, - ), + textStyle: pushButtonTextStyle, + focusColor: accentColor, + focusPlacement: BusyMaxPushButtonFocusPlacement.outside, ); - final textButtonStyle = _semanticButtonStyle( - _yaruDesktopButtonStyle(base.textButtonTheme.style), + final textButtonStyle = _semanticPushButtonStyle( + base.textButtonTheme.style, foreground: accentColor, background: Colors.transparent, + hoveredBackground: colors.controlHover, + pressedBackground: colors.controlActive, disabledForeground: colors.disabledForeground, disabledBackground: Colors.transparent, - textStyle: _normalizeTextStyleProperty( - base.textButtonTheme.style?.textStyle, - normalizer: normalizer, - fallback: textTheme.labelLarge, - ), + textStyle: pushButtonTextStyle, + focusColor: standardFocusColor, ); final popoverSurfaceSide = BorderSide( color: highContrast ? colors.border : colors.floatingBorder, @@ -205,6 +219,24 @@ class BusyMaxYaruTheme { constraints: BusyMaxTooltipStyle.constraints, waitDuration: BusyMaxMotion.tooltipWait, ); + const transparentIndicator = WidgetStatePropertyAll( + Colors.transparent, + ); + final yaruRadioTheme = + base.extension()?.copyWith( + indicatorColor: transparentIndicator, + ) ?? + const YaruRadioThemeData(indicatorColor: transparentIndicator); + final yaruCheckboxTheme = + base.extension()?.copyWith( + indicatorColor: transparentIndicator, + ) ?? + const YaruCheckboxThemeData(indicatorColor: transparentIndicator); + final yaruSwitchTheme = + base.extension()?.copyWith( + indicatorColor: transparentIndicator, + ) ?? + const YaruSwitchThemeData(indicatorColor: transparentIndicator); return base.copyWith( brightness: brightness, @@ -217,8 +249,17 @@ class BusyMaxYaruTheme { cardTheme: cardTheme, extensions: [ for (final extension in base.extensions.values) - if (extension is! BusyMaxSurfaceColors) extension, + if (extension is! BusyMaxSurfaceColors && + extension is! BusyMaxNativeSearchEntryTheme && + extension is! YaruRadioThemeData && + extension is! YaruCheckboxThemeData && + extension is! YaruSwitchThemeData) + extension, colors, + nativeSearchEntry, + yaruRadioTheme, + yaruCheckboxTheme, + yaruSwitchTheme, ], dividerColor: colors.divider, appBarTheme: base.appBarTheme.copyWith( @@ -1127,20 +1168,17 @@ MenuStyle _semanticMenuSurfaceStyle( ); } -/// Keeps Yaru's horizontal breathing room while allowing its own minimum -/// button height to remain the control height. +/// Applies the modern Ubuntu text-button content box to Yaru's shared shape. /// -/// Yaru 10.2 applies its common padding on every edge. For a single-line -/// desktop action that vertical padding grows the control beyond Yaru's -/// declared button-height token. GTK-style buttons use the height token as -/// their metric, so normalize only that incompatible axis here, once, instead -/// of constraining individual buttons. -ButtonStyle? _yaruDesktopButtonStyle(ButtonStyle? base) { - final sourcePadding = base?.padding; - if (base == null || sourcePadding == null) { - return base; - } - return base.copyWith( +/// GTK's 24 px content minimum plus 5 px vertical padding produces the 34 px +/// normal control height. Flutter's minimum size already includes padding, so +/// the same values are declared here without adding a second outer inset. +ButtonStyle _yaruDesktopPushButtonStyle( + ButtonStyle? base, { + required WidgetStateProperty textStyle, +}) { + return (base ?? const ButtonStyle()).copyWith( + textStyle: textStyle, // Flutter's Linux-wide compact density would otherwise reduce Yaru's // declared 34 px button minimum to 26 px. Yaru already defines the native // control metric, so do not apply a second density reduction to buttons. @@ -1149,19 +1187,75 @@ ButtonStyle? _yaruDesktopButtonStyle(ButtonStyle? base) { // declaring it on the shared button style keeps widget tests and fallback // shells from adding a mobile-only 48 px tap-target wrapper. tapTargetSize: MaterialTapTargetSize.shrinkWrap, - padding: WidgetStateProperty.resolveWith((states) { - return switch (sourcePadding.resolve(states)) { - final EdgeInsets padding => EdgeInsets.only( - left: padding.left, - right: padding.right, - ), - final EdgeInsetsDirectional padding => EdgeInsetsDirectional.only( - start: padding.start, - end: padding.end, - ), - final padding => padding, - }; + minimumSize: const WidgetStatePropertyAll(Size(34, 34)), + padding: const WidgetStatePropertyAll( + EdgeInsets.symmetric(horizontal: 17, vertical: 5), + ), + elevation: const WidgetStatePropertyAll(0), + shadowColor: const WidgetStatePropertyAll(Colors.transparent), + surfaceTintColor: const WidgetStatePropertyAll(Colors.transparent), + splashFactory: NoSplash.splashFactory, + ); +} + +Color _opaqueButtonHover(Color background, Color foreground) { + return Color.alphaBlend(foreground.withValues(alpha: 0.10), background); +} + +Color _opaqueButtonPressed(Color background) { + return Color.alphaBlend(Colors.black.withValues(alpha: 0.20), background); +} + +/// Defines the complete visual state contract for Flutter-rendered push +/// buttons. Icon buttons intentionally continue to use [_semanticButtonStyle] +/// so push-button padding and typography cannot leak into compact controls. +ButtonStyle _semanticPushButtonStyle( + ButtonStyle? base, { + required Color foreground, + required Color background, + required Color hoveredBackground, + required Color pressedBackground, + Color? selectedBackground, + required Color disabledForeground, + required Color disabledBackground, + required WidgetStateProperty textStyle, + required Color focusColor, + BusyMaxPushButtonFocusPlacement focusPlacement = + BusyMaxPushButtonFocusPlacement.inset, +}) { + return _yaruDesktopPushButtonStyle(base, textStyle: textStyle).copyWith( + foregroundColor: WidgetStateProperty.resolveWith((states) { + return states.contains(WidgetState.disabled) + ? disabledForeground + : foreground; + }), + iconColor: WidgetStateProperty.resolveWith((states) { + return states.contains(WidgetState.disabled) + ? disabledForeground + : foreground; }), + backgroundColor: WidgetStateProperty.resolveWith((states) { + if (states.contains(WidgetState.disabled)) { + return disabledBackground; + } + if (states.contains(WidgetState.pressed)) { + return pressedBackground; + } + if (states.contains(WidgetState.hovered)) { + return hoveredBackground; + } + if (selectedBackground != null && states.contains(WidgetState.selected)) { + return selectedBackground; + } + return background; + }), + // The resolver above owns the complete surface state. Suppress Material's + // additional tint and ripple so it cannot double-composite those colors. + overlayColor: const WidgetStatePropertyAll(Colors.transparent), + backgroundBuilder: busyMaxPushButtonFocusBuilder( + focusColor, + placement: focusPlacement, + ), ); } diff --git a/lib/src/app/common/busymax_design_values.dart b/lib/src/app/common/busymax_design_values.dart index 3281ff20..d2c39fe7 100644 --- a/lib/src/app/common/busymax_design_values.dart +++ b/lib/src/app/common/busymax_design_values.dart @@ -33,6 +33,23 @@ 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); + + static const Duration dialogInsets = Duration(milliseconds: 160); + static const Curve dialogInsetsCurve = Curves.easeOutCubic; + + /// Behavioral delay; unlike presentation durations this is never disabled. + static const Duration tooltipWait = Duration(milliseconds: 500); + + static const Duration sidebar = Duration(milliseconds: 200); + static const Duration search = Duration(milliseconds: 160); + static const Duration calendarPeriod = Duration(milliseconds: 200); + static const Duration crossfade = Duration(milliseconds: 140); + static const Duration accountDisclosure = Duration(milliseconds: 160); + static const Duration taskCompletion = Duration(milliseconds: 120); + static const Duration taskListMutation = Duration(milliseconds: 180); + static const Duration taskEditorOpen = Duration(milliseconds: 180); + static const Duration taskEditorClose = Duration(milliseconds: 140); + static const Curve presentationCurve = Curves.easeOutCubic; } enum BusyMaxSemanticColorRole { diff --git a/lib/src/app/common/busymax_motion_widgets.dart b/lib/src/app/common/busymax_motion_widgets.dart new file mode 100644 index 00000000..5031a338 --- /dev/null +++ b/lib/src/app/common/busymax_motion_widgets.dart @@ -0,0 +1,707 @@ +import 'package:flutter/widgets.dart'; + +import 'busymax_design_values.dart'; + +/// A full-size child revealed by changing only its clipped horizontal slot. +/// +/// [transitionGeneration] must change only for an explicit user interaction. +/// Responsive changes can therefore settle immediately without chasing the +/// window size. The child keeps its full layout width throughout the reveal. +class BusyMaxHorizontalReveal extends StatefulWidget { + const BusyMaxHorizontalReveal({ + super.key, + required this.visible, + required this.width, + required this.transitionGeneration, + required this.child, + this.duration = BusyMaxMotion.sidebar, + }); + + final bool visible; + final double width; + final int transitionGeneration; + final Duration duration; + final Widget child; + + @override + State createState() => + _BusyMaxHorizontalRevealState(); +} + +class _BusyMaxHorizontalRevealState extends State + with SingleTickerProviderStateMixin { + late final AnimationController _controller = AnimationController( + vsync: this, + animationBehavior: AnimationBehavior.preserve, + value: widget.visible ? 1 : 0, + ); + late int _generation; + bool _disableAnimations = false; + + @override + void initState() { + super.initState(); + _generation = widget.transitionGeneration; + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + final disabled = MediaQuery.disableAnimationsOf(context); + if (disabled != _disableAnimations) { + _disableAnimations = disabled; + if (disabled && _controller.isAnimating) { + _controller.value = widget.visible ? 1 : 0; + } + } + } + + @override + void didUpdateWidget(covariant BusyMaxHorizontalReveal oldWidget) { + super.didUpdateWidget(oldWidget); + final target = widget.visible ? 1.0 : 0.0; + if (widget.transitionGeneration == _generation) { + // Ordinary data/theme rebuilds must not terminate an in-flight reveal. + // A visibility change without a semantic generation is a responsive + // layout change and intentionally settles without animation. + if (widget.visible != oldWidget.visible) { + _controller + ..stop() + ..value = target; + } + return; + } + _generation = widget.transitionGeneration; + if (_disableAnimations || widget.duration == Duration.zero) { + _controller.value = target; + return; + } + final distance = (target - _controller.value).abs(); + if (distance == 0) { + // A same-frame reversal can target the current endpoint while an older + // simulation is still headed away from it. + _controller + ..stop() + ..value = target; + return; + } + _controller.animateTo( + target, + duration: widget.duration * distance, + curve: BusyMaxMotion.presentationCurve, + ); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final active = widget.visible; + return AnimatedBuilder( + animation: _controller, + child: SizedBox(width: widget.width, child: widget.child), + builder: (context, child) => IgnorePointer( + ignoring: !active, + child: ExcludeFocus( + excluding: !active, + child: ExcludeSemantics( + excluding: !active, + child: TickerMode( + enabled: active || _controller.value > 0, + child: ClipRect( + child: Align( + alignment: AlignmentDirectional.centerStart, + widthFactor: _controller.value, + child: child, + ), + ), + ), + ), + ), + ), + ); + } +} + +class BusyMaxVerticalReveal extends StatefulWidget { + const BusyMaxVerticalReveal({ + super.key, + required this.visible, + required this.child, + this.duration = BusyMaxMotion.search, + }); + + final bool visible; + final Widget child; + final Duration duration; + + @override + State createState() => _BusyMaxVerticalRevealState(); +} + +class _BusyMaxVerticalRevealState extends State + with SingleTickerProviderStateMixin { + late final AnimationController _controller = AnimationController( + vsync: this, + animationBehavior: AnimationBehavior.preserve, + value: widget.visible ? 1 : 0, + ); + bool _disableAnimations = false; + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + final disabled = MediaQuery.disableAnimationsOf(context); + if (disabled != _disableAnimations) { + _disableAnimations = disabled; + if (disabled) _controller.value = widget.visible ? 1 : 0; + } + } + + @override + void didUpdateWidget(covariant BusyMaxVerticalReveal oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.visible == widget.visible) return; + final target = widget.visible ? 1.0 : 0.0; + if (_disableAnimations || widget.duration == Duration.zero) { + _controller.value = target; + return; + } + final distance = (target - _controller.value).abs(); + if (distance == 0) return; + _controller.animateTo( + target, + duration: widget.duration * distance, + curve: BusyMaxMotion.presentationCurve, + ); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) => AnimatedBuilder( + animation: _controller, + child: widget.child, + builder: (context, child) => IgnorePointer( + ignoring: !widget.visible, + child: ExcludeFocus( + excluding: !widget.visible, + child: ExcludeSemantics( + excluding: !widget.visible, + child: TickerMode( + enabled: widget.visible || _controller.value > 0, + child: ClipRect( + child: SizeTransition( + sizeFactor: _controller, + alignment: Alignment.topCenter, + child: FadeTransition(opacity: _controller, child: child), + ), + ), + ), + ), + ), + ), + ); +} + +/// Bounded directional period transition driven by an explicit generation. +class BusyMaxDirectionalSwitcher extends StatefulWidget { + const BusyMaxDirectionalSwitcher({ + super.key, + required this.generation, + required this.direction, + required this.child, + this.duration = BusyMaxMotion.calendarPeriod, + }); + + final int generation; + final int direction; + final Widget child; + final Duration duration; + + @override + State createState() => + _BusyMaxDirectionalSwitcherState(); +} + +class _BusyMaxDirectionalSwitcherState extends State + with SingleTickerProviderStateMixin { + late final AnimationController _controller = AnimationController( + vsync: this, + animationBehavior: AnimationBehavior.preserve, + value: 1, + ); + late int _generation; + late final List _slots = [widget.child, const SizedBox.shrink()]; + var _index = 0; + var _direction = 0; + bool _disableAnimations = false; + + @override + void initState() { + super.initState(); + _generation = widget.generation; + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + final disabled = MediaQuery.disableAnimationsOf(context); + if (disabled != _disableAnimations) { + _disableAnimations = disabled; + if (disabled) _controller.value = 1; + } + } + + @override + void didUpdateWidget(covariant BusyMaxDirectionalSwitcher oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.generation == _generation || widget.direction == 0) { + _slots[_index] = widget.child; + return; + } + _generation = widget.generation; + _direction = widget.direction.sign; + _index = 1 - _index; + _slots[_index] = widget.child; + if (_disableAnimations || widget.duration == Duration.zero) { + _controller.value = 1; + return; + } + _controller + ..value = 0 + ..animateTo( + 1, + duration: widget.duration, + curve: BusyMaxMotion.presentationCurve, + ); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final textDirection = Directionality.of(context); + final visualDirection = + _direction * (textDirection == TextDirection.rtl ? -1 : 1); + return ClipRect( + child: AnimatedBuilder( + animation: _controller, + builder: (context, _) => Stack( + fit: StackFit.expand, + children: [ + for (var slot = 0; slot < _slots.length; slot++) + if (slot == _index || (_controller.value < 1 && slot != _index)) + IgnorePointer( + ignoring: slot != _index, + child: ExcludeFocus( + excluding: slot != _index, + child: ExcludeSemantics( + excluding: slot != _index, + child: FractionalTranslation( + translation: Offset( + slot == _index + ? visualDirection * .08 * (1 - _controller.value) + : -visualDirection * .08 * _controller.value, + 0, + ), + child: Opacity( + opacity: slot == _index + ? .72 + .28 * _controller.value + : 1 - _controller.value, + child: _slots[slot], + ), + ), + ), + ), + ), + ], + ), + ), + ); + } +} + +/// Moves the existing live surface into its new period without replacing it. +class BusyMaxDirectionalEntrance extends StatefulWidget { + const BusyMaxDirectionalEntrance({ + super.key, + required this.generation, + required this.direction, + required this.child, + this.duration = BusyMaxMotion.calendarPeriod, + }); + + final int generation; + final int direction; + final Widget child; + final Duration duration; + + @override + State createState() => + _BusyMaxDirectionalEntranceState(); +} + +class _BusyMaxDirectionalEntranceState extends State + with SingleTickerProviderStateMixin { + late final AnimationController _controller = AnimationController( + vsync: this, + animationBehavior: AnimationBehavior.preserve, + value: 1, + ); + late int _generation; + int _direction = 0; + bool _disableAnimations = false; + + @override + void initState() { + super.initState(); + _generation = widget.generation; + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + final disabled = MediaQuery.disableAnimationsOf(context); + if (disabled != _disableAnimations) { + _disableAnimations = disabled; + if (disabled) _controller.value = 1; + } + } + + @override + void didUpdateWidget(covariant BusyMaxDirectionalEntrance oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.generation == _generation || widget.direction == 0) return; + _generation = widget.generation; + _direction = widget.direction.sign; + if (_disableAnimations || widget.duration == Duration.zero) { + _controller.value = 1; + return; + } + _controller + ..value = 0 + ..animateTo( + 1, + duration: widget.duration, + curve: BusyMaxMotion.presentationCurve, + ); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final rtl = Directionality.of(context) == TextDirection.rtl; + final visual = _direction * (rtl ? -1 : 1); + return ClipRect( + child: AnimatedBuilder( + animation: _controller, + child: widget.child, + builder: (context, child) => FractionalTranslation( + translation: Offset(visual * .08 * (1 - _controller.value), 0), + child: Opacity(opacity: .72 + .28 * _controller.value, child: child), + ), + ), + ); + } +} + +/// Crossfades a fixed set of live pages without reconstructing destinations. +class BusyMaxRetainedCrossfade extends StatefulWidget { + const BusyMaxRetainedCrossfade({ + super.key, + required this.index, + required this.children, + this.duration = BusyMaxMotion.crossfade, + }) : assert(children.length > 0), + assert(index >= 0 && index < children.length); + + final int index; + final List children; + final Duration duration; + + @override + State createState() => + _BusyMaxRetainedCrossfadeState(); +} + +/// Retains the last normal and alternate presentations while switching. +/// Updates affect only the currently active side, so an inactive planner is +/// never given search results (and vice versa). +class BusyMaxBinaryPresentation extends StatefulWidget { + const BusyMaxBinaryPresentation({ + super.key, + required this.alternateActive, + required this.child, + this.duration = BusyMaxMotion.search, + }); + + final bool alternateActive; + final Widget child; + final Duration duration; + + @override + State createState() => + _BusyMaxBinaryPresentationState(); +} + +class _BusyMaxBinaryPresentationState extends State { + Widget _normal = const SizedBox.shrink(); + Widget _alternate = const SizedBox.shrink(); + + @override + void initState() { + super.initState(); + if (widget.alternateActive) { + _alternate = widget.child; + } else { + _normal = widget.child; + } + } + + @override + void didUpdateWidget(covariant BusyMaxBinaryPresentation oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.alternateActive) { + _alternate = widget.child; + } else { + _normal = widget.child; + } + } + + @override + Widget build(BuildContext context) { + return BusyMaxRetainedCrossfade( + index: widget.alternateActive ? 1 : 0, + duration: widget.duration, + children: [_normal, _alternate], + ); + } +} + +/// A semantic destination host: data rebuilds update the active destination, +/// while every previously visited destination keeps its live element state. +/// Only a changed [transitionKey] starts a crossfade. +class BusyMaxKeyedCrossfade extends StatefulWidget { + const BusyMaxKeyedCrossfade({ + super.key, + required this.transitionKey, + required this.child, + this.duration = BusyMaxMotion.crossfade, + }); + + final Object transitionKey; + final Widget child; + final Duration duration; + + @override + State createState() => _BusyMaxKeyedCrossfadeState(); +} + +class _BusyMaxKeyedCrossfadeState extends State { + late final List _keys; + late final List _destinationKeys; + late final List _destinations; + var _index = 0; + + @override + void initState() { + super.initState(); + final key = GlobalKey( + debugLabel: 'busymax-destination-${widget.transitionKey}', + ); + _keys = [widget.transitionKey]; + _destinationKeys = [key]; + _destinations = [KeyedSubtree(key: key, child: widget.child)]; + } + + @override + void didUpdateWidget(covariant BusyMaxKeyedCrossfade oldWidget) { + super.didUpdateWidget(oldWidget); + final destination = _keys.indexOf(widget.transitionKey); + if (destination < 0) { + _keys.add(widget.transitionKey); + final key = GlobalKey( + debugLabel: 'busymax-destination-${widget.transitionKey}', + ); + _destinationKeys.add(key); + _destinations.add(KeyedSubtree(key: key, child: widget.child)); + _index = _keys.length - 1; + } else { + _index = destination; + _destinations[destination] = KeyedSubtree( + key: _destinationKeys[destination], + child: widget.child, + ); + } + } + + @override + Widget build(BuildContext context) => BusyMaxRetainedCrossfade( + index: _index, + duration: widget.duration, + // Widgets are immutable. Do not let an older retained host observe later + // in-place destination-list edits during element reconciliation. + children: List.of(_destinations, growable: false), + ); +} + +class _BusyMaxRetainedCrossfadeState extends State + with SingleTickerProviderStateMixin { + late final AnimationController _controller = AnimationController( + vsync: this, + animationBehavior: AnimationBehavior.preserve, + value: 1, + ); + late int _current = widget.index; + int? _outgoing; + bool _disableAnimations = false; + + @override + void initState() { + super.initState(); + _controller.addStatusListener(_handleStatus); + } + + void _handleStatus(AnimationStatus status) { + if (status == AnimationStatus.completed && _outgoing != null && mounted) { + setState(() => _outgoing = null); + } + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + final disabled = MediaQuery.disableAnimationsOf(context); + if (disabled != _disableAnimations) { + _disableAnimations = disabled; + if (disabled) { + _controller.stop(); + _outgoing = null; + _controller.value = 1; + } + } + } + + @override + void didUpdateWidget(covariant BusyMaxRetainedCrossfade oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.index == _current) return; + if (widget.index == _outgoing && _controller.isAnimating) { + // A -> B -> A reverses from the presentation currently on screen. + // Swapping the roles and complementing progress preserves both page + // opacities instead of replaying a complete transition from an endpoint. + final previousCurrent = _current; + _current = widget.index; + _outgoing = previousCurrent; + _controller.value = 1 - _controller.value; + } else { + _outgoing = _current; + _current = widget.index; + _controller.value = 0; + } + if (_disableAnimations || widget.duration == Duration.zero) { + _finish(); + return; + } + final distance = 1 - _controller.value; + if (distance == 0) { + _finish(); + return; + } + _controller.animateTo( + 1, + duration: widget.duration * distance, + curve: BusyMaxMotion.presentationCurve, + ); + } + + void _finish() { + if (!mounted) return; + _outgoing = null; + _controller.value = 1; + } + + @override + void dispose() { + _controller.removeStatusListener(_handleStatus); + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return AnimatedBuilder( + animation: _controller, + builder: (context, _) => Stack( + fit: StackFit.loose, + children: [ + for (var index = 0; index < widget.children.length; index++) + _RetainedPage( + key: ValueKey(('busymax-retained-page', index)), + active: index == _current, + outgoing: index == _outgoing, + opacity: index == _current + ? _controller.value + : index == _outgoing + ? 1 - _controller.value + : 0, + child: widget.children[index], + ), + ], + ), + ); + } +} + +class _RetainedPage extends StatelessWidget { + const _RetainedPage({ + super.key, + required this.active, + required this.outgoing, + required this.opacity, + required this.child, + }); + + final bool active; + final bool outgoing; + final double opacity; + final Widget child; + + @override + Widget build(BuildContext context) { + return Offstage( + offstage: !active && !outgoing, + child: IgnorePointer( + ignoring: !active, + child: ExcludeFocus( + excluding: !active, + child: ExcludeSemantics( + excluding: !active, + child: TickerMode( + enabled: active, + child: Opacity(opacity: opacity, child: child), + ), + ), + ), + ), + ); + } +} diff --git a/lib/src/app/common/busymax_mutation_list.dart b/lib/src/app/common/busymax_mutation_list.dart new file mode 100644 index 00000000..fb32a057 --- /dev/null +++ b/lib/src/app/common/busymax_mutation_list.dart @@ -0,0 +1,301 @@ +import 'package:flutter/widgets.dart'; + +import '../../schedule/task_list_mutation_intent.dart'; +import 'busymax_design_values.dart'; + +typedef BusyMaxMutationItemBuilder = + Widget Function(BuildContext context, T item, int index); + +/// Lazy list reconciliation for one identified, successful local mutation. +/// Unrelated data changes replace the projection without entrance effects. +class BusyMaxMutationList extends StatefulWidget { + const BusyMaxMutationList({ + super.key, + required this.items, + required this.identityOf, + required this.itemBuilder, + this.mutation, + this.mutationApplied, + this.padding, + this.separatorBuilder, + this.emptyBuilder, + this.onMutationConsumed, + this.footer, + this.controller, + }); + + final List items; + final String Function(T item) identityOf; + final BusyMaxMutationItemBuilder itemBuilder; + final TaskListMutationIntent? mutation; + final bool Function(T item, TaskListMutationIntent mutation)? mutationApplied; + final EdgeInsetsGeometry? padding; + final IndexedWidgetBuilder? separatorBuilder; + final WidgetBuilder? emptyBuilder; + final ValueChanged? onMutationConsumed; + final Widget? footer; + final ScrollController? controller; + + @override + State> createState() => _BusyMaxMutationListState(); +} + +class _BusyMaxMutationListState extends State> + with SingleTickerProviderStateMixin { + late List _presented = List.of(widget.items); + late final AnimationController _mutationAnimation = AnimationController( + vsync: this, + animationBehavior: AnimationBehavior.preserve, + value: 1, + )..addStatusListener(_handleAnimationStatus); + int? _handledGeneration; + String? _entering; + String? _exiting; + T? _outgoing; + int? _outgoingIndex; + TaskListMutationIntent? _activeStructuralMutation; + int? _animationGeneration; + final Set _consumedGenerations = {}; + bool _disableAnimations = false; + + bool get _hasActiveMutation => _activeStructuralMutation != null; + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + final disabled = MediaQuery.disableAnimationsOf(context); + if (disabled == _disableAnimations) return; + _disableAnimations = disabled; + if (disabled && _hasActiveMutation) { + _settleActiveMutation(rebuild: false); + } + } + + @override + void didUpdateWidget(covariant BusyMaxMutationList oldWidget) { + super.didUpdateWidget(oldWidget); + final mutation = widget.mutation; + if (_hasActiveMutation) { + final activeGeneration = _activeStructuralMutation!.generation; + if (mutation == null || mutation.generation != activeGeneration) { + _settleActiveMutation(rebuild: false); + } else { + _reconcileActiveProjection(); + return; + } + } + if (mutation == null || mutation.generation == _handledGeneration) { + _presented = List.of(widget.items); + return; + } + final target = mutation.taskKey; + final currentIndex = widget.items.indexWhere( + (item) => widget.identityOf(item) == target, + ); + final previousIndex = _presented.indexWhere( + (item) => widget.identityOf(item) == target, + ); + if (mutation.presentation == TaskListMutationPresentation.insertion) { + if (currentIndex < 0) { + _presented = List.of(widget.items); + return; + } + _handledGeneration = mutation.generation; + _entering = target; + _activeStructuralMutation = mutation; + _presented = List.of(widget.items); + _startMutationAnimation(mutation.generation); + return; + } + if (currentIndex >= 0) { + final item = widget.items[currentIndex]; + if (!(widget.mutationApplied?.call(item, mutation) ?? true)) { + _presented = List.of(widget.items); + return; + } + _handledGeneration = mutation.generation; + _presented = List.of(widget.items); + _notifyConsumed(mutation); + return; + } + if (previousIndex < 0) { + _presented = List.of(widget.items); + return; + } + final outgoing = _presented[previousIndex]; + _handledGeneration = mutation.generation; + _exiting = target; + _outgoing = outgoing; + _outgoingIndex = previousIndex; + _activeStructuralMutation = mutation; + _presented = List.of(widget.items) + ..insert(previousIndex.clamp(0, widget.items.length), outgoing); + _startMutationAnimation(mutation.generation); + } + + void _reconcileActiveProjection() { + final exiting = _exiting; + if (exiting == null) { + final entering = _entering; + _presented = List.of(widget.items); + if (entering != null && + !_presented.any((item) => widget.identityOf(item) == entering)) { + _settleActiveMutation(rebuild: false); + } + return; + } + final outgoing = _outgoing; + if (outgoing == null) { + _settleActiveMutation(rebuild: false); + return; + } + _presented = List.of(widget.items); + if (_presented.any((item) => widget.identityOf(item) == exiting)) { + _settleActiveMutation(rebuild: false); + return; + } + _presented.insert( + (_outgoingIndex ?? _presented.length).clamp(0, _presented.length), + outgoing, + ); + } + + void _startMutationAnimation(int generation) { + _animationGeneration = generation; + _mutationAnimation + ..stop() + ..value = 0; + if (_disableAnimations || BusyMaxMotion.taskListMutation == Duration.zero) { + _settleActiveMutation(rebuild: false); + return; + } + _mutationAnimation.animateTo( + 1, + duration: BusyMaxMotion.taskListMutation, + curve: BusyMaxMotion.presentationCurve, + ); + } + + void _handleAnimationStatus(AnimationStatus status) { + final activeGeneration = _activeStructuralMutation?.generation; + if (status == AnimationStatus.completed && + _hasActiveMutation && + _animationGeneration == activeGeneration && + mounted) { + _settleActiveMutation(rebuild: true); + } + } + + void _settleActiveMutation({required bool rebuild}) { + final mutation = _activeStructuralMutation; + _mutationAnimation.stop(); + _entering = null; + _exiting = null; + _outgoing = null; + _outgoingIndex = null; + _activeStructuralMutation = null; + _animationGeneration = null; + _presented = List.of(widget.items); + _mutationAnimation.value = 1; + if (mutation != null) _notifyConsumed(mutation); + if (rebuild && mounted) setState(() {}); + } + + void _notifyConsumed(TaskListMutationIntent mutation) { + if (!_consumedGenerations.add(mutation.generation)) return; + final callback = widget.onMutationConsumed; + if (callback == null) return; + WidgetsBinding.instance.addPostFrameCallback((_) => callback(mutation)); + } + + @override + void dispose() { + final mutation = _activeStructuralMutation; + if (mutation != null) { + // The row lifecycle is already owned here rather than by a lazy child. + // Finish the screen-scoped intent even if this whole list is replaced. + _notifyConsumed(mutation); + } + _mutationAnimation.removeStatusListener(_handleAnimationStatus); + _mutationAnimation.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + if (_presented.isEmpty && widget.emptyBuilder != null) { + final empty = widget.emptyBuilder!(context); + if (widget.footer == null) return empty; + return ListView( + controller: widget.controller, + padding: widget.padding, + children: [empty, widget.footer!], + ); + } + final separated = widget.separatorBuilder != null; + final logicalCount = _presented.length + (widget.footer == null ? 0 : 1); + return ListView.builder( + controller: widget.controller, + padding: widget.padding, + itemCount: separated + ? (logicalCount == 0 ? 0 : logicalCount * 2 - 1) + : logicalCount, + itemBuilder: (context, rawIndex) { + if (separated && rawIndex.isOdd) { + return widget.separatorBuilder!(context, rawIndex ~/ 2); + } + final index = separated ? rawIndex ~/ 2 : rawIndex; + if (index == _presented.length) return widget.footer!; + final item = _presented[index]; + final identity = widget.identityOf(item); + return _MutationCell( + key: ValueKey(('mutation-cell', identity)), + entering: identity == _entering, + exiting: identity == _exiting, + animation: _mutationAnimation, + child: widget.itemBuilder(context, item, index), + ); + }, + ); + } +} + +class _MutationCell extends StatelessWidget { + const _MutationCell({ + super.key, + required this.entering, + required this.exiting, + required this.animation, + required this.child, + }); + + final bool entering; + final bool exiting; + final Animation animation; + final Widget child; + + @override + Widget build(BuildContext context) { + final Animation progress = exiting + ? ReverseAnimation(animation) + : entering + ? animation + : const AlwaysStoppedAnimation(1); + return IgnorePointer( + ignoring: exiting, + child: ExcludeFocus( + excluding: exiting, + child: ExcludeSemantics( + excluding: exiting, + child: ClipRect( + child: SizeTransition( + sizeFactor: progress, + alignment: Alignment.topCenter, + child: FadeTransition(opacity: progress, child: child), + ), + ), + ), + ), + ); + } +} diff --git a/lib/src/app/linux/linux_header_style.dart b/lib/src/app/linux/linux_header_style.dart new file mode 100644 index 00000000..0384de20 --- /dev/null +++ b/lib/src/app/linux/linux_header_style.dart @@ -0,0 +1,1142 @@ +import 'dart:async'; +import 'dart:math' as math; + +import 'package:flutter/material.dart'; +import 'package:yaru/yaru.dart'; + +import '../busymax_design.dart'; +import '../busymax_native_search_entry_theme.dart'; +import '../busymax_surface_colors.dart'; +import '../../platform/gtk_header_icon_service.dart'; +import 'linux_window_host.dart'; + +abstract final class BusyMaxLinuxHeaderStyle { + static const double symbolicIconSize = 16; + static const double searchEntryHeight = 32; + static const double searchEntryRadius = 9; + static const double searchEntryBorderWidth = 1; + static const double searchEntryInnerFocusWidth = 1; + static const double searchEntryHorizontalPadding = 8; + static const double searchEntryIconGap = 6; + static const Duration searchChangedDelay = Duration(milliseconds: 150); + static const Duration searchEntryFocusDuration = Duration(milliseconds: 200); + static const double compoundButtonHorizontalPadding = 9; + static const double compoundButtonVerticalPadding = 4; + static const double viewIconGap = 6; + static const double controlRadius = 6; + static const double activeForegroundOpacity = 1; + static const double inactiveForegroundOpacity = .50; + static const double disabledActiveForegroundOpacity = .38; + static const double disabledInactiveForegroundOpacity = .19; + static const double hoverBackgroundStrength = .07; + static const double pressedBackgroundStrength = .16; + static const double selectedBackgroundStrength = .10; + static const double selectedHoverBackgroundStrength = .13; + static const double selectedPressedBackgroundStrength = .19; +} + +/// Physical window-control obstructions inside the current main header. +/// +/// These remain physical in RTL because GTK's decoration layout is physical. +/// Application-header layouts consume them at their corresponding edge while +/// retaining the complete header as the title-centering coordinate system. +class LinuxPageHeaderInsetsScope extends InheritedWidget { + const LinuxPageHeaderInsetsScope({ + super.key, + required this.leftObstruction, + required this.rightObstruction, + required super.child, + }); + + final double leftObstruction; + final double rightObstruction; + + static const _fallback = LinuxPageHeaderInsetsScope( + leftObstruction: 0, + rightObstruction: 0, + child: SizedBox.shrink(), + ); + + static LinuxPageHeaderInsetsScope of(BuildContext context) => + context + .dependOnInheritedWidgetOfExactType() ?? + _fallback; + + @override + bool updateShouldNotify(LinuxPageHeaderInsetsScope oldWidget) => + leftObstruction != oldWidget.leftObstruction || + rightObstruction != oldWidget.rightObstruction; +} + +Color busyMaxLinuxHeaderForeground( + BuildContext context, { + bool disabled = false, +}) { + final active = LinuxWindowMetricsScope.of(context).windowActive; + final opacity = switch ((active, disabled)) { + (true, false) => BusyMaxLinuxHeaderStyle.activeForegroundOpacity, + (false, false) => BusyMaxLinuxHeaderStyle.inactiveForegroundOpacity, + (true, true) => BusyMaxLinuxHeaderStyle.disabledActiveForegroundOpacity, + (false, true) => BusyMaxLinuxHeaderStyle.disabledInactiveForegroundOpacity, + }; + final foreground = BusyMaxSurfaceColors.of(context).foreground; + return foreground.withValues(alpha: foreground.a * opacity); +} + +TextStyle busyMaxLinuxHeaderTitleStyle(BuildContext context) => + _busyMaxLinuxHeaderTextStyle(context, busyMaxHeaderTitleStyle(context)); + +TextStyle busyMaxLinuxHeaderBrandStyle(BuildContext context) => + _busyMaxLinuxHeaderTextStyle(context, busyMaxHeaderBrandStyle(context)); + +TextStyle _busyMaxLinuxHeaderTextStyle(BuildContext context, TextStyle style) { + final baseColor = style.color ?? BusyMaxSurfaceColors.of(context).foreground; + final opacity = LinuxWindowMetricsScope.of(context).windowActive + ? BusyMaxLinuxHeaderStyle.activeForegroundOpacity + : BusyMaxLinuxHeaderStyle.inactiveForegroundOpacity; + return style.copyWith( + color: baseColor.withValues(alpha: baseColor.a * opacity), + ); +} + +WidgetStateProperty busyMaxLinuxHeaderControlBackground( + BuildContext context, +) { + final foreground = busyMaxLinuxHeaderForeground(context); + Color layer(double strength) => + foreground.withValues(alpha: foreground.a * strength); + return WidgetStateProperty.resolveWith((states) { + if (states.contains(WidgetState.disabled)) return Colors.transparent; + final selected = states.contains(WidgetState.selected); + final pressed = states.contains(WidgetState.pressed); + final hovered = states.contains(WidgetState.hovered); + if (selected && pressed) { + return layer(BusyMaxLinuxHeaderStyle.selectedPressedBackgroundStrength); + } + if (selected && hovered) { + return layer(BusyMaxLinuxHeaderStyle.selectedHoverBackgroundStrength); + } + if (pressed) { + return layer(BusyMaxLinuxHeaderStyle.pressedBackgroundStrength); + } + if (selected) { + return layer(BusyMaxLinuxHeaderStyle.selectedBackgroundStrength); + } + if (hovered) { + return layer(BusyMaxLinuxHeaderStyle.hoverBackgroundStrength); + } + return Colors.transparent; + }); +} + +class BusyMaxLinuxHeaderIconButton extends StatelessWidget { + const BusyMaxLinuxHeaderIconButton({ + super.key, + required this.icon, + required this.tooltip, + required this.onPressed, + this.focusNode, + this.selected = false, + this.selectedIcon, + this.semanticLabel, + }); + + final BusyMaxLinuxHeaderIcon icon; + final String? tooltip; + final VoidCallback? onPressed; + final FocusNode? focusNode; + final bool selected; + final BusyMaxLinuxHeaderIcon? selectedIcon; + final String? semanticLabel; + + @override + Widget build(BuildContext context) { + final button = BusyMaxHeaderIconButton( + icon: BusyMaxGtkHeaderIcon(icon), + tooltip: tooltip, + onPressed: onPressed, + focusNode: focusNode, + isSelected: selected, + selectedIcon: selectedIcon == null + ? null + : BusyMaxGtkHeaderIcon(selectedIcon!), + iconSize: BusyMaxLinuxHeaderStyle.symbolicIconSize, + fixedSize: const Size.square(BusyMaxSizes.headerIconButton), + foregroundColor: busyMaxLinuxHeaderForeground(context), + disabledForegroundColor: busyMaxLinuxHeaderForeground( + context, + disabled: true, + ), + backgroundColor: busyMaxLinuxHeaderControlBackground(context), + overlayColor: const WidgetStatePropertyAll(Colors.transparent), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular( + BusyMaxLinuxHeaderStyle.controlRadius, + ), + ), + focusBorderRadius: BusyMaxLinuxHeaderStyle.controlRadius, + ); + return semanticLabel == null + ? button + : Semantics( + label: semanticLabel, + button: true, + enabled: onPressed != null, + child: button, + ); + } +} + +class BusyMaxLinuxHeaderMenuButton extends StatelessWidget { + const BusyMaxLinuxHeaderMenuButton({ + super.key, + required this.tooltip, + required this.entries, + required this.onSelected, + this.icon = BusyMaxLinuxHeaderIcon.mainMenu, + this.controller, + this.enabled = true, + this.highlightWhenOpen = true, + }); + + final String tooltip; + final BusyMaxLinuxHeaderIcon icon; + final List> entries; + final ValueChanged onSelected; + final BusyMaxMenuController? controller; + final bool enabled; + final bool highlightWhenOpen; + + @override + Widget build(BuildContext context) { + return BusyMaxMenuButton( + tooltip: tooltip, + icon: BusyMaxGtkHeaderIcon(icon), + entries: entries, + onSelected: onSelected, + controller: controller, + enabled: enabled, + highlightWhenOpen: highlightWhenOpen, + triggerBuilder: (context, trigger) => trigger.anchor( + child: BusyMaxLinuxHeaderIconButton( + icon: icon, + tooltip: tooltip, + focusNode: trigger.focusNode, + selected: highlightWhenOpen && trigger.isOpen, + onPressed: trigger.onPressed, + ), + ), + ); + } +} + +class BusyMaxLinuxViewMenuButton extends StatelessWidget { + const BusyMaxLinuxViewMenuButton({ + super.key, + required this.tooltip, + required this.icon, + required this.entries, + required this.onSelected, + this.controller, + this.enabled = true, + }); + + final String tooltip; + final BusyMaxLinuxHeaderIcon icon; + final List> entries; + final ValueChanged onSelected; + final BusyMaxMenuController? controller; + final bool enabled; + + @override + Widget build(BuildContext context) { + return BusyMaxMenuButton( + tooltip: tooltip, + icon: BusyMaxGtkHeaderIcon(icon), + entries: entries, + onSelected: onSelected, + controller: controller, + enabled: enabled, + triggerBuilder: (context, trigger) => trigger.anchor( + child: _BusyMaxLinuxViewMenuTrigger( + tooltip: tooltip, + icon: icon, + focusNode: trigger.focusNode, + selected: trigger.isOpen, + onPressed: trigger.onPressed, + ), + ), + ); + } +} + +class _BusyMaxLinuxViewMenuTrigger extends StatelessWidget { + const _BusyMaxLinuxViewMenuTrigger({ + required this.tooltip, + required this.icon, + required this.focusNode, + required this.selected, + required this.onPressed, + }); + + final String tooltip; + final BusyMaxLinuxHeaderIcon icon; + final FocusNode focusNode; + final bool selected; + final VoidCallback? onPressed; + + @override + Widget build(BuildContext context) { + final foreground = busyMaxLinuxHeaderForeground(context); + final disabledForeground = busyMaxLinuxHeaderForeground( + context, + disabled: true, + ); + final baseBackground = busyMaxLinuxHeaderControlBackground(context); + final style = ButtonStyle( + minimumSize: const WidgetStatePropertyAll( + Size(0, BusyMaxSizes.headerIconButton), + ), + maximumSize: const WidgetStatePropertyAll( + Size(double.infinity, BusyMaxSizes.headerIconButton), + ), + padding: const WidgetStatePropertyAll( + EdgeInsets.symmetric( + horizontal: BusyMaxLinuxHeaderStyle.compoundButtonHorizontalPadding, + vertical: BusyMaxLinuxHeaderStyle.compoundButtonVerticalPadding, + ), + ), + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + foregroundColor: WidgetStateProperty.resolveWith( + (states) => states.contains(WidgetState.disabled) + ? disabledForeground + : foreground, + ), + backgroundColor: WidgetStateProperty.resolveWith( + (states) => baseBackground.resolve({ + ...states, + if (selected) WidgetState.selected, + }), + ), + overlayColor: const WidgetStatePropertyAll(Colors.transparent), + shape: WidgetStatePropertyAll( + RoundedRectangleBorder( + borderRadius: BorderRadius.circular( + BusyMaxLinuxHeaderStyle.controlRadius, + ), + ), + ), + animationDuration: BusyMaxMotion.fast, + splashFactory: NoSplash.splashFactory, + ); + final button = SizedBox( + height: BusyMaxSizes.headerIconButton, + child: TextButton( + focusNode: focusNode, + onPressed: onPressed, + style: style, + child: Builder( + builder: (context) => IconTheme( + data: IconThemeData( + color: DefaultTextStyle.of(context).style.color, + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + BusyMaxGtkHeaderIcon(icon), + const SizedBox(width: BusyMaxLinuxHeaderStyle.viewIconGap), + const BusyMaxGtkHeaderIcon( + BusyMaxLinuxHeaderIcon.viewMenuArrow, + ), + ], + ), + ), + ), + ), + ); + final focused = YaruTheme.maybeOf(context)?.focusBorders == true + ? YaruFocusBorder.primary( + borderRadius: BorderRadius.circular( + BusyMaxLinuxHeaderStyle.controlRadius, + ), + child: button, + ) + : button; + return Tooltip(message: tooltip, child: focused); + } +} + +class BusyMaxGtkHeaderIcon extends StatelessWidget { + const BusyMaxGtkHeaderIcon(this.icon, {super.key, this.direction}); + + final BusyMaxLinuxHeaderIcon icon; + final TextDirection? direction; + + @override + Widget build(BuildContext context) { + final effectiveDirection = direction ?? Directionality.of(context); + final asset = GtkHeaderIconScope.of( + context, + ).catalog.assetFor(icon, effectiveDirection); + final color = IconTheme.of(context).color; + return ExcludeSemantics( + child: SizedBox.square( + dimension: BusyMaxLinuxHeaderStyle.symbolicIconSize, + child: asset == null + ? CustomPaint( + size: const Size.square( + BusyMaxLinuxHeaderStyle.symbolicIconSize, + ), + painter: _BusyMaxGtkHeaderFallbackPainter( + icon: icon, + color: color ?? const Color(0xFF000000), + ), + ) + : Image.memory( + asset.bytes, + width: BusyMaxLinuxHeaderStyle.symbolicIconSize, + height: BusyMaxLinuxHeaderStyle.symbolicIconSize, + fit: BoxFit.contain, + color: color, + colorBlendMode: BlendMode.srcIn, + filterQuality: FilterQuality.medium, + gaplessPlayback: true, + excludeFromSemantics: true, + ), + ), + ); + } +} + +class _BusyMaxGtkHeaderFallbackPainter extends CustomPainter { + const _BusyMaxGtkHeaderFallbackPainter({ + required this.icon, + required this.color, + }); + + final BusyMaxLinuxHeaderIcon icon; + final Color color; + + @override + void paint(Canvas canvas, Size size) { + final paint = Paint() + ..color = color + ..strokeWidth = 1.5 + ..strokeCap = StrokeCap.round + ..strokeJoin = StrokeJoin.round; + if (icon == BusyMaxLinuxHeaderIcon.filter) { + final path = Path() + ..moveTo(2, 3) + ..lineTo(14, 3) + ..lineTo(9.25, 8.25) + ..lineTo(9.25, 12.25) + ..lineTo(6.75, 13.5) + ..lineTo(6.75, 8.25) + ..close(); + canvas.drawPath(path, paint..style = PaintingStyle.fill); + return; + } + paint.style = PaintingStyle.stroke; + canvas + ..drawRect(const Rect.fromLTWH(2.5, 2.5, 11, 11), paint) + ..drawLine(const Offset(4.5, 4.5), const Offset(11.5, 11.5), paint) + ..drawLine(const Offset(11.5, 4.5), const Offset(4.5, 11.5), paint); + } + + @override + bool shouldRepaint(_BusyMaxGtkHeaderFallbackPainter oldDelegate) => + icon != oldDelegate.icon || color != oldDelegate.color; +} + +class BusyMaxLinuxHeaderControlGroup extends StatelessWidget { + const BusyMaxLinuxHeaderControlGroup({super.key, required this.children}); + + final List children; + + @override + Widget build(BuildContext context) { + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + for (var index = 0; index < children.length; index++) ...[ + if (index > 0) const SizedBox(width: BusyMaxSpacing.headerInset), + children[index], + ], + ], + ); + } +} + +enum _BusyMaxLinuxHeaderSlot { leading, title, trailing } + +enum BusyMaxLinuxHeaderCenterAllocation { centered, fillBetweenControls } + +/// GTK-style application header geometry with an explicit center allocation. +class BusyMaxLinuxHeaderLayout extends StatelessWidget { + const BusyMaxLinuxHeaderLayout({ + super.key, + required this.leading, + required this.title, + required this.trailing, + this.maxContentWidth, + this.centerAllocation = BusyMaxLinuxHeaderCenterAllocation.centered, + }); + + final Widget leading; + final Widget title; + final Widget trailing; + final double? maxContentWidth; + final BusyMaxLinuxHeaderCenterAllocation centerAllocation; + + @override + Widget build(BuildContext context) { + final insets = LinuxPageHeaderInsetsScope.of(context); + final direction = Directionality.of(context); + return SizedBox( + height: BusyMaxSizes.toolbarHeight, + child: Stack( + fit: StackFit.expand, + children: [ + const LinuxTitlebarGestureRegion(child: SizedBox.expand()), + CustomMultiChildLayout( + delegate: _BusyMaxLinuxHeaderLayoutDelegate( + direction: direction, + leftObstruction: insets.leftObstruction, + rightObstruction: insets.rightObstruction, + maxContentWidth: maxContentWidth, + centerAllocation: centerAllocation, + ), + children: [ + LayoutId(id: _BusyMaxLinuxHeaderSlot.leading, child: leading), + LayoutId(id: _BusyMaxLinuxHeaderSlot.title, child: title), + LayoutId(id: _BusyMaxLinuxHeaderSlot.trailing, child: trailing), + ], + ), + ], + ), + ); + } +} + +class _BusyMaxLinuxHeaderLayoutDelegate extends MultiChildLayoutDelegate { + _BusyMaxLinuxHeaderLayoutDelegate({ + required this.direction, + required this.leftObstruction, + required this.rightObstruction, + required this.maxContentWidth, + required this.centerAllocation, + }); + + final TextDirection direction; + final double leftObstruction; + final double rightObstruction; + final double? maxContentWidth; + final BusyMaxLinuxHeaderCenterAllocation centerAllocation; + + @override + void performLayout(Size size) { + final centeredInset = maxContentWidth == null + ? 0.0 + : math.max(0.0, (size.width - maxContentWidth!) / 2); + final leftInset = math.max( + leftObstruction + BusyMaxSpacing.headerInset, + centeredInset, + ); + final rightInset = math.max( + rightObstruction + BusyMaxSpacing.headerInset, + centeredInset, + ); + final sideConstraints = BoxConstraints.loose(size); + final leadingSize = layoutChild( + _BusyMaxLinuxHeaderSlot.leading, + sideConstraints, + ); + final trailingSize = layoutChild( + _BusyMaxLinuxHeaderSlot.trailing, + sideConstraints, + ); + + late final Offset leadingOffset; + late final Offset trailingOffset; + late final double leftOccupiedEdge; + late final double rightOccupiedEdge; + if (direction == TextDirection.ltr) { + leadingOffset = Offset(leftInset, (size.height - leadingSize.height) / 2); + trailingOffset = Offset( + size.width - rightInset - trailingSize.width, + (size.height - trailingSize.height) / 2, + ); + leftOccupiedEdge = leadingOffset.dx + leadingSize.width; + rightOccupiedEdge = trailingOffset.dx; + } else { + leadingOffset = Offset( + size.width - rightInset - leadingSize.width, + (size.height - leadingSize.height) / 2, + ); + trailingOffset = Offset( + leftInset, + (size.height - trailingSize.height) / 2, + ); + leftOccupiedEdge = trailingOffset.dx + trailingSize.width; + rightOccupiedEdge = leadingOffset.dx; + } + positionChild(_BusyMaxLinuxHeaderSlot.leading, leadingOffset); + positionChild(_BusyMaxLinuxHeaderSlot.trailing, trailingOffset); + + final safeLeftEdge = leftOccupiedEdge + BusyMaxSpacing.headerInset; + final safeRightEdge = rightOccupiedEdge - BusyMaxSpacing.headerInset; + switch (centerAllocation) { + case BusyMaxLinuxHeaderCenterAllocation.centered: + final centerX = size.width / 2; + final safeHalfWidth = math.max( + 0, + math.min(centerX - safeLeftEdge, safeRightEdge - centerX), + ); + final titleSize = layoutChild( + _BusyMaxLinuxHeaderSlot.title, + BoxConstraints.loose(Size(safeHalfWidth * 2, size.height)), + ); + positionChild( + _BusyMaxLinuxHeaderSlot.title, + Offset( + centerX - titleSize.width / 2, + (size.height - titleSize.height) / 2, + ), + ); + case BusyMaxLinuxHeaderCenterAllocation.fillBetweenControls: + final availableWidth = math.max(0.0, safeRightEdge - safeLeftEdge); + final titleSize = layoutChild( + _BusyMaxLinuxHeaderSlot.title, + BoxConstraints( + minWidth: availableWidth, + maxWidth: availableWidth, + minHeight: 0, + maxHeight: size.height, + ), + ); + positionChild( + _BusyMaxLinuxHeaderSlot.title, + Offset(safeLeftEdge, (size.height - titleSize.height) / 2), + ); + } + } + + @override + bool shouldRelayout(_BusyMaxLinuxHeaderLayoutDelegate oldDelegate) => + direction != oldDelegate.direction || + leftObstruction != oldDelegate.leftObstruction || + rightObstruction != oldDelegate.rightObstruction || + maxContentWidth != oldDelegate.maxContentWidth || + centerAllocation != oldDelegate.centerAllocation; +} + +class BusyMaxLinuxHeaderTitle extends StatelessWidget { + const BusyMaxLinuxHeaderTitle(this.text, {super.key, this.brand = false}); + + final String text; + final bool brand; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: BusyMaxSpacing.md), + child: Text( + text, + textAlign: TextAlign.center, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: brand + ? busyMaxLinuxHeaderBrandStyle(context) + : busyMaxLinuxHeaderTitleStyle(context), + ), + ); + } +} + +/// The bounded center-slot presentation corresponding to the former native +/// `GtkSearchEntry` title-stack child. +class BusyMaxLinuxHeaderSearchField extends StatefulWidget { + const BusyMaxLinuxHeaderSearchField({ + super.key, + required this.controller, + required this.focusRequest, + required this.semanticLabel, + required this.onChanged, + required this.onClear, + this.autofocus = true, + }); + + /// Mirrors GtkEntry:max-width-chars as a natural-width hint. It is not a + /// hard maximum when the header title child expands. + static const int naturalWidthChars = 48; + static const shellKey = ValueKey('busymax-linux-search-shell'); + static const innerFocusKey = ValueKey( + 'busymax-linux-search-inner-focus', + ); + static const primaryIconKey = ValueKey( + 'busymax-linux-search-primary-icon', + ); + static const secondaryIconKey = ValueKey( + 'busymax-linux-search-secondary-icon', + ); + static const clearKey = ValueKey('busymax-linux-search-clear'); + + final TextEditingController controller; + final int focusRequest; + final String semanticLabel; + final ValueChanged onChanged; + final VoidCallback onClear; + final bool autofocus; + + @override + State createState() => + _BusyMaxLinuxHeaderSearchFieldState(); +} + +class _BusyMaxLinuxHeaderSearchFieldState + extends State { + final _focusNode = FocusNode(debugLabel: 'BusyMax Linux header Search'); + late bool _isEmpty; + Timer? _searchChangedTimer; + String? _pendingSearchValue; + var _clearHovered = false; + var _clearPressed = false; + + @override + void initState() { + super.initState(); + _isEmpty = widget.controller.text.isEmpty; + widget.controller.addListener(_handleControllerChanged); + _focusNode.addListener(_handleFocusChanged); + if (widget.autofocus) { + _focusAndSelectAll(); + } + } + + @override + void didUpdateWidget(covariant BusyMaxLinuxHeaderSearchField oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.controller != widget.controller) { + _cancelPendingSearchChanged(); + oldWidget.controller.removeListener(_handleControllerChanged); + _isEmpty = widget.controller.text.isEmpty; + widget.controller.addListener(_handleControllerChanged); + } + if (oldWidget.focusRequest != widget.focusRequest) { + _focusAndSelectAll(); + } + } + + @override + void dispose() { + _cancelPendingSearchChanged(); + widget.controller.removeListener(_handleControllerChanged); + _focusNode + ..removeListener(_handleFocusChanged) + ..dispose(); + super.dispose(); + } + + void _handleControllerChanged() { + final pending = _pendingSearchValue; + if (pending != null && widget.controller.text != pending) { + _cancelPendingSearchChanged(); + } + final isEmpty = widget.controller.text.isEmpty; + if (_isEmpty != isEmpty && mounted) { + setState(() => _isEmpty = isEmpty); + } + } + + void _cancelPendingSearchChanged() { + _searchChangedTimer?.cancel(); + _searchChangedTimer = null; + _pendingSearchValue = null; + } + + void _handleTextChanged(String value) { + _cancelPendingSearchChanged(); + if (value.isEmpty) { + widget.onChanged(''); + return; + } + + _pendingSearchValue = value; + _searchChangedTimer = Timer(BusyMaxLinuxHeaderStyle.searchChangedDelay, () { + if (!mounted) return; + final pending = _pendingSearchValue; + _pendingSearchValue = null; + _searchChangedTimer = null; + if (pending != null && widget.controller.text == pending) { + widget.onChanged(pending); + } + }); + } + + void _handleFocusChanged() { + if (mounted) { + setState(() {}); + } + } + + void _focusAndSelectAll() { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + _focusNode.requestFocus(); + widget.controller.selection = TextSelection( + baseOffset: 0, + extentOffset: widget.controller.text.length, + ); + }); + } + + void _clear() { + _cancelPendingSearchChanged(); + widget.onClear(); + if (widget.controller.text.isNotEmpty) { + widget.controller.clear(); + } + _focusNode.requestFocus(); + } + + void _setClearHovered(bool hovered) { + if (_clearHovered != hovered) { + setState(() => _clearHovered = hovered); + } + } + + void _setClearPressed(bool pressed) { + if (_clearPressed != pressed) { + setState(() => _clearPressed = pressed); + } + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final windowActive = LinuxWindowMetricsScope.of(context).windowActive; + final focused = _focusNode.hasFocus; + final searchTheme = BusyMaxNativeSearchEntryTheme.of(context); + final searchStyle = searchTheme.stateFor( + windowActive: windowActive, + focused: focused, + ); + final direction = Directionality.of(context); + final textStyle = (theme.textTheme.bodyMedium ?? const TextStyle()) + .copyWith(color: searchStyle.foreground, fontWeight: FontWeight.normal); + final widthProbe = TextPainter( + text: TextSpan( + text: List.filled( + BusyMaxLinuxHeaderSearchField.naturalWidthChars, + '0', + ).join(), + style: textStyle, + ), + maxLines: 1, + textDirection: Directionality.of(context), + textScaler: MediaQuery.textScalerOf(context), + )..layout(); + final naturalWidth = + widthProbe.width + + BusyMaxLinuxHeaderStyle.searchEntryHorizontalPadding * 2 + + searchStyle.borderLeft + + searchStyle.borderRight; + widthProbe.dispose(); + final primaryIconForeground = direction == TextDirection.rtl + ? searchStyle.primaryIconForegroundRtl + : searchStyle.primaryIconForeground; + final secondaryIconForeground = direction == TextDirection.rtl + ? searchStyle.secondaryIconForegroundRtl + : searchStyle.secondaryIconForeground; + final clearColor = !windowActive + ? secondaryIconForeground + : _clearPressed + ? theme.colorScheme.primary + : _clearHovered + ? searchStyle.foreground + : secondaryIconForeground; + final focusDuration = MediaQuery.disableAnimationsOf(context) + ? Duration.zero + : BusyMaxLinuxHeaderStyle.searchEntryFocusDuration; + final devicePixelRatio = MediaQuery.devicePixelRatioOf(context); + double snapToPhysicalPixel(double value) => + (value * devicePixelRatio).roundToDouble() / devicePixelRatio; + final innerFocusWidth = searchStyle.hasInnerFocus + ? snapToPhysicalPixel(searchStyle.innerFocusWidth) + : 0.0; + final innerFocusColor = searchStyle.hasInnerFocus + ? searchStyle.innerFocusColor + : searchStyle.innerFocusColor.withValues(alpha: 0); + final innerFocusInsets = EdgeInsets.fromLTRB( + snapToPhysicalPixel(searchStyle.borderLeft), + snapToPhysicalPixel(searchStyle.borderTop), + snapToPhysicalPixel(searchStyle.borderRight), + snapToPhysicalPixel(searchStyle.borderBottom), + ); + final innerFocusRadius = snapToPhysicalPixel( + math.max( + 0.0, + searchStyle.radius - + math.max( + math.max(innerFocusInsets.left, innerFocusInsets.right), + math.max(innerFocusInsets.top, innerFocusInsets.bottom), + ), + ), + ); + + return FocusScope( + child: LayoutBuilder( + builder: (context, constraints) { + final width = constraints.hasBoundedWidth + ? constraints.maxWidth + : naturalWidth; + return SizedBox( + width: width, + height: BusyMaxLinuxHeaderStyle.searchEntryHeight, + child: Listener( + behavior: HitTestBehavior.opaque, + child: AnimatedContainer( + key: BusyMaxLinuxHeaderSearchField.shellKey, + duration: focusDuration, + curve: Curves.easeOut, + decoration: BoxDecoration( + color: searchStyle.background, + borderRadius: BorderRadius.circular(searchStyle.radius), + ), + foregroundDecoration: BoxDecoration( + borderRadius: BorderRadius.circular(searchStyle.radius), + border: Border( + top: BorderSide( + color: searchStyle.borderColor, + width: searchStyle.borderTop, + ), + right: BorderSide( + color: searchStyle.borderColor, + width: searchStyle.borderRight, + ), + bottom: BorderSide( + color: searchStyle.borderColor, + width: searchStyle.borderBottom, + ), + left: BorderSide( + color: searchStyle.borderColor, + width: searchStyle.borderLeft, + ), + ), + ), + child: Stack( + fit: StackFit.expand, + children: [ + GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: _focusNode.requestFocus, + child: Padding( + padding: const EdgeInsetsDirectional.only( + start: BusyMaxLinuxHeaderStyle + .searchEntryHorizontalPadding, + end: BusyMaxLinuxHeaderStyle + .searchEntryHorizontalPadding, + ), + child: Row( + children: [ + ExcludeSemantics( + key: BusyMaxLinuxHeaderSearchField.primaryIconKey, + child: IconTheme( + data: IconThemeData( + color: primaryIconForeground, + ), + child: const BusyMaxGtkHeaderIcon( + BusyMaxLinuxHeaderIcon.searchEntryFind, + ), + ), + ), + const SizedBox( + width: BusyMaxLinuxHeaderStyle.searchEntryIconGap, + ), + Expanded( + child: Semantics( + label: widget.semanticLabel, + textField: true, + child: TextField( + controller: widget.controller, + focusNode: _focusNode, + autofocus: false, + maxLines: 1, + style: textStyle, + cursorColor: windowActive + ? theme.colorScheme.primary + : searchStyle.foreground, + cursorWidth: 1, + decoration: const InputDecoration( + isCollapsed: true, + contentPadding: EdgeInsets.zero, + border: InputBorder.none, + enabledBorder: InputBorder.none, + focusedBorder: InputBorder.none, + disabledBorder: InputBorder.none, + errorBorder: InputBorder.none, + focusedErrorBorder: InputBorder.none, + filled: false, + ), + onChanged: _handleTextChanged, + ), + ), + ), + if (!_isEmpty) ...[ + const SizedBox( + width: + BusyMaxLinuxHeaderStyle.searchEntryIconGap, + ), + Semantics( + container: true, + button: true, + label: MaterialLocalizations.of( + context, + ).clearButtonTooltip, + onTap: _clear, + child: ExcludeSemantics( + key: BusyMaxLinuxHeaderSearchField + .secondaryIconKey, + child: MouseRegion( + key: BusyMaxLinuxHeaderSearchField.clearKey, + cursor: SystemMouseCursors.click, + onEnter: (_) => _setClearHovered(true), + onExit: (_) { + _setClearHovered(false); + _setClearPressed(false); + }, + child: Listener( + behavior: HitTestBehavior.opaque, + onPointerDown: (_) => + _setClearPressed(true), + onPointerUp: (_) => + _setClearPressed(false), + onPointerCancel: (_) => + _setClearPressed(false), + child: GestureDetector( + behavior: HitTestBehavior.opaque, + excludeFromSemantics: true, + onTap: _clear, + child: IconTheme( + data: IconThemeData( + color: clearColor, + ), + child: const BusyMaxGtkHeaderIcon( + BusyMaxLinuxHeaderIcon.searchClear, + ), + ), + ), + ), + ), + ), + ), + ], + ], + ), + ), + ), + IgnorePointer( + child: AnimatedContainer( + key: BusyMaxLinuxHeaderSearchField.innerFocusKey, + duration: focusDuration, + curve: Curves.easeOut, + margin: innerFocusInsets, + foregroundDecoration: BoxDecoration( + borderRadius: BorderRadius.circular(innerFocusRadius), + border: Border.all( + color: innerFocusColor, + width: innerFocusWidth, + strokeAlign: BorderSide.strokeAlignInside, + ), + ), + ), + ), + ], + ), + ), + ), + ); + }, + ), + ); + } +} + +ButtonStyle busyMaxLinuxHeaderTextButtonStyle( + BuildContext context, { + required bool suggested, +}) { + final geometry = ButtonStyle( + minimumSize: const WidgetStatePropertyAll( + Size(BusyMaxSizes.headerIconButton, BusyMaxSizes.headerIconButton), + ), + padding: const WidgetStatePropertyAll( + EdgeInsets.symmetric(horizontal: BusyMaxSpacing.md), + ), + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + textStyle: WidgetStatePropertyAll( + (Theme.of(context).textTheme.bodyMedium ?? const TextStyle()).copyWith( + fontWeight: FontWeight.normal, + ), + ), + shape: WidgetStatePropertyAll( + RoundedRectangleBorder( + borderRadius: BorderRadius.circular( + BusyMaxLinuxHeaderStyle.controlRadius, + ), + ), + ), + animationDuration: BusyMaxMotion.fast, + splashFactory: NoSplash.splashFactory, + ); + if (suggested) return geometry; + return geometry.copyWith( + foregroundColor: WidgetStateProperty.resolveWith( + (states) => states.contains(WidgetState.disabled) + ? busyMaxLinuxHeaderForeground(context, disabled: true) + : busyMaxLinuxHeaderForeground(context), + ), + backgroundColor: busyMaxLinuxHeaderControlBackground(context), + overlayColor: const WidgetStatePropertyAll(Colors.transparent), + elevation: const WidgetStatePropertyAll(0), + shadowColor: const WidgetStatePropertyAll(Colors.transparent), + surfaceTintColor: const WidgetStatePropertyAll(Colors.transparent), + ); +} + +class BusyMaxLinuxHeaderTextButton extends StatelessWidget { + const BusyMaxLinuxHeaderTextButton.standard({ + super.key, + required this.label, + required this.onPressed, + }) : suggested = false; + + const BusyMaxLinuxHeaderTextButton.suggested({ + super.key, + required this.label, + required this.onPressed, + }) : suggested = true; + + final String label; + final VoidCallback? onPressed; + final bool suggested; + + @override + Widget build(BuildContext context) { + final style = busyMaxLinuxHeaderTextButtonStyle( + context, + suggested: suggested, + ); + return suggested + ? BusyMaxPushButton.suggested( + onPressed: onPressed, + style: style, + child: Text(label), + ) + : BusyMaxPushButton.standard( + onPressed: onPressed, + style: style, + child: Text(label), + ); + } +} diff --git a/lib/src/app/linux/linux_page_frame.dart b/lib/src/app/linux/linux_page_frame.dart new file mode 100644 index 00000000..fdbf26d3 --- /dev/null +++ b/lib/src/app/linux/linux_page_frame.dart @@ -0,0 +1,250 @@ +import 'package:flutter/material.dart'; + +import '../busymax_design.dart'; +import '../busymax_surface_colors.dart'; +import 'linux_header_style.dart'; +import 'linux_window_host.dart'; + +/// The canonical Linux page composition. +/// +/// The sidebar header and body are a single full-height child of one clipped +/// allocation, so their visible edge is physically identical at every frame. +class LinuxPageFrame extends StatefulWidget { + const LinuxPageFrame({ + super.key, + required this.header, + required this.body, + this.sidebarHeader, + this.sidebarBody, + this.sidebarAvailable = false, + this.sidebarExpanded = false, + this.sidebarTransitionGeneration = 0, + }); + + final Widget header; + final Widget body; + final Widget? sidebarHeader; + final Widget? sidebarBody; + final bool sidebarAvailable; + final bool sidebarExpanded; + final int sidebarTransitionGeneration; + + @override + State createState() => _LinuxPageFrameState(); +} + +class _LinuxPageFrameState extends State + with SingleTickerProviderStateMixin { + late final AnimationController _controller = AnimationController( + vsync: this, + animationBehavior: AnimationBehavior.preserve, + value: _targetVisible ? 1 : 0, + ); + late int _generation; + bool _disableAnimations = false; + + bool get _hasSidebar => + widget.sidebarHeader != null && widget.sidebarBody != null; + bool get _targetVisible => + _hasSidebar && widget.sidebarAvailable && widget.sidebarExpanded; + + @override + void initState() { + super.initState(); + _generation = widget.sidebarTransitionGeneration; + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + final disabled = MediaQuery.disableAnimationsOf(context); + if (disabled != _disableAnimations) { + _disableAnimations = disabled; + if (disabled && _controller.isAnimating) { + _controller.value = _targetVisible ? 1 : 0; + } + } + } + + @override + void didUpdateWidget(covariant LinuxPageFrame oldWidget) { + super.didUpdateWidget(oldWidget); + final target = _targetVisible ? 1.0 : 0.0; + if (widget.sidebarTransitionGeneration == _generation) { + final oldTarget = + oldWidget.sidebarHeader != null && + oldWidget.sidebarBody != null && + oldWidget.sidebarAvailable && + oldWidget.sidebarExpanded; + if (oldTarget != _targetVisible) { + _controller + ..stop() + ..value = target; + } + return; + } + _generation = widget.sidebarTransitionGeneration; + if (_disableAnimations) { + _controller.value = target; + return; + } + final distance = (target - _controller.value).abs(); + if (distance == 0) { + _controller + ..stop() + ..value = target; + return; + } + _controller.animateTo( + target, + duration: BusyMaxMotion.sidebar * distance, + curve: BusyMaxMotion.presentationCurve, + ); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final colors = BusyMaxSurfaceColors.of(context); + final metrics = LinuxWindowMetricsScope.of(context); + final textDirection = Directionality.of(context); + final sidebarAtLeft = textDirection == TextDirection.ltr; + final sidebarControlInset = sidebarAtLeft + ? metrics.leftControlInset + : metrics.rightControlInset; + final oppositeControlInset = sidebarAtLeft + ? metrics.rightControlInset + : metrics.leftControlInset; + final sidebar = _hasSidebar + ? SizedBox( + width: BusyMaxSizes.sidebarWidth, + child: ColoredBox( + color: colors.sidebar, + child: Column( + children: [ + SizedBox( + height: BusyMaxLinuxWindowMetrics.headerHeight, + child: Padding( + padding: sidebarAtLeft + ? EdgeInsets.only(left: sidebarControlInset) + : EdgeInsets.only(right: sidebarControlInset), + child: widget.sidebarHeader!, + ), + ), + Expanded(child: widget.sidebarBody!), + ], + ), + ), + ) + : null; + final main = _LinuxFrameMain( + header: ColoredBox(color: colors.window, child: widget.header), + body: widget.body, + ); + return AnimatedBuilder( + animation: _controller, + child: main, + builder: (context, mainChild) { + final devicePixelRatio = MediaQuery.devicePixelRatioOf(context); + final rawWidth = BusyMaxSizes.sidebarWidth * _controller.value; + final presentedWidth = + (rawWidth * devicePixelRatio).round() / devicePixelRatio; + final widthFactor = presentedWidth / BusyMaxSizes.sidebarWidth; + final sidebarSideOverlap = (sidebarControlInset - presentedWidth) + .clamp(0.0, double.infinity) + .toDouble(); + final leftObstruction = sidebarAtLeft + ? sidebarSideOverlap + : oppositeControlInset; + final rightObstruction = sidebarAtLeft + ? oppositeControlInset + : sidebarSideOverlap; + final sidebarInteractive = _targetVisible && _controller.value == 1; + return Row( + children: [ + if (sidebar != null) + DecoratedBox( + key: const ValueKey('linux-sidebar-viewport'), + decoration: BoxDecoration( + border: BorderDirectional( + end: BorderSide( + color: colors.sidebarBorder.withValues( + alpha: presentedWidth == 0 ? 0 : 1, + ), + ), + ), + ), + child: IgnorePointer( + ignoring: !sidebarInteractive, + child: ExcludeFocus( + excluding: !sidebarInteractive, + child: ExcludeSemantics( + excluding: !sidebarInteractive, + child: TickerMode( + enabled: sidebarInteractive || _controller.value > 0, + child: ClipRect( + child: Align( + alignment: AlignmentDirectional.centerStart, + widthFactor: widthFactor, + child: sidebar, + ), + ), + ), + ), + ), + ), + ), + Expanded( + child: Column( + children: [ + SizedBox( + height: BusyMaxLinuxWindowMetrics.headerHeight, + child: LinuxPageHeaderInsetsScope( + leftObstruction: leftObstruction, + rightObstruction: rightObstruction, + child: (mainChild! as _LinuxFrameMain).header, + ), + ), + Expanded(child: (mainChild as _LinuxFrameMain).body), + ], + ), + ), + ], + ); + }, + ); + } +} + +class _LinuxFrameMain extends StatelessWidget { + const _LinuxFrameMain({required this.header, required this.body}); + + final Widget header; + final Widget body; + + @override + Widget build(BuildContext context) => const SizedBox.shrink(); +} + +class BusyMaxLinuxBrandHeader extends StatelessWidget { + const BusyMaxLinuxBrandHeader({super.key}); + + @override + Widget build(BuildContext context) { + return LinuxTitlebarGestureRegion( + child: Center( + child: Text( + 'BusyMax', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: busyMaxLinuxHeaderBrandStyle(context), + ), + ), + ); + } +} diff --git a/lib/src/app/linux/linux_window_host.dart b/lib/src/app/linux/linux_window_host.dart new file mode 100644 index 00000000..84c3764f --- /dev/null +++ b/lib/src/app/linux/linux_window_host.dart @@ -0,0 +1,445 @@ +import 'dart:async'; +import 'dart:ui' show AppExitResponse; + +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:yaru/yaru.dart'; + +import '../../l10n/l10n.dart'; +import '../../platform/gtk_window_preferences_service.dart'; +import '../busymax_design.dart'; +import '../busymax_surface_colors.dart'; +import '../busymax_window_close.dart'; + +/// Shared dimensions for Flutter-owned Linux chrome. +abstract final class BusyMaxLinuxWindowMetrics { + static const double headerHeight = BusyMaxSizes.toolbarHeight; + static const double controlSize = kYaruWindowControlSize; + static const double controlSpacing = 14; + static const double controlHorizontalPadding = 10; + + static double decorationWidth(GtkWindowDecorationElement decoration) => + switch (decoration) { + GtkWindowDecorationElement.fallbackApplicationMenu => 0, + GtkWindowDecorationElement.windowIcon || + GtkWindowDecorationElement.minimize || + GtkWindowDecorationElement.maximize || + GtkWindowDecorationElement.close => controlSize, + }; + + static double clusterWidth(List decorations) => + decorations.isEmpty + ? 0 + : controlHorizontalPadding * 2 + + decorations.fold(0, (width, item) { + return width + decorationWidth(item); + }) + + controlSpacing * (decorations.length - 1); +} + +class LinuxWindowMetricsScope extends InheritedWidget { + const LinuxWindowMetricsScope({ + super.key, + required this.leftControlInset, + required this.rightControlInset, + required this.windowActive, + required this.preferences, + required super.child, + }); + + final double leftControlInset; + final double rightControlInset; + final bool windowActive; + final GtkWindowPreferences preferences; + + static final LinuxWindowMetricsScope _fallback = LinuxWindowMetricsScope( + leftControlInset: 0, + rightControlInset: 0, + windowActive: true, + preferences: GtkWindowPreferences.defaults(), + child: const SizedBox.shrink(), + ); + + static LinuxWindowMetricsScope of(BuildContext context) => + context.dependOnInheritedWidgetOfExactType() ?? + _fallback; + + @override + bool updateShouldNotify(LinuxWindowMetricsScope oldWidget) => + leftControlInset != oldWidget.leftControlInset || + rightControlInset != oldWidget.rightControlInset || + windowActive != oldWidget.windowActive || + preferences != oldWidget.preferences; +} + +/// Owns the stable, window-level system controls above the route navigator. +/// +/// The overlay contains only the configured control clusters. Application +/// header content remains inside page routes and is therefore covered by +/// ordinary dialog barriers. +class LinuxWindowHost extends ConsumerStatefulWidget { + const LinuxWindowHost({ + super.key, + required this.child, + this.closeCoordinator, + }); + + final Widget child; + final BusyMaxWindowCloseCoordinator? closeCoordinator; + + @override + ConsumerState createState() => _LinuxWindowHostState(); +} + +class _LinuxWindowHostState extends ConsumerState { + final _localCloseCoordinator = BusyMaxWindowCloseCoordinator(); + late final AppLifecycleListener _lifecycleListener; + YaruWindowInstance? _window; + Stream? _windowStates; + + BusyMaxWindowCloseCoordinator get _closeCoordinator => + widget.closeCoordinator ?? _localCloseCoordinator; + + @override + void initState() { + super.initState(); + _lifecycleListener = AppLifecycleListener( + onExitRequested: () async => await _closeCoordinator.requestClose() + ? AppExitResponse.exit + : AppExitResponse.cancel, + ); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + final window = YaruWindow.of(context); + if (identical(window, _window)) return; + _window = window; + _windowStates = window.states(); + } + + @override + void dispose() { + _lifecycleListener.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final preferences = + ref.watch(gtkWindowPreferencesProvider).valueOrNull ?? + GtkWindowPreferences.defaults(); + return BusyMaxWindowCloseScope( + coordinator: _closeCoordinator, + child: StreamBuilder( + stream: _windowStates, + builder: (context, snapshot) { + final state = snapshot.data ?? const YaruWindowState(); + final leftDecorations = _meaningfulDecorations( + preferences.decorationLayout.left, + state, + ); + final rightDecorations = _meaningfulDecorations( + preferences.decorationLayout.right, + state, + ); + final leftInset = BusyMaxLinuxWindowMetrics.clusterWidth( + leftDecorations, + ); + final rightInset = BusyMaxLinuxWindowMetrics.clusterWidth( + rightDecorations, + ); + return LinuxWindowMetricsScope( + leftControlInset: leftInset, + rightControlInset: rightInset, + windowActive: state.isActive != false, + preferences: preferences, + child: _LinuxWindowOverlay( + leftDecorations: leftDecorations, + rightDecorations: rightDecorations, + state: state, + child: widget.child, + ), + ); + }, + ), + ); + } +} + +List _meaningfulDecorations( + List decorations, + YaruWindowState state, +) => decorations + .where((decoration) { + return switch (decoration) { + // BusyMax does not register GTK's fallback application-menu model. + GtkWindowDecorationElement.fallbackApplicationMenu => false, + GtkWindowDecorationElement.windowIcon => true, + GtkWindowDecorationElement.minimize => state.isMinimizable != false, + GtkWindowDecorationElement.maximize => + state.isMaximizable != false || + state.isRestorable == true || + state.isMaximized == true || + state.isFullscreen == true, + GtkWindowDecorationElement.close => state.isClosable != false, + }; + }) + .toList(growable: false); + +class _LinuxWindowOverlay extends StatefulWidget { + const _LinuxWindowOverlay({ + required this.leftDecorations, + required this.rightDecorations, + required this.state, + required this.child, + }); + + final List leftDecorations; + final List rightDecorations; + final YaruWindowState state; + final Widget child; + + @override + State<_LinuxWindowOverlay> createState() => _LinuxWindowOverlayState(); +} + +class _LinuxWindowOverlayState extends State<_LinuxWindowOverlay> { + late final OverlayEntry _entry = OverlayEntry(builder: _buildOverlay); + + @override + void didUpdateWidget(covariant _LinuxWindowOverlay oldWidget) { + super.didUpdateWidget(oldWidget); + _entry.markNeedsBuild(); + } + + @override + void dispose() { + _entry.remove(); + _entry.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) => Overlay(initialEntries: [_entry]); + + Widget _buildOverlay(BuildContext context) { + return Stack( + children: [ + Positioned.fill(child: widget.child), + if (widget.leftDecorations.isNotEmpty) + Positioned( + key: const ValueKey('linux-window-controls-left'), + left: 0, + top: 0, + height: BusyMaxLinuxWindowMetrics.headerHeight, + child: _LinuxWindowControlCluster( + decorations: widget.leftDecorations, + state: widget.state, + ), + ), + if (widget.rightDecorations.isNotEmpty) + Positioned( + key: const ValueKey('linux-window-controls-right'), + right: 0, + top: 0, + height: BusyMaxLinuxWindowMetrics.headerHeight, + child: _LinuxWindowControlCluster( + decorations: widget.rightDecorations, + state: widget.state, + ), + ), + ], + ); + } +} + +class _LinuxWindowControlCluster extends StatelessWidget { + const _LinuxWindowControlCluster({ + required this.decorations, + required this.state, + }); + + final List decorations; + final YaruWindowState state; + + @override + Widget build(BuildContext context) { + final foreground = BusyMaxSurfaceColors.of(context).foreground; + return Material( + type: MaterialType.transparency, + child: _LinuxWindowActivationOpacity( + opacity: state.isActive == false ? .5 : 1, + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: BusyMaxLinuxWindowMetrics.controlHorizontalPadding, + ), + child: Row( + textDirection: TextDirection.ltr, + mainAxisSize: MainAxisSize.min, + children: [ + for (var index = 0; index < decorations.length; index++) ...[ + if (index > 0) + const SizedBox( + width: BusyMaxLinuxWindowMetrics.controlSpacing, + ), + _buildDecoration(context, decorations[index], foreground), + ], + ], + ), + ), + ), + ); + } + + Widget _buildDecoration( + BuildContext context, + GtkWindowDecorationElement decoration, + Color foreground, + ) { + final window = YaruWindow.of(context); + final material = MaterialLocalizations.of(context); + final iconColor = WidgetStatePropertyAll(foreground); + final restorePresentation = + state.isMaximized == true || state.isFullscreen == true; + return switch (decoration) { + GtkWindowDecorationElement.fallbackApplicationMenu => + const SizedBox.shrink(), + GtkWindowDecorationElement.windowIcon => SizedBox.square( + dimension: BusyMaxLinuxWindowMetrics.controlSize, + child: Center( + child: Image.asset( + 'assets/branding/busymax-logo.png', + width: BusyMaxSizes.iconSm, + height: BusyMaxSizes.iconSm, + excludeFromSemantics: true, + ), + ), + ), + GtkWindowDecorationElement.minimize => Tooltip( + message: context.l10n.windowMinimize, + child: YaruWindowControl( + iconColor: iconColor, + semanticLabel: context.l10n.windowMinimize, + type: YaruWindowControlType.minimize, + onTap: state.isMinimizable == false ? null : window.minimize, + ), + ), + GtkWindowDecorationElement.maximize => Tooltip( + message: restorePresentation + ? context.l10n.windowRestore + : context.l10n.windowMaximize, + child: YaruWindowControl( + iconColor: iconColor, + semanticLabel: restorePresentation + ? context.l10n.windowRestore + : context.l10n.windowMaximize, + type: restorePresentation + ? YaruWindowControlType.restore + : YaruWindowControlType.maximize, + onTap: restorePresentation + ? window.restore + : state.isMaximizable == false + ? null + : window.maximize, + ), + ), + GtkWindowDecorationElement.close => Tooltip( + message: material.closeButtonTooltip, + child: YaruWindowControl( + iconColor: iconColor, + semanticLabel: material.closeButtonLabel, + type: YaruWindowControlType.close, + onTap: state.isClosable == false ? null : window.close, + ), + ), + }; + } +} + +class _LinuxWindowActivationOpacity extends StatelessWidget { + const _LinuxWindowActivationOpacity({ + required this.opacity, + required this.child, + }); + + final double opacity; + final Widget child; + + @override + Widget build(BuildContext context) { + const key = ValueKey('linux-window-controls-opacity'); + if (MediaQuery.disableAnimationsOf(context)) { + return Opacity(key: key, opacity: opacity, child: child); + } + return AnimatedOpacity( + key: key, + opacity: opacity, + duration: BusyMaxMotion.fast, + child: child, + ); + } +} + +/// Adds native titlebar gestures only to a page-selected empty header region. +class LinuxTitlebarGestureRegion extends StatelessWidget { + const LinuxTitlebarGestureRegion({super.key, required this.child}); + + final Widget child; + + @override + Widget build(BuildContext context) { + final preferences = LinuxWindowMetricsScope.of(context).preferences; + final window = YaruWindow.of(context); + return Listener( + behavior: HitTestBehavior.translucent, + onPointerDown: (event) { + if (event.kind == PointerDeviceKind.mouse && + event.buttons == kMiddleMouseButton) { + unawaited( + _performTitlebarAction(context, preferences.middleClick, window), + ); + } + }, + child: GestureDetector( + excludeFromSemantics: true, + behavior: HitTestBehavior.translucent, + onPanStart: (_) => unawaited(window.drag()), + onDoubleTap: () => unawaited( + _performTitlebarAction(context, preferences.doubleClick, window), + ), + onSecondaryTap: () => unawaited( + _performTitlebarAction(context, preferences.rightClick, window), + ), + child: child, + ), + ); + } +} + +Future _performTitlebarAction( + BuildContext context, + GtkTitlebarAction action, + YaruWindowInstance window, +) async { + switch (action) { + case GtkTitlebarAction.none: + return; + case GtkTitlebarAction.minimize: + await window.minimize(); + case GtkTitlebarAction.toggleMaximize: + final state = await window.state(); + if (!context.mounted) return; + if (state.isMaximized == true || state.isFullscreen == true) { + await window.restore(); + } else if (state.isMaximizable != false) { + await window.maximize(); + } + case GtkTitlebarAction.menu: + await window.showMenu(); + case GtkTitlebarAction.lower: + await const GtkWindowPreferencesService().lowerWindow(); + } +} diff --git a/lib/src/calendar_providers/cloud_calendar_client.dart b/lib/src/calendar_providers/cloud_calendar_client.dart index 3c89242a..5fad6deb 100644 --- a/lib/src/calendar_providers/cloud_calendar_client.dart +++ b/lib/src/calendar_providers/cloud_calendar_client.dart @@ -40,6 +40,7 @@ abstract interface class CloudCalendarClient { required CalendarEventMutation mutation, CalendarGuestUpdatePolicy guestUpdatePolicy = CalendarGuestUpdatePolicy.send, + String? ifMatch, }); Future deleteEvent({ @@ -47,6 +48,7 @@ abstract interface class CloudCalendarClient { required String eventId, CalendarGuestUpdatePolicy guestUpdatePolicy = CalendarGuestUpdatePolicy.send, + String? ifMatch, }); Future moveEvent({ diff --git a/lib/src/core/auth/oauth_models.dart b/lib/src/core/auth/oauth_models.dart index 5a27de53..eaf23629 100644 --- a/lib/src/core/auth/oauth_models.dart +++ b/lib/src/core/auth/oauth_models.dart @@ -106,7 +106,15 @@ class OAuthRefreshException extends OAuthException { super.code, super.message, { required this.statusCode, + this.oauthError, + this.oauthErrorDescription, }); final int statusCode; + + /// The structured OAuth `error` value returned by the provider. + final String? oauthError; + + /// The provider's redacted OAuth `error_description`, when present. + final String? oauthErrorDescription; } diff --git a/lib/src/core/http/request_dispatch_exception.dart b/lib/src/core/http/request_dispatch_exception.dart index 7514cc2d..a307c699 100644 --- a/lib/src/core/http/request_dispatch_exception.dart +++ b/lib/src/core/http/request_dispatch_exception.dart @@ -1,3 +1,5 @@ +import 'dart:collection'; + /// A request failure that is known to have happened before the HTTP client /// forwarded the request to the network. /// @@ -31,3 +33,26 @@ final class KnownUnsentRequestException @override String toString() => 'KnownUnsentRequestException($code)'; } + +/// Resolves the substantive failure while preserving pre-dispatch wrappers. +/// +/// Classification and user messaging can inspect the returned cause without +/// weakening the wrapper's replay-safety guarantee. Malformed cause graphs are +/// bounded and identity-checked so they cannot loop forever. +Object resolveEffectiveSyncFailure(Object error) { + const maximumCauseDepth = 8; + final visited = HashSet.identity(); + var current = error; + + for (var depth = 0; depth < maximumCauseDepth; depth += 1) { + if (!visited.add(current)) { + return current; + } + if (current is! RequestNotDispatchedException || current.cause == null) { + return current; + } + current = current.cause!; + } + + return current; +} diff --git a/lib/src/dav/auth/dav_account_dialogs.dart b/lib/src/dav/auth/dav_account_dialogs.dart index 34f04c3a..7a67decc 100644 --- a/lib/src/dav/auth/dav_account_dialogs.dart +++ b/lib/src/dav/auth/dav_account_dialogs.dart @@ -1,9 +1,9 @@ import 'package:flutter/material.dart'; +import 'package:yaru/yaru.dart'; import '../../app/busymax_design.dart'; import '../../app/busymax_dialogs.dart'; import '../../l10n/l10n.dart'; -import '../../platform/linux_header_bar_service.dart'; final class AppleICloudCredentialInput { AppleICloudCredentialInput({required this.email, required this.password}); @@ -19,11 +19,9 @@ final class AppleICloudCredentialInput { Future showAppleICloudCredentialDialog( BuildContext context, { String? fixedEmail, - LinuxHeaderBarService? headerBarService, }) { return showBusyMaxModalDialog( context, - headerBarService: headerBarService, barrierDismissible: false, builder: (context) => _AppleCredentialDialog(fixedEmail: fixedEmail), ); @@ -32,11 +30,9 @@ Future showAppleICloudCredentialDialog( Future showNextcloudServerDialog( BuildContext context, { String? initialServer, - LinuxHeaderBarService? headerBarService, }) { return showBusyMaxModalDialog( context, - headerBarService: headerBarService, barrierDismissible: false, builder: (context) => _NextcloudServerDialog(initialServer: initialServer), ); @@ -89,55 +85,62 @@ final class _AppleCredentialDialogState extends State<_AppleCredentialDialog> { ], children: [ Text(l10n.appleAppSpecificPasswordHelp), - const SizedBox(height: BusyMaxSpacing.md), - TextField( - key: const Key('apple-account-email-field'), - controller: _email, - focusNode: _emailFocus, - autofocus: widget.fixedEmail == null, - readOnly: widget.fixedEmail != null, - keyboardType: TextInputType.emailAddress, - autofillHints: const [AutofillHints.username, AutofillHints.email], - textInputAction: TextInputAction.next, - onSubmitted: (_) => _passwordFocus.requestFocus(), - onChanged: (_) => setState(() {}), - decoration: InputDecoration( - labelText: l10n.appleAccountEmail, - errorText: emailMissing ? l10n.requiredField : null, - ), - ), - const SizedBox(height: BusyMaxSpacing.md), - Semantics( - textField: true, - label: l10n.appleAppSpecificPassword, - child: TextField( - key: const Key('apple-app-specific-password-field'), - controller: _password, - focusNode: _passwordFocus, - autofocus: widget.fixedEmail != null, - obscureText: true, - enableSuggestions: false, - autocorrect: false, - autofillHints: const [AutofillHints.password], - textInputAction: TextInputAction.done, - onSubmitted: (_) => _submit(), - onChanged: (_) => setState(() {}), - decoration: InputDecoration( - labelText: l10n.appleAppSpecificPassword, - errorText: passwordMissing ? l10n.requiredField : null, + BusyMaxGroupedList( + filled: true, + children: [ + YaruListTile.square( + title: TextField( + key: const Key('apple-account-email-field'), + controller: _email, + focusNode: _emailFocus, + autofocus: widget.fixedEmail == null, + readOnly: widget.fixedEmail != null, + keyboardType: TextInputType.emailAddress, + autofillHints: const [ + AutofillHints.username, + AutofillHints.email, + ], + textInputAction: TextInputAction.next, + onSubmitted: (_) => _passwordFocus.requestFocus(), + onChanged: (_) => setState(() {}), + decoration: busyMaxGroupedTextFieldDecoration( + context, + labelText: l10n.appleAccountEmail, + errorText: emailMissing ? l10n.requiredField : null, + ), + ), + ), + YaruListTile.square( + title: Semantics( + textField: true, + label: l10n.appleAppSpecificPassword, + child: TextField( + key: const Key('apple-app-specific-password-field'), + controller: _password, + focusNode: _passwordFocus, + autofocus: widget.fixedEmail != null, + obscureText: true, + enableSuggestions: false, + autocorrect: false, + autofillHints: const [AutofillHints.password], + textInputAction: TextInputAction.done, + onSubmitted: (_) => _submit(), + onChanged: (_) => setState(() {}), + decoration: busyMaxGroupedTextFieldDecoration( + context, + labelText: l10n.appleAppSpecificPassword, + errorText: passwordMissing ? l10n.requiredField : null, + ), + ), + ), ), - ), + ], ), const SizedBox(height: BusyMaxSpacing.md), Text( l10n.appleAppSpecificPasswordResetWarning, style: Theme.of(context).textTheme.bodySmall, ), - const SizedBox(height: BusyMaxSpacing.sm), - Text( - l10n.davCachedOfflineNotice, - style: Theme.of(context).textTheme.bodySmall, - ), ], ); } @@ -191,30 +194,41 @@ final class _NextcloudServerDialogState extends State<_NextcloudServerDialog> { ), ], children: [ - TextField( - key: const Key('nextcloud-server-field'), - controller: _server, - autofocus: true, - keyboardType: TextInputType.url, - autofillHints: const [AutofillHints.url], - textInputAction: TextInputAction.done, - onChanged: (_) => setState(() {}), - onSubmitted: (_) => _submit(), - decoration: InputDecoration( - labelText: l10n.nextcloudServerUrl, - hintText: 'https://cloud.example.com/remote.php/dav', - helperText: l10n.nextcloudServerUrlHelp, - helperMaxLines: 2, - errorText: _submitted && _server.text.trim().isEmpty - ? l10n.requiredField - : null, - ), + BusyMaxGroupedList( + filled: true, + children: [ + YaruListTile.square( + title: TextField( + key: const Key('nextcloud-server-field'), + controller: _server, + autofocus: true, + keyboardType: TextInputType.url, + autofillHints: const [AutofillHints.url], + textInputAction: TextInputAction.done, + onChanged: (_) => setState(() {}), + onSubmitted: (_) => _submit(), + decoration: + busyMaxGroupedTextFieldDecoration( + context, + labelText: l10n.nextcloudServerUrl, + errorText: _submitted && _server.text.trim().isEmpty + ? l10n.requiredField + : null, + ).copyWith( + hintText: 'https://cloud.example.com/remote.php/dav', + ), + ), + ), + ], ), - const SizedBox(height: BusyMaxSpacing.md), - Text(l10n.nextcloudBrowserAuthorizationHelp), const SizedBox(height: BusyMaxSpacing.sm), Text( - l10n.davCachedOfflineNotice, + l10n.nextcloudServerUrlHelp, + style: Theme.of(context).textTheme.bodySmall, + ), + const SizedBox(height: BusyMaxSpacing.md), + Text( + l10n.nextcloudBrowserAuthorizationHelp, style: Theme.of(context).textTheme.bodySmall, ), ], diff --git a/lib/src/dav/ical/ical_recurrence.dart b/lib/src/dav/ical/ical_recurrence.dart index 5cfac1ea..d190786f 100644 --- a/lib/src/dav/ical/ical_recurrence.dart +++ b/lib/src/dav/ical/ical_recurrence.dart @@ -13,12 +13,14 @@ final class IcalRecurrenceLimits { this.maximumOccurrences = 10000, this.maximumPeriods = 1000000, this.maximumRuleValues = 1024, + this.maximumCandidatesPerPeriod = 100000, this.maximumProjectionRange = const Duration(days: 366 * 20), }); final int maximumOccurrences; final int maximumPeriods; final int maximumRuleValues; + final int maximumCandidatesPerPeriod; final Duration maximumProjectionRange; } @@ -118,7 +120,11 @@ final class IcalRecurrenceExpander { ); var generatedForCount = 0; for (var period = 0; period < limits.maximumPeriods; period += 1) { - final candidates = rule.candidatesForPeriod(current.localValue, period); + final candidates = rule.candidatesForPeriod( + current.localValue, + period, + maximumCandidates: limits.maximumCandidatesPerPeriod, + ); for (final wallValue in candidates) { if (wallValue.isBefore(current.localValue)) continue; final value = _withWallValue(current, wallValue); @@ -370,7 +376,11 @@ final class IcalRecurrenceExpander { period < limits.maximumPeriods; period += 1 ) { - final candidates = rule.candidatesForPeriod(anchor.localValue, period); + final candidates = rule.candidatesForPeriod( + anchor.localValue, + period, + maximumCandidates: limits.maximumCandidatesPerPeriod, + ); if (candidates.isEmpty && rule.periodStartsAfter( period, @@ -578,7 +588,11 @@ final class _RecurrenceRule { final List byMonth; final List bySetPosition; - List candidatesForPeriod(DateTime start, int period) { + List candidatesForPeriod( + DateTime start, + int period, { + required int maximumCandidates, + }) { final candidates = switch (frequency) { _Frequency.secondly => _secondCandidates(start, period), _Frequency.minutely => _minuteCandidates(start, period), @@ -588,8 +602,19 @@ final class _RecurrenceRule { _Frequency.monthly => _monthlyCandidates(start, period), _Frequency.yearly => _yearlyCandidates(start, period), }; - final filtered = candidates.where(_matchesAllFilters).toSet().toList() - ..sort(); + final filteredSet = {}; + var inspected = 0; + for (final candidate in candidates) { + inspected += 1; + if (inspected > maximumCandidates) { + throw _limitError( + 'IcalRecurrenceCandidateLimitExceeded', + 'A recurrence period exceeded the safe candidate work limit.', + ); + } + if (_matchesAllFilters(candidate)) filteredSet.add(candidate); + } + final filtered = filteredSet.toList()..sort(); if (bySetPosition.isEmpty) return filtered; final selected = {}; for (final position in bySetPosition) { @@ -657,68 +682,65 @@ final class _RecurrenceRule { _Frequency.yearly => _addYears(start, period * interval), }; - List _secondCandidates(DateTime start, int period) => [ - _periodAnchor(start, period), - ]; + Iterable _secondCandidates(DateTime start, int period) sync* { + yield _periodAnchor(start, period); + } - List _minuteCandidates(DateTime start, int period) { + Iterable _minuteCandidates(DateTime start, int period) sync* { final anchor = _periodAnchor(start, period); - return [ - for (final second in bySecond.isEmpty ? [start.second] : bySecond) - _wall( + for (final second in bySecond.isEmpty ? [start.second] : bySecond) { + yield _wall( + anchor.year, + anchor.month, + anchor.day, + anchor.hour, + anchor.minute, + second, + ); + } + } + + Iterable _hourCandidates(DateTime start, int period) sync* { + final anchor = _periodAnchor(start, period); + for (final minute in byMinute.isEmpty ? [start.minute] : byMinute) { + for (final second in bySecond.isEmpty ? [start.second] : bySecond) { + yield _wall( anchor.year, anchor.month, anchor.day, anchor.hour, - anchor.minute, + minute, second, - ), - ]; - } - - List _hourCandidates(DateTime start, int period) { - final anchor = _periodAnchor(start, period); - return [ - for (final minute in byMinute.isEmpty ? [start.minute] : byMinute) - for (final second in bySecond.isEmpty ? [start.second] : bySecond) - _wall( - anchor.year, - anchor.month, - anchor.day, - anchor.hour, - minute, - second, - ), - ]; + ); + } + } } - List _dailyCandidates(DateTime start, int period) { + Iterable _dailyCandidates(DateTime start, int period) { final date = _periodAnchor(start, period); return _timesForDate(date, start); } - List _weeklyCandidates(DateTime start, int period) { + Iterable _weeklyCandidates(DateTime start, int period) sync* { final anchor = _periodAnchor(start, period); final week = _startOfWeek(anchor, weekStart); final weekdays = byDay.isEmpty ? [start.weekday] : byDay.map((value) => value.weekday).toSet().toList(); - return [ - for (final weekday in weekdays) - ..._timesForDate(_addDays(week, (weekday - weekStart) % 7), start), - ]; + for (final weekday in weekdays) { + yield* _timesForDate(_addDays(week, (weekday - weekStart) % 7), start); + } } - List _monthlyCandidates(DateTime start, int period) { + Iterable _monthlyCandidates(DateTime start, int period) sync* { final anchor = _periodAnchor(start, period); - if (byMonth.isNotEmpty && !byMonth.contains(anchor.month)) return const []; - return [ - for (final day in _monthDays(anchor.year, anchor.month, start.day)) - ..._timesForDate(_wall(anchor.year, anchor.month, day), start), - ]; + if (byMonth.isNotEmpty && !byMonth.contains(anchor.month)) return; + for (final day in _monthDays(anchor.year, anchor.month, start.day)) { + yield* _timesForDate(_wall(anchor.year, anchor.month, day), start); + } } - List _yearlyCandidates(DateTime start, int period) { + Iterable _yearlyCandidates(DateTime start, int period) sync* { final anchor = _periodAnchor(start, period); final year = anchor.year; final dates = []; @@ -752,7 +774,9 @@ final class _RecurrenceRule { ]); } } - return [for (final date in dates) ..._timesForDate(date, start)]; + for (final date in dates) { + yield* _timesForDate(date, start); + } } List _monthDays(int year, int month, int defaultDay) { @@ -767,12 +791,15 @@ final class _RecurrenceRule { ]; } - List _timesForDate(DateTime date, DateTime start) => [ - for (final hour in byHour.isEmpty ? [start.hour] : byHour) - for (final minute in byMinute.isEmpty ? [start.minute] : byMinute) - for (final second in bySecond.isEmpty ? [start.second] : bySecond) - _wall(date.year, date.month, date.day, hour, minute, second), - ]; + Iterable _timesForDate(DateTime date, DateTime start) sync* { + for (final hour in byHour.isEmpty ? [start.hour] : byHour) { + for (final minute in byMinute.isEmpty ? [start.minute] : byMinute) { + for (final second in bySecond.isEmpty ? [start.second] : bySecond) { + yield _wall(date.year, date.month, date.day, hour, minute, second); + } + } + } + } bool _matchesAllFilters(DateTime date) { if (byMonth.isNotEmpty && !byMonth.contains(date.month)) return false; diff --git a/lib/src/dav/mutation/dav_pending_operations.dart b/lib/src/dav/mutation/dav_pending_operations.dart index 46f34c2a..262d5c05 100644 --- a/lib/src/dav/mutation/dav_pending_operations.dart +++ b/lib/src/dav/mutation/dav_pending_operations.dart @@ -374,29 +374,41 @@ final class DavPendingOperationQueue { updatedRaw, silent: object.suppressScheduling, ); - await (_database.update( - _database.pendingOps, - )..where((row) => row.id.equals(operation.id))).write( - PendingOpsCompanion( - requestJson: Value( - jsonEncode({ - 'schemaVersion': davPendingOperationSchemaVersion, - 'uid': object.uid, - 'initialMemberName': object.initialMemberName, - 'rawIcs': updatedRaw, - 'componentType': object.componentType.toUpperCase(), - if (object.suppressScheduling) 'suppressScheduling': true, - }), - ), - state: Value(DavPendingState.pending.storageValue), - retryClassification: const Value('conditional_create'), - nextAttemptAtUtc: const Value(null), - lastErrorCode: const Value(null), - lastErrorMessage: const Value(null), - lastError: const Value(null), - updatedAtUtc: Value(nowUtc.toIso8601String()), - ), - ); + final updated = + await (_database.update(_database.pendingOps)..where( + (row) => + row.id.equals(operation.id) & + row.state.equals(operation.state) & + row.attemptCount.equals(operation.attemptCount) & + row.requestJson.equals(operation.requestJson) & + row.updatedAtUtc.equals(operation.updatedAtUtc), + )) + .write( + PendingOpsCompanion( + requestJson: Value( + jsonEncode({ + 'schemaVersion': davPendingOperationSchemaVersion, + 'uid': object.uid, + 'initialMemberName': object.initialMemberName, + 'rawIcs': updatedRaw, + 'componentType': object.componentType.toUpperCase(), + if (object.suppressScheduling) 'suppressScheduling': true, + }), + ), + state: Value(DavPendingState.pending.storageValue), + retryClassification: const Value('conditional_create'), + nextAttemptAtUtc: const Value(null), + lastErrorCode: const Value(null), + lastErrorMessage: const Value(null), + lastError: const Value(null), + updatedAtUtc: Value(nowUtc.toIso8601String()), + ), + ); + if (updated != 1) { + throw StateError( + 'The DAV creation changed before the edit could be saved.', + ); + } } /// Cancels a create only while it is provably unsent. @@ -412,7 +424,17 @@ final class DavPendingOperationQueue { localProjectionId: localProjectionId, ); if (operation == null) return false; - await _database.pendingOpsDao.deleteOp(operation.id); + final deleted = + await (_database.delete(_database.pendingOps)..where( + (row) => + row.id.equals(operation.id) & + row.state.equals(operation.state) & + row.attemptCount.equals(operation.attemptCount) & + row.requestJson.equals(operation.requestJson) & + row.updatedAtUtc.equals(operation.updatedAtUtc), + )) + .go(); + if (deleted != 1) return false; return true; } @@ -1179,11 +1201,14 @@ final class DavPendingOperationsReplayer { final changedCollections = {}; for (final listed in operations) { - final op = await _database.pendingOpsDao.getOp(listed.id); - if (op == null || !isDavPendingOperation(op)) continue; - if (op.dependsOnOpId != null && await _opExists(op.dependsOnOpId!)) { + final candidate = await _database.pendingOpsDao.getOp(listed.id); + if (candidate == null || !isDavPendingOperation(candidate)) continue; + if (candidate.dependsOnOpId != null && + await _opExists(candidate.dependsOnOpId!)) { continue; } + final op = await _claimCurrentOperation(candidate); + if (op == null) continue; if (_copyConfirmationMissing(op)) { final error = const DavException( kind: DavErrorKind.protocol, @@ -1196,7 +1221,6 @@ final class DavPendingOperationsReplayer { continue; } try { - await _markInProgress(op); final result = await _replay(op); if (result.outcome == DavMutationOutcome.conflict) { await _recordConflict(op, result); @@ -1759,19 +1783,43 @@ final class DavPendingOperationsReplayer { await _setAccountState(AccountConnectionState.temporarilyUnavailable); } - Future _markInProgress(PendingOp op) { - return (_database.update( - _database.pendingOps, - )..where((row) => row.id.equals(op.id))).write( - PendingOpsCompanion( - state: Value(DavPendingState.inProgress.storageValue), - nextAttemptAtUtc: const Value(null), - requestJson: op.operationType == 'dav.move' - ? Value(_markMoveMayHaveCompleted(op.requestJson)) - : const Value.absent(), - updatedAtUtc: Value(_nowUtc().toUtc().toIso8601String()), - ), - ); + Future _claimCurrentOperation(PendingOp snapshot) { + return _database.transaction(() async { + final timestamp = _nowUtc().toUtc().toIso8601String(); + final requestJson = snapshot.operationType == 'dav.move' + ? _markMoveMayHaveCompleted(snapshot.requestJson) + : snapshot.requestJson; + final updated = + await (_database.update(_database.pendingOps)..where( + (row) => + row.id.equals(snapshot.id) & + row.state.equals(snapshot.state) & + row.attemptCount.equals(snapshot.attemptCount) & + row.requestJson.equals(snapshot.requestJson) & + row.updatedAtUtc.equals(snapshot.updatedAtUtc), + )) + .write( + PendingOpsCompanion( + state: Value(DavPendingState.inProgress.storageValue), + nextAttemptAtUtc: const Value(null), + requestJson: Value(requestJson), + updatedAtUtc: Value(timestamp), + ), + ); + if (updated != 1) return null; + final claimed = await _database.pendingOpsDao.getOp(snapshot.id); + // Replay decisions distinguish a fresh pending request from a request + // recovered after an interrupted dispatch. The database row is claimed + // in_progress, while this immutable payload retains the pre-claim state + // for that decision. + return claimed?.copyWith( + state: snapshot.state, + // The marker is durable crash-recovery bookkeeping, not part of the + // first dispatch decision. The CAS above proves this is still the + // payload that was claimed. + requestJson: snapshot.requestJson, + ); + }); } Future _setAccountState(AccountConnectionState state) { diff --git a/lib/src/dav/presentation/nextcloud_collection_dialog.dart b/lib/src/dav/presentation/nextcloud_collection_dialog.dart index 8b85cadf..8e8b1187 100644 --- a/lib/src/dav/presentation/nextcloud_collection_dialog.dart +++ b/lib/src/dav/presentation/nextcloud_collection_dialog.dart @@ -1,26 +1,30 @@ import 'dart:async'; -import '../../ui/common/schedule/native_collection_export.dart'; -import 'nextcloud_scheduling_dialog.dart'; -import 'dart:math' as math; + import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:busymax/l10n/generated/app_localizations.dart'; -import 'package:busymax/src/app/app_bootstrap.dart'; -import 'package:busymax/src/dav/nextcloud/nextcloud_collection_controller.dart'; -import 'package:busymax/src/dav/nextcloud/nextcloud_collection_service.dart'; -import 'package:busymax/src/dav/nextcloud/nextcloud_dav_context.dart'; -import 'package:busymax/src/dav/nextcloud/nextcloud_trash_service.dart'; -import 'package:busymax/src/dav/dav_errors.dart'; -import 'package:busymax/src/dav/xml/dav_xml.dart'; +import 'package:yaru/yaru.dart'; + +import '../../app/app_bootstrap.dart'; +import '../../app/busymax_design.dart'; +import '../../app/busymax_dialogs.dart'; +import '../../dav/dav_errors.dart'; +import '../../dav/nextcloud/nextcloud_collection_controller.dart'; +import '../../dav/nextcloud/nextcloud_collection_service.dart'; +import '../../dav/nextcloud/nextcloud_dav_context.dart'; +import '../../dav/nextcloud/nextcloud_trash_service.dart'; +import '../../dav/xml/dav_xml.dart'; +import '../../l10n/l10n.dart'; +import '../../ui/common/schedule/native_collection_export.dart'; +import 'nextcloud_scheduling_dialog.dart'; Future showLinuxNextcloudCollectionDialog( BuildContext context, { required String accountId, required String collectionId, -}) => showDialog( - context: context, +}) => showBusyMaxModalDialog( + context, barrierDismissible: false, - builder: (_) => LinuxNextcloudCollectionDialog( + builder: (dialogContext) => LinuxNextcloudCollectionDialog( accountId: accountId, collectionId: collectionId, ), @@ -69,29 +73,29 @@ class _LinuxNextcloudCollectionDialogState super.dispose(); } - Future _confirm(String message, String action) async => - await showDialog( - context: context, - builder: (dialogContext) => AlertDialog( - title: Text(message), - actions: [ - TextButton( - onPressed: () => Navigator.pop(dialogContext, false), - child: Text(AppLocalizations.of(context).cancel), - ), - FilledButton( - onPressed: () => Navigator.pop(dialogContext, true), - child: Text(action), - ), - ], - ), - ) ?? - false; + Future _confirm({ + required String title, + required String message, + required String action, + bool destructive = false, + }) => showBusyMaxConfirm( + context, + title: title, + message: message, + confirmLabel: action, + destructive: destructive, + ); Future _close() async { if (model.busy) return; - final l10n = AppLocalizations.of(context); - if (model.dirty && !await _confirm(l10n.discardChanges, l10n.discard)) { + final l10n = context.l10n; + if (model.dirty && + !await _confirm( + title: l10n.discard, + message: l10n.discardChanges, + action: l10n.discard, + destructive: true, + )) { return; } if (mounted) Navigator.pop(context); @@ -102,19 +106,23 @@ class _LinuxNextcloudCollectionDialogState property, () => TextEditingController(text: model.values[property]), ); - final input = TextField( - key: ValueKey('nextcloud-${property.localName}'), - controller: controller, - enabled: model.state!.collection.canWriteMetadata && !model.busy, - maxLines: - property.localName == 'calendar-description' || - property.localName == 'calendar-timezone' - ? 3 - : 1, - decoration: InputDecoration(labelText: label), - onChanged: (value) => model.change(property, value), + final multiline = + property.localName == 'calendar-description' || + property.localName == 'calendar-timezone'; + return YaruListTile.square( + title: TextField( + key: ValueKey('nextcloud-${property.localName}'), + controller: controller, + enabled: model.state!.collection.canWriteMetadata && !model.busy, + maxLines: multiline ? 3 : 1, + decoration: busyMaxGroupedTextFieldDecoration( + context, + labelText: label, + alignLabelWithHint: multiline, + ), + onChanged: (value) => model.change(property, value), + ), ); - return Padding(padding: const EdgeInsets.only(bottom: 12), child: input); } Widget _check( @@ -122,20 +130,30 @@ class _LinuxNextcloudCollectionDialogState String label, String yes, String no, - ) => Padding( - padding: const EdgeInsets.only(bottom: 12), - child: CheckboxListTile( - contentPadding: EdgeInsets.zero, - title: Text(label), - value: model.values[property] == yes, - onChanged: model.state!.collection.canWriteMetadata && !model.busy - ? (value) => model.change(property, value == true ? yes : no) - : null, + ) => BusyMaxSwitchRow( + title: label, + value: model.values[property] == yes, + enabled: model.state!.collection.canWriteMetadata && !model.busy, + onChanged: (value) => model.change(property, value ? yes : no), + ); + + Future _showExportComplete(String path) => showBusyMaxModalDialog( + context, + builder: (dialogContext) => BusyMaxDialogShell( + title: context.l10n.nextcloudExportCollection, + actions: [ + BusyMaxPushButton.suggested( + onPressed: () => Navigator.of(dialogContext).pop(), + child: Text(context.l10n.close), + ), + ], + children: [Text(context.l10n.exportedFile(path))], ), ); + @override Widget build(BuildContext context) { - final l10n = AppLocalizations.of(context); + final l10n = context.l10n; final state = model.state; final role = state == null ? '' @@ -151,228 +169,243 @@ class _LinuxNextcloudCollectionDialogState onPopInvokedWithResult: (didPop, result) { if (!didPop) unawaited(_close()); }, - child: AlertDialog( - title: Text(l10n.nextcloudCollectionSettings), - content: SizedBox( - width: 620, - height: math.min(500.0, MediaQuery.sizeOf(context).height * .6), - child: SingleChildScrollView( - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - mainAxisSize: MainAxisSize.min, + child: BusyMaxDialogShell( + key: const ValueKey('nextcloud-collection-dialog'), + title: l10n.nextcloudCollectionSettings, + maxWidth: 680, + header: BusyMaxEditorHeader( + title: l10n.nextcloudCollectionSettings, + cancelLabel: l10n.close, + saveLabel: l10n.save, + onCancel: _close, + cancelEnabled: !model.busy, + saving: model.busy && model.dirty, + onSave: + model.busy || + !model.dirty || + state?.collection.canWriteMetadata != true + ? null + : model.save, + ), + children: [ + if (model.busy) const Center(child: YaruCircularProgressIndicator()), + if (model.failed) + Text( + model.error is DavException && + (model.error! as DavException).kind == + DavErrorKind.authorization + ? l10n.nextcloudOperationDenied + : l10n.nextcloudServerUnavailable, + ), + if (model.refreshPending) Text(l10n.nextcloudRefreshPending), + if (state == null && !model.busy) + BusyMaxGroupedList( + filled: true, children: [ - if (model.busy) - Center(child: const CircularProgressIndicator()), - if (model.failed) - Padding( - padding: const EdgeInsets.only(bottom: 12), - child: Text( - model.error is DavException && - (model.error! as DavException).kind == - DavErrorKind.authorization - ? l10n.nextcloudOperationDenied - : l10n.nextcloudServerUnavailable, - ), - ), - if (model.refreshPending) Text(l10n.nextcloudRefreshPending), - if (state == null && !model.busy) - TextButton(onPressed: model.load, child: Text(l10n.retry)), - if (state != null) ...[ - if (state.collection.collection.eventProjectionEnabled) - TextButton( - onPressed: model.busy - ? null - : () => showLinuxNextcloudSchedulingDialog( - context, - accountId: widget.accountId, - collectionId: widget.collectionId, - ), - child: Text(l10n.nextcloudSchedulingInbox), - ), - Text('${state.collection.collection.displayName} · $role'), - if (state.collection.collection.ownerHref case final owner?) - SelectableText(owner), - if (state.collection.collection.readOnly && - state.collection.canWriteMetadata) - Text(l10n.nextcloudMetadataEditable), - const SizedBox(height: 12), - _field( - const DavPropertyName(davNamespace, 'displayname'), - l10n.title, - ), - _field( - const DavPropertyName(appleIcalNamespace, 'calendar-color'), - l10n.calendarColor, + BusyMaxActionRow( + title: l10n.retry, + leading: const Icon(YaruIcons.sync), + onTap: model.load, + ), + ], + ), + if (state != null) ...[ + BusyMaxGroupedList( + title: l10n.metadata, + filled: true, + children: [ + YaruListTile.square( + leading: const Icon(YaruIcons.calendar), + title: Text(state.collection.collection.displayName), + subtitle: Text(role), + ), + if (state.collection.collection.ownerHref case final owner?) + YaruListTile.square( + leading: const Icon(Icons.person_outline), + title: SelectableText(owner), ), - _field( - const DavPropertyName(appleIcalNamespace, 'calendar-order'), - l10n.nextcloudServerOrder, + if (state.collection.collection.readOnly && + state.collection.canWriteMetadata) + YaruListTile.square( + leading: const Icon(Icons.info_outline), + title: Text(l10n.nextcloudMetadataEditable), ), - _field( - const DavPropertyName( - caldavNamespace, - 'calendar-description', - ), - l10n.description, + _field( + const DavPropertyName(davNamespace, 'displayname'), + l10n.title, + ), + _field( + const DavPropertyName(appleIcalNamespace, 'calendar-color'), + l10n.calendarColor, + ), + _field( + const DavPropertyName(appleIcalNamespace, 'calendar-order'), + l10n.nextcloudServerOrder, + ), + _field( + const DavPropertyName( + caldavNamespace, + 'calendar-description', ), - _field( - const DavPropertyName(caldavNamespace, 'calendar-timezone'), - l10n.nextcloudCalendarTimezone, + l10n.description, + ), + _field( + const DavPropertyName(caldavNamespace, 'calendar-timezone'), + l10n.nextcloudCalendarTimezone, + ), + _check( + const DavPropertyName(owncloudNamespace, 'calendar-enabled'), + l10n.nextcloudCalendarEnabled, + '1', + '0', + ), + _check( + const DavPropertyName( + caldavNamespace, + 'schedule-calendar-transp', ), - _check( - const DavPropertyName( - owncloudNamespace, - 'calendar-enabled', - ), - l10n.nextcloudCalendarEnabled, - '1', - '0', + l10n.nextcloudAvailability, + 'opaque', + 'transparent', + ), + ], + ), + BusyMaxGroupedList( + title: l10n.nextcloudSharing, + filled: true, + children: [ + for (final share in state.shares) ...[ + YaruListTile.square( + leading: const Icon(Icons.person_outline), + title: Text(share.recipient.label), + subtitle: share.status.isEmpty ? null : Text(share.status), ), - _check( - const DavPropertyName( - caldavNamespace, - 'schedule-calendar-transp', + BusyMaxActionRow( + title: share.writable + ? l10n.nextcloudWriteAccess + : l10n.nextcloudReadAccess, + leading: Icon( + share.writable ? Icons.edit_outlined : Icons.visibility, ), - l10n.nextcloudAvailability, - 'opaque', - 'transparent', + enabled: !model.busy && state.canShare, + onTap: () => model.share(share.recipient, !share.writable), ), - const SizedBox(height: 12), - Text(l10n.nextcloudSharing), - for (final share in state.shares) - Padding( - padding: const EdgeInsets.symmetric(vertical: 6), - child: Wrap( - spacing: 8, - crossAxisAlignment: WrapCrossAlignment.center, - children: [ - Text(share.recipient.label), - TextButton( - onPressed: !model.busy && state.canShare - ? () => model.share( - share.recipient, - !share.writable, - ) - : null, - child: Text( - share.writable - ? l10n.nextcloudWriteAccess - : l10n.nextcloudReadAccess, - ), - ), - if (state.canShare) - TextButton( - onPressed: model.busy - ? null - : () async { - if (await _confirm( - l10n.nextcloudRevokeShare, - l10n.nextcloudRevokeShare, - )) { - await model.share( - share.recipient, - null, - ); - } - }, - child: Text(l10n.nextcloudRevokeShare), - ), - ], - ), + if (state.canShare) + BusyMaxActionRow( + title: l10n.nextcloudRevokeShare, + leading: const Icon(Icons.link_off), + enabled: !model.busy, + onTap: () async { + if (await _confirm( + title: l10n.nextcloudRevokeShare, + message: l10n.nextcloudRevokeShare, + action: l10n.nextcloudRevokeShare, + destructive: true, + )) { + await model.share(share.recipient, null); + } + }, ), - if (state.canShare) ...[ - const SizedBox(height: 8), - TextField( + ], + if (state.canShare) ...[ + YaruListTile.square( + title: TextField( controller: query, enabled: !model.busy, - decoration: InputDecoration( + textInputAction: TextInputAction.search, + decoration: busyMaxGroupedTextFieldDecoration( + context, labelText: l10n.nextcloudRecipientSearch, ), onChanged: (_) => model.clearSearch(), - onSubmitted: (value) => model.search(value), + onSubmitted: model.search, ), - TextButton( - onPressed: model.busy - ? null - : () => model.search(query.text), - child: Text(l10n.windowsSearch), + ), + BusyMaxActionRow( + title: l10n.windowsSearch, + leading: const Icon(YaruIcons.search), + enabled: !model.busy, + onTap: () => model.search(query.text), + ), + for (final recipient in model.recipients) ...[ + YaruListTile.square( + leading: const Icon(Icons.person_add_outlined), + title: Text(recipient.label), ), - for (final recipient in model.recipients) - Wrap( - spacing: 8, - crossAxisAlignment: WrapCrossAlignment.center, - children: [ - Text(recipient.label), - TextButton( - onPressed: model.busy - ? null - : () => model.share(recipient, false), - child: Text(l10n.nextcloudReadAccess), - ), - TextButton( - onPressed: model.busy - ? null - : () => model.share(recipient, true), - child: Text(l10n.nextcloudWriteAccess), - ), - ], - ), - ], - if (state.publishUrl != null) - SelectableText(state.publishUrl.toString()), - if (state.canPublish) - TextButton( - onPressed: model.busy - ? null - : () async { - final publish = state.publishUrl == null; - if (!publish || - await _confirm( - l10n.nextcloudPublishWarning, - l10n.nextcloudPublish, - )) { - await model.publish(publish); - } - }, - child: Text( - state.publishUrl == null - ? l10n.nextcloudPublish - : l10n.nextcloudUnpublish, - ), + BusyMaxActionRow( + title: l10n.nextcloudReadAccess, + leading: const Icon(Icons.visibility), + enabled: !model.busy, + onTap: () => model.share(recipient, false), ), + BusyMaxActionRow( + title: l10n.nextcloudWriteAccess, + leading: const Icon(Icons.edit_outlined), + enabled: !model.busy, + onTap: () => model.share(recipient, true), + ), + ], ], + if (state.publishUrl != null) + YaruListTile.square( + leading: const Icon(Icons.link), + title: SelectableText(state.publishUrl.toString()), + ), + if (state.canPublish) + BusyMaxActionRow( + title: state.publishUrl == null + ? l10n.nextcloudPublish + : l10n.nextcloudUnpublish, + leading: Icon( + state.publishUrl == null ? Icons.public : Icons.link_off, + ), + enabled: !model.busy, + onTap: () async { + final publish = state.publishUrl == null; + if (!publish || + await _confirm( + title: l10n.nextcloudPublish, + message: l10n.nextcloudPublishWarning, + action: l10n.nextcloudPublish, + )) { + await model.publish(publish); + } + }, + ), ], ), - ), - ), - actions: [ - TextButton( - onPressed: model.busy - ? null - : () => model.exportTo((resources) async { + BusyMaxGroupedList( + title: l10n.actionsSection, + filled: true, + children: [ + if (state.collection.collection.eventProjectionEnabled) + BusyMaxActionRow( + title: l10n.nextcloudSchedulingInbox, + leading: const Icon(Icons.inbox_outlined), + enabled: !model.busy, + onTap: () => showLinuxNextcloudSchedulingDialog( + context, + accountId: widget.accountId, + collectionId: widget.collectionId, + ), + ), + BusyMaxActionRow( + title: l10n.nextcloudExportCollection, + leading: const Icon(Icons.file_download_outlined), + enabled: !model.busy, + onTap: () => model.exportTo((resources) async { final path = await exportNativeCollectionWithDirectoryDialog( resources, ); if (mounted && path != null) { - await _confirm(l10n.exportedFile(path), l10n.close); + await _showExportComplete(path); } }), - child: Text(l10n.nextcloudExportCollection), - ), - TextButton( - onPressed: model.busy ? null : _close, - child: Text(l10n.close), - ), - FilledButton( - onPressed: - model.busy || - !model.dirty || - state?.collection.canWriteMetadata != true - ? null - : model.save, - child: Text(l10n.save), - ), + ), + ], + ), + ], ], ), ); @@ -382,10 +415,10 @@ class _LinuxNextcloudCollectionDialogState Future showLinuxNextcloudTrashDialog( BuildContext context, { required String accountId, -}) => showDialog( - context: context, +}) => showBusyMaxModalDialog( + context, barrierDismissible: false, - builder: (_) => _LinuxNextcloudTrashDialog(accountId: accountId), + builder: (dialogContext) => _LinuxNextcloudTrashDialog(accountId: accountId), ); class _LinuxNextcloudTrashDialog extends ConsumerStatefulWidget { @@ -434,34 +467,18 @@ class _LinuxNextcloudTrashState } Future _mutate(NextcloudTrashItem item, bool permanent) async { - final l10n = AppLocalizations.of(context); - final confirmed = - await showDialog( - context: context, - builder: (dialogContext) => AlertDialog( - title: Text( - permanent - ? l10n.nextcloudPermanentDeleteWarning - : l10n.nextcloudRestore, - ), - content: Text(item.title), - actions: [ - TextButton( - onPressed: () => Navigator.pop(dialogContext, false), - child: Text(l10n.cancel), - ), - FilledButton( - onPressed: () => Navigator.pop(dialogContext, true), - child: Text( - permanent - ? l10n.nextcloudPermanentDelete - : l10n.nextcloudRestore, - ), - ), - ], - ), - ) ?? - false; + final l10n = context.l10n; + final confirmed = await showBusyMaxConfirm( + context, + title: permanent ? l10n.nextcloudPermanentDelete : l10n.nextcloudRestore, + message: permanent + ? '${item.title}\n\n${l10n.nextcloudPermanentDeleteWarning}' + : item.title, + confirmLabel: permanent + ? l10n.nextcloudPermanentDelete + : l10n.nextcloudRestore, + destructive: permanent, + ); if (!confirmed || !mounted) return; setState(() { busy = true; @@ -491,84 +508,86 @@ class _LinuxNextcloudTrashState @override Widget build(BuildContext context) { - final l10n = AppLocalizations.of(context); - return AlertDialog( - title: Text(l10n.nextcloudTrash), - content: SizedBox( - width: 620, - height: math.min(460.0, MediaQuery.sizeOf(context).height * .6), - child: SingleChildScrollView( - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - if (busy) Center(child: const CircularProgressIndicator()), - if (failed) - Text( - error is DavException && - (error! as DavException).code == 'DavTrashUnsupported' - ? l10n.nextcloudUnsupported - : l10n.nextcloudServerUnavailable, - ), - if (refreshPending) Text(l10n.nextcloudRefreshPending), - if (listing?.retentionSeconds != null) - Text( - l10n.nextcloudTrashRetention( - (listing!.retentionSeconds! / 86400).ceil(), + final l10n = context.l10n; + return PopScope( + canPop: !busy, + child: BusyMaxDialogShell( + key: const ValueKey('nextcloud-trash-dialog'), + title: l10n.nextcloudTrash, + maxWidth: 680, + header: BusyMaxEditorHeader( + title: l10n.nextcloudTrash, + cancelLabel: l10n.close, + saveLabel: l10n.refresh, + onCancel: () => Navigator.of(context).pop(), + cancelEnabled: !busy, + onSave: busy ? null : _load, + ), + children: [ + if (busy) const Center(child: YaruCircularProgressIndicator()), + if (failed) + Text( + error is DavException && + (error! as DavException).code == 'DavTrashUnsupported' + ? l10n.nextcloudUnsupported + : l10n.nextcloudServerUnavailable, + ), + if (refreshPending) Text(l10n.nextcloudRefreshPending), + if (listing?.retentionSeconds != null) + Text( + l10n.nextcloudTrashRetention( + (listing!.retentionSeconds! / 86400).ceil(), + ), + ), + if (listing?.items.isEmpty == true) Text(l10n.nextcloudTrashEmpty), + if (listing?.items.isNotEmpty == true) + BusyMaxGroupedList( + filled: true, + children: [ + for (final item in listing!.items) ...[ + YaruListTile.square( + leading: Icon(switch (item.kind) { + NextcloudTrashKind.event => YaruIcons.calendar, + NextcloudTrashKind.task => Icons.task_alt, + NextcloudTrashKind.calendar => YaruIcons.calendar, + NextcloudTrashKind.taskList => Icons.checklist, + NextcloudTrashKind.mixedCollection => + Icons.view_agenda_outlined, + }), + title: Text(item.title), + subtitle: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (item.deletedAt != null) Text(item.deletedAt!), + Text(switch (item.kind) { + NextcloudTrashKind.event => l10n.calendarEvents, + NextcloudTrashKind.task => l10n.calendarTasks, + NextcloudTrashKind.calendar => l10n.calendar, + NextcloudTrashKind.taskList => l10n.taskLists, + NextcloudTrashKind.mixedCollection => + l10n.collectionSupportsEventsAndTasks, + }), + ], + ), ), - ), - if (listing?.items.isEmpty == true) - Text(l10n.nextcloudTrashEmpty), - for (final item in listing?.items ?? const []) - Padding( - padding: const EdgeInsets.symmetric(vertical: 8), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Text(item.title), - if (item.deletedAt != null) Text(item.deletedAt!), - Text(switch (item.kind) { - NextcloudTrashKind.event => l10n.calendarEvents, - NextcloudTrashKind.task => l10n.calendarTasks, - NextcloudTrashKind.calendar => l10n.calendar, - NextcloudTrashKind.taskList => l10n.taskLists, - NextcloudTrashKind.mixedCollection => - l10n.collectionSupportsEventsAndTasks, - }), - Wrap( - spacing: 8, - children: [ - TextButton( - onPressed: - busy || refreshPending || !item.canRestore - ? null - : () => _mutate(item, false), - child: Text(l10n.nextcloudRestore), - ), - TextButton( - onPressed: - busy || - refreshPending || - !item.canPermanentlyDelete - ? null - : () => _mutate(item, true), - child: Text(l10n.nextcloudPermanentDelete), - ), - ], - ), - ], + BusyMaxActionRow( + title: l10n.nextcloudRestore, + leading: const Icon(Icons.restore), + enabled: !busy && !refreshPending && item.canRestore, + onTap: () => _mutate(item, false), ), - ), - ], - ), - ), + BusyMaxActionRow( + title: l10n.nextcloudPermanentDelete, + leading: const Icon(YaruIcons.trash), + enabled: + !busy && !refreshPending && item.canPermanentlyDelete, + onTap: () => _mutate(item, true), + ), + ], + ], + ), + ], ), - actions: [ - TextButton(onPressed: busy ? null : _load, child: Text(l10n.refresh)), - FilledButton( - onPressed: busy ? null : () => Navigator.pop(context), - child: Text(l10n.close), - ), - ], ); } } diff --git a/lib/src/dav/presentation/nextcloud_scheduling_dialog.dart b/lib/src/dav/presentation/nextcloud_scheduling_dialog.dart index 581f0f07..4ddc205f 100644 --- a/lib/src/dav/presentation/nextcloud_scheduling_dialog.dart +++ b/lib/src/dav/presentation/nextcloud_scheduling_dialog.dart @@ -1,25 +1,29 @@ -import 'package:busymax/src/l10n/time_format_scope.dart'; import 'dart:async'; -import 'dart:math' as math; + import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:intl/intl.dart'; -import 'package:busymax/l10n/generated/app_localizations.dart'; -import 'package:busymax/src/app/app_bootstrap.dart'; -import 'package:busymax/src/dav/dav_errors.dart'; -import 'package:busymax/src/dav/nextcloud/nextcloud_scheduling_controller.dart'; -import 'package:busymax/src/dav/nextcloud/nextcloud_scheduling_service.dart'; -import 'package:busymax/src/features/calendar/presentation/event_editor_draft.dart'; +import 'package:yaru/yaru.dart'; + +import '../../app/app_bootstrap.dart'; +import '../../app/busymax_design.dart'; +import '../../app/busymax_dialogs.dart'; +import '../../dav/dav_errors.dart'; +import '../../dav/nextcloud/nextcloud_scheduling_controller.dart'; +import '../../dav/nextcloud/nextcloud_scheduling_service.dart'; +import '../../features/calendar/presentation/event_editor_draft.dart'; +import '../../l10n/l10n.dart'; +import '../../l10n/time_format_scope.dart'; Future showLinuxNextcloudSchedulingDialog( BuildContext context, { required String accountId, required String collectionId, EventEditorDraft? draft, -}) => showDialog( - context: context, +}) => showBusyMaxModalDialog( + context, barrierDismissible: false, - builder: (_) => LinuxNextcloudSchedulingDialog( + builder: (dialogContext) => LinuxNextcloudSchedulingDialog( accountId: accountId, collectionId: collectionId, draft: draft, @@ -67,101 +71,101 @@ class _LinuxNextcloudSchedulingDialogState } Future _acknowledge(NextcloudInboxMessage message) async { - final l10n = AppLocalizations.of(context); - final confirmed = await showDialog( - context: context, - builder: (dialogContext) => AlertDialog( - title: Text(l10n.nextcloudAcknowledge), - content: Text(l10n.nextcloudAcknowledgeConfirm), - actions: [ - TextButton( - onPressed: () => Navigator.pop(dialogContext, false), - child: Text(l10n.cancel), - ), - FilledButton( - onPressed: () => Navigator.pop(dialogContext, true), - child: Text(l10n.nextcloudAcknowledge), - ), - ], - ), + final l10n = context.l10n; + final confirmed = await showBusyMaxConfirm( + context, + title: l10n.nextcloudAcknowledge, + message: l10n.nextcloudAcknowledgeConfirm, + confirmLabel: l10n.nextcloudAcknowledge, ); - if (confirmed == true && mounted) await model.acknowledge(message); + if (confirmed && mounted) await model.acknowledge(message); } @override Widget build(BuildContext context) { - final l10n = AppLocalizations.of(context); + final l10n = context.l10n; final date = DateFormat.yMMMd(Localizations.localeOf(context).toString()); String dateTime(DateTime value) => formatClockDateTime(context, value, date.format(value)); - final size = MediaQuery.sizeOf(context); final error = model.error; - return AlertDialog( + final title = widget.draft == null + ? l10n.nextcloudSchedulingInbox + : l10n.nextcloudGuestAvailability; + return BusyMaxDialogShell( key: const ValueKey('nextcloud-scheduling-dialog'), - - title: Text( - widget.draft == null - ? l10n.nextcloudSchedulingInbox - : l10n.nextcloudGuestAvailability, - ), - content: SizedBox( - width: math.max(180, math.min(560, size.width - 96)), - height: math.max(160, math.min(480, size.height - 220)), - child: SingleChildScrollView( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + title: title, + maxWidth: 600, + actions: [ + BusyMaxPushButton.standard( + onPressed: model.busy ? null : model.load, + child: Text(l10n.refresh), + ), + BusyMaxPushButton.suggested( + onPressed: () => Navigator.of(context).pop(), + child: Text(l10n.close), + ), + ], + children: [ + if (widget.draft == null) + Text(l10n.nextcloudInboxExplanation) + else if (widget.draft!.start != null && widget.draft!.end != null) + SelectableText( + '${dateTime(widget.draft!.start!)} – ${dateTime(widget.draft!.end!)}' + ' (${widget.draft!.startTimeZone ?? ref.read(localTimeZoneProvider)})', + ), + if (model.busy) const Center(child: YaruCircularProgressIndicator()), + if (error != null) + Text( + error is DavException && error.kind == DavErrorKind.authorization + ? l10n.nextcloudOperationDenied + : l10n.nextcloudServerUnavailable, + ), + if (model.loaded && + !model.busy && + error == null && + widget.draft == null && + model.messages.isEmpty) + Text(l10n.nextcloudInboxEmpty), + if (model.messages.isNotEmpty) + BusyMaxGroupedList( + filled: true, children: [ - if (widget.draft == null) - Text(l10n.nextcloudInboxExplanation) - else if (widget.draft!.start != null && widget.draft!.end != null) - SelectableText( - '${dateTime(widget.draft!.start!)} – ${dateTime(widget.draft!.end!)}' - ' (${widget.draft!.startTimeZone ?? ref.read(localTimeZoneProvider)})', - ), - const SizedBox(height: 12), - if (model.busy) const CircularProgressIndicator(), - if (error != null) - Padding( - padding: const EdgeInsets.only(bottom: 12), - child: Text( - error is DavException && - error.kind == DavErrorKind.authorization - ? l10n.nextcloudOperationDenied - : l10n.nextcloudServerUnavailable, - ), + for (final message in model.messages) ...[ + YaruListTile.square( + leading: const Icon(Icons.inbox_outlined), + title: SelectableText(message.title), + subtitle: message.method == null + ? null + : Text(message.method!), ), - if (model.loaded && - !model.busy && - error == null && - widget.draft == null && - model.messages.isEmpty) - Text(l10n.nextcloudInboxEmpty), - for (final message in model.messages) - Padding( - padding: const EdgeInsets.only(bottom: 12), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - SelectableText(message.title), - Text(message.method ?? ''), - TextButton( - onPressed: model.busy - ? null - : () => _acknowledge(message), - child: Text(l10n.nextcloudAcknowledge), - ), - ], - ), + BusyMaxActionRow( + title: l10n.nextcloudAcknowledge, + leading: const Icon(Icons.done_outlined), + enabled: !model.busy, + onTap: () => _acknowledge(message), ), + ], + ], + ), + if (model.availability.isNotEmpty) + BusyMaxGroupedList( + filled: true, + children: [ for (final result in model.availability) - Padding( - padding: const EdgeInsets.only(bottom: 16), - child: Column( + YaruListTile.square( + leading: Icon( + result.availability == NextcloudAvailability.unknown + ? YaruIcons.warning + : result.intervals.isEmpty + ? Icons.event_available_outlined + : Icons.event_busy_outlined, + ), + title: SelectableText( + result.recipient.replaceFirst(RegExp(r'^mailto:'), ''), + ), + subtitle: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - SelectableText( - result.recipient.replaceFirst(RegExp(r'^mailto:'), ''), - ), Text( result.availability == NextcloudAvailability.unknown ? l10n.nextcloudAvailabilityUnknown @@ -178,17 +182,6 @@ class _LinuxNextcloudSchedulingDialogState ), ], ), - ), - ), - actions: [ - TextButton( - onPressed: model.busy ? null : model.load, - child: Text(l10n.refresh), - ), - FilledButton( - onPressed: () => Navigator.pop(context), - child: Text(l10n.close), - ), ], ); } diff --git a/lib/src/features/auth/data/auth_repository.dart b/lib/src/features/auth/data/auth_repository.dart index 00b3dd4d..259dfea8 100644 --- a/lib/src/features/auth/data/auth_repository.dart +++ b/lib/src/features/auth/data/auth_repository.dart @@ -18,6 +18,14 @@ import '../../../microsoft_todo/oauth/microsoft_oauth_service.dart'; import '../../sync/sync_auth_error.dart'; import 'package:busymax/src/providers/busy_provider.dart'; +String _microsoftGraphScopeName(String scope) { + final normalized = scope.trim().toLowerCase(); + const graphPrefix = 'https://graph.microsoft.com/'; + return normalized.startsWith(graphPrefix) + ? normalized.substring(graphPrefix.length) + : normalized; +} + enum AuthSessionStatus { unconfigured, loading, @@ -195,7 +203,9 @@ class AuthRepository { case BusyProvider.microsoft: await _microsoftOAuth?.signOutAccount(accountId); case BusyProvider.google: - await _oAuth.clearLocalSession(accountId: accountId); + // Keep the existing credential until reconnection replaces it or the + // user explicitly removes the account. + break; case BusyProvider.appleICloud: case BusyProvider.nextcloud: // DAV credentials are cleared through SecretStore once the account is @@ -286,13 +296,10 @@ class AuthRepository { } bool _hasRequiredMicrosoftScopes(OAuthTokenSet tokenSet) { - return tokenSet.scopes.contains('https://graph.microsoft.com/User.Read') && - tokenSet.scopes.contains( - 'https://graph.microsoft.com/Tasks.ReadWrite', - ) && - tokenSet.scopes.contains( - 'https://graph.microsoft.com/Calendars.ReadWrite', - ); + final granted = tokenSet.scopes.map(_microsoftGraphScopeName).toSet(); + return granted.contains('user.read') && + granted.contains('tasks.readwrite') && + granted.contains('calendars.readwrite'); } Future _upsertGoogleSignedInAccount( diff --git a/lib/src/features/auth/presentation/sign_in_screen.dart b/lib/src/features/auth/presentation/sign_in_screen.dart index 57ff368e..2c8d62c1 100644 --- a/lib/src/features/auth/presentation/sign_in_screen.dart +++ b/lib/src/features/auth/presentation/sign_in_screen.dart @@ -1,5 +1,4 @@ import 'dart:async'; -import 'dart:io'; import 'dart:math' as math; import 'package:flutter/foundation.dart'; @@ -12,8 +11,10 @@ import 'package:yaru/yaru.dart'; import '../../../app/app_bootstrap.dart'; import '../../../app/busymax_design.dart'; import '../../../app/busymax_glyphs.dart'; -import '../../../app/busymax_keyboard_shortcuts_dialog.dart'; +import '../../../app/busymax_shortcuts.dart'; import '../../../app/busymax_yaru_theme.dart'; +import '../../../app/linux/linux_page_frame.dart'; +import '../../../app/linux/linux_header_style.dart'; import '../../../dav/auth/dav_account_dialogs.dart'; import '../../../dav/dav_errors.dart'; import '../../../dav/http/dav_http_transport.dart'; @@ -25,8 +26,6 @@ import 'package:busymax/src/core/auth/oauth_models.dart'; 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 } @@ -44,30 +43,8 @@ class _SignInScreenState extends ConsumerState { var _step = _OnboardingStep.accounts; _OnboardingProvider? _signingInProvider; String? _errorMessage; - var _headerBarReady = false; - var _nativeHeaderBarAvailable = false; var _finishingSetup = false; DavCancellationToken? _davCancellation; - var _headerBarUpdateGeneration = 0; - late final LinuxHeaderBarSession _headerBarSession; - StreamSubscription? _headerBarActions; - - @override - void initState() { - super.initState(); - _headerBarSession = ref.read(linuxHeaderBarServiceProvider).claimSession(); - _headerBarActions = _headerBarSession.actions.listen( - _handleHeaderBarAction, - ); - unawaited(_initializeHeaderBar()); - } - - @override - void dispose() { - _headerBarSession.dispose(); - unawaited(_headerBarActions?.cancel()); - super.dispose(); - } @override Widget build(BuildContext context) { @@ -91,229 +68,139 @@ class _SignInScreenState extends ConsumerState { ), _OnboardingStep.preferences => true, }; - return Scaffold( - body: ColoredBox( - color: BusyMaxSurfaceColors.of(context).window, - child: SafeArea( - top: !Platform.isLinux || !_nativeHeaderBarAvailable, - child: LayoutBuilder( - builder: (context, constraints) { - final compact = constraints.maxWidth < 720; - final horizontalPadding = compact - ? BusyMaxSpacing.md - : BusyMaxSpacing.xxl; - final verticalPadding = compact - ? BusyMaxSpacing.md - : BusyMaxSpacing.xxl; - final availableWidth = math.max( - 0.0, - constraints.maxWidth - horizontalPadding * 2, - ); - final shadowGutter = math.min( - BusyMaxSpacing.sm, - availableWidth / 2, - ); - final contentRailWidth = math.min( - busyMaxOnboardingContentMaxWidth.toDouble(), - math.max(0.0, availableWidth - shadowGutter * 2), - ); - final scrollViewportWidth = contentRailWidth + shadowGutter * 2; - _updateHeaderBar( - canGoBack: canGoBack, - canContinue: canContinue, - backLabel: backLabel, - continueLabel: continueLabel, - contentWidth: contentRailWidth.round(), - ); - - return Padding( - padding: EdgeInsets.symmetric( - horizontal: horizontalPadding, - vertical: verticalPadding, - ), - child: Center( - child: SizedBox( - width: scrollViewportWidth, - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Flexible( - child: SingleChildScrollView( - key: const ValueKey('onboarding-scroll-viewport'), - padding: EdgeInsets.symmetric( - horizontal: shadowGutter, - ), - child: SizedBox( - key: const ValueKey('onboarding-content-rail'), - width: contentRailWidth, - child: switch (_step) { - _OnboardingStep.accounts => - _AccountsOnboardingStep( - accounts: accounts, - googleConfigured: - config.hasGoogleOAuthClientId, - microsoftConfigured: - config.hasMicrosoftOAuthClientId, - isGoogleSigningIn: - _signingInProvider == - _OnboardingProvider.google, - isMicrosoftSigningIn: - _signingInProvider == - _OnboardingProvider.microsoft, - isAppleSigningIn: - _signingInProvider == - _OnboardingProvider.appleICloud, - isNextcloudSigningIn: - _signingInProvider == - _OnboardingProvider.nextcloud, - errorMessage: _errorMessage, - missingConfigMessage: kReleaseMode - ? l10n.providerNotConfigured - : config.missingClientIdMessage, - onAddGoogle: () => - _signIn(_OnboardingProvider.google), - onAddMicrosoft: () => - _signIn(_OnboardingProvider.microsoft), - onAddApple: () => _signIn( - _OnboardingProvider.appleICloud, - ), - onAddNextcloud: () => - _signIn(_OnboardingProvider.nextcloud), - onAddSubscription: () => - context.go('/settings?page=accounts'), - onCancelSignIn: _cancelSignIn, + return CallbackShortcuts( + bindings: {BusyMaxShortcutActivators.back: _previousStep}, + child: Focus( + autofocus: true, + child: Scaffold( + body: LinuxPageFrame( + header: _OnboardingHeader( + title: l10n.onboardingSetupTitle, + canGoBack: canGoBack, + canContinue: canContinue && !_finishingSetup, + backLabel: backLabel, + continueLabel: continueLabel, + onBack: _previousStep, + onContinue: _nextStep, + ), + body: ColoredBox( + color: BusyMaxSurfaceColors.of(context).window, + child: SafeArea( + top: false, + child: LayoutBuilder( + builder: (context, constraints) { + final compact = constraints.maxWidth < 720; + final horizontalPadding = compact + ? BusyMaxSpacing.md + : BusyMaxSpacing.xxl; + final verticalPadding = compact + ? BusyMaxSpacing.md + : BusyMaxSpacing.xxl; + final availableWidth = math.max( + 0.0, + constraints.maxWidth - horizontalPadding * 2, + ); + final shadowGutter = math.min( + BusyMaxSpacing.sm, + availableWidth / 2, + ); + final contentRailWidth = math.min( + BusyMaxSizes.onboardingContentMaxWidth, + math.max(0.0, availableWidth - shadowGutter * 2), + ); + final scrollViewportWidth = + contentRailWidth + shadowGutter * 2; + return Padding( + padding: EdgeInsets.symmetric( + horizontal: horizontalPadding, + vertical: verticalPadding, + ), + child: Center( + child: SizedBox( + width: scrollViewportWidth, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Flexible( + child: SingleChildScrollView( + key: const ValueKey( + 'onboarding-scroll-viewport', ), - _OnboardingStep.preferences => - _PreferencesOnboardingStep( - settings: settings, - settingsController: settingsController, + padding: EdgeInsets.symmetric( + horizontal: shadowGutter, + ), + child: SizedBox( + key: const ValueKey( + 'onboarding-content-rail', + ), + width: contentRailWidth, + child: switch (_step) { + _OnboardingStep.accounts => + _AccountsOnboardingStep( + accounts: accounts, + googleConfigured: + config.hasGoogleOAuthClientId, + microsoftConfigured: + config.hasMicrosoftOAuthClientId, + isGoogleSigningIn: + _signingInProvider == + _OnboardingProvider.google, + isMicrosoftSigningIn: + _signingInProvider == + _OnboardingProvider.microsoft, + isAppleSigningIn: + _signingInProvider == + _OnboardingProvider.appleICloud, + isNextcloudSigningIn: + _signingInProvider == + _OnboardingProvider.nextcloud, + errorMessage: _errorMessage, + missingConfigMessage: kReleaseMode + ? l10n.providerNotConfigured + : config.missingClientIdMessage, + onAddGoogle: () => _signIn( + _OnboardingProvider.google, + ), + onAddMicrosoft: () => _signIn( + _OnboardingProvider.microsoft, + ), + onAddApple: () => _signIn( + _OnboardingProvider.appleICloud, + ), + onAddNextcloud: () => _signIn( + _OnboardingProvider.nextcloud, + ), + onAddSubscription: () => context.go( + '/settings?page=accounts', + ), + onCancelSignIn: _cancelSignIn, + ), + _OnboardingStep.preferences => + _PreferencesOnboardingStep( + settings: settings, + settingsController: + settingsController, + ), + }, ), - }, - ), + ), + ), + ], ), ), - if (_showFlutterFooterFallback) - Padding( - padding: EdgeInsets.symmetric( - horizontal: shadowGutter, - ), - child: _OnboardingFooter( - canGoBack: canGoBack, - canContinue: canContinue, - backLabel: backLabel, - continueLabel: continueLabel, - onBack: _previousStep, - onContinue: _nextStep, - ), - ), - ], - ), - ), + ), + ); + }, ), - ); - }, + ), + ), ), ), ), ); } - Future _initializeHeaderBar() async { - await _headerBarSession.initialize(); - if (!mounted) { - return; - } - setState(() { - _headerBarReady = true; - _nativeHeaderBarAvailable = _headerBarSession.isAvailable; - }); - } - - bool get _showFlutterFooterFallback { - if (!Platform.isLinux) { - return true; - } - return _headerBarReady && !_nativeHeaderBarAvailable; - } - - void _updateHeaderBar({ - required bool canGoBack, - required bool canContinue, - required String backLabel, - required String continueLabel, - required int contentWidth, - }) { - if (_finishingSetup) { - return; - } - if (!_headerBarReady && Platform.isLinux) { - return; - } - final title = context.l10n.onboardingSetupTitle; - final generation = ++_headerBarUpdateGeneration; - WidgetsBinding.instance.addPostFrameCallback((_) { - if (!mounted || - _finishingSetup || - generation != _headerBarUpdateGeneration) { - return; - } - unawaited(() async { - await _headerBarSession.updateState( - BusyMaxHeaderBarState( - title: title, - viewMode: ref.read(appSettingsControllerProvider).scheduleViewMode, - canRefresh: false, - canCreateEvent: false, - canCreateTask: false, - searchActive: false, - searchQuery: '', - canShowSidebar: false, - sidebarVisible: false, - navigationVisible: false, - scheduleControlsVisible: false, - backVisible: false, - ), - ); - if (!mounted || - _finishingSetup || - generation != _headerBarUpdateGeneration) { - return; - } - await _headerBarSession.setOnboardingControls( - visible: true, - canGoBack: canGoBack, - canContinue: canContinue, - backLabel: backLabel, - continueLabel: continueLabel, - contentWidth: contentWidth, - ); - }()); - }); - } - - void _handleHeaderBarAction(BusyMaxHeaderBarAction action) { - if (!_headerBarSession.isCurrent) { - return; - } - if (action == BusyMaxHeaderBarAction.back) { - _previousStep(); - return; - } - if (action == BusyMaxHeaderBarAction.continueSetup) { - unawaited(_nextStep()); - return; - } - if (action == BusyMaxHeaderBarAction.keyboardShortcuts) { - unawaited( - showBusyMaxKeyboardShortcutsDialog( - context, - headerBarService: ref.read(linuxHeaderBarServiceProvider), - ), - ); - } - } - Future _signIn(_OnboardingProvider provider) async { if (_signingInProvider != null) { return; @@ -321,16 +208,10 @@ class _SignInScreenState extends ConsumerState { AppleICloudCredentialInput? appleInput; String? nextcloudServer; if (provider == _OnboardingProvider.appleICloud) { - appleInput = await showAppleICloudCredentialDialog( - context, - headerBarService: ref.read(linuxHeaderBarServiceProvider), - ); + appleInput = await showAppleICloudCredentialDialog(context); if (appleInput == null || !mounted) return; } else if (provider == _OnboardingProvider.nextcloud) { - nextcloudServer = await showNextcloudServerDialog( - context, - headerBarService: ref.read(linuxHeaderBarServiceProvider), - ); + nextcloudServer = await showNextcloudServerDialog(context); if (nextcloudServer == null || !mounted) return; } setState(() { @@ -446,25 +327,12 @@ class _SignInScreenState extends ConsumerState { } _finishingSetup = true; - _headerBarUpdateGeneration++; - await _clearOnboardingHeaderBar(); + if (mounted) setState(() {}); await ref.read(authSessionControllerProvider.notifier).load(); if (mounted) { context.go('/schedule'); } } - - Future _clearOnboardingHeaderBar() async { - await _headerBarSession.initialize(); - await _headerBarSession.setOnboardingControls( - visible: false, - canGoBack: false, - canContinue: false, - backLabel: '', - continueLabel: '', - force: true, - ); - } } class _AccountsOnboardingStep extends StatelessWidget { @@ -877,8 +745,9 @@ class _ProviderSignInButton extends StatelessWidget { } } -class _OnboardingFooter extends StatelessWidget { - const _OnboardingFooter({ +class _OnboardingHeader extends StatelessWidget { + const _OnboardingHeader({ + required this.title, required this.canGoBack, required this.canContinue, required this.backLabel, @@ -887,6 +756,7 @@ class _OnboardingFooter extends StatelessWidget { required this.onContinue, }); + final String title; final bool canGoBack; final bool canContinue; final String backLabel; @@ -896,50 +766,26 @@ class _OnboardingFooter extends StatelessWidget { @override Widget build(BuildContext context) { - return Padding( - padding: const EdgeInsets.only(top: BusyMaxSpacing.xl), - child: Row( - children: [ - TextButton( - key: const ValueKey('onboarding-back-button'), - onPressed: canGoBack ? onBack : null, - style: _onboardingTextButtonStyle(context), - child: Text(backLabel), - ), - const Spacer(), - TextButton( - key: const ValueKey('onboarding-continue-button'), - onPressed: canContinue ? onContinue : null, - style: _onboardingTextButtonStyle(context), - child: Text(continueLabel), - ), - ], + return BusyMaxLinuxHeaderLayout( + maxContentWidth: BusyMaxSizes.onboardingContentMaxWidth, + leading: BusyMaxLinuxHeaderTextButton.standard( + key: const ValueKey('onboarding-back-button'), + label: backLabel, + onPressed: canGoBack ? onBack : null, + ), + title: BusyMaxLinuxHeaderTitle( + title, + key: const ValueKey('onboarding-header-title'), + ), + trailing: BusyMaxLinuxHeaderTextButton.suggested( + key: const ValueKey('onboarding-continue-button'), + label: continueLabel, + onPressed: canContinue ? onContinue : null, ), ); } } -ButtonStyle _onboardingTextButtonStyle(BuildContext context) { - final labelStyle = Theme.of(context).textTheme.labelLarge; - return ButtonStyle( - padding: const WidgetStatePropertyAll(EdgeInsets.zero), - minimumSize: const WidgetStatePropertyAll(Size.zero), - tapTargetSize: MaterialTapTargetSize.shrinkWrap, - backgroundColor: const WidgetStatePropertyAll(Colors.transparent), - overlayColor: const WidgetStatePropertyAll(Colors.transparent), - elevation: const WidgetStatePropertyAll(0), - textStyle: WidgetStateProperty.resolveWith((states) { - final emphasize = - !states.contains(WidgetState.disabled) && - (states.contains(WidgetState.hovered) || - states.contains(WidgetState.focused)); - return labelStyle?.copyWith( - decoration: emphasize ? TextDecoration.underline : TextDecoration.none, - ); - }), - ); -} - String _onboardingErrorMessage(BuildContext context, Object error) { if (error is NetworkUnavailableException) { return context.l10n.networkOfflineTryAgain; diff --git a/lib/src/features/calendar/data/calendar_event_detail.dart b/lib/src/features/calendar/data/calendar_event_detail.dart index c8f447f8..f2fe0c14 100644 --- a/lib/src/features/calendar/data/calendar_event_detail.dart +++ b/lib/src/features/calendar/data/calendar_event_detail.dart @@ -146,6 +146,43 @@ final class CalendarEventDetail { final int projectionVersion; final String? providerRecurringEventId; final String? providerOriginalStartKey; + + /// Whether this row has enough authoritative recurrence identity to require + /// the user to choose an occurrence/series mutation scope. + /// + /// The denormalized provider ID is not sufficient on its own: an old or + /// partially recovered row can retain that value after the provider payload + /// describes an ordinary event. Cloud rows therefore have to agree with the + /// stored provider payload. DAV rows use their projected iCalendar + /// recurrence semantics. + bool get requiresRecurringMutationScope { + final seriesId = providerRecurringEventId?.trim(); + if (seriesId == null || seriesId.isEmpty) return false; + return switch (provider) { + BusyProvider.google => _cloudRecurrenceIdentityMatches( + raw: raw, + baselineRaw: baselineRaw, + seriesId: seriesId, + seriesKey: 'recurringEventId', + originalStartKey: 'originalStartTime', + ), + BusyProvider.microsoft => _cloudRecurrenceIdentityMatches( + raw: raw, + baselineRaw: baselineRaw, + seriesId: seriesId, + seriesKey: 'seriesMasterId', + originalStartKey: 'originalStart', + ), + BusyProvider.appleICloud || + BusyProvider.nextcloud || + BusyProvider.webCal => + _hasProjectedIcalRecurrence(recurrence) || + (recurrenceIdKey?.trim().isNotEmpty ?? false), + }; + } + + String? get recurringMutationSeriesId => + requiresRecurringMutationScope ? providerRecurringEventId!.trim() : null; final String? etagOrChangeKey; final String? status; final String title; @@ -194,6 +231,41 @@ final class CalendarEventDetail { final Object? baselineRaw; } +bool _cloudRecurrenceIdentityMatches({ + required Object? raw, + required Object? baselineRaw, + required String seriesId, + required String seriesKey, + required String originalStartKey, +}) { + // Prefer the current provider payload. A stored provider baseline is a valid + // fallback only when the current payload is unavailable; denormalized + // columns are never used as proof of recurrence because they can be stale. + final payload = raw is Map && raw.isNotEmpty ? raw : baselineRaw; + if (payload is! Map || payload.isEmpty) return false; + final rawSeriesId = payload[seriesKey]?.toString().trim(); + if (rawSeriesId == null || rawSeriesId.isEmpty || rawSeriesId != seriesId) { + return false; + } + final rawStart = payload[originalStartKey]; + final hasRawStart = switch (rawStart) { + final String value => value.trim().isNotEmpty, + final Map value => value.isNotEmpty, + _ => false, + }; + return hasRawStart; +} + +bool _hasProjectedIcalRecurrence(Object? recurrence) { + if (recurrence is List) return recurrence.isNotEmpty; + if (recurrence is! Map) return false; + for (final key in const ['rules', 'dates', 'excludedDates']) { + final values = recurrence[key]; + if (values is List && values.isNotEmpty) return true; + } + return false; +} + Object? _decodeJson(String? value) { if (value == null || value.isEmpty) return null; try { diff --git a/lib/src/features/calendar/data/calendar_repository.dart b/lib/src/features/calendar/data/calendar_repository.dart index cc8b55ab..9a9e81ff 100644 --- a/lib/src/features/calendar/data/calendar_repository.dart +++ b/lib/src/features/calendar/data/calendar_repository.dart @@ -383,8 +383,7 @@ class CalendarRepository { _database.pendingOps.calendarSourceId.equalsExp( _database.calendarSources.id, ) & - _database.pendingOps.operationType.equals('calendar.create') & - _database.pendingOps.state.equals('pending'), + _database.pendingOps.operationType.equals('calendar.create'), ), ]); query.where( @@ -699,17 +698,29 @@ class CalendarRepository { updatedAtLocal: Value(now.millisecondsSinceEpoch), ), ); - 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 { + var rewroteCreate = false; + if (_isProvablyUnsentCalendarCreate(createOp)) { + final createSnapshot = createOp!; + final request = _calendarPendingRequest(createSnapshot) + ..['summary'] = title; + rewroteCreate = + await (_database.update(_database.pendingOps)..where( + (row) => + row.id.equals(createSnapshot.id) & + row.state.equals(createSnapshot.state) & + row.attemptCount.equals(createSnapshot.attemptCount) & + row.requestJson.equals(createSnapshot.requestJson) & + row.updatedAtUtc.equals(createSnapshot.updatedAtUtc), + )) + .write( + PendingOpsCompanion( + requestJson: Value(jsonEncode(request)), + updatedAtUtc: Value(nowUtc), + ), + ) == + 1; + } + if (!rewroteCreate) { await _enqueueOrMergeCalendarPatch( source, request: { @@ -720,6 +731,7 @@ class CalendarRepository { : calendarMutationScopeGlobal, }, nowUtc: nowUtc, + dependsOnOpId: createOp?.id, ); } }); @@ -835,7 +847,22 @@ class CalendarRepository { final now = _now(); final nowUtc = now.toUtc().toIso8601String(); await _database.transaction(() async { - if (createOp != null) { + if (_isProvablyUnsentCalendarCreate(createOp)) { + final deleted = + await (_database.delete(_database.pendingOps)..where( + (row) => + row.id.equals(createOp!.id) & + row.state.equals(createOp.state) & + row.attemptCount.equals(createOp.attemptCount) & + row.requestJson.equals(createOp.requestJson) & + row.updatedAtUtc.equals(createOp.updatedAtUtc), + )) + .go(); + if (deleted != 1) { + throw StateError( + 'The calendar creation is no longer safe to cancel locally.', + ); + } await (_database.delete(_database.pendingOps)..where( (row) => row.accountId.equals(source.accountId) & @@ -847,7 +874,10 @@ class CalendarRepository { )..where((row) => row.id.equals(sourceId))).go(); return; } - if ((await _pendingCalendarWork(source.id)).isNotEmpty) { + final otherWork = (await _pendingCalendarWork( + source.id, + )).where((operation) => operation.id != createOp?.id); + if (otherWork.isNotEmpty) { throw CalendarMutationNotAllowed( operation: removalMode == CalendarRemovalMode.removeFromList ? CalendarMutationOperation.removeCalendar @@ -881,6 +911,7 @@ class CalendarRepository { ), calendarSourceId: Value(source.id), providerCalendarId: Value(source.providerCalendarId), + dependsOnOpId: Value(createOp?.id), requestJson: jsonEncode({ calendarRemovalPreviousHiddenKey: source.hidden, }), @@ -1328,11 +1359,11 @@ class CalendarRepository { throw StateError('A complete event recovery baseline is unavailable.'); } baseline.putIfAbsent('id', () => event.providerEventId); + // Preserve the original-start component of the local row identity for + // legacy occurrence rows, but never manufacture a series identity. The + // provider baseline alone decides whether the restored event is recurring. switch (provider) { case BusyProvider.google: - if (event.providerRecurringEventId case final recurringId?) { - baseline.putIfAbsent('recurringEventId', () => recurringId); - } if (event.providerOriginalStartKey case final originalStart?) { baseline.putIfAbsent( 'originalStartTime', @@ -1342,9 +1373,6 @@ class CalendarRepository { ); } case BusyProvider.microsoft: - if (event.providerRecurringEventId case final recurringId?) { - baseline.putIfAbsent('seriesMasterId', () => recurringId); - } if (event.providerOriginalStartKey case final originalStart?) { baseline.putIfAbsent('originalStart', () => originalStart); } @@ -1803,12 +1831,16 @@ class CalendarRepository { allDay: Value(draft.allDay), startDate: Value(draft.allDay ? _date(draft.start) : null), startDateTime: Value( - draft.allDay ? null : draft.start?.toIso8601String(), + draft.allDay + ? null + : _eventDateTimeWireValue(draft.start, startTimeZone), ), startTimeZone: Value(startTimeZone), endDate: Value(draft.allDay ? _date(draft.end) : null), endDateTime: Value( - draft.allDay ? null : draft.end?.toIso8601String(), + draft.allDay + ? null + : _eventDateTimeWireValue(draft.end, endTimeZone), ), endTimeZone: Value(endTimeZone), recurrenceJson: Value(_json(draft.recurrence)), @@ -2061,7 +2093,7 @@ class CalendarRepository { return true; } final provider = BusyProviderCodec.requireStorageValue(source.provider); - final recurringOccurrence = existing.providerRecurringEventId != null; + final recurringOccurrence = _eventRequiresRecurringScope(existing); final recurringScope = draft.recurringMutationScope; if (recurringOccurrence && recurringScope == null) { throw UnsupportedError('A recurring-event editing scope is required.'); @@ -2100,8 +2132,12 @@ class CalendarRepository { _localTimeZone, ); final seriesMutation = - recurringScope == RecurringEventMutationScope.entireSeries || - recurringScope == RecurringEventMutationScope.thisAndFuture; + recurringOccurrence && + (recurringScope == RecurringEventMutationScope.entireSeries || + recurringScope == RecurringEventMutationScope.thisAndFuture); + final seriesBaseline = seriesMutation + ? await _seriesMasterBaseline(existing) + : null; final request = _eventDeltaRequest( draft, editBaseline, @@ -2158,7 +2194,7 @@ class CalendarRepository { } final requestJson = jsonEncode(request); await _database.transaction(() async { - final predecessor = await _latestPendingEventEdit( + final predecessor = await _latestPendingEventMutation( accountId: draft.accountId, eventId: eventId, seriesEvent: seriesMutation ? existing : null, @@ -2205,12 +2241,16 @@ class CalendarRepository { eventId: Value(eventId), dependsOnOpId: Value(predecessor?.id), requestJson: requestJson, - baselineUpdatedUtc: seriesMutation - ? const Value(null) - : Value(editBaseline.updatedAtServer), - baselineRawJson: seriesMutation - ? const Value(null) - : Value(_eventDetailBaselineRawJson(editBaseline)), + baselineUpdatedUtc: Value( + seriesBaseline?.updatedAtServer ?? editBaseline.updatedAtServer, + ), + baselineRawJson: Value( + (seriesBaseline == null + ? null + : seriesBaseline.baselineRawJson ?? + seriesBaseline.rawJson) ?? + _eventDetailBaselineRawJson(editBaseline), + ), createdAtUtc: DateTime.now().toUtc().toIso8601String(), updatedAtUtc: DateTime.now().toUtc().toIso8601String(), ), @@ -2246,7 +2286,7 @@ class CalendarRepository { final destinationProvider = BusyProviderCodec.requireStorageValue( destinationSource.provider, ); - final recurring = existing.providerRecurringEventId != null; + final recurring = _eventRequiresRecurringScope(existing); final scope = draft.recurringMutationScope; if (recurring && scope == null) { throw UnsupportedError('A recurring-event movement scope is required.'); @@ -2332,7 +2372,7 @@ class CalendarRepository { required EventEditorDraft draft, required CalendarGuestUpdatePolicy guestUpdatePolicy, }) async { - final recurring = existing.providerRecurringEventId != null; + final recurring = _eventRequiresRecurringScope(existing); final scope = draft.recurringMutationScope; final provider = BusyProvider.google; final startTimeZone = _effectiveStartTimeZone( @@ -2510,7 +2550,7 @@ class CalendarRepository { collectionId: sourceCollectionId, objectId: objectId, ); - final recurring = existing.providerRecurringEventId != null; + final recurring = _eventRequiresRecurringScope(existing); final target = IcalComponentKey( componentType: 'VEVENT', uid: uid, @@ -2665,7 +2705,7 @@ class CalendarRepository { required BusyProvider destinationProvider, required LocationResult? effectiveSelection, }) { - final recurring = existing.providerRecurringEventId != null; + final recurring = _eventRequiresRecurringScope(existing); final scope = draft.recurringMutationScope; Object? recurrence = draft.recurrence; if (recurring && scope == RecurringEventMutationScope.singleOccurrence) { @@ -2841,6 +2881,9 @@ class CalendarRepository { required String dependsOnOperationId, }) async { final series = recurringScope == RecurringEventMutationScope.entireSeries; + final seriesBaseline = series + ? await _seriesMasterBaseline(existing) + : null; final request = { calendarEventGuestUpdatePolicyKey: guestUpdatePolicy.name, calendarEventCopyConfirmationRequiredKey: true, @@ -2869,12 +2912,16 @@ class CalendarRepository { 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), + baselineUpdatedUtc: Value( + seriesBaseline?.updatedAtServer ?? existing.updatedAtServer, + ), + baselineRawJson: Value( + (seriesBaseline == null + ? null + : seriesBaseline.baselineRawJson ?? + seriesBaseline.rawJson) ?? + existing.baselineRawJson, + ), createdAtUtc: now, updatedAtUtc: now, ), @@ -3256,7 +3303,7 @@ class CalendarRepository { } } - Future _latestPendingEventEdit({ + Future _latestPendingEventMutation({ required String accountId, required String eventId, CalendarEvent? seriesEvent, @@ -3287,8 +3334,19 @@ class CalendarRepository { row.accountId.equals(accountId) & row.entityType.equals('event') & row.eventId.isIn(eventIds) & - (row.operationType.equals('event.patch') | - (row.operationType.isNull() & row.operation.equals('patch'))), + (row.operationType.isIn(const [ + 'event.create', + 'event.patch', + 'event.move', + 'event.respond', + ]) | + (row.operationType.isNull() & + row.operation.isIn(const [ + 'create', + 'patch', + 'move', + 'respond', + ]))), ) ..orderBy([ (row) => OrderingTerm.desc(row.createdAtUtc), @@ -3307,6 +3365,29 @@ class CalendarRepository { return edits.isEmpty ? null : edits.first; } + Future _seriesMasterBaseline(CalendarEvent occurrence) async { + final seriesId = occurrence.providerRecurringEventId; + if (seriesId == null || seriesId.isEmpty) { + throw StateError( + 'The recurring series master identifier is unavailable.', + ); + } + final master = + await (_database.select(_database.calendarEvents)..where( + (row) => + row.accountId.equals(occurrence.accountId) & + row.provider.equals(occurrence.provider) & + row.providerCalendarId.equals(occurrence.providerCalendarId) & + row.providerEventId.equals(seriesId) & + row.providerRecurringEventId.isNull(), + )) + .getSingleOrNull(); + if (master == null) { + throw StateError('The recurring series master baseline is unavailable.'); + } + return master; + } + Future deleteLocalEvent( String eventId, { RecurringEventMutationScope? recurringScope, @@ -3332,7 +3413,7 @@ class CalendarRepository { ); } final provider = BusyProviderCodec.requireStorageValue(existing.provider); - final recurringOccurrence = existing.providerRecurringEventId != null; + final recurringOccurrence = _eventRequiresRecurringScope(existing); if (recurringOccurrence && recurringScope == null) { throw UnsupportedError('A recurring-event deletion scope is required.'); } @@ -3344,8 +3425,12 @@ class CalendarRepository { ); } final seriesMutation = - recurringScope == RecurringEventMutationScope.entireSeries || - recurringScope == RecurringEventMutationScope.thisAndFuture; + recurringOccurrence && + (recurringScope == RecurringEventMutationScope.entireSeries || + recurringScope == RecurringEventMutationScope.thisAndFuture); + final seriesBaseline = seriesMutation + ? await _seriesMasterBaseline(existing) + : null; final request = { calendarEventGuestUpdatePolicyKey: guestUpdatePolicy.name, if (recurringScope != null) @@ -3359,6 +3444,11 @@ class CalendarRepository { }; final now = _now().millisecondsSinceEpoch; await _database.transaction(() async { + final predecessor = await _latestPendingEventMutation( + accountId: existing.accountId, + eventId: existing.id, + seriesEvent: seriesMutation ? existing : null, + ); if (seriesMutation) { await _markCloudSeriesDeleted( existing: existing, @@ -3389,13 +3479,18 @@ class CalendarRepository { calendarSourceId: Value(existing.calendarSourceId), providerCalendarId: Value(existing.providerCalendarId), eventId: Value(existing.id), + dependsOnOpId: Value(predecessor?.id), requestJson: jsonEncode(request), - baselineUpdatedUtc: seriesMutation - ? const Value(null) - : Value(existing.updatedAtServer), - baselineRawJson: seriesMutation - ? const Value(null) - : Value(existing.baselineRawJson), + baselineUpdatedUtc: Value( + seriesBaseline?.updatedAtServer ?? existing.updatedAtServer, + ), + baselineRawJson: Value( + (seriesBaseline == null + ? null + : seriesBaseline.baselineRawJson ?? + seriesBaseline.rawJson) ?? + existing.baselineRawJson, + ), createdAtUtc: DateTime.now().toUtc().toIso8601String(), updatedAtUtc: DateTime.now().toUtc().toIso8601String(), ), @@ -3455,7 +3550,7 @@ class CalendarRepository { final updatedRaw = provider == BusyProvider.microsoft ? _withMicrosoftResponse(raw, response) : raw; - final predecessor = await _latestPendingEventEdit( + final predecessor = await _latestPendingEventMutation( accountId: existing.accountId, eventId: eventId, ); @@ -3537,7 +3632,7 @@ class CalendarRepository { source.davCollectionId == null) { throw schedulingDenied('DavAttendeeResponseUnavailable'); } - final recurring = existing.providerRecurringEventId != null; + final recurring = _eventRequiresRecurringScope(existing); if (recurring && (recurringScope == null || recurringScope == RecurringEventMutationScope.thisAndFuture)) { @@ -3760,7 +3855,7 @@ class CalendarRepository { if (collectionId == null || uid == null || start == null || end == null) { throw StateError('The DAV event projection is incomplete.'); } - final recurring = existing.providerRecurringEventId != null; + final recurring = _eventRequiresRecurringScope(existing); final recurringScope = draft.recurringMutationScope; if (recurring && recurringScope == null) { throw UnsupportedError( @@ -3927,7 +4022,7 @@ class CalendarRepository { if (collectionId == null || uid == null) { throw StateError('The DAV event projection is incomplete.'); } - final recurring = existing.providerRecurringEventId != null; + final recurring = _eventRequiresRecurringScope(existing); final scheduling = await _davSchedulingPolicy(source); if (recurringScope == RecurringEventMutationScope.singleOccurrence && scheduling?.canReply == true && @@ -4166,11 +4261,15 @@ class CalendarRepository { allDay: Value(draft.allDay), startDate: Value(draft.allDay ? _date(draft.start) : null), startDateTime: Value( - draft.allDay ? null : draft.start?.toIso8601String(), + draft.allDay + ? null + : _eventDateTimeWireValue(draft.start, startTimeZone), ), startTimeZone: Value(startTimeZone), endDate: Value(draft.allDay ? _date(draft.end) : null), - endDateTime: Value(draft.allDay ? null : draft.end?.toIso8601String()), + endDateTime: Value( + draft.allDay ? null : _eventDateTimeWireValue(draft.end, endTimeZone), + ), endTimeZone: Value(endTimeZone), recurrenceJson: draft.recurrenceChanged ? Value(_json(draft.recurrence)) @@ -4200,22 +4299,23 @@ class CalendarRepository { ..where( (row) => row.calendarSourceId.equals(sourceId) & - row.operationType.equals('calendar.create') & - row.state.equals('pending'), + row.operationType.equals('calendar.create'), ) ..limit(1)) .getSingleOrNull(); } Future> _pendingCalendarWork(String sourceId) { - return (_database.select(_database.pendingOps)..where( - (row) => - row.calendarSourceId.equals(sourceId) & - row.state.equals('pending'), - )) - .get(); + return (_database.select( + _database.pendingOps, + )..where((row) => row.calendarSourceId.equals(sourceId))).get(); } + bool _isProvablyUnsentCalendarCreate(PendingOp? operation) => + operation != null && + operation.state == 'pending' && + operation.attemptCount == 0; + Future> _pendingCalendarPatchFields(String sourceId) async { final operations = await (_database.select(_database.pendingOps)..where( @@ -4393,8 +4493,9 @@ class CalendarRepository { } } - Future markGoogleRecurringMastersDeleted({ + Future markExpandedRecurringMastersDeleted({ required String accountId, + required BusyProvider provider, required String providerCalendarId, required Set providerRecurringEventIds, }) async { @@ -4403,7 +4504,7 @@ class CalendarRepository { } final source = sourceId( accountId: accountId, - provider: BusyProvider.google, + provider: provider, providerCalendarId: providerCalendarId, ); await _database.transaction(() async { @@ -4412,7 +4513,7 @@ class CalendarRepository { (row) => row.accountId.equals(accountId) & row.calendarSourceId.equals(source) & - row.provider.equals(BusyProvider.google.storageValue) & + row.provider.equals(provider.storageValue) & row.providerEventId.isIn(providerRecurringEventIds) & row.providerRecurringEventId.isNull() & row.syncStatus.equals('synced') & @@ -4421,9 +4522,11 @@ class CalendarRepository { .get(); if (masters.isEmpty) return; - final resolutions = LocationResolutionRepository(_database); - for (final master in masters) { - await resolutions.reconcileGoogleSeriesMaster(master); + if (provider == BusyProvider.google) { + final resolutions = LocationResolutionRepository(_database); + for (final master in masters) { + await resolutions.reconcileGoogleSeriesMaster(master); + } } await (_database.update( @@ -4840,14 +4943,14 @@ String? _eventDetailBaselineRawJson(CalendarEventDetail detail) { } DateTime? _eventDetailStartDateTime(CalendarEventDetail detail) { - return providerDateTimeAsWallTime( + return providerDateTimeAsCivilTime( detail.allDay ? detail.startDate : detail.startDateTime, detail.startTimeZone, ); } DateTime? _eventDetailEndDateTime(CalendarEventDetail detail) { - return providerDateTimeAsWallTime( + return providerDateTimeAsCivilTime( detail.allDay ? detail.endDate : detail.endDateTime, detail.endTimeZone, ); @@ -4897,14 +5000,22 @@ CalendarEventsCompanion _eventPatchProjection({ ? Value(draft.allDay ? _date(draft.start) : null) : const Value.absent(), startDateTime: rangeChanged - ? Value(draft.allDay ? null : draft.start?.toIso8601String()) + ? Value( + draft.allDay + ? null + : _eventDateTimeWireValue(draft.start, startTimeZone), + ) : 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()) + ? Value( + draft.allDay + ? null + : _eventDateTimeWireValue(draft.end, endTimeZone), + ) : const Value.absent(), endTimeZone: rangeChanged ? Value(endTimeZone) : const Value.absent(), recurrenceJson: request.containsKey(calendarEventRecurrenceField) @@ -4989,8 +5100,8 @@ Map _eventDeltaRequest( } final rangeChanged = draft.allDay != original.allDay || - draft.start != _eventDetailStartDateTime(original) || - draft.end != _eventDetailEndDateTime(original) || + !_sameCivilDateTime(draft.start, _eventDetailStartDateTime(original)) || + !_sameCivilDateTime(draft.end, _eventDetailEndDateTime(original)) || draft.startTimeZone != original.startTimeZone || draft.endTimeZone != original.endTimeZone; if (rangeChanged) { @@ -5038,6 +5149,18 @@ Map _eventDeltaRequest( return result; } +bool _sameCivilDateTime(DateTime? left, DateTime? right) { + if (left == null || right == null) return left == right; + return left.year == right.year && + left.month == right.month && + left.day == right.day && + left.hour == right.hour && + left.minute == right.minute && + left.second == right.second && + left.millisecond == right.millisecond && + left.microsecond == right.microsecond; +} + Map _eventRequest( EventEditorDraft draft, BusyProvider provider, { @@ -5064,8 +5187,8 @@ Map _eventRequest( (isCreate || draft.locationChange.changed)) 'structuredLocation': _graphLocation(draft), 'allDay': draft.allDay, - 'start': draft.start?.toIso8601String(), - 'end': draft.end?.toIso8601String(), + 'start': _eventDateTimeWireValue(draft.start, startTimeZone), + 'end': _eventDateTimeWireValue(draft.end, endTimeZone), 'startTimeZone': startTimeZone, 'endTimeZone': endTimeZone, if (isCreate || draft.recurrenceChanged) @@ -5093,6 +5216,24 @@ Map _eventRequest( }; } +String? _eventDateTimeWireValue(DateTime? value, String? timeZone) { + if (value == null) return null; + if (isUtcTimeZone(timeZone)) { + return DateTime.utc( + value.year, + value.month, + value.day, + value.hour, + value.minute, + value.second, + value.millisecond, + value.microsecond, + ).toIso8601String(); + } + if (timeZone == null || timeZone.isEmpty) return value.toIso8601String(); + return providerWallTimeIso8601String(value); +} + Map _graphLocation(EventEditorDraft draft) { final selection = draft.locationChange.selection; return microsoftStructuredLocation( @@ -5424,6 +5565,9 @@ String? _storedEventStart(CalendarEvent event) => String? _storedEventEnd(CalendarEvent event) => event.allDay ? event.endDate : event.endDateTime; +bool _eventRequiresRecurringScope(CalendarEvent event) => + CalendarEventDetail.fromRow(event).requiresRecurringMutationScope; + DateTime? _storedEventStartDateTime(CalendarEvent event) => providerDateTimeAsCivilTime(_storedEventStart(event), event.startTimeZone); diff --git a/lib/src/features/calendar/presentation/calendar_color_dialog.dart b/lib/src/features/calendar/presentation/calendar_color_dialog.dart index 1774a912..9bcf4f82 100644 --- a/lib/src/features/calendar/presentation/calendar_color_dialog.dart +++ b/lib/src/features/calendar/presentation/calendar_color_dialog.dart @@ -4,7 +4,6 @@ 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( @@ -12,11 +11,9 @@ Future showCalendarColorDialog( required BusyProvider provider, required String? currentBackgroundColor, required String? currentColorId, - required LinuxHeaderBarService headerBarService, }) { return showBusyMaxModalDialog( context, - headerBarService: headerBarService, barrierDismissible: false, builder: (dialogContext) => _CalendarColorDialog( provider: provider, diff --git a/lib/src/features/calendar/presentation/event_editor.dart b/lib/src/features/calendar/presentation/event_editor.dart index 0babb376..be174714 100644 --- a/lib/src/features/calendar/presentation/event_editor.dart +++ b/lib/src/features/calendar/presentation/event_editor.dart @@ -8,10 +8,10 @@ import 'package:yaru/yaru.dart'; import '../../../app/busymax_design.dart'; import '../../../app/busymax_dialogs.dart'; +import '../../../app/busymax_window_close.dart'; import '../../../calendar_providers/calendar_colors.dart'; import '../../../calendar_providers/calendar_mutation.dart'; import '../../../l10n/l10n.dart'; -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'; @@ -33,13 +33,11 @@ Future showBusyMaxEventEditorDialog( required EventEditorDraft initialDraft, required List sources, required List accounts, - LinuxHeaderBarService? headerBarService, bool allowDelete = true, Map> categorySuggestionsByAccount = const {}, }) async { return showBusyMaxModalEditorDialog( context, - headerBarService: headerBarService, maxWidth: BusyMaxSizes.detailsWidth, maxHeight: 720, builder: (context) { @@ -48,7 +46,6 @@ Future showBusyMaxEventEditorDialog( sources: sources, accounts: accounts, categorySuggestionsByAccount: categorySuggestionsByAccount, - headerBarService: headerBarService, onCancel: () => Navigator.of(context).pop(), onSave: (draft) => unawaited( _completeEventEditorSave( @@ -57,7 +54,6 @@ Future showBusyMaxEventEditorDialog( initialDraft: initialDraft, sources: sources, accounts: accounts, - headerBarService: headerBarService, ), ), onDelete: allowDelete && initialDraft.eventId != null @@ -68,7 +64,6 @@ Future showBusyMaxEventEditorDialog( scope: scope, initialDraft: initialDraft, sources: sources, - headerBarService: headerBarService, ), ) : null, @@ -121,7 +116,6 @@ Future _completeEventEditorSave( required EventEditorDraft initialDraft, required List sources, required List accounts, - LinuxHeaderBarService? headerBarService, }) async { final move = _eventMoveContext( initialDraft: initialDraft, @@ -138,7 +132,6 @@ Future _completeEventEditorSave( _calendarMoveLabel(move.destination, accounts), ), confirmLabel: context.l10n.copyAndDelete, - headerBarService: headerBarService, ); if (!confirmed || !context.mounted) return; } @@ -151,7 +144,6 @@ Future _completeEventEditorSave( context, provider: provider, action: CalendarGuestDeliveryAction.save, - headerBarService: headerBarService, ); if (choice == null) return; guestUpdatePolicy = choice; @@ -245,7 +237,6 @@ Future _completeEventEditorDelete( required RecurringEventMutationScope? scope, required EventEditorDraft initialDraft, required List sources, - LinuxHeaderBarService? headerBarService, }) async { var guestUpdatePolicy = CalendarGuestUpdatePolicy.send; final provider = _providerForDraft(initialDraft, sources); @@ -256,7 +247,6 @@ Future _completeEventEditorDelete( context, provider: provider, action: CalendarGuestDeliveryAction.delete, - headerBarService: headerBarService, ); if (choice == null) return; guestUpdatePolicy = choice; @@ -304,7 +294,6 @@ class EventEditor extends ConsumerStatefulWidget { this.accounts = const [], this.onDelete, this.categorySuggestionsByAccount = const {}, - this.headerBarService, }); final EventEditorDraft initialDraft; @@ -314,7 +303,6 @@ class EventEditor extends ConsumerStatefulWidget { final VoidCallback onCancel; final ValueChanged onSave; final EventEditorDeleteCallback? onDelete; - final LinuxHeaderBarService? headerBarService; @override ConsumerState createState() => _EventEditorState(); @@ -357,6 +345,13 @@ class _EventEditorState extends ConsumerState { @override Widget build(BuildContext context) { + return BusyMaxWindowCloseGuard( + onCloseRequested: _confirmWindowClose, + child: _buildEditor(context), + ); + } + + Widget _buildEditor(BuildContext context) { final l10n = context.l10n; final dirty = _hasUnsavedChanges; final title = widget.initialDraft.eventId == null @@ -419,7 +414,6 @@ class _EventEditorState extends ConsumerState { }, }, child: Focus( - autofocus: true, focusNode: _shortcutFocusNode, onKeyEvent: _handleEditorKeyEvent, child: BusyMaxModalEditorScaffold( @@ -598,7 +592,7 @@ class _EventEditorState extends ConsumerState { currentSource.davEffectivePermissions['canQueryFreeBusy'] == true && _draft.attendees.isNotEmpty) - TextButton( + BusyMaxPushButton.standard( onPressed: () => showLinuxNextcloudSchedulingDialog( context, accountId: currentSource!.accountId, @@ -684,7 +678,6 @@ class _EventEditorState extends ConsumerState { ), ], ), - const SizedBox(height: BusyMaxSpacing.lg), ], ), ), @@ -692,27 +685,28 @@ class _EventEditorState extends ConsumerState { } Future _cancel() async { - if (_confirmingCancel) { - return; + if (await _confirmWindowClose() && mounted) { + widget.onCancel(); } + } + + Future _confirmWindowClose() async { if (!_hasUnsavedChanges) { - widget.onCancel(); - return; + return true; + } + if (_confirmingCancel) { + return false; } _confirmingCancel = true; try { - final discard = await showBusyMaxConfirm( + return await showBusyMaxConfirm( context, title: context.l10n.discardChanges, message: context.l10n.discardChangesConfirmation, confirmLabel: context.l10n.discardChangesAction, destructive: true, - headerBarService: widget.headerBarService, ); - if (discard && mounted) { - widget.onCancel(); - } } finally { _confirmingCancel = false; } @@ -888,7 +882,6 @@ class _EventEditorState extends ConsumerState { timeZone: _draft.startTimeZone, limits: EventRecurrenceCodec.limitsFor(provider), useNativeDatePicker: false, - headerBarService: widget.headerBarService, ); if (result == null || !mounted) return; if (!result.repeats) { diff --git a/lib/src/features/calendar/presentation/event_editor_draft.dart b/lib/src/features/calendar/presentation/event_editor_draft.dart index 2383139e..84c4bcc9 100644 --- a/lib/src/features/calendar/presentation/event_editor_draft.dart +++ b/lib/src/features/calendar/presentation/event_editor_draft.dart @@ -225,7 +225,7 @@ class EventEditorDraft { accountId: detail.accountId, sourceId: detail.sourceId, providerCalendarId: detail.providerCalendarId, - providerRecurringEventId: detail.providerRecurringEventId, + providerRecurringEventId: detail.recurringMutationSeriesId, eventType: detail.eventType, title: detail.title, allDay: detail.allDay, @@ -653,7 +653,7 @@ DateTime? _eventEditorDateTime({ }) { final value = allDay ? date ?? dateTime : dateTime; if (value == null || value.isEmpty) return null; - return providerDateTimeAsWallTime(value, timeZone); + return providerDateTimeAsCivilTime(value, timeZone); } Map _jsonMap(Object? value) { diff --git a/lib/src/features/calendar/presentation/event_guest_delivery_dialog.dart b/lib/src/features/calendar/presentation/event_guest_delivery_dialog.dart index 0bfa63c3..c57e62d3 100644 --- a/lib/src/features/calendar/presentation/event_guest_delivery_dialog.dart +++ b/lib/src/features/calendar/presentation/event_guest_delivery_dialog.dart @@ -4,7 +4,6 @@ import '../../../app/busymax_design.dart'; import '../../../app/busymax_dialogs.dart'; import '../../../calendar_providers/calendar_mutation.dart'; import '../../../l10n/l10n.dart'; -import '../../../platform/linux_header_bar_service.dart'; import '../../../providers/busy_provider.dart'; enum CalendarGuestDeliveryAction { save, delete } @@ -13,7 +12,6 @@ Future showCalendarGuestDeliveryDialog( BuildContext context, { required BusyProvider provider, required CalendarGuestDeliveryAction action, - LinuxHeaderBarService? headerBarService, }) async { if (provider == BusyProvider.nextcloud) { final saving = action == CalendarGuestDeliveryAction.save; @@ -23,7 +21,6 @@ Future showCalendarGuestDeliveryDialog( message: context.l10n.nextcloudSchedulingPending, confirmLabel: saving ? context.l10n.save : context.l10n.sendCancellation, destructive: !saving, - headerBarService: headerBarService, ); return confirmed ? CalendarGuestUpdatePolicy.send : null; } @@ -39,7 +36,6 @@ Future showCalendarGuestDeliveryDialog( : context.l10n.microsoftNotifyGuestsDeleteMessage, confirmLabel: saving ? context.l10n.save : context.l10n.delete, destructive: !saving, - headerBarService: headerBarService, ); return confirmed ? CalendarGuestUpdatePolicy.send : null; } @@ -48,7 +44,6 @@ Future showCalendarGuestDeliveryDialog( } return showBusyMaxModalDialog( context, - headerBarService: headerBarService, barrierDismissible: false, builder: (dialogContext) => _GoogleGuestDeliveryDialog(action: action), ); diff --git a/lib/src/features/calendar/presentation/ical_import_flow.dart b/lib/src/features/calendar/presentation/ical_import_flow.dart index 6dcf9d2e..af0ab30a 100644 --- a/lib/src/features/calendar/presentation/ical_import_flow.dart +++ b/lib/src/features/calendar/presentation/ical_import_flow.dart @@ -1,6 +1,7 @@ import 'package:file_selector/file_selector.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:yaru/yaru.dart'; import '../../../app/app_bootstrap.dart'; import '../../../app/busymax_design.dart'; @@ -9,7 +10,6 @@ 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( @@ -43,15 +43,11 @@ Future showIcsImportFlow( account.id: account.selectorLabel, }; if (!context.mounted) return; - final selection = await showBusyMaxModalDialog( + final selection = await showIcalImportPreviewDialog( context, - headerBarService: ref.read(linuxHeaderBarServiceProvider), - barrierDismissible: false, - builder: (dialogContext) => _IcalImportPreviewDialog( - preview: preview, - destinations: destinations, - accountLabels: accountLabels, - ), + preview: preview, + destinations: destinations, + accountLabels: accountLabels, ); if (selection == null || !context.mounted) return; final report = await service.importPreview( @@ -64,7 +60,6 @@ Future showIcsImportFlow( if (!context.mounted) return; await showBusyMaxModalDialog( context, - headerBarService: ref.read(linuxHeaderBarServiceProvider), builder: (dialogContext) => _IcalImportReportDialog(report: report), ); } on Object catch (error) { @@ -79,6 +74,23 @@ Future showIcsImportFlow( } } +Future showIcalImportPreviewDialog( + BuildContext context, { + required IcalImportPreview preview, + required List destinations, + required Map accountLabels, +}) { + return showBusyMaxModalDialog( + context, + barrierDismissible: false, + builder: (dialogContext) => _IcalImportPreviewDialog( + preview: preview, + destinations: destinations, + accountLabels: accountLabels, + ), + ); +} + class _IcalImportPreviewDialog extends StatefulWidget { const _IcalImportPreviewDialog({ required this.preview, @@ -142,11 +154,30 @@ class _IcalImportPreviewDialogState extends State<_IcalImportPreviewDialog> { Text(l10n.nextcloudNativeImport), if (widget.preview.nativePreview.schedulingMethod != null) Text(l10n.nextcloudImportMethod), - CheckboxListTile( - contentPadding: EdgeInsets.zero, - title: Text(l10n.nextcloudImportCopies), - value: _newCopies, - onChanged: (value) => setState(() => _newCopies = value == true), + BusyMaxGroupedList( + filled: true, + children: [ + BusyMaxYaruFocusBorder( + borderStrokeAlign: BorderSide.strokeAlignInside, + builder: (context, rowFocusNode) => YaruCheckboxListTile( + key: const ValueKey('nextcloud-import-copies'), + title: Text(l10n.nextcloudImportCopies), + value: _newCopies, + onChanged: _onNewCopiesChanged, + focusNode: rowFocusNode, + control: BusyMaxYaruFocusBorder( + builder: (context, controlFocusNode) => YaruCheckbox( + value: _newCopies, + onChanged: _onNewCopiesChanged, + focusNode: controlFocusNode, + hasFocusBorder: false, + ), + ), + shape: const RoundedRectangleBorder(), + hasFocusBorder: false, + ), + ), + ], ), ], if (widget.preview.invalidEventCount > 0) @@ -169,6 +200,10 @@ class _IcalImportPreviewDialogState extends State<_IcalImportPreviewDialog> { ], ); } + + void _onNewCopiesChanged(bool? value) { + setState(() => _newCopies = value == true); + } } class _IcalImportReportDialog extends StatelessWidget { diff --git a/lib/src/features/diagnostics/presentation/diagnostics_screen.dart b/lib/src/features/diagnostics/presentation/diagnostics_screen.dart index 6991f9e5..362b8200 100644 --- a/lib/src/features/diagnostics/presentation/diagnostics_screen.dart +++ b/lib/src/features/diagnostics/presentation/diagnostics_screen.dart @@ -13,11 +13,17 @@ import '../../../google_tasks/api/google_tasks_api_surface.dart'; import '../../../google_tasks/api/tasks_discovery_revision.dart'; import '../../../l10n/l10n.dart'; import '../../sync/pending_op_resolution_service.dart'; +import '../../sync/sync_auth_error.dart'; class DiagnosticsPanel extends ConsumerWidget { - const DiagnosticsPanel({super.key, this.scrollable = true}); + const DiagnosticsPanel({ + super.key, + this.scrollable = true, + this.sectionHeaderStyle, + }); final bool scrollable; + final TextStyle? sectionHeaderStyle; @override Widget build(BuildContext context, WidgetRef ref) { @@ -35,7 +41,7 @@ class DiagnosticsPanel extends ConsumerWidget { header: true, child: Text( l10n.googleTasksApi, - style: busyMaxSectionHeaderStyle(context), + style: sectionHeaderStyle ?? busyMaxSectionHeaderStyle(context), ), ), const SizedBox(height: 8), @@ -203,9 +209,16 @@ class _BlockedPendingOpTile extends StatelessWidget { if (!context.mounted) { return; } - ScaffoldMessenger.of( - context, - ).showSnackBar(SnackBar(content: Text(redactForLog(error.toString())))); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + syncFailureMessage( + error, + networkUnavailableMessage: context.l10n.networkOfflineTryAgain, + ), + ), + ), + ); } } @@ -232,9 +245,16 @@ class _BlockedPendingOpTile extends StatelessWidget { if (!context.mounted) { return; } - ScaffoldMessenger.of( - context, - ).showSnackBar(SnackBar(content: Text(redactForLog(error.toString())))); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + syncFailureMessage( + error, + networkUnavailableMessage: context.l10n.networkOfflineTryAgain, + ), + ), + ), + ); } } } diff --git a/lib/src/features/feedback/presentation/feedback_dialog.dart b/lib/src/features/feedback/presentation/feedback_dialog.dart index fdda7c68..eaa61d67 100644 --- a/lib/src/features/feedback/presentation/feedback_dialog.dart +++ b/lib/src/features/feedback/presentation/feedback_dialog.dart @@ -10,7 +10,6 @@ import 'package:yaru/yaru.dart'; import '../../../app/busymax_design.dart'; import '../../../app/busymax_dialogs.dart'; import '../../../l10n/l10n.dart'; -import '../../../platform/linux_header_bar_service.dart'; import '../data/feedback_api_client.dart'; import '../data/feedback_submission.dart'; import '../../connectivity/network_connectivity_service.dart'; @@ -29,16 +28,13 @@ class FeedbackAppMetadata { Future showBusyMaxFeedbackDialog( BuildContext context, { required FeedbackSubmissionService submissionService, - LinuxHeaderBarService? headerBarService, }) { return showBusyMaxModalEditorDialog( context, - headerBarService: headerBarService, maxWidth: 680, maxHeight: 760, builder: (dialogContext) => BusyMaxFeedbackDialog( submissionService: submissionService, - headerBarService: headerBarService, onCancel: () => Navigator.of(dialogContext).pop(), ), ); @@ -52,7 +48,6 @@ class BusyMaxFeedbackDialog extends StatefulWidget { this.metadataLoader, this.submissionIdGenerator, this.osVersionProvider, - this.headerBarService, }); final FeedbackSubmissionService submissionService; @@ -60,7 +55,6 @@ class BusyMaxFeedbackDialog extends StatefulWidget { final FeedbackAppMetadataLoader? metadataLoader; final FeedbackSubmissionIdGenerator? submissionIdGenerator; final FeedbackOsVersionProvider? osVersionProvider; - final LinuxHeaderBarService? headerBarService; @override State createState() => _BusyMaxFeedbackDialogState(); @@ -98,6 +92,9 @@ class _BusyMaxFeedbackDialogState extends State { @override Widget build(BuildContext context) { final l10n = context.l10n; + final technicalDetailsOnChanged = _submitting + ? null + : _onTechnicalDetailsChanged; final categoryInvalid = _validationAttempted && !FeedbackValidation.categoryIsValid(_category); final subjectInvalid = @@ -213,20 +210,26 @@ class _BusyMaxFeedbackDialogState extends State { BusyMaxGroupedList( filled: true, children: [ - YaruCheckboxListTile( - key: const Key('feedback-technical-details'), - value: _includeTechnicalDetails, - onChanged: _submitting - ? null - : (value) { - setState(() { - _includeTechnicalDetails = value ?? false; - _draftChanged(); - }); - }, - title: Text(l10n.feedbackIncludeTechnicalDetails), - subtitle: Text(l10n.feedbackTechnicalDetailsDisclosure), - shape: const RoundedRectangleBorder(), + BusyMaxYaruFocusBorder( + borderStrokeAlign: BorderSide.strokeAlignInside, + builder: (context, rowFocusNode) => YaruCheckboxListTile( + key: const Key('feedback-technical-details'), + value: _includeTechnicalDetails, + onChanged: technicalDetailsOnChanged, + focusNode: rowFocusNode, + control: BusyMaxYaruFocusBorder( + builder: (context, controlFocusNode) => YaruCheckbox( + value: _includeTechnicalDetails, + onChanged: technicalDetailsOnChanged, + focusNode: controlFocusNode, + hasFocusBorder: false, + ), + ), + title: Text(l10n.feedbackIncludeTechnicalDetails), + subtitle: Text(l10n.feedbackTechnicalDetailsDisclosure), + shape: const RoundedRectangleBorder(), + hasFocusBorder: false, + ), ), ], ), @@ -245,7 +248,6 @@ class _BusyMaxFeedbackDialogState extends State { ), ), ], - const SizedBox(height: BusyMaxSpacing.lg), ], ), ), @@ -253,6 +255,13 @@ class _BusyMaxFeedbackDialogState extends State { ); } + void _onTechnicalDetailsChanged(bool? value) { + setState(() { + _includeTechnicalDetails = value ?? false; + _draftChanged(); + }); + } + Future _cancel() async { if (_submitting || _confirmingCancel) { return; @@ -277,7 +286,6 @@ class _BusyMaxFeedbackDialogState extends State { confirmLabel: context.l10n.discardChangesAction, destructive: true, barrierColor: Colors.transparent, - headerBarService: widget.headerBarService, ); if (discard && mounted) { widget.onCancel(); diff --git a/lib/src/features/recurrence/presentation/recurrence_editor.dart b/lib/src/features/recurrence/presentation/recurrence_editor.dart index 263f4056..ee39c0fe 100644 --- a/lib/src/features/recurrence/presentation/recurrence_editor.dart +++ b/lib/src/features/recurrence/presentation/recurrence_editor.dart @@ -6,7 +6,6 @@ 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'; @@ -22,12 +21,10 @@ Future showRecurrenceEditorDialog( String? timeZone, RecurrenceRuleLimits limits = RecurrenceRuleLimits.rfc5545, Color? barrierColor, - LinuxHeaderBarService? headerBarService, }) { return showBusyMaxModalEditorDialog( context, barrierColor: barrierColor, - headerBarService: headerBarService, maxWidth: 620, maxHeight: 820, builder: (context) => RecurrenceEditorDialog( @@ -494,7 +491,6 @@ class _RecurrenceEditorDialogState extends State { ), ], ), - const SizedBox(height: BusyMaxSpacing.lg), ], ); } diff --git a/lib/src/features/schedule/presentation/schedule_agenda_view.dart b/lib/src/features/schedule/presentation/schedule_agenda_view.dart index b4179f70..70b5e2f8 100644 --- a/lib/src/features/schedule/presentation/schedule_agenda_view.dart +++ b/lib/src/features/schedule/presentation/schedule_agenda_view.dart @@ -12,6 +12,7 @@ import '../../../schedule/schedule_item.dart'; import '../../../schedule/schedule_projection.dart'; import '../../../schedule/schedule_range.dart'; import '../../../schedule/schedule_sorting.dart'; +import '../../../schedule/task_list_mutation_intent.dart'; import 'schedule_event_block.dart'; import 'schedule_search_result_text.dart'; import '../../../schedule/schedule_search_criteria.dart'; @@ -33,6 +34,9 @@ class ScheduleAgendaView extends StatefulWidget { this.onItemAnchorAvailable, this.searchCriteria, this.searchQuery = '', + this.taskListMutationIntent, + this.onTaskListMutationConsumed, + this.emptyBuilder, }); final ScheduleSearchCriteria? searchCriteria; @@ -54,6 +58,9 @@ class ScheduleAgendaView extends StatefulWidget { final VoidCallback? onLoadMoreOverdue; final VoidCallback? onLoadMoreNoDate; final ScheduleItemAnchorCallback? onItemAnchorAvailable; + final TaskListMutationIntent? taskListMutationIntent; + final ValueChanged? onTaskListMutationConsumed; + final WidgetBuilder? emptyBuilder; @override State createState() => _ScheduleAgendaViewState(); @@ -61,6 +68,11 @@ class ScheduleAgendaView extends StatefulWidget { class _ScheduleAgendaViewState extends State { var _loadMoreArmed = true; + final _outgoingTasks = {}; + final _exitingTaskKeys = {}; + final _exitingMutations = {}; + final _enteringTaskKeys = {}; + int? _handledMutationGeneration; @override void didUpdateWidget(covariant ScheduleAgendaView oldWidget) { @@ -68,6 +80,61 @@ class _ScheduleAgendaViewState extends State { if (oldWidget.range.end != widget.range.end) { _loadMoreArmed = true; } + _reconcileMutation(oldWidget); + } + + void _reconcileMutation(ScheduleAgendaView oldWidget) { + final intent = widget.taskListMutationIntent; + if (intent == null || _handledMutationGeneration == intent.generation) { + return; + } + bool matches(TaskScheduleItem task) => + task.accountId == intent.accountId && + task.sourceId == intent.taskListId && + task.id == intent.taskId; + final current = widget.items.whereType().where(matches); + if (intent.presentation == TaskListMutationPresentation.insertion) { + if (current.isEmpty) return; + _handledMutationGeneration = intent.generation; + _enteringTaskKeys.add(intent.taskKey); + _notifyMutationConsumed(intent); + return; + } + if (current.isNotEmpty) { + final task = current.first; + if (intent.presentation == TaskListMutationPresentation.completion && + intent.completed != null) { + final checklistItemId = intent.checklistItemId; + if (checklistItemId == null) { + if (task.completed != intent.completed) return; + } else { + final checklistItem = task.checklistItems + .where((item) => item.id == checklistItemId) + .firstOrNull; + if (checklistItem?.completed != intent.completed) return; + } + } + _handledMutationGeneration = intent.generation; + _notifyMutationConsumed(intent); + return; + } + final oldIndex = oldWidget.items.indexWhere( + (item) => item is TaskScheduleItem && matches(item), + ); + if (oldIndex < 0) return; + final oldTask = oldWidget.items[oldIndex] as TaskScheduleItem; + _handledMutationGeneration = intent.generation; + _outgoingTasks[intent.taskKey] = (item: oldTask, index: oldIndex); + _exitingTaskKeys.add(intent.taskKey); + _exitingMutations[intent.taskKey] = intent; + } + + void _notifyMutationConsumed(TaskListMutationIntent intent) { + final callback = widget.onTaskListMutationConsumed; + if (callback == null) return; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) callback(intent); + }); } bool _handleScroll(ScrollNotification notification) { @@ -88,9 +155,26 @@ class _ScheduleAgendaViewState extends State { @override Widget build(BuildContext context) { + final presentedItems = List.of(widget.items); + for (final outgoing in _outgoingTasks.values) { + if (presentedItems.any( + (item) => + item is TaskScheduleItem && + _agendaTaskKey(item) == _agendaTaskKey(outgoing.item), + )) { + continue; + } + presentedItems.insert( + outgoing.index.clamp(0, presentedItems.length), + outgoing.item, + ); + } + if (presentedItems.isEmpty && widget.emptyBuilder != null) { + return widget.emptyBuilder!(context); + } final sections = widget.searchCriteria == null - ? _ordinarySections(context) - : _searchSections(context); + ? _ordinarySections(context, presentedItems) + : _searchSections(context, presentedItems); return NotificationListener( onNotification: _handleScroll, child: ColoredBox( @@ -119,9 +203,12 @@ class _ScheduleAgendaViewState extends State { ); } - List<_AgendaSection> _ordinarySections(BuildContext context) { - final dated = widget.items.where((item) => item.start != null).toList(); - final noDateTasks = ScheduleProjection.noDateTasks(widget.items); + List<_AgendaSection> _ordinarySections( + BuildContext context, + List items, + ) { + final dated = items.where((item) => item.start != null).toList(); + final noDateTasks = ScheduleProjection.noDateTasks(items); final groups = ScheduleProjection.groupByDay(dated); final rangeStart = ScheduleProjection.day(widget.range.start); final rangeEnd = ScheduleProjection.day(widget.range.end); @@ -134,7 +221,7 @@ class _ScheduleAgendaViewState extends State { final orderedOverdueTasks = ScheduleProjection.arrangeHierarchy( overdueTasks, ); - final hierarchy = _AgendaHierarchyIndex(widget.items); + final hierarchy = _AgendaHierarchyIndex(items); final emittedTasks = {}; final overdueEntries = _entriesFor( orderedOverdueTasks, @@ -184,8 +271,11 @@ class _ScheduleAgendaViewState extends State { ]; } - List<_AgendaSection> _searchSections(BuildContext context) { - final orderedItems = List.of(widget.items) + List<_AgendaSection> _searchSections( + BuildContext context, + List items, + ) { + final orderedItems = List.of(items) ..sort(compareScheduleSearchResultPresentation); final datedGroups = >{}; final undated = []; @@ -262,12 +352,14 @@ class _ScheduleAgendaViewState extends State { return entries; } - Widget _standaloneRow(ScheduleItem item) { + Widget _standaloneRow(ScheduleItem item, {bool animateMutation = true}) { void select(BuildContext context, [Offset? globalPosition]) => widget.onItemSelected(context, item, globalPosition); final task = item is TaskScheduleItem ? item : null; - return _AgendaRow( + final row = _AgendaRow( item: item, + completedOverride: _completionOverride(task), + animateCompletion: _completionOverride(task) != null, searchCriteria: widget.searchCriteria, searchQuery: widget.searchQuery, onAnchorAvailable: widget.onItemAnchorAvailable, @@ -276,6 +368,40 @@ class _ScheduleAgendaViewState extends State { ? null : (completed) => widget.onTaskCompletionChanged(task, completed), ); + return animateMutation && task != null ? _wrapTaskMutation(task, row) : row; + } + + bool? _completionOverride(TaskScheduleItem? task) { + final intent = widget.taskListMutationIntent; + if (task == null || + intent == null || + intent.presentation != TaskListMutationPresentation.completion || + intent.checklistItemId != null || + intent.taskKey != _agendaTaskKey(task)) { + return null; + } + return intent.completed; + } + + Widget _wrapTaskMutation(TaskScheduleItem task, Widget child) { + final key = _agendaTaskKey(task); + return _AgendaMutationTransition( + key: ValueKey('agenda-mutation-$key'), + entering: _enteringTaskKeys.contains(key), + exiting: _exitingTaskKeys.contains(key), + onFinished: () { + if (!mounted) return; + setState(() { + _enteringTaskKeys.remove(key); + if (_exitingTaskKeys.remove(key)) { + _outgoingTasks.remove(key); + final intent = _exitingMutations.remove(key); + if (intent != null) _notifyMutationConsumed(intent); + } + }); + }, + child: child, + ); } Widget _taskGroup( @@ -284,16 +410,17 @@ class _ScheduleAgendaViewState extends State { Set emitted, ) { emitted.add(_agendaTaskKey(root)); - return Column( + final group = Column( key: ValueKey( 'agenda-task-group-${root.accountId}-${root.sourceId}-${root.id}', ), mainAxisSize: MainAxisSize.min, children: [ - _standaloneRow(root), + _standaloneRow(root, animateMutation: false), ..._nestedRows(root, children, emitted, hierarchyRoot: root, depth: 1), ], ); + return _wrapTaskMutation(root, group); } Widget _detachedTaskGroup( @@ -372,13 +499,15 @@ class _ScheduleAgendaViewState extends State { required int depth, bool showSource = false, }) { - return _AgendaSubtaskRow( + final completionOverride = _completionOverride(task); + final row = _AgendaSubtaskRow( key: ValueKey( 'agenda-subtask-${task.accountId}-${task.sourceId}-${task.id}', ), depth: depth, title: task.title, - completed: task.completed, + completed: completionOverride ?? task.completed, + animateCompletion: completionOverride != null, subtitleBuilder: (context) { final searchCriteria = widget.searchCriteria; if (searchCriteria != null) { @@ -403,6 +532,7 @@ class _ScheduleAgendaViewState extends State { onCompletionChanged: (completed) => widget.onTaskCompletionChanged(task, completed), ); + return _wrapTaskMutation(task, row); } Widget _nestedChecklistRow( @@ -410,11 +540,13 @@ class _ScheduleAgendaViewState extends State { TaskChecklistItemEntity item, { required int depth, }) { + final completionOverride = _checklistCompletionOverride(parent, item); return _AgendaSubtaskRow( key: ValueKey('agenda-checklist-${parent.id}-${item.id}'), depth: depth, title: item.title, - completed: item.completed, + completed: completionOverride ?? item.completed, + animateCompletion: completionOverride != null, onTap: (context, [globalPosition]) => widget.onItemSelected(context, parent, globalPosition), onCompletionChanged: widget.onChecklistItemCompletionChanged == null @@ -426,6 +558,20 @@ class _ScheduleAgendaViewState extends State { ), ); } + + bool? _checklistCompletionOverride( + TaskScheduleItem parent, + TaskChecklistItemEntity item, + ) { + final intent = widget.taskListMutationIntent; + if (intent == null || + intent.presentation != TaskListMutationPresentation.completion || + intent.taskKey != _agendaTaskKey(parent) || + intent.checklistItemId != item.id) { + return null; + } + return intent.completed; + } } class _AgendaSection { @@ -451,6 +597,114 @@ class _AgendaLoadMoreRow extends StatelessWidget { } } +class _AgendaMutationTransition extends StatefulWidget { + const _AgendaMutationTransition({ + super.key, + required this.entering, + required this.exiting, + required this.onFinished, + required this.child, + }); + + final bool entering; + final bool exiting; + final VoidCallback onFinished; + final Widget child; + + @override + State<_AgendaMutationTransition> createState() => + _AgendaMutationTransitionState(); +} + +class _AgendaMutationTransitionState extends State<_AgendaMutationTransition> + with SingleTickerProviderStateMixin { + late final AnimationController _controller = AnimationController( + vsync: this, + animationBehavior: AnimationBehavior.preserve, + value: widget.entering ? 0 : 1, + ); + bool _disableAnimations = false; + bool _finishScheduled = false; + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) => _run()); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + final disabled = MediaQuery.disableAnimationsOf(context); + if (disabled != _disableAnimations) { + _disableAnimations = disabled; + if (disabled && (widget.entering || widget.exiting)) { + _controller.value = widget.exiting ? 0 : 1; + _finishAfterFrame(); + } + } + } + + @override + void didUpdateWidget(covariant _AgendaMutationTransition oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.entering != widget.entering || + oldWidget.exiting != widget.exiting) { + _run(); + } + } + + Future _run() async { + if (!mounted || (!widget.entering && !widget.exiting)) return; + final target = widget.exiting ? 0.0 : 1.0; + if (_disableAnimations) { + _controller.value = target; + _finishAfterFrame(); + return; + } else { + await _controller.animateTo( + target, + duration: BusyMaxMotion.taskListMutation, + curve: BusyMaxMotion.presentationCurve, + ); + } + if (mounted) widget.onFinished(); + } + + void _finishAfterFrame() { + if (_finishScheduled) return; + _finishScheduled = true; + WidgetsBinding.instance.addPostFrameCallback((_) { + _finishScheduled = false; + if (mounted) widget.onFinished(); + }); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) => IgnorePointer( + ignoring: widget.exiting, + child: ExcludeFocus( + excluding: widget.exiting, + child: ExcludeSemantics( + excluding: widget.exiting, + child: ClipRect( + child: SizeTransition( + sizeFactor: _controller, + alignment: Alignment.topCenter, + child: FadeTransition(opacity: _controller, child: widget.child), + ), + ), + ), + ), + ); +} + class _AgendaRow extends StatelessWidget { const _AgendaRow({ required this.item, @@ -459,6 +713,8 @@ class _AgendaRow extends StatelessWidget { this.searchCriteria, this.searchQuery = '', this.onTaskCompletionChanged, + this.completedOverride, + this.animateCompletion = false, }); final ScheduleSearchCriteria? searchCriteria; @@ -467,10 +723,13 @@ class _AgendaRow extends StatelessWidget { final ScheduleItemTapCallback onTap; final ScheduleItemAnchorCallback? onAnchorAvailable; final ValueChanged? onTaskCompletionChanged; + final bool? completedOverride; + final bool animateCompletion; @override Widget build(BuildContext context) { final task = item is TaskScheduleItem ? item as TaskScheduleItem : null; + final completed = completedOverride ?? task?.completed ?? false; final onAnchorAvailable = this.onAnchorAvailable; if (onAnchorAvailable != null) { WidgetsBinding.instance.addPostFrameCallback((_) { @@ -482,7 +741,11 @@ class _AgendaRow extends StatelessWidget { return BusyMaxActionRow( title: item.title, - titleWidget: _AgendaItemTitle(item: item), + titleWidget: _AgendaItemTitle( + item: item, + completedOverride: completedOverride, + animateCompletion: animateCompletion, + ), subtitleWidget: searchCriteria == null ? _AgendaItemSubtitle(item: item) : Text( @@ -498,11 +761,15 @@ class _AgendaRow extends StatelessWidget { leading: _AgendaItemMarker(item: item), trailing: task == null ? null - : YaruCheckbox( - value: task.completed, - onChanged: onTaskCompletionChanged == null - ? null - : (value) => onTaskCompletionChanged!(value ?? false), + : BusyMaxYaruFocusBorder( + builder: (context, focusNode) => YaruCheckbox( + value: completed, + focusNode: focusNode, + hasFocusBorder: false, + onChanged: onTaskCompletionChanged == null + ? null + : (value) => onTaskCompletionChanged!(value ?? false), + ), ), onActivated: onTap, ); @@ -562,6 +829,7 @@ class _AgendaSubtaskRow extends StatelessWidget { this.subtitleBuilder, this.onAnchorAvailable, this.onCompletionChanged, + this.animateCompletion = false, }); final int depth; @@ -571,6 +839,7 @@ class _AgendaSubtaskRow extends StatelessWidget { final String Function(BuildContext context)? subtitleBuilder; final ValueChanged? onAnchorAvailable; final ValueChanged? onCompletionChanged; + final bool animateCompletion; @override Widget build(BuildContext context) { @@ -595,16 +864,19 @@ class _AgendaSubtaskRow extends StatelessWidget { padding: const EdgeInsetsDirectional.only(start: BusyMaxSpacing.xs), child: BusyMaxActionRow( title: title, - titleWidget: Text( - title, - maxLines: 2, - overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.bodyMedium?.copyWith( + titleWidget: AnimatedDefaultTextStyle( + duration: + MediaQuery.disableAnimationsOf(context) || !animateCompletion + ? Duration.zero + : BusyMaxMotion.taskCompletion, + curve: BusyMaxMotion.presentationCurve, + style: Theme.of(context).textTheme.bodyMedium!.copyWith( decoration: completed ? TextDecoration.lineThrough : null, color: completed ? Theme.of(context).colorScheme.onSurfaceVariant : Theme.of(context).colorScheme.onSurface, ), + child: Text(title, maxLines: 2, overflow: TextOverflow.ellipsis), ), subtitle: subtitle, leading: Icon( @@ -612,11 +884,15 @@ class _AgendaSubtaskRow extends StatelessWidget { size: BusyMaxSizes.iconSm, color: colors.mutedForeground, ), - trailing: YaruCheckbox( - value: completed, - onChanged: onCompletionChanged == null - ? null - : (value) => onCompletionChanged!(value ?? false), + trailing: BusyMaxYaruFocusBorder( + builder: (context, focusNode) => YaruCheckbox( + value: completed, + focusNode: focusNode, + hasFocusBorder: false, + onChanged: onCompletionChanged == null + ? null + : (value) => onCompletionChanged!(value ?? false), + ), ), onActivated: onTap, ), @@ -653,25 +929,32 @@ class _AgendaItemMarker extends StatelessWidget { } class _AgendaItemTitle extends StatelessWidget { - const _AgendaItemTitle({required this.item}); + const _AgendaItemTitle({ + required this.item, + this.completedOverride, + this.animateCompletion = false, + }); final ScheduleItem item; + final bool? completedOverride; + final bool animateCompletion; @override Widget build(BuildContext context) { final task = item is TaskScheduleItem ? item as TaskScheduleItem : null; + final completed = completedOverride ?? task?.completed == true; final colorScheme = Theme.of(context).colorScheme; - return Text( - item.title, - maxLines: 2, - overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.bodyMedium?.copyWith( + return AnimatedDefaultTextStyle( + duration: MediaQuery.disableAnimationsOf(context) || !animateCompletion + ? Duration.zero + : BusyMaxMotion.taskCompletion, + curve: BusyMaxMotion.presentationCurve, + style: Theme.of(context).textTheme.bodyMedium!.copyWith( fontWeight: FontWeight.w600, - decoration: task?.completed == true ? TextDecoration.lineThrough : null, - color: task?.completed == true - ? colorScheme.onSurfaceVariant - : colorScheme.onSurface, + decoration: completed ? TextDecoration.lineThrough : null, + color: completed ? colorScheme.onSurfaceVariant : colorScheme.onSurface, ), + child: Text(item.title, maxLines: 2, overflow: TextOverflow.ellipsis), ); } } 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 0d0f5386..f8e4f452 100644 --- a/lib/src/features/schedule/presentation/schedule_day_week_view.dart +++ b/lib/src/features/schedule/presentation/schedule_day_week_view.dart @@ -16,6 +16,7 @@ import '../../../ui/common/schedule/schedule_interactions.dart'; import '../../../ui/common/schedule/schedule_preview_label.dart'; import '../../../ui/common/schedule/schedule_planner_events.dart'; import '../../../schedule/schedule_range.dart'; +import '../../../schedule/schedule_navigation_intent.dart'; import 'schedule_event_block.dart'; import 'schedule_item_chip.dart'; import 'schedule_item_selection.dart'; @@ -41,6 +42,7 @@ class ScheduleDayWeekView extends StatefulWidget { this.dayEndMinute = defaultScheduleDayEndMinute, this.onRangeCreated, this.onReschedule, + this.navigationIntent, }); final ScheduleRange range; @@ -56,6 +58,7 @@ class ScheduleDayWeekView extends StatefulWidget { final int dayEndMinute; final ValueChanged? onRangeCreated; final ScheduleRescheduleCallback? onReschedule; + final ScheduleNavigationIntent? navigationIntent; @override State createState() => _ScheduleDayWeekViewState(); @@ -67,6 +70,8 @@ class _ScheduleDayWeekViewState extends State { final _interactionKey = GlobalKey(); var _fullDayBarHeight = _fullDayBarDefaultHeight; var _heightPerMinute = _defaultHeightPerMinute; + int? _activeNavigationGeneration; + bool _disableAnimations = false; @override void initState() { @@ -78,6 +83,13 @@ class _ScheduleDayWeekViewState extends State { @override void didChangeDependencies() { super.didChangeDependencies(); + final disabled = MediaQuery.disableAnimationsOf(context); + if (disabled != _disableAnimations) { + _disableAnimations = disabled; + if (disabled && _activeNavigationGeneration != null) { + _settleNavigation(widget.navigationIntent); + } + } _reloadEvents(); } @@ -87,7 +99,14 @@ class _ScheduleDayWeekViewState extends State { _reloadEvents(); if (oldWidget.daysShowed != widget.daysShowed || !_sameDay(_plannerStartDate(oldWidget), _plannerStartDate(widget))) { - _jumpToDate(_plannerStartDate(widget)); + final intent = widget.navigationIntent; + if (intent != null && + intent.cause == ScheduleNavigationCause.adjacentPeriod && + intent.generation != oldWidget.navigationIntent?.generation) { + _animateToDate(_plannerStartDate(widget), intent); + } else { + _jumpToDate(_plannerStartDate(widget)); + } } if (oldWidget.dayStartMinute != widget.dayStartMinute || oldWidget.dayEndMinute != widget.dayEndMinute) { @@ -130,7 +149,11 @@ class _ScheduleDayWeekViewState extends State { initialVerticalScrollOffset: visibleMinutes.start * _heightPerMinute, daySeparationWidth: 1, dayEventsArranger: const SchedulePlannerEventArranger(), - onDayChange: (day) => widget.onDaySelected(_day(day)), + onDayChange: (day) { + if (_activeNavigationGeneration == null) { + widget.onDaySelected(_day(day)); + } + }, daysHeaderParam: icv.DaysHeaderParam( daysHeaderHeight: widget.daysShowed == 1 ? 0 : 50, daysHeaderColor: workspaceColor, @@ -141,7 +164,9 @@ class _ScheduleDayWeekViewState extends State { ), fullDayParam: icv.FullDayParam( showMultiDayEvents: false, - fullDayEventsBarVisibility: showFullDayBar, + // Keep the zero-height bar mounted so its horizontal scroll position + // stays synchronized while moving through days without all-day items. + fullDayEventsBarVisibility: true, fullDayEventsBarHeight: fullDayBarHeight, fullDayEventHeight: showFullDayBar ? 24 : 0, fullDayEventsBarLeftWidget: Center( @@ -316,57 +341,66 @@ class _ScheduleDayWeekViewState extends State { }, ), ); - return ScheduleInteractionRegion( - key: _interactionKey, - coordinates: SchedulePlannerCoordinates( - key: _plannerKey, - headerHeight: daysHeaderHeight, - allDayHeight: fullDayBarHeight, - ), - onRangeCreated: widget.onRangeCreated, - onEmptySlot: widget.onEmptySlot, - onReschedule: widget.onReschedule, - previewColor: colorScheme.primary, - previewBuilder: (context, interval, allDay) => Padding( - padding: const EdgeInsets.all(4), - child: Text( - schedulePreviewLabel(context, interval, allDay), - style: Theme.of(context).textTheme.labelSmall, - maxLines: 2, - overflow: TextOverflow.ellipsis, + return NotificationListener( + onNotification: (notification) { + if (notification.metrics.axis == Axis.horizontal && + notification.dragDetails != null) { + _interruptNavigation(); + } + return false; + }, + child: ScheduleInteractionRegion( + key: _interactionKey, + coordinates: SchedulePlannerCoordinates( + key: _plannerKey, + headerHeight: daysHeaderHeight, + allDayHeight: fullDayBarHeight, ), - ), - child: Stack( - clipBehavior: Clip.none, - children: [ - planner, - if (showFullDayBar) - Positioned( - top: - daysHeaderHeight + - fullDayBarHeight - - _allDayResizeHandleHeight / 2, - left: clockRulerWidth( - context, - Theme.of(context).textTheme.bodySmall ?? const TextStyle(), - ), - right: 0, - child: Center( - child: ScheduleInteractionBlocker( - child: _AllDayResizeHandle( - onTap: _toggleFullDayBarHeight, - onVerticalDragUpdate: (delta) { - setState(() { - _fullDayBarHeight = (_fullDayBarHeight + delta) - .clamp(_fullDayBarMinHeight, _fullDayBarMaxHeight) - .toDouble(); - }); - }, + onRangeCreated: widget.onRangeCreated, + onEmptySlot: widget.onEmptySlot, + onReschedule: widget.onReschedule, + previewColor: colorScheme.primary, + previewBuilder: (context, interval, allDay) => Padding( + padding: const EdgeInsets.all(4), + child: Text( + schedulePreviewLabel(context, interval, allDay), + style: Theme.of(context).textTheme.labelSmall, + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ), + child: Stack( + clipBehavior: Clip.none, + children: [ + planner, + if (showFullDayBar) + Positioned( + top: + daysHeaderHeight + + fullDayBarHeight - + _allDayResizeHandleHeight / 2, + left: clockRulerWidth( + context, + Theme.of(context).textTheme.bodySmall ?? const TextStyle(), + ), + right: 0, + child: Center( + child: ScheduleInteractionBlocker( + child: _AllDayResizeHandle( + onTap: _toggleFullDayBarHeight, + onVerticalDragUpdate: (delta) { + setState(() { + _fullDayBarHeight = (_fullDayBarHeight + delta) + .clamp(_fullDayBarMinHeight, _fullDayBarMaxHeight) + .toDouble(); + }); + }, + ), ), ), ), - ), - ], + ], + ), ), ); } @@ -397,6 +431,88 @@ class _ScheduleDayWeekViewState extends State { return; } _plannerKey.currentState?.jumpToDate(date); + // The planner reuses its stateful day cells while scrolling, but those + // cells do not reload their cached events when their day changes. Notify + // them again after the jump has rebuilt the cells for the destination. + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + _reloadEvents(); + } + }); + }); + } + + void _animateToDate(DateTime date, ScheduleNavigationIntent intent) { + WidgetsBinding.instance.addPostFrameCallback((_) async { + if (!mounted || (_interactionKey.currentState?.active ?? false)) return; + final planner = _plannerKey.currentState; + if (planner == null || !planner.mainHorizontalController.hasClients) { + _jumpToDate(date); + return; + } + _activeNavigationGeneration = intent.generation; + final difference = _calendarDayDifference(planner.initialDate, date); + final index = planner.widget.textDirection == TextDirection.rtl + ? -difference + : difference; + final target = planner.dayWidthCalculator.offsetForIndex(index); + if (_disableAnimations) { + planner.mainHorizontalController.jumpTo(target); + _finishNavigation(intent.generation); + return; + } + final distance = (target - planner.mainHorizontalController.offset).abs(); + final page = planner.dayWidth * widget.daysShowed; + final fraction = page <= 0 ? 1.0 : (distance / page).clamp(0.0, 1.0); + if (fraction == 0) { + _finishNavigation(intent.generation); + return; + } + try { + await planner.mainHorizontalController.animateTo( + target, + duration: BusyMaxMotion.calendarPeriod * fraction, + curve: BusyMaxMotion.presentationCurve, + ); + } finally { + _finishNavigation(intent.generation); + } + }); + } + + void _settleNavigation(ScheduleNavigationIntent? intent) { + if (intent == null) return; + final planner = _plannerKey.currentState; + if (planner == null || !planner.mainHorizontalController.hasClients) return; + final difference = _calendarDayDifference( + planner.initialDate, + _plannerStartDate(widget), + ); + final index = planner.widget.textDirection == TextDirection.rtl + ? -difference + : difference; + planner.mainHorizontalController.jumpTo( + planner.dayWidthCalculator.offsetForIndex(index), + ); + _finishNavigation(intent.generation); + } + + void _interruptNavigation() { + if (_activeNavigationGeneration == null) return; + final controller = _plannerKey.currentState?.mainHorizontalController; + if (controller?.hasClients ?? false) { + controller!.jumpTo(controller.offset); + } + _activeNavigationGeneration = null; + } + + void _finishNavigation(int generation) { + if (!mounted || _activeNavigationGeneration != generation) return; + _activeNavigationGeneration = null; + // Destination day cells cache their events in the dependency. Refresh + // after their dates have changed, preserving the existing correction. + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) _reloadEvents(); }); } @@ -413,6 +529,12 @@ class _ScheduleDayWeekViewState extends State { } } +int _calendarDayDifference(DateTime origin, DateTime target) => DateTime.utc( + target.year, + target.month, + target.day, +).difference(DateTime.utc(origin.year, origin.month, origin.day)).inDays; + DateTime _plannerStartDate(ScheduleDayWeekView widget) { return widget.daysShowed == 1 ? _day(widget.selectedDate) diff --git a/lib/src/features/schedule/presentation/schedule_event_block.dart b/lib/src/features/schedule/presentation/schedule_event_block.dart index 0e508bcc..d3d93560 100644 --- a/lib/src/features/schedule/presentation/schedule_event_block.dart +++ b/lib/src/features/schedule/presentation/schedule_event_block.dart @@ -7,7 +7,6 @@ import '../../../app/busymax_surface_colors.dart'; import '../../../l10n/l10n.dart'; import '../../../schedule/schedule_item.dart'; import '../../../schedule/schedule_projection.dart'; -import '../../../ui/common/schedule/schedule_interactions.dart'; import 'schedule_item_selection.dart'; class ScheduleEventBlock extends StatefulWidget { @@ -53,9 +52,6 @@ class _ScheduleEventBlockState extends State { final titleMaxLines = showTime || contentHeight < 36 ? 1 : 2; final tooltipDetails = _tooltipDetails(context); final interactive = widget.onTap != null; - final canDrag = - widget.item.canReschedule && - ScheduleInteractionRegion.maybeOf(context)?.widget.onReschedule != null; final focusBorder = BorderSide(color: colorScheme.primary, width: 2); final sourceAccent = ScheduleProjection.colorForItem( widget.item, @@ -73,8 +69,6 @@ class _ScheduleEventBlockState extends State { enabled: interactive, mouseCursor: !interactive ? MouseCursor.defer - : canDrag - ? SystemMouseCursors.move : SystemMouseCursors.click, shortcuts: _activationShortcuts, actions: >{ diff --git a/lib/src/features/schedule/presentation/schedule_search_filters.dart b/lib/src/features/schedule/presentation/schedule_search_filters.dart index d287317f..bd9a1d5e 100644 --- a/lib/src/features/schedule/presentation/schedule_search_filters.dart +++ b/lib/src/features/schedule/presentation/schedule_search_filters.dart @@ -25,6 +25,7 @@ class ScheduleSearchFilters extends StatelessWidget { required this.sources, required this.taskLists, this.sidebar = true, + this.showEndBorder = true, }); final ScheduleSearchCriteria value; @@ -39,6 +40,7 @@ class ScheduleSearchFilters extends StatelessWidget { /// The narrow dialog sets this to false because [BusyMaxDialogShell] owns /// its surface and scrolling. final bool sidebar; + final bool showEndBorder; @override Widget build(BuildContext context) { @@ -150,6 +152,7 @@ class ScheduleSearchFilters extends StatelessWidget { } return BusyMaxSidebarSurface( + showEndBorder: showEndBorder, child: ListView( key: const ValueKey('schedule-search-filters'), padding: const EdgeInsetsDirectional.symmetric( diff --git a/lib/src/features/schedule/presentation/schedule_sidebar.dart b/lib/src/features/schedule/presentation/schedule_sidebar.dart index 86601d34..927fc891 100644 --- a/lib/src/features/schedule/presentation/schedule_sidebar.dart +++ b/lib/src/features/schedule/presentation/schedule_sidebar.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:math' as math; import '../../../dav/presentation/nextcloud_collection_dialog.dart'; import '../../../dav/nextcloud/nextcloud_dav_context.dart'; @@ -16,7 +17,6 @@ import '../../../calendar_providers/calendar_colors.dart'; import '../../../calendar_providers/calendar_provider_capabilities.dart'; import '../../../dav/dav_errors.dart'; import '../../../l10n/l10n.dart'; -import '../../../platform/linux_header_bar_provider.dart'; import '../../../schedule/schedule_item.dart'; import '../../../schedule/schedule_projection.dart'; import '../../../schedule/schedule_sidebar_order.dart'; @@ -29,6 +29,7 @@ import '../../calendar/data/calendar_repository.dart'; import '../../connectivity/network_connectivity_service.dart'; import '../../calendar/presentation/calendar_color_dialog.dart'; import '../../sync/sync_auth_error.dart'; +import '../../sync/sync_failure_notification_policy.dart'; import '../../task_lists/data/task_lists_repository.dart'; import '../../tasks/domain/task_capabilities.dart'; import 'package:busymax/src/providers/busy_provider.dart'; @@ -44,6 +45,7 @@ class ScheduleSidebar extends ConsumerWidget { required this.onMonthSelected, required this.onYearSelected, required this.onWeekSelected, + this.showEndBorder = true, }); final DateTime selectedDate; @@ -53,6 +55,7 @@ class ScheduleSidebar extends ConsumerWidget { final ValueChanged onMonthSelected; final ValueChanged onYearSelected; final ValueChanged onWeekSelected; + final bool showEndBorder; @override Widget build(BuildContext context, WidgetRef ref) { @@ -71,6 +74,7 @@ class ScheduleSidebar extends ConsumerWidget { if (!account.isSubscription) account.id, ]; return BusyMaxSidebarSurface( + showEndBorder: showEndBorder, child: Column( children: [ MiniCalendar( @@ -200,6 +204,10 @@ class _SourceRow extends ConsumerWidget { value: 'refresh', label: context.l10n.refreshCalendar, icon: YaruIcons.refresh, + enabled: account.isSyncEligible, + tooltip: account.needsReconnect + ? accountReconnectRequiredSyncMessage + : null, ), if (providerWebUri != null) BusyMaxMenuEntry( @@ -372,19 +380,43 @@ class _AccountSourcesGroup extends ConsumerStatefulWidget { _AccountSourcesGroupState(); } -class _AccountSourcesGroupState extends ConsumerState<_AccountSourcesGroup> { +class _AccountSourcesGroupState extends ConsumerState<_AccountSourcesGroup> + with SingleTickerProviderStateMixin { var _expanded = true; var _creatingCalendar = false; var _creatingTaskList = false; + late final AnimationController _expansion = AnimationController( + vsync: this, + animationBehavior: AnimationBehavior.preserve, + value: 1, + ); + bool _disableAnimations = false; + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + final disabled = MediaQuery.disableAnimationsOf(context); + if (disabled != _disableAnimations) { + _disableAnimations = disabled; + if (disabled) _expansion.value = _expanded ? 1 : 0; + } + } @override void didUpdateWidget(covariant _AccountSourcesGroup oldWidget) { super.didUpdateWidget(oldWidget); if (oldWidget.account.id != widget.account.id) { _expanded = true; + _expansion.value = 1; } } + @override + void dispose() { + _expansion.dispose(); + super.dispose(); + } + @override Widget build(BuildContext context) { final account = widget.account; @@ -424,59 +456,92 @@ class _AccountSourcesGroupState extends ConsumerState<_AccountSourcesGroup> { enabled: capabilities.taskListActionEnabled, ), ]; - return Column( - crossAxisAlignment: CrossAxisAlignment.stretch, + final children = Column( children: [ - _AccountHeaderRow( - account: account, - expanded: _expanded, - onToggleExpanded: _toggleExpanded, - actions: headerActions, - onActionSelected: _handleCollectionAction, + _AccountCalendarSources(account: account), + Builder( + builder: (context) { + final lists = ref + .watch(appSettingsControllerProvider) + .sidebarOrder + .apply( + SidebarOrderSection.taskLists, + ref.watch(sidebarTaskListsProvider(account.id)).valueOrNull ?? + const [], + (list) => list.id, + accountId: account.id, + ); + if (lists.isEmpty) { + return BusyMaxActionRow(title: context.l10n.noTaskListsSynced); + } + return Column( + children: [ + for (final list in lists) + _TaskListScheduleRow( + key: ValueKey(( + 'schedule-task-list', + list.accountId, + list.id, + )), + account: account, + list: list, + siblingIds: lists.map((list) => list.id).toList(), + ), + ], + ); + }, ), - if (_expanded) ...[ - _AccountCalendarSources(account: account), - Builder( - builder: (context) { - final lists = ref - .watch(appSettingsControllerProvider) - .sidebarOrder - .apply( - SidebarOrderSection.taskLists, - ref - .watch(sidebarTaskListsProvider(account.id)) - .valueOrNull ?? - const [], - (list) => list.id, - accountId: account.id, - ); - if (lists.isEmpty) { - return BusyMaxActionRow(title: context.l10n.noTaskListsSynced); - } - return Column( - children: [ - for (final list in lists) - _TaskListScheduleRow( - key: ValueKey(( - 'schedule-task-list', - list.accountId, - list.id, - )), - account: account, - list: list, - siblingIds: lists.map((list) => list.id).toList(), - ), - ], - ); - }, + ], + ); + return AnimatedBuilder( + animation: _expansion, + child: children, + builder: (context, child) => Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _AccountHeaderRow( + account: account, + expanded: _expanded, + expansionProgress: _expansion.value, + onToggleExpanded: _toggleExpanded, + actions: headerActions, + onActionSelected: _handleCollectionAction, + ), + ClipRect( + child: Align( + alignment: Alignment.topCenter, + heightFactor: _expansion.value, + child: IgnorePointer( + ignoring: !_expanded, + child: ExcludeFocus( + excluding: !_expanded, + child: ExcludeSemantics( + excluding: !_expanded, + child: TickerMode(enabled: _expanded, child: child!), + ), + ), + ), + ), ), ], - ], + ), ); } void _toggleExpanded() { setState(() => _expanded = !_expanded); + final target = _expanded ? 1.0 : 0.0; + if (_disableAnimations) { + _expansion.value = target; + return; + } + final distance = (target - _expansion.value).abs(); + if (distance == 0) return; + _expansion.animateTo( + target, + duration: BusyMaxMotion.accountDisclosure * distance, + curve: BusyMaxMotion.presentationCurve, + ); } Future _handleCollectionAction( @@ -650,6 +715,7 @@ class _AccountHeaderRow extends StatelessWidget { const _AccountHeaderRow({ required this.account, required this.expanded, + required this.expansionProgress, required this.onToggleExpanded, required this.actions, required this.onActionSelected, @@ -657,6 +723,7 @@ class _AccountHeaderRow extends StatelessWidget { final AccountEntity account; final bool expanded; + final double expansionProgress; final VoidCallback onToggleExpanded; final List<_AccountHeaderCollectionAction> actions; final ValueChanged onActionSelected; @@ -794,9 +861,8 @@ class _AccountHeaderRow extends StatelessWidget { ? MaterialLocalizations.of(context).expandedIconTapHint : MaterialLocalizations.of(context).collapsedIconTapHint, iconSize: BusyMaxSizes.sidebarActionIcon, - icon: AnimatedRotation( - turns: expanded ? 0.25 : 0, - duration: const Duration(milliseconds: 160), + icon: Transform.rotate( + angle: expansionProgress * math.pi / 2, child: Icon( BusyMaxGlyphs.collapsedFor(Directionality.of(context)), size: 16, @@ -1078,6 +1144,10 @@ class _TaskListScheduleRow extends ConsumerWidget { value: 'refresh', label: context.l10n.refreshList, icon: YaruIcons.refresh, + enabled: account.isSyncEligible, + tooltip: account.needsReconnect + ? accountReconnectRequiredSyncMessage + : null, ), if (providerWebUri != null) BusyMaxMenuEntry( @@ -1184,6 +1254,9 @@ Future _refreshCalendarSource( WidgetRef ref, CalendarSourceEntity source, ) async { + if (!await _manualRefreshAccountIsEligible(context, ref, source.accountId)) { + return; + } try { await ref .read(accountSyncOperationsProvider) @@ -1201,6 +1274,9 @@ Future _refreshTaskListAccount( WidgetRef ref, String accountId, ) async { + if (!await _manualRefreshAccountIsEligible(context, ref, accountId)) { + return; + } try { await ref .read(accountSyncOperationsProvider) @@ -1220,7 +1296,8 @@ Future _handleRefreshFailure( Object error, ) async { try { - if (isMissingOAuthTokenError(error)) { + if (syncFailureNotificationDisposition(error) == + SyncFailureNotificationDisposition.reconnectRequired) { await ref.read(authRepositoryProvider).markReconnectRequired(accountId); } } on Object { @@ -1243,6 +1320,29 @@ Future _handleRefreshFailure( ); } +Future _manualRefreshAccountIsEligible( + BuildContext context, + WidgetRef ref, + String accountId, +) async { + final account = await ref + .read(accountsRepositoryProvider) + .accountById(accountId); + if (account?.isSyncEligible == true) { + return true; + } + if (account?.needsReconnect == true && context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + context.l10n.refreshFailed(accountReconnectRequiredSyncMessage), + ), + ), + ); + } + return false; +} + bool _canRenameTaskList( AccountEntity account, TaskListEntity list, @@ -1404,7 +1504,6 @@ Future _createAccountCalendar( 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 { @@ -1443,7 +1542,6 @@ Future _createAccountTaskList( 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 { @@ -1510,7 +1608,6 @@ Future _changeCalendarColor( provider: source.provider, currentBackgroundColor: source.backgroundColor, currentColorId: source.colorId, - headerBarService: ref.read(linuxHeaderBarServiceProvider), ); if (!context.mounted || choice == null) return; try { @@ -1555,7 +1652,6 @@ Future _renameCalendar( ? context.l10n.setAction : context.l10n.rename, initialValue: source.summary, - headerBarService: ref.read(linuxHeaderBarServiceProvider), ); if (!context.mounted || title == null || @@ -1614,7 +1710,6 @@ Future _deleteCalendar( ? context.l10n.removeAction : context.l10n.delete, destructive: true, - headerBarService: ref.read(linuxHeaderBarServiceProvider), ); if (!confirmed) { return; @@ -1669,7 +1764,6 @@ Future _renameTaskList( label: context.l10n.title, actionLabel: context.l10n.rename, initialValue: list.title, - headerBarService: ref.read(linuxHeaderBarServiceProvider), ); if (!context.mounted || title == null || @@ -1720,7 +1814,6 @@ Future _deleteTaskList( ? context.l10n.unshare : context.l10n.delete, destructive: true, - headerBarService: ref.read(linuxHeaderBarServiceProvider), ); if (!confirmed) { return; @@ -1789,7 +1882,6 @@ Future _renameSubscriptionSource( label: context.l10n.subscriptionName, actionLabel: context.l10n.rename, initialValue: source.summary, - headerBarService: ref.read(linuxHeaderBarServiceProvider), ); if (name == null || !context.mounted) return; try { @@ -1815,7 +1907,6 @@ Future _colorSubscriptionSource( actionLabel: context.l10n.save, initialValue: source.backgroundColor, message: context.l10n.subscriptionColorHelp, - headerBarService: ref.read(linuxHeaderBarServiceProvider), ); if (color == null || !context.mounted) return; try { @@ -1840,7 +1931,6 @@ Future _unsubscribeSource( message: context.l10n.unsubscribeCalendarConfirmation, confirmLabel: context.l10n.unsubscribe, destructive: true, - headerBarService: ref.read(linuxHeaderBarServiceProvider), ); if (!confirmed || !context.mounted) return; try { diff --git a/lib/src/features/schedule/presentation/schedule_task_chip.dart b/lib/src/features/schedule/presentation/schedule_task_chip.dart index ad7a9e14..86e0fae5 100644 --- a/lib/src/features/schedule/presentation/schedule_task_chip.dart +++ b/lib/src/features/schedule/presentation/schedule_task_chip.dart @@ -117,12 +117,16 @@ class ScheduleTaskChip extends StatelessWidget { if (showCheckbox) ...[ SizedBox.square( dimension: checkboxSize, - child: YaruCheckbox( - value: item.completed, - onChanged: onCompletionChanged == null - ? null - : (value) => - onCompletionChanged!(value ?? false), + child: BusyMaxYaruFocusBorder( + builder: (context, focusNode) => YaruCheckbox( + value: item.completed, + focusNode: focusNode, + hasFocusBorder: false, + onChanged: onCompletionChanged == null + ? null + : (value) => + onCompletionChanged!(value ?? false), + ), ), ), const SizedBox(width: BusyMaxSpacing.xs), diff --git a/lib/src/features/schedule/presentation/schedule_toolbar.dart b/lib/src/features/schedule/presentation/schedule_toolbar.dart index 2599d8a2..82983a3c 100644 --- a/lib/src/features/schedule/presentation/schedule_toolbar.dart +++ b/lib/src/features/schedule/presentation/schedule_toolbar.dart @@ -2,10 +2,12 @@ import 'package:flutter/material.dart'; import 'package:yaru/yaru.dart'; import '../../../app/busymax_design.dart'; -import '../../../app/busymax_glyphs.dart'; import '../../../app/busymax_shortcuts.dart'; +import '../../../app/common/busymax_motion_widgets.dart'; +import '../../../app/linux/linux_header_style.dart'; import '../../../l10n/l10n.dart'; import '../../../l10n/localized_formatters.dart'; +import '../../../platform/gtk_header_icon_service.dart'; import '../../../schedule/schedule_range.dart'; import '../../../schedule/schedule_view_mode.dart'; @@ -39,9 +41,17 @@ class ScheduleToolbar extends StatelessWidget { this.sidebarVisible = false, this.onToggleSidebar, this.onSearch, + this.searchActive = false, + this.searchController, + this.searchFocusRequest = 0, + this.onSearchChanged, + this.onClearSearch, + this.onSearchFilters, this.onMenuSelected, this.createMenuController, - }); + }) : assert(!searchActive || searchController != null), + assert(!searchActive || onSearchChanged != null), + assert(!searchActive || onClearSearch != null); final ScheduleViewMode mode; final ScheduleRange range; @@ -60,185 +70,199 @@ class ScheduleToolbar extends StatelessWidget { final bool sidebarVisible; final VoidCallback? onToggleSidebar; final VoidCallback? onSearch; + final bool searchActive; + final TextEditingController? searchController; + final int searchFocusRequest; + final ValueChanged? onSearchChanged; + final VoidCallback? onClearSearch; + final VoidCallback? onSearchFilters; final ValueChanged? onMenuSelected; final BusyMaxMenuController? createMenuController; @override Widget build(BuildContext context) { final showPaging = mode != ScheduleViewMode.agenda; + final viewPresentations = [ + for (final value in ScheduleViewMode.values) + _scheduleViewPresentation(context, value), + ]; + final currentView = viewPresentations[mode.index]; return LayoutBuilder( builder: (context, constraints) { final compact = constraints.maxWidth < 760; - return SizedBox( - height: BusyMaxSizes.toolbarHeight, - child: Row( + return BusyMaxLinuxHeaderLayout( + centerAllocation: + BusyMaxLinuxHeaderCenterAllocation.fillBetweenControls, + leading: BusyMaxLinuxHeaderControlGroup( + key: const ValueKey('schedule-header-leading-actions'), children: [ - const SizedBox(width: BusyMaxSpacing.sm), if (canShowSidebar && onToggleSidebar != null) - YaruIconButton( + BusyMaxLinuxHeaderIconButton( + key: const ValueKey('schedule-sidebar-button'), tooltip: _shortcutTooltip( sidebarVisible ? context.l10n.hideSidebar : context.l10n.showSidebar, BusyMaxShortcutLabels.sidebar, ), - icon: Icon( - sidebarVisible - ? Icons.vertical_split - : Icons.vertical_split_outlined, - ), + icon: BusyMaxLinuxHeaderIcon.sidebar, + selected: sidebarVisible, onPressed: onToggleSidebar, ), - Tooltip( - message: _shortcutTooltip( - context.l10n.today, - BusyMaxShortcutLabels.today, - ), - excludeFromSemantics: true, - child: YaruIconButton( - tooltip: null, - icon: Icon( - Icons.today_outlined, - semanticLabel: context.l10n.today, + if (!searchActive) + BusyMaxLinuxHeaderIconButton( + key: const ValueKey('schedule-today-button'), + tooltip: _shortcutTooltip( + context.l10n.today, + BusyMaxShortcutLabels.today, ), + semanticLabel: context.l10n.today, + icon: BusyMaxLinuxHeaderIcon.today, onPressed: onToday, ), - ), - const SizedBox(width: BusyMaxSpacing.sm), - if (showPaging) ...[ - YaruIconButton( + if (!searchActive && showPaging) ...[ + BusyMaxLinuxHeaderIconButton( + key: const ValueKey('schedule-previous-button'), tooltip: _shortcutTooltip( MaterialLocalizations.of(context).previousPageTooltip, BusyMaxShortcutLabels.previousPeriod, ), - icon: Icon( - BusyMaxGlyphs.previousFor(Directionality.of(context)), - ), + icon: BusyMaxLinuxHeaderIcon.previous, onPressed: onPrevious, ), - YaruIconButton( + BusyMaxLinuxHeaderIconButton( + key: const ValueKey('schedule-next-button'), tooltip: _shortcutTooltip( MaterialLocalizations.of(context).nextPageTooltip, BusyMaxShortcutLabels.nextPeriod, ), - icon: Icon(BusyMaxGlyphs.nextFor(Directionality.of(context))), + icon: BusyMaxLinuxHeaderIcon.next, onPressed: onNext, ), - const SizedBox(width: BusyMaxSpacing.sm), ], - Expanded( - child: _fittingRangeTitle( - context, - localizedScheduleHeading( - Localizations.localeOf(context).toLanguageTag(), - mode, - range, - selectedDate, - agendaLabel: context.l10n.viewAgenda, - ), - ), - ), - BusyMaxMenuButton( - tooltip: _shortcutTooltip( - _modeLabel(context, mode), - BusyMaxShortcutLabels.forViewMode(mode), + ], + ), + title: BusyMaxBinaryPresentation( + alternateActive: searchActive, + child: Align( + alignment: Alignment.center, + child: searchActive + ? BusyMaxLinuxHeaderSearchField( + key: const ValueKey('schedule-header-search-field'), + controller: searchController!, + focusRequest: searchFocusRequest, + semanticLabel: MaterialLocalizations.of( + context, + ).searchFieldLabel, + onChanged: onSearchChanged!, + onClear: onClearSearch!, + ) + : _fittingRangeTitle( + context, + localizedScheduleHeading( + Localizations.localeOf(context).toLanguageTag(), + mode, + range, + selectedDate, + agendaLabel: context.l10n.viewAgenda, + ), + ), + ), + ), + trailing: BusyMaxLinuxHeaderControlGroup( + key: const ValueKey('schedule-header-trailing-actions'), + children: [ + if (!searchActive) ...[ + BusyMaxLinuxViewMenuButton( + key: const ValueKey('schedule-view-button'), + tooltip: context.l10n.viewSelector, + icon: currentView.headerIcon, + entries: [ + for ( + var index = 0; + index < viewPresentations.length; + index++ + ) + BusyMaxMenuEntry( + value: ScheduleViewMode.values[index], + label: viewPresentations[index].label, + icon: viewPresentations[index].fallbackIcon, + nativeIconName: resolvedNativeHeaderIconName( + context, + viewPresentations[index].headerIcon, + ), + role: BusyMaxMenuEntryRole.radio, + selected: mode == ScheduleViewMode.values[index], + shortcut: viewPresentations[index].shortcut, + ), + ], + onSelected: onModeChanged, ), - icon: Icon(_modeIcon(mode)), - entries: [ - for (final value in ScheduleViewMode.values) + BusyMaxLinuxHeaderMenuButton<_ScheduleCreateAction>( + key: const ValueKey('schedule-create-button'), + tooltip: context.l10n.create, + icon: BusyMaxLinuxHeaderIcon.create, + controller: createMenuController, + enabled: canCreateEvent || canCreateTask, + entries: [ BusyMaxMenuEntry( - value: value, - label: _modeLabel(context, value), - icon: _modeIcon(value), - role: BusyMaxMenuEntryRole.radio, - selected: mode == value, - shortcut: BusyMaxShortcutLabels.forViewMode(value), + value: _ScheduleCreateAction.event, + label: context.l10n.createEventAtTime, + icon: Icons.event_outlined, + nativeIconName: 'x-office-calendar-symbolic', + enabled: canCreateEvent, + shortcut: BusyMaxShortcutLabels.newEvent, ), - ], - onSelected: onModeChanged, - ), - BusyMaxMenuButton<_ScheduleCreateAction>( - tooltip: context.l10n.create, - icon: const Icon(YaruIcons.plus), - controller: createMenuController, - enabled: canCreateEvent || canCreateTask, - entries: [ - BusyMaxMenuEntry( - value: _ScheduleCreateAction.event, - label: context.l10n.createEventAtTime, - icon: Icons.event_outlined, - enabled: canCreateEvent, - shortcut: BusyMaxShortcutLabels.newEvent, - ), - BusyMaxMenuEntry( - value: _ScheduleCreateAction.task, - label: context.l10n.createTaskAtDate, - icon: Icons.task_alt_outlined, - enabled: canCreateTask, - shortcut: BusyMaxShortcutLabels.newTask, - ), - ], - onSelected: (value) { - switch (value) { - case _ScheduleCreateAction.event: - onCreateEvent(); - case _ScheduleCreateAction.task: - onCreateTask(); - } - }, - ), - if (!compact) - YaruIconButton( + BusyMaxMenuEntry( + value: _ScheduleCreateAction.task, + label: context.l10n.createTaskAtDate, + icon: Icons.task_alt_outlined, + nativeIconName: 'checkbox-checked-symbolic', + enabled: canCreateTask, + shortcut: BusyMaxShortcutLabels.newTask, + ), + ], + onSelected: (value) { + switch (value) { + case _ScheduleCreateAction.event: + onCreateEvent(); + case _ScheduleCreateAction.task: + onCreateTask(); + } + }, + ), + ], + if (!searchActive && !compact) + BusyMaxLinuxHeaderIconButton( + key: const ValueKey('schedule-refresh-button'), tooltip: context.l10n.refreshAll, - icon: const Icon(YaruIcons.refresh), + icon: BusyMaxLinuxHeaderIcon.refresh, onPressed: canRefresh ? onRefresh : null, ), + if (searchActive && onSearchFilters != null) + BusyMaxLinuxHeaderIconButton( + key: const ValueKey('schedule-search-filter-button'), + tooltip: context.l10n.searchFiltersAction, + icon: BusyMaxLinuxHeaderIcon.filter, + onPressed: onSearchFilters, + ), if (onSearch != null) - YaruIconButton( + BusyMaxLinuxHeaderIconButton( + key: const ValueKey('schedule-search-button'), tooltip: _shortcutTooltip( MaterialLocalizations.of(context).searchFieldLabel, BusyMaxShortcutLabels.search, ), - icon: const Icon(YaruIcons.search), + icon: BusyMaxLinuxHeaderIcon.search, + selected: searchActive, onPressed: onSearch, ), if (onMenuSelected != null) - BusyMaxMenuButton( - tooltip: context.l10n.mainMenu, - entries: [ - if (compact) - BusyMaxMenuEntry( - value: ScheduleToolbarMenuAction.refresh, - label: context.l10n.refreshAll, - icon: YaruIcons.refresh, - enabled: canRefresh, - ), - BusyMaxMenuEntry( - value: ScheduleToolbarMenuAction.settings, - label: context.l10n.settings, - icon: YaruIcons.settings, - shortcut: BusyMaxShortcutLabels.settings, - ), - BusyMaxMenuEntry( - value: ScheduleToolbarMenuAction.keyboardShortcuts, - label: context.l10n.keyboardShortcuts, - icon: Icons.keyboard_alt_outlined, - shortcut: BusyMaxShortcutLabels.keyboardShortcuts, - ), - BusyMaxMenuEntry( - value: ScheduleToolbarMenuAction.reportIssue, - label: context.l10n.reportAnIssue, - icon: YaruIcons.warning, - ), - BusyMaxMenuEntry( - value: ScheduleToolbarMenuAction.about, - label: context.l10n.aboutBusyMax, - icon: Icons.info_outline, - ), - ], + BusyMaxMainMenuButton( + includeRefresh: compact && !searchActive, + canRefresh: canRefresh, onSelected: onMenuSelected!, ), - const SizedBox(width: BusyMaxSpacing.sm), ], ), ); @@ -247,8 +271,70 @@ class ScheduleToolbar extends StatelessWidget { } } +class BusyMaxMainMenuButton extends StatelessWidget { + const BusyMaxMainMenuButton({ + super.key, + required this.onSelected, + this.includeRefresh = false, + this.canRefresh = false, + this.settingsSelected = false, + }); + + final ValueChanged onSelected; + final bool includeRefresh; + final bool canRefresh; + final bool settingsSelected; + + @override + Widget build(BuildContext context) { + return BusyMaxLinuxHeaderMenuButton( + key: const ValueKey('busymax-main-menu-button'), + tooltip: context.l10n.mainMenu, + icon: BusyMaxLinuxHeaderIcon.mainMenu, + entries: [ + if (includeRefresh) + BusyMaxMenuEntry( + value: ScheduleToolbarMenuAction.refresh, + label: context.l10n.refreshAll, + icon: YaruIcons.refresh, + nativeIconName: 'view-refresh-symbolic', + enabled: canRefresh, + ), + BusyMaxMenuEntry( + value: ScheduleToolbarMenuAction.settings, + label: context.l10n.settings, + icon: YaruIcons.settings, + nativeIconName: 'preferences-system-symbolic', + enabled: !settingsSelected, + shortcut: BusyMaxShortcutLabels.settings, + ), + BusyMaxMenuEntry( + value: ScheduleToolbarMenuAction.keyboardShortcuts, + label: context.l10n.keyboardShortcuts, + icon: Icons.keyboard_alt_outlined, + nativeIconName: 'input-keyboard-symbolic', + shortcut: BusyMaxShortcutLabels.keyboardShortcuts, + ), + BusyMaxMenuEntry( + value: ScheduleToolbarMenuAction.reportIssue, + label: context.l10n.reportAnIssue, + icon: YaruIcons.warning, + nativeIconName: 'dialog-warning-symbolic', + ), + BusyMaxMenuEntry( + value: ScheduleToolbarMenuAction.about, + label: context.l10n.aboutBusyMax, + icon: Icons.info_outline, + nativeIconName: 'help-about-symbolic', + ), + ], + onSelected: onSelected, + ); + } +} + Widget _fittingRangeTitle(BuildContext context, String title) { - final style = busyMaxHeaderTitleStyle(context); + final style = busyMaxLinuxHeaderTitleStyle(context); return LayoutBuilder( builder: (context, constraints) { final painter = TextPainter( @@ -257,33 +343,70 @@ Widget _fittingRangeTitle(BuildContext context, String title) { textDirection: Directionality.of(context), textScaler: MediaQuery.textScalerOf(context), )..layout(); - final titleFits = painter.width <= constraints.maxWidth; + final titleFits = + painter.width + BusyMaxSpacing.md * 2 <= constraints.maxWidth; painter.dispose(); if (!titleFits) { return const SizedBox.shrink(); } - return Text(title, maxLines: 1, style: style); + return Padding( + padding: const EdgeInsets.symmetric(horizontal: BusyMaxSpacing.md), + child: Text(title, maxLines: 1, style: style), + ); }, ); } -IconData _modeIcon(ScheduleViewMode mode) { - return switch (mode) { - ScheduleViewMode.day => Icons.calendar_view_day_outlined, - ScheduleViewMode.week => Icons.view_week_outlined, - ScheduleViewMode.month => Icons.calendar_view_month, - ScheduleViewMode.year => Icons.calendar_today_outlined, - ScheduleViewMode.agenda => Icons.view_agenda_outlined, - }; +final class _ScheduleViewPresentation { + const _ScheduleViewPresentation({ + required this.headerIcon, + required this.fallbackIcon, + required this.label, + required this.shortcut, + }); + + final BusyMaxLinuxHeaderIcon headerIcon; + final IconData fallbackIcon; + final String label; + final String shortcut; } -String _modeLabel(BuildContext context, ScheduleViewMode mode) { +_ScheduleViewPresentation _scheduleViewPresentation( + BuildContext context, + ScheduleViewMode mode, +) { + final shortcut = BusyMaxShortcutLabels.forViewMode(mode); return switch (mode) { - ScheduleViewMode.day => context.l10n.viewDay, - ScheduleViewMode.week => context.l10n.viewWeek, - ScheduleViewMode.month => context.l10n.viewMonth, - ScheduleViewMode.year => context.l10n.viewYear, - ScheduleViewMode.agenda => context.l10n.viewAgenda, + ScheduleViewMode.day => _ScheduleViewPresentation( + headerIcon: BusyMaxLinuxHeaderIcon.viewDay, + fallbackIcon: Icons.calendar_view_day_outlined, + label: context.l10n.viewDay, + shortcut: shortcut, + ), + ScheduleViewMode.week => _ScheduleViewPresentation( + headerIcon: BusyMaxLinuxHeaderIcon.viewWeek, + fallbackIcon: Icons.view_week_outlined, + label: context.l10n.viewWeek, + shortcut: shortcut, + ), + ScheduleViewMode.month => _ScheduleViewPresentation( + headerIcon: BusyMaxLinuxHeaderIcon.viewMonth, + fallbackIcon: Icons.calendar_view_month, + label: context.l10n.viewMonth, + shortcut: shortcut, + ), + ScheduleViewMode.year => _ScheduleViewPresentation( + headerIcon: BusyMaxLinuxHeaderIcon.viewYear, + fallbackIcon: Icons.calendar_today_outlined, + label: context.l10n.viewYear, + shortcut: shortcut, + ), + ScheduleViewMode.agenda => _ScheduleViewPresentation( + headerIcon: BusyMaxLinuxHeaderIcon.viewAgenda, + fallbackIcon: Icons.view_agenda_outlined, + label: context.l10n.viewAgenda, + shortcut: shortcut, + ), }; } diff --git a/lib/src/features/schedule/presentation/schedule_workspace.dart b/lib/src/features/schedule/presentation/schedule_workspace.dart index aa8dbf2e..bc226726 100644 --- a/lib/src/features/schedule/presentation/schedule_workspace.dart +++ b/lib/src/features/schedule/presentation/schedule_workspace.dart @@ -1,8 +1,5 @@ import 'dart:async'; -import 'dart:io'; - import 'package:flutter/material.dart'; -import 'package:yaru/yaru.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter/services.dart'; import 'package:go_router/go_router.dart'; @@ -11,11 +8,15 @@ import 'package:url_launcher/url_launcher.dart'; import '../../../app/app_bootstrap.dart'; import '../../../app/busymax_about_dialog.dart'; import '../../../app/busymax_design.dart'; +import '../../../app/busymax_header_actions.dart'; +import '../../../app/common/busymax_motion_widgets.dart'; import '../../../app/busymax_dialogs.dart'; import '../../../app/busymax_keyboard_shortcuts_dialog.dart'; import '../../../app/busymax_layout.dart'; import '../../../app/busymax_shortcuts.dart'; import '../../../app/busymax_surface_colors.dart'; +import '../../../app/busymax_window_close.dart'; +import '../../../app/linux/linux_page_frame.dart'; import '../../../core/logging/redacting_logger.dart'; import '../../../calendar_providers/calendar_mutation.dart'; import '../../../features/accounts/data/accounts_repository.dart'; @@ -24,10 +25,7 @@ import '../../../features/connectivity/network_connectivity_service.dart'; import '../../../features/feedback/presentation/feedback_dialog.dart'; import '../../../features/sync/sync_auth_error.dart'; import '../../../l10n/l10n.dart'; -import '../../../l10n/localized_formatters.dart'; import '../../../l10n/week_preferences_scope.dart'; -import '../../../platform/linux_header_bar_service.dart'; -import '../../../platform/linux_header_bar_provider.dart'; import '../../../schedule/schedule_commands.dart'; import '../../../schedule/schedule_filters.dart'; import '../../../schedule/schedule_item.dart'; @@ -36,6 +34,8 @@ import '../../../ui/common/schedule/schedule_interactions.dart'; import '../../calendar/domain/event_timing_policy.dart'; import '../../../schedule/schedule_projection.dart'; import '../../../schedule/schedule_range.dart'; +import '../../../schedule/schedule_navigation_intent.dart'; +import '../../../schedule/task_list_mutation_intent.dart'; import '../../../schedule/schedule_repository.dart'; import '../../../schedule/schedule_scope.dart'; import '../../../schedule/schedule_source_visibility.dart'; @@ -182,12 +182,8 @@ class _ScheduleWorkspaceState extends ConsumerState { var _mode = ScheduleViewMode.week; late ScheduleScope _scope; _TaskDetailsTarget? _taskDetailsTarget; - late final LinuxHeaderBarSession _headerBarSession; - StreamSubscription? _headerBarActions; - StreamSubscription? _headerBarSearchEvents; - var _headerBarReady = false; - var _nativeHeaderBarAvailable = false; var _sidebarCollapsed = false; + var _sidebarTransitionGeneration = 0; var _searchActive = false; var _searchQuery = ''; bool? _sidebarBeforeSearch; @@ -227,6 +223,13 @@ class _ScheduleWorkspaceState extends ConsumerState { var _taskDetailsDirty = false; final _anchoredPopoverController = ScheduleAnchoredPopoverController(); var _handlingModalHeaderAction = false; + final _taskDetailsOverlayKey = GlobalKey<_ScheduleTaskDetailsOverlayState>(); + var _taskDetailsLifecycleGeneration = 0; + Future? _taskDetailsDismissal; + var _navigationGeneration = 0; + ScheduleNavigationIntent? _navigationIntent; + var _taskListMutationGeneration = 0; + TaskListMutationIntent? _taskListMutationIntent; @override void initState() { @@ -236,14 +239,6 @@ class _ScheduleWorkspaceState extends ConsumerState { } _scope = widget.initialScope; _applyInitialScope(); - _headerBarSession = ref.read(linuxHeaderBarServiceProvider).claimSession(); - _headerBarActions = _headerBarSession.actions.listen( - _handleHeaderBarAction, - ); - _headerBarSearchEvents = _headerBarSession.searchEvents.listen( - _handleHeaderBarSearchEvent, - ); - unawaited(_initializeHeaderBar()); _scheduleInitialTaskWatch(); } @@ -254,14 +249,6 @@ class _ScheduleWorkspaceState extends ConsumerState { if (createChoiceMenuSession != null) { unawaited(createChoiceMenuSession.dismiss()); } - _headerBarSession.dispose(); - if (_taskDetailsTarget != null) { - unawaited( - releaseBusyMaxModalBarrier(ref.read(linuxHeaderBarServiceProvider)), - ); - } - unawaited(_headerBarActions?.cancel()); - unawaited(_headerBarSearchEvents?.cancel()); unawaited(_initialTaskTargetSubscription?.cancel()); _searchController.dispose(); _workspaceFocusNode.dispose(); @@ -298,6 +285,9 @@ class _ScheduleWorkspaceState extends ConsumerState { @override Widget build(BuildContext context) { + // Sync expands recurring cloud events into occurrence rows after the local + // save. Requery when those rows arrive so the month view updates itself. + ref.watch(scheduleDataRevisionProvider); final settings = ref.watch(appSettingsControllerProvider); _syncModeFromSettings(settings.scheduleViewMode); final range = _range(context); @@ -457,11 +447,9 @@ class _ScheduleWorkspaceState extends ConsumerState { ? ScheduleViewMode.agenda : _mode; _consumePendingCommand(visibleSources, accounts, sources); - final showFallbackHeader = _showFlutterHeaderFallback; - final canShowFallbackSidebar = - BusyMaxLayoutRules.showSidebar( - MediaQuery.sizeOf(context).width, - ); + final canShowSidebar = BusyMaxLayoutRules.showSidebar( + MediaQuery.sizeOf(context).width, + ); Widget buildSidebar() { if (searchActive && _searchCriteria != null) { return SizedBox( @@ -483,6 +471,7 @@ class _ScheduleWorkspaceState extends ConsumerState { selectedDate: _selectedDate, firstWeekday: firstWeekday, items: miniCalendarItems, + showEndBorder: false, onDateSelected: _openDay, onMonthSelected: _setMonth, onYearSelected: _setYear, @@ -493,196 +482,162 @@ class _ScheduleWorkspaceState extends ConsumerState { ); } - final main = Column( - children: [ - if (showFallbackHeader) ...[ - if (_searchActive) - Padding( - padding: const EdgeInsets.symmetric( - horizontal: BusyMaxSpacing.md, - vertical: BusyMaxSpacing.sm, - ), - child: Row( - children: [ - Expanded( - child: BusyMaxSearchField( - controller: _searchController, - autofocus: true, - focusRequest: _fallbackSearchFocusRequest, - hintText: MaterialLocalizations.of( - context, - ).searchFieldLabel, - onChanged: _setSearchQuery, - onClear: _clearSearchQuery, - ), - ), - if (!canShowFallbackSidebar || - _sidebarCollapsed) - YaruIconButton( - tooltip: context.l10n.searchFiltersAction, - icon: const Icon(Icons.filter_list), - onPressed: _showSearchFilters, - ), - YaruIconButton( - tooltip: context.l10n.close, - icon: const Icon(Icons.close), - onPressed: _closeSearch, - ), - ], - ), - ) - else - ScheduleToolbar( - mode: _mode, - range: range, - selectedDate: _selectedDate, - onToday: _goToToday, - onPrevious: _previous, - onNext: _next, - onModeChanged: _setMode, - canCreateEvent: writableSources.isNotEmpty, + final header = ScheduleToolbar( + mode: _mode, + range: range, + selectedDate: _selectedDate, + onToday: _goToToday, + onPrevious: _previous, + onNext: _next, + onModeChanged: _setMode, + canCreateEvent: writableSources.isNotEmpty, + canCreateTask: canCreateTask, + onCreateEvent: () => unawaited( + _openNewEvent( + writableSources, + _defaultSelectedDateStart(), + ), + ), + onCreateTask: () => unawaited( + _openNewTask( + accounts, + due: _day(_defaultSelectedDateStart()), + ), + ), + createMenuController: _createMenuController, + onRefresh: () => unawaited(_refreshAll()), + canRefresh: accounts.isNotEmpty, + canShowSidebar: canShowSidebar, + sidebarVisible: canShowSidebar && !_sidebarCollapsed, + onToggleSidebar: () => _handleHeaderAction( + BusyMaxHeaderAction.sidebarToggle, + ), + onSearch: () => + _handleHeaderAction(BusyMaxHeaderAction.search), + searchActive: searchActive, + searchController: _searchController, + searchFocusRequest: _fallbackSearchFocusRequest, + onSearchChanged: _setSearchQuery, + onClearSearch: _clearSearchQuery, + onSearchFilters: + searchActive && (!canShowSidebar || _sidebarCollapsed) + ? _showSearchFilters + : null, + onMenuSelected: _handleToolbarMenu, + ); + final main = BusyMaxBinaryPresentation( + alternateActive: searchActive, + child: Listener( + behavior: HitTestBehavior.translucent, + onPointerDown: _recordSchedulePointer, + child: _ScheduleBody( + isLoading: scheduleLoading, + isUnavailable: scheduleUnavailable, + mode: displayMode, + range: displayRange, + selectedDate: searchActive + ? displayRange.start + : _selectedDate, + firstWeekday: _firstWeekday(context), + dayStartMinute: settings.scheduleDayStartMinute, + dayEndMinute: settings.scheduleDayEndMinute, + hasAnySources: + visibility.hasCalendarSources || + visibility.hasTaskLists, + hasAccounts: accounts.isNotEmpty, + items: items, + onOpenSettings: () => + unawaited(context.push('/settings')), + onRetry: _retrySchedule, + onRefresh: accounts.isEmpty + ? null + : () => unawaited(_refreshAll()), + onDaySelected: _setDate, + onYearDaySelected: _openDay, + onMonthSelected: _setMonth, + onWeekSelected: _setWeek, + onEmptySlot: (start) => unawaited( + _openCreateChoice( + accounts, + visibleSources, + start, canCreateTask: canCreateTask, - onCreateEvent: () => unawaited( - _openNewEvent( - writableSources, - _defaultSelectedDateStart(), - ), - ), - onCreateTask: () => unawaited( - _openNewTask( - accounts, - due: _day(_defaultSelectedDateStart()), - ), - ), - createMenuController: _createMenuController, - onRefresh: () => unawaited(_refreshAll()), - canRefresh: accounts.isNotEmpty, - canShowSidebar: canShowFallbackSidebar, - sidebarVisible: - canShowFallbackSidebar && !_sidebarCollapsed, - onToggleSidebar: () => _handleHeaderBarAction( - BusyMaxHeaderBarAction.sidebarToggle, - ), - onSearch: () => _handleHeaderBarAction( - BusyMaxHeaderBarAction.search, - ), - onMenuSelected: _handleFallbackToolbarMenu, ), - const Divider(height: 1), - ], - Expanded( - child: Listener( - behavior: HitTestBehavior.translucent, - onPointerDown: _recordSchedulePointer, - child: _ScheduleBody( - isLoading: scheduleLoading, - isUnavailable: scheduleUnavailable, - mode: displayMode, - range: displayRange, - selectedDate: searchActive - ? displayRange.start - : _selectedDate, - firstWeekday: _firstWeekday(context), - dayStartMinute: settings.scheduleDayStartMinute, - dayEndMinute: settings.scheduleDayEndMinute, - hasAnySources: - visibility.hasCalendarSources || - visibility.hasTaskLists, - hasAccounts: accounts.isNotEmpty, - items: items, - onOpenSettings: () => - unawaited(context.push('/settings')), - onRetry: _retrySchedule, - onRefresh: accounts.isEmpty - ? null - : () => unawaited(_refreshAll()), - onDaySelected: _setDate, - onYearDaySelected: _openDay, - onMonthSelected: _setMonth, - onWeekSelected: _setWeek, - onEmptySlot: (start) => unawaited( - _openCreateChoice( - accounts, - visibleSources, - start, - canCreateTask: canCreateTask, - ), - ), - onRangeCreated: writableSources.isNotEmpty - ? (interval) => unawaited( - _openNewEvent( - visibleSources, - interval.start, - end: interval.end, - ), - ) - : null, - onReschedule: _rescheduleEvent, - onCreateAtDay: (day, {anchorContext}) => - unawaited( - _openCreateChoice( - accounts, - visibleSources, - DateTime(day.year, day.month, day.day, 9), - canCreateTask: canCreateTask, - anchorContext: anchorContext, - ), + ), + onRangeCreated: writableSources.isNotEmpty + ? (interval) => unawaited( + _openNewEvent( + visibleSources, + interval.start, + end: interval.end, ), - onNewEvent: () => unawaited( - _openNewEvent(visibleSources, _selectedDate), - ), - onNewTask: () => - unawaited(_openNewTask(accounts)), - onPrevious: _previous, - onNext: _next, - onAgendaLoadMore: - !searchActive && - _mode == ScheduleViewMode.agenda - ? _loadMoreAgendaDays - : null, - hasMoreAgendaOverdueTasks: - !searchActive && - _mode == ScheduleViewMode.agenda && - (snapshot.data?.hasMoreOverdueTasks ?? false), - hasMoreAgendaNoDateTasks: - !searchActive && - _mode == ScheduleViewMode.agenda && - (snapshot.data?.hasMoreNoDateTasks ?? false), - onAgendaLoadMoreOverdue: - !searchActive && - _mode == ScheduleViewMode.agenda - ? _loadMoreAgendaOverdueTasks - : null, - onAgendaLoadMoreNoDate: - !searchActive && - _mode == ScheduleViewMode.agenda - ? _loadMoreAgendaNoDateTasks - : null, - onItemSelected: - (context, item, [globalPosition]) => - unawaited( - _openItem( - context, - item, - searchActive - ? sources - : visibleSources, - globalPosition: globalPosition, - ), - ), - onItemAnchorAvailable: _handleItemAnchorAvailable, - onTaskCompletionChanged: _setTaskCompleted, - onChecklistItemCompletionChanged: - _setChecklistItemCompleted, - canCreateEvent: writableSources.isNotEmpty, + ) + : null, + onReschedule: _rescheduleEvent, + onCreateAtDay: (day, {anchorContext}) => unawaited( + _openCreateChoice( + accounts, + visibleSources, + DateTime(day.year, day.month, day.day, 9), canCreateTask: canCreateTask, - searchActive: searchActive, - searchCriteria: _searchCriteria, - searchQuery: _searchQuery, + anchorContext: anchorContext, ), ), + onNewEvent: () => unawaited( + _openNewEvent(visibleSources, _selectedDate), + ), + onNewTask: () => unawaited(_openNewTask(accounts)), + onPrevious: _previous, + onNext: _next, + onAgendaLoadMore: + !searchActive && _mode == ScheduleViewMode.agenda + ? _loadMoreAgendaDays + : null, + hasMoreAgendaOverdueTasks: + !searchActive && + _mode == ScheduleViewMode.agenda && + (snapshot.data?.hasMoreOverdueTasks ?? false), + hasMoreAgendaNoDateTasks: + !searchActive && + _mode == ScheduleViewMode.agenda && + (snapshot.data?.hasMoreNoDateTasks ?? false), + onAgendaLoadMoreOverdue: + !searchActive && _mode == ScheduleViewMode.agenda + ? _loadMoreAgendaOverdueTasks + : null, + onAgendaLoadMoreNoDate: + !searchActive && _mode == ScheduleViewMode.agenda + ? _loadMoreAgendaNoDateTasks + : null, + onItemSelected: (context, item, [globalPosition]) => + unawaited( + _openItem( + context, + item, + searchActive ? sources : visibleSources, + globalPosition: globalPosition, + ), + ), + onItemAnchorAvailable: _handleItemAnchorAvailable, + onTaskCompletionChanged: _setTaskCompleted, + onChecklistItemCompletionChanged: + _setChecklistItemCompleted, + canCreateEvent: writableSources.isNotEmpty, + canCreateTask: canCreateTask, + searchActive: searchActive, + searchCriteria: _searchCriteria, + searchQuery: _searchQuery, + navigationIntent: _navigationIntent, + taskListMutationIntent: _taskListMutationIntent, + onTaskListMutationConsumed: (intent) { + if (!mounted) return; + if (_taskListMutationIntent?.generation == + intent.generation) { + setState(() => _taskListMutationIntent = null); + } + }, ), - ], + ), ); return Scaffold( backgroundColor: BusyMaxSurfaceColors.of(context).window, @@ -691,33 +646,33 @@ class _ScheduleWorkspaceState extends ConsumerState { final showSidebar = BusyMaxLayoutRules.showSidebar( constraints.maxWidth, ); - _updateHeaderBarState( - context, - range: range, - accounts: accounts, - canCreateEvent: writableSources.isNotEmpty, - canCreateTask: canCreateTask, - showSidebar: showSidebar, + _latestCanShowSidebar = showSidebar; + final sidebar = BusyMaxBinaryPresentation( + alternateActive: + searchActive && _searchCriteria != null, + child: buildSidebar(), + ); + final frame = LinuxPageFrame( + header: header, + body: main, + sidebarHeader: const BusyMaxLinuxBrandHeader(), + sidebarBody: sidebar, + sidebarAvailable: showSidebar, + sidebarExpanded: !_sidebarCollapsed, + sidebarTransitionGeneration: + _sidebarTransitionGeneration, ); - final body = !showSidebar || _sidebarCollapsed - ? main - : Row( - children: [ - buildSidebar(), - Expanded(child: main), - ], - ); return _ScheduleTaskDetailsOverlay( + key: _taskDetailsOverlayKey, target: _taskDetailsTarget, onClose: () => unawaited(_requestCloseTaskDetails()), + onWindowCloseRequested: _confirmDiscardTaskDetails, onDirtyChanged: (dirty) { _taskDetailsDirty = dirty; }, - onMutationCommitted: () { - if (mounted) setState(() {}); - }, - child: body, + onMutationCommitted: _handleEditorTaskMutation, + child: frame, ); }, ), @@ -751,129 +706,33 @@ class _ScheduleWorkspaceState extends ConsumerState { ); } - bool get _showFlutterHeaderFallback { - if (!Platform.isLinux) { - return true; - } - return _headerBarReady && !_nativeHeaderBarAvailable; - } - - Future _initializeHeaderBar() async { - await _headerBarSession.initialize(); - if (!mounted) { - return; - } - setState(() { - _headerBarReady = true; - _nativeHeaderBarAvailable = _headerBarSession.isAvailable; - }); - if (_headerBarSession.isAvailable) { - unawaited( - _headerBarSession.setOnboardingControls( - visible: false, - canGoBack: false, - canContinue: false, - backLabel: '', - continueLabel: '', - force: true, - ), - ); - } - } - - void _updateHeaderBarState( - BuildContext context, { - required ScheduleRange range, - required List accounts, - required bool canCreateEvent, - required bool canCreateTask, - required bool showSidebar, - }) { - _latestCanShowSidebar = showSidebar; - if (!_nativeHeaderBarAvailable) { - return; - } - final titleRange = localizedScheduleHeading( - Localizations.localeOf(context).toLanguageTag(), - _mode, - range, - _selectedDate, - agendaLabel: context.l10n.viewAgenda, - ); - final sidebarVisible = showSidebar && !_sidebarCollapsed; - final headerBarState = BusyMaxHeaderBarState( - title: titleRange, - viewMode: _mode, - canRefresh: accounts.isNotEmpty, - canCreateEvent: canCreateEvent, - canCreateTask: canCreateTask, - searchActive: _searchActive, - searchQuery: _searchQuery, - canShowSidebar: showSidebar || _searchActive, - sidebarVisible: sidebarVisible, - navigationVisible: !_searchActive && _mode != ScheduleViewMode.agenda, - scheduleControlsVisible: true, - backVisible: false, - ); - WidgetsBinding.instance.addPostFrameCallback((_) { - if (!mounted) { - return; - } - unawaited(_headerBarSession.updateState(headerBarState)); - }); - } - - void _handleFallbackToolbarMenu(ScheduleToolbarMenuAction action) { + void _handleToolbarMenu(ScheduleToolbarMenuAction action) { switch (action) { case ScheduleToolbarMenuAction.refresh: - _handleHeaderBarAction(BusyMaxHeaderBarAction.refresh); + _handleHeaderAction(BusyMaxHeaderAction.refresh); case ScheduleToolbarMenuAction.settings: - _handleHeaderBarAction(BusyMaxHeaderBarAction.settings); + _handleHeaderAction(BusyMaxHeaderAction.settings); case ScheduleToolbarMenuAction.keyboardShortcuts: - _handleHeaderBarAction(BusyMaxHeaderBarAction.keyboardShortcuts); + _handleHeaderAction(BusyMaxHeaderAction.keyboardShortcuts); case ScheduleToolbarMenuAction.reportIssue: - _handleHeaderBarAction(BusyMaxHeaderBarAction.reportIssue); + _handleHeaderAction(BusyMaxHeaderAction.reportIssue); case ScheduleToolbarMenuAction.about: - _handleHeaderBarAction(BusyMaxHeaderBarAction.aboutBusyMax); + _handleHeaderAction(BusyMaxHeaderAction.aboutBusyMax); } } - void _handleHeaderBarAction(BusyMaxHeaderBarAction action) { - if (!_headerBarSession.isCurrent) { - return; - } + void _handleHeaderAction(BusyMaxHeaderAction action) { if (!_canHandleRouteShortcut()) { if (_taskDetailsTarget != null || _anchoredPopoverController.isOpen) { unawaited(_dismissModalThenHandleHeaderAction(action)); } return; } - _dispatchHeaderBarAction(action); - } - - void _handleHeaderBarSearchEvent(BusyMaxHeaderBarSearchEvent event) { - if (!_headerBarSession.isCurrent) { - return; - } - switch (event) { - case BusyMaxHeaderBarSearchQueryChanged(:final query): - _setSearchQuery(query); - case BusyMaxHeaderBarSearchFocusChanged(:final focused): - // Leave text editing while retaining a target for workspace shortcuts - // when GTK returns keyboard focus to the Flutter view. - if (focused) _workspaceFocusNode.requestFocus(); - return; - case BusyMaxHeaderBarSearchCleared(): - _clearSearchQuery(); - case BusyMaxHeaderBarSearchEscapePressed(): - if (_searchActive) { - _closeSearch(); - } - } + _dispatchHeaderAction(action); } Future _dismissModalThenHandleHeaderAction( - BusyMaxHeaderBarAction action, + BusyMaxHeaderAction action, ) async { if (_handlingModalHeaderAction) { return; @@ -884,104 +743,93 @@ class _ScheduleWorkspaceState extends ConsumerState { if (!await _confirmDiscardTaskDetails()) { return; } - _closeTaskDetails(); - await WidgetsBinding.instance.endOfFrame; + await _closeTaskDetails(); } else if (_anchoredPopoverController.isOpen) { await _anchoredPopoverController.dismiss(); } else { return; } - if (!mounted || - !_headerBarSession.isCurrent || - !_canHandleRouteShortcut()) { + if (!mounted || !_canHandleRouteShortcut()) { return; } - _dispatchHeaderBarAction(action); + _dispatchHeaderAction(action); } finally { _handlingModalHeaderAction = false; } } - void _dispatchHeaderBarAction(BusyMaxHeaderBarAction action) { + void _dispatchHeaderAction(BusyMaxHeaderAction action) { switch (action) { - case BusyMaxHeaderBarAction.back: - case BusyMaxHeaderBarAction.continueSetup: + case BusyMaxHeaderAction.back: + case BusyMaxHeaderAction.continueSetup: return; - case BusyMaxHeaderBarAction.sidebarToggle: + case BusyMaxHeaderAction.sidebarToggle: if (!_latestCanShowSidebar) { if (_searchActive) unawaited(_showSearchFilters()); return; } - setState(() => _sidebarCollapsed = !_sidebarCollapsed); - case BusyMaxHeaderBarAction.today: + setState(() { + _sidebarCollapsed = !_sidebarCollapsed; + _sidebarTransitionGeneration += 1; + }); + case BusyMaxHeaderAction.today: _goToToday(); - case BusyMaxHeaderBarAction.previous: + case BusyMaxHeaderAction.previous: if (_mode == ScheduleViewMode.agenda) { return; } _previous(); - case BusyMaxHeaderBarAction.next: + case BusyMaxHeaderAction.next: if (_mode == ScheduleViewMode.agenda) { return; } _next(); - case BusyMaxHeaderBarAction.viewModeDay: + case BusyMaxHeaderAction.viewModeDay: _setMode(ScheduleViewMode.day); - case BusyMaxHeaderBarAction.viewModeWeek: + case BusyMaxHeaderAction.viewModeWeek: _setMode(ScheduleViewMode.week); - case BusyMaxHeaderBarAction.viewModeMonth: + case BusyMaxHeaderAction.viewModeMonth: _setMode(ScheduleViewMode.month); - case BusyMaxHeaderBarAction.viewModeYear: + case BusyMaxHeaderAction.viewModeYear: _setMode(ScheduleViewMode.year); - case BusyMaxHeaderBarAction.viewModeAgenda: + case BusyMaxHeaderAction.viewModeAgenda: _setMode(ScheduleViewMode.agenda); - case BusyMaxHeaderBarAction.search: + case BusyMaxHeaderAction.search: if (_searchActive) { _closeSearch(); } else { _openSearch(); _focusSearch(); } - case BusyMaxHeaderBarAction.createEvent: + case BusyMaxHeaderAction.createEvent: if (_latestWritableSources.isEmpty) { return; } unawaited( _openNewEvent(_latestWritableSources, _defaultSelectedDateStart()), ); - case BusyMaxHeaderBarAction.createTask: + case BusyMaxHeaderAction.createTask: if (!_latestCanCreateTask) { return; } unawaited( _openNewTask(_latestAccounts, due: _day(_defaultSelectedDateStart())), ); - case BusyMaxHeaderBarAction.refresh: + case BusyMaxHeaderAction.refresh: unawaited(_refreshAll()); - case BusyMaxHeaderBarAction.settings: + case BusyMaxHeaderAction.settings: unawaited(context.push('/settings')); - case BusyMaxHeaderBarAction.keyboardShortcuts: - unawaited( - showBusyMaxKeyboardShortcutsDialog( - context, - headerBarService: ref.read(linuxHeaderBarServiceProvider), - ), - ); - case BusyMaxHeaderBarAction.reportIssue: + case BusyMaxHeaderAction.keyboardShortcuts: + unawaited(showBusyMaxKeyboardShortcutsDialog(context)); + case BusyMaxHeaderAction.reportIssue: unawaited( showBusyMaxFeedbackDialog( context, submissionService: ref.read(feedbackSubmissionServiceProvider), - headerBarService: ref.read(linuxHeaderBarServiceProvider), - ), - ); - case BusyMaxHeaderBarAction.aboutBusyMax: - unawaited( - showBusyMaxAboutDialog( - context, - headerBarService: ref.read(linuxHeaderBarServiceProvider), ), ); + case BusyMaxHeaderAction.aboutBusyMax: + unawaited(showBusyMaxAboutDialog(context)); } } @@ -1057,41 +905,30 @@ class _ScheduleWorkspaceState extends ConsumerState { }); } - void _focusSearchFilters() { - if (_nativeHeaderBarAvailable) unawaited(_headerBarSession.focusContent()); - } - Widget _searchFilterPanel({VoidCallback? refresh, bool sidebar = true}) => - Listener( - onPointerDown: (_) => _focusSearchFilters(), - child: Focus( - onFocusChange: (focused) { - if (focused) _focusSearchFilters(); - }, - child: ScheduleSearchFilters( - value: _searchCriteria!, - accounts: _latestAccounts, - sources: _searchSources, - taskLists: _searchTaskLists, - sidebar: sidebar, - onChanged: (value) { - setState( - () => _searchCriteria = value.copyWith( - firstWeekday: _firstWeekday(context), - ), - ); - refresh?.call(); - }, - onClear: () { - setState( - () => _searchCriteria = _initialSearchCriteria?.copyWith( - firstWeekday: _firstWeekday(context), - ), - ); - refresh?.call(); - }, - ), - ), + ScheduleSearchFilters( + value: _searchCriteria!, + accounts: _latestAccounts, + sources: _searchSources, + taskLists: _searchTaskLists, + sidebar: sidebar, + showEndBorder: false, + onChanged: (value) { + setState( + () => _searchCriteria = value.copyWith( + firstWeekday: _firstWeekday(context), + ), + ); + refresh?.call(); + }, + onClear: () { + setState( + () => _searchCriteria = _initialSearchCriteria?.copyWith( + firstWeekday: _firstWeekday(context), + ), + ); + refresh?.call(); + }, ); Future _showSearchFilters() async { @@ -1102,7 +939,6 @@ class _ScheduleWorkspaceState extends ConsumerState { var dismissScheduled = false; await showBusyMaxModalDialog( context, - headerBarService: ref.read(linuxHeaderBarServiceProvider), builder: (dialogContext) => StatefulBuilder( builder: (context, update) { if (dismissWhenWide && @@ -1244,6 +1080,7 @@ class _ScheduleWorkspaceState extends ConsumerState { void _setDate(DateTime date) { setState(() { + _navigationIntent = null; if (_scope == ScheduleScope.today || _scope == ScheduleScope.upcoming) { _scope = ScheduleScope.all; } @@ -1355,6 +1192,12 @@ class _ScheduleWorkspaceState extends ConsumerState { void _goToToday() { if (_searchActive) return; setState(() { + _navigationIntent = ScheduleNavigationIntent( + cause: ScheduleNavigationCause.today, + target: _day(DateTime.now()), + direction: 0, + generation: ++_navigationGeneration, + ); _selectedDate = _day(DateTime.now()); if (_mode == ScheduleViewMode.agenda) { _resetAgendaLoadedDays(); @@ -1374,6 +1217,7 @@ class _ScheduleWorkspaceState extends ConsumerState { return; } setState(() { + _navigationIntent = null; _mode = mode; _lastSettingsMode = mode; if (mode == ScheduleViewMode.agenda) { @@ -1392,15 +1236,7 @@ class _ScheduleWorkspaceState extends ConsumerState { } void _focusSearch() { - if (_nativeHeaderBarAvailable) { - unawaited(_headerBarSession.focusSearch()); - return; - } - if (_showFlutterHeaderFallback) { - // The shared adapter translates this request to Yaru's private text - // entry without replacing Yaru's geometry or interaction states. - setState(() => _fallbackSearchFocusRequest += 1); - } + setState(() => _fallbackSearchFocusRequest += 1); } void _setSearchQuery(String value) { @@ -1441,7 +1277,7 @@ class _ScheduleWorkspaceState extends ConsumerState { return; } setState(() { - _selectedDate = switch (_mode) { + final target = switch (_mode) { ScheduleViewMode.day => DateUtils.addDaysToDate(_selectedDate, -1), ScheduleViewMode.week => DateUtils.addDaysToDate(_selectedDate, -7), ScheduleViewMode.month => DateUtils.addMonthsToMonthDate( @@ -1455,6 +1291,13 @@ class _ScheduleWorkspaceState extends ConsumerState { ), ScheduleViewMode.agenda => _selectedDate, }; + _selectedDate = target; + _navigationIntent = ScheduleNavigationIntent( + cause: ScheduleNavigationCause.adjacentPeriod, + target: target, + direction: -1, + generation: ++_navigationGeneration, + ); if (_scope == ScheduleScope.today || _scope == ScheduleScope.upcoming) { _scope = ScheduleScope.all; } @@ -1467,7 +1310,7 @@ class _ScheduleWorkspaceState extends ConsumerState { return; } setState(() { - _selectedDate = switch (_mode) { + final target = switch (_mode) { ScheduleViewMode.day => DateUtils.addDaysToDate(_selectedDate, 1), ScheduleViewMode.week => DateUtils.addDaysToDate(_selectedDate, 7), ScheduleViewMode.month => DateUtils.addMonthsToMonthDate( @@ -1481,6 +1324,13 @@ class _ScheduleWorkspaceState extends ConsumerState { ), ScheduleViewMode.agenda => _selectedDate, }; + _selectedDate = target; + _navigationIntent = ScheduleNavigationIntent( + cause: ScheduleNavigationCause.adjacentPeriod, + target: target, + direction: 1, + generation: ++_navigationGeneration, + ); if (_scope == ScheduleScope.today || _scope == ScheduleScope.upcoming) { _scope = ScheduleScope.all; } @@ -1518,7 +1368,7 @@ class _ScheduleWorkspaceState extends ConsumerState { } _focusSearch(); case _ScheduleShortcut.sidebar: - _handleHeaderBarAction(BusyMaxHeaderBarAction.sidebarToggle); + _handleHeaderAction(BusyMaxHeaderAction.sidebarToggle); case _ScheduleShortcut.dismissSearch: _closeSearch(); case _ScheduleShortcut.previous: @@ -1725,7 +1575,17 @@ class _ScheduleWorkspaceState extends ConsumerState { if (writableSources.isEmpty) { return; } - final source = writableSources.first; + final settings = ref.read(appSettingsControllerProvider); + final allWritableSources = writableCalendarSources(_searchSources); + final source = + preferredCreationDestination( + allWritableSources, + selected: settings.defaultCalendar, + lastUsed: settings.lastUsedCalendar, + destinationOf: (source) => + CreationDestination(accountId: source.accountId, id: source.id), + ) ?? + writableSources.first; await _openEventEditor( EventEditorDraft.newEvent( accountId: source.accountId, @@ -1737,7 +1597,7 @@ class _ScheduleWorkspaceState extends ConsumerState { startTimeZone: ref.read(localTimeZoneProvider), endTimeZone: ref.read(localTimeZoneProvider), ), - writableSources, + allWritableSources.isEmpty ? writableSources : allWritableSources, ); } @@ -1897,6 +1757,8 @@ class _ScheduleWorkspaceState extends ConsumerState { bool Function()? isRequestCurrent, }) async { if (_taskDetailsTarget == target) { + _taskDetailsLifecycleGeneration += 1; + _taskDetailsOverlayKey.currentState?.present(); return true; } final replacingOpenTarget = _taskDetailsTarget != null; @@ -1906,16 +1768,12 @@ class _ScheduleWorkspaceState extends ConsumerState { if (!mounted || !(isRequestCurrent?.call() ?? true)) { return false; } + _taskDetailsLifecycleGeneration += 1; setState(() { _taskDetailsTarget = target; _taskDetailsDirty = false; }); - if (replacingOpenTarget) { - return true; - } - unawaited( - acquireBusyMaxModalBarrier(ref.read(linuxHeaderBarServiceProvider)), - ); + _taskDetailsOverlayKey.currentState?.present(); return true; } @@ -2012,7 +1870,7 @@ class _ScheduleWorkspaceState extends ConsumerState { _taskDetailsTarget != visibleTarget) { return false; } - _closeTaskDetails(); + await _closeTaskDetails(); return true; } @@ -2035,7 +1893,7 @@ class _ScheduleWorkspaceState extends ConsumerState { _taskDetailsTarget != visibleTarget) { return; } - _closeTaskDetails(); + await _closeTaskDetails(); } Future _handleInitialTaskUnavailable( @@ -2058,7 +1916,7 @@ class _ScheduleWorkspaceState extends ConsumerState { _taskDetailsTarget != visibleTarget) { return; } - _closeTaskDetails(); + await _closeTaskDetails(); } Future _handleInitialTaskTarget( @@ -2105,7 +1963,7 @@ class _ScheduleWorkspaceState extends ConsumerState { if (!await _confirmDiscardTaskDetails()) { return; } - _closeTaskDetails(); + await _closeTaskDetails(); } Future _confirmDiscardTaskDetails() async { @@ -2119,22 +1977,34 @@ class _ScheduleWorkspaceState extends ConsumerState { confirmLabel: context.l10n.discardChangesAction, destructive: true, barrierColor: Colors.transparent, - headerBarService: ref.read(linuxHeaderBarServiceProvider), ); } - void _closeTaskDetails() { + Future _closeTaskDetails() { + final pending = _taskDetailsDismissal; + if (pending != null) return pending; + final operation = _performTaskDetailsDismissal(); + _taskDetailsDismissal = operation; + return operation.whenComplete(() { + if (identical(_taskDetailsDismissal, operation)) { + _taskDetailsDismissal = null; + } + }); + } + + Future _performTaskDetailsDismissal() async { final target = _taskDetailsTarget; if (target == null) { return; } + final generation = ++_taskDetailsLifecycleGeneration; + await _taskDetailsOverlayKey.currentState?.dismiss(); + if (!mounted || generation != _taskDetailsLifecycleGeneration) return; setState(() { _taskDetailsTarget = null; _taskDetailsDirty = false; }); - unawaited( - releaseBusyMaxModalBarrier(ref.read(linuxHeaderBarServiceProvider)), - ); + if (!mounted || generation != _taskDetailsLifecycleGeneration) return; if (_initialTaskRouteTarget == target.scheduleTarget) { _goToTaskListRoute(target); } @@ -2168,6 +2038,18 @@ class _ScheduleWorkspaceState extends ConsumerState { await ref .read(calendarRepositoryProvider) .updateLocalEvent(draft, guestUpdatePolicy: guestUpdatePolicy); + if (draft.eventId == null) { + unawaited( + ref + .read(appSettingsControllerProvider.notifier) + .rememberCalendar( + CreationDestination( + accountId: draft.accountId, + id: draft.sourceId, + ), + ), + ); + } final sourceAccountId = originalAccountId; if (sourceAccountId != null && sourceAccountId != draft.accountId) { unawaited(_syncMovedEvent(draft.accountId, sourceAccountId)); @@ -2195,7 +2077,6 @@ class _ScheduleWorkspaceState extends ConsumerState { context, provider: request.item.provider, action: CalendarGuestDeliveryAction.save, - headerBarService: ref.read(linuxHeaderBarServiceProvider), ), requestSync: (accountId) async => ref .read( @@ -2235,6 +2116,14 @@ class _ScheduleWorkspaceState extends ConsumerState { Future _syncCalendarMutation(String accountId) async { try { + final account = await ref + .read(accountsRepositoryProvider) + .accountById(accountId); + if (account?.isSyncEligible != true) { + throw AccountNotSyncEligibleException( + needsReconnect: account?.needsReconnect == true, + ); + } await ref .read(accountSyncOperationsProvider) .syncCalendar(accountId, full: false); @@ -2283,7 +2172,6 @@ class _ScheduleWorkspaceState extends ConsumerState { sources: editableSources, accounts: _latestAccounts, categorySuggestionsByAccount: _categorySuggestionsByAccount(), - headerBarService: ref.read(linuxHeaderBarServiceProvider), ); if (!mounted || result == null) { return; @@ -2346,7 +2234,6 @@ class _ScheduleWorkspaceState extends ConsumerState { context, provider: item.provider, action: CalendarGuestDeliveryAction.delete, - headerBarService: ref.read(linuxHeaderBarServiceProvider), ); if (choice == null) return; guestUpdatePolicy = choice; @@ -2367,7 +2254,6 @@ class _ScheduleWorkspaceState extends ConsumerState { ? context.l10n.nextcloudDeclineAndRemove : context.l10n.delete, destructive: true, - headerBarService: ref.read(linuxHeaderBarServiceProvider), ); if (!confirmed) return; } @@ -2380,11 +2266,25 @@ class _ScheduleWorkspaceState extends ConsumerState { return; } if (item is TaskScheduleItem) { - await ref - .read(tasksRepositoryForAccountProvider(item.accountId)) - .deleteTask(item.sourceId, item.id); - if (mounted) { - setState(() {}); + final intent = TaskListMutationIntent( + presentation: TaskListMutationPresentation.removal, + accountId: item.accountId, + taskListId: item.sourceId, + taskId: item.id, + generation: ++_taskListMutationGeneration, + ); + setState(() => _taskListMutationIntent = intent); + try { + await ref + .read(tasksRepositoryForAccountProvider(item.accountId)) + .deleteTask(item.sourceId, item.id); + if (mounted) setState(() {}); + } on Object { + if (mounted && + _taskListMutationIntent?.generation == intent.generation) { + setState(() => _taskListMutationIntent = null); + } + rethrow; } } } @@ -2450,7 +2350,6 @@ class _ScheduleWorkspaceState extends ConsumerState { supportsThisAndFollowingEventMutation(provider); return showBusyMaxModalEditorDialog( context, - headerBarService: ref.read(linuxHeaderBarServiceProvider), maxHeight: 420, builder: (dialogContext) => BusyMaxModalEditorScaffold( title: context.l10n.recurringEventScope, @@ -2536,6 +2435,16 @@ class _ScheduleWorkspaceState extends ConsumerState { } } } + if (initialList == null) { + final settings = ref.read(appSettingsControllerProvider); + initialList = preferredCreationDestination( + _searchTaskLists.where((list) => !list.pendingDelete), + selected: settings.defaultTaskList, + lastUsed: settings.lastUsedTaskList, + destinationOf: (list) => + CreationDestination(accountId: list.accountId, id: list.id), + ); + } final draft = await showBusyMaxNewTaskDialog( context, ref: ref, @@ -2544,16 +2453,33 @@ class _ScheduleWorkspaceState extends ConsumerState { initialList?.accountId ?? ref.read(activeAccountProvider), initialListId: initialList?.id, initialDueUtc: due, - headerBarService: ref.read(linuxHeaderBarServiceProvider), ); if (draft == null) { return; } - await ref + final taskId = await ref .read(tasksRepositoryForAccountProvider(draft.accountId)) .createTask(draft.taskListId, draft.input); + unawaited( + ref + .read(appSettingsControllerProvider.notifier) + .rememberTaskList( + CreationDestination( + accountId: draft.accountId, + id: draft.taskListId, + ), + ), + ); if (mounted) { - setState(() {}); + setState(() { + _taskListMutationIntent = TaskListMutationIntent( + presentation: TaskListMutationPresentation.insertion, + accountId: draft.accountId, + taskListId: draft.taskListId, + taskId: taskId, + generation: ++_taskListMutationGeneration, + ); + }); } } @@ -2581,19 +2507,65 @@ class _ScheduleWorkspaceState extends ConsumerState { 'status': completed ? 'completed' : 'needsAction', 'completed': completed ? DateTime.now().toUtc().toIso8601String() : null, }; - await ref - .read(tasksRepositoryForAccountProvider(item.accountId)) - .patchTask(item.sourceId, item.id, TaskPatchInput(fields)); - if (mounted) { - setState(() {}); + final intent = TaskListMutationIntent( + presentation: TaskListMutationPresentation.completion, + accountId: item.accountId, + taskListId: item.sourceId, + taskId: item.id, + completed: completed, + generation: ++_taskListMutationGeneration, + ); + setState(() => _taskListMutationIntent = intent); + try { + await ref + .read(tasksRepositoryForAccountProvider(item.accountId)) + .patchTask(item.sourceId, item.id, TaskPatchInput(fields)); + } on Object { + if (mounted && _taskListMutationIntent?.generation == intent.generation) { + setState(() => _taskListMutationIntent = null); + } + rethrow; } } + void _handleEditorTaskMutation(TaskMutationResult result) { + if (!mounted) return; + final createdId = result.createdTaskId; + setState(() { + _taskListMutationIntent = TaskListMutationIntent( + presentation: switch (result.kind) { + TaskMutationKind.deleted => TaskListMutationPresentation.removal, + TaskMutationKind.moved => TaskListMutationPresentation.removal, + TaskMutationKind.duplicated => TaskListMutationPresentation.insertion, + TaskMutationKind.completion => + TaskListMutationPresentation.completion, + _ => TaskListMutationPresentation.completion, + }, + accountId: result.accountId, + taskListId: result.taskListId, + taskId: createdId ?? result.taskId, + checklistItemId: result.checklistItemId, + completed: result.completed, + generation: ++_taskListMutationGeneration, + ); + }); + } + Future _setChecklistItemCompleted( TaskScheduleItem parent, TaskChecklistItemEntity item, bool completed, ) async { + final intent = TaskListMutationIntent( + presentation: TaskListMutationPresentation.completion, + accountId: parent.accountId, + taskListId: parent.sourceId, + taskId: parent.id, + checklistItemId: item.id, + completed: completed, + generation: ++_taskListMutationGeneration, + ); + setState(() => _taskListMutationIntent = intent); try { await ref .read(tasksRepositoryForAccountProvider(parent.accountId)) @@ -2603,9 +2575,11 @@ class _ScheduleWorkspaceState extends ConsumerState { checklistItemId: item.id, completed: completed, ); - if (mounted) setState(() {}); } on Object catch (error) { if (!mounted) return; + if (_taskListMutationIntent?.generation == intent.generation) { + setState(() => _taskListMutationIntent = null); + } ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text( @@ -2860,6 +2834,9 @@ class _ScheduleBody extends StatelessWidget { required this.canCreateEvent, required this.canCreateTask, required this.searchActive, + this.navigationIntent, + this.taskListMutationIntent, + this.onTaskListMutationConsumed, this.searchCriteria, this.searchQuery = '', }); @@ -2908,6 +2885,9 @@ class _ScheduleBody extends StatelessWidget { final bool canCreateEvent; final bool canCreateTask; final bool searchActive; + final ScheduleNavigationIntent? navigationIntent; + final TaskListMutationIntent? taskListMutationIntent; + final ValueChanged? onTaskListMutationConsumed; final ScheduleSearchCriteria? searchCriteria; final String searchQuery; @@ -2926,93 +2906,108 @@ class _ScheduleBody extends StatelessWidget { onRefresh: onRefresh, ); } - if (items.isEmpty && searchActive) { - if (searchCriteria?.hasSources == false) { - return Center(child: Text(context.l10n.searchNoSources)); - } - return const ScheduleSearchEmptyState(); - } - if (items.isEmpty && mode == ScheduleViewMode.agenda) { - return ScheduleEmptyState( - onNewEvent: canCreateEvent ? onNewEvent : null, - onNewTask: canCreateTask ? onNewTask : null, - ); - } - return switch (mode) { - ScheduleViewMode.day => ScheduleDayWeekView( - key: const ValueKey('schedule-day-planner'), - range: range, - selectedDate: selectedDate, - daysShowed: 1, - dayStartMinute: dayStartMinute, - dayEndMinute: dayEndMinute, - items: items, - onDaySelected: onDaySelected, - onEmptySlot: onEmptySlot, - onRangeCreated: onRangeCreated, - onReschedule: onReschedule, - onItemSelected: onItemSelected, - onTaskCompletionChanged: onTaskCompletionChanged, - ), - ScheduleViewMode.week => ScheduleDayWeekView( - key: const ValueKey('schedule-week-planner'), - range: range, - selectedDate: selectedDate, - daysShowed: 7, - dayStartMinute: dayStartMinute, - dayEndMinute: dayEndMinute, - items: items, - onDaySelected: onDaySelected, - onEmptySlot: onEmptySlot, - onRangeCreated: onRangeCreated, - onReschedule: onReschedule, - onItemSelected: onItemSelected, - onTaskCompletionChanged: onTaskCompletionChanged, - ), - ScheduleViewMode.month => _HorizontalSchedulePager( - onPrevious: onPrevious, - onNext: onNext, - child: ScheduleMonthView( + final modeView = BusyMaxKeyedCrossfade( + transitionKey: mode, + child: switch (mode) { + ScheduleViewMode.day => ScheduleDayWeekView( + key: const ValueKey('schedule-day-planner'), range: range, selectedDate: selectedDate, + daysShowed: 1, + dayStartMinute: dayStartMinute, + dayEndMinute: dayEndMinute, items: items, - firstWeekday: firstWeekday, onDaySelected: onDaySelected, - onCreateAtDay: onCreateAtDay, + onEmptySlot: onEmptySlot, + onRangeCreated: onRangeCreated, onReschedule: onReschedule, onItemSelected: onItemSelected, onTaskCompletionChanged: onTaskCompletionChanged, + navigationIntent: navigationIntent, ), - ), - ScheduleViewMode.year => _HorizontalSchedulePager( - onPrevious: onPrevious, - onNext: onNext, - child: ScheduleYearView( + ScheduleViewMode.week => ScheduleDayWeekView( + key: const ValueKey('schedule-week-planner'), + range: range, selectedDate: selectedDate, + daysShowed: 7, + dayStartMinute: dayStartMinute, + dayEndMinute: dayEndMinute, items: items, - firstWeekday: firstWeekday, - onDaySelected: onYearDaySelected, - onMonthSelected: onMonthSelected, - onWeekSelected: onWeekSelected, - onCreateAtDay: (day) => onCreateAtDay(day), + onDaySelected: onDaySelected, + onEmptySlot: onEmptySlot, + onRangeCreated: onRangeCreated, + onReschedule: onReschedule, + onItemSelected: onItemSelected, + onTaskCompletionChanged: onTaskCompletionChanged, + navigationIntent: navigationIntent, ), - ), - ScheduleViewMode.agenda => ScheduleAgendaView( - searchCriteria: searchCriteria, - searchQuery: searchQuery, - range: range, - items: items, - hasMoreOverdueTasks: hasMoreAgendaOverdueTasks, - hasMoreNoDateTasks: hasMoreAgendaNoDateTasks, - onLoadMore: onAgendaLoadMore, - onLoadMoreOverdue: onAgendaLoadMoreOverdue, - onLoadMoreNoDate: onAgendaLoadMoreNoDate, - onItemSelected: onItemSelected, - onItemAnchorAvailable: onItemAnchorAvailable, - onTaskCompletionChanged: onTaskCompletionChanged, - onChecklistItemCompletionChanged: onChecklistItemCompletionChanged, - ), - }; + ScheduleViewMode.month => _HorizontalSchedulePager( + onPrevious: onPrevious, + onNext: onNext, + child: ScheduleMonthView( + range: range, + selectedDate: selectedDate, + items: items, + firstWeekday: firstWeekday, + onDaySelected: onDaySelected, + onCreateAtDay: onCreateAtDay, + onReschedule: onReschedule, + onItemSelected: onItemSelected, + onTaskCompletionChanged: onTaskCompletionChanged, + ), + ), + ScheduleViewMode.year => _HorizontalSchedulePager( + onPrevious: onPrevious, + onNext: onNext, + child: ScheduleYearView( + selectedDate: selectedDate, + items: items, + firstWeekday: firstWeekday, + onDaySelected: onYearDaySelected, + onMonthSelected: onMonthSelected, + onWeekSelected: onWeekSelected, + onCreateAtDay: (day) => onCreateAtDay(day), + ), + ), + ScheduleViewMode.agenda => ScheduleAgendaView( + searchCriteria: searchCriteria, + searchQuery: searchQuery, + range: range, + items: items, + hasMoreOverdueTasks: hasMoreAgendaOverdueTasks, + hasMoreNoDateTasks: hasMoreAgendaNoDateTasks, + onLoadMore: onAgendaLoadMore, + onLoadMoreOverdue: onAgendaLoadMoreOverdue, + onLoadMoreNoDate: onAgendaLoadMoreNoDate, + onItemSelected: onItemSelected, + onItemAnchorAvailable: onItemAnchorAvailable, + onTaskCompletionChanged: onTaskCompletionChanged, + onChecklistItemCompletionChanged: onChecklistItemCompletionChanged, + taskListMutationIntent: taskListMutationIntent, + onTaskListMutationConsumed: onTaskListMutationConsumed, + emptyBuilder: searchActive + ? (context) => searchCriteria?.hasSources == false + ? Center(child: Text(context.l10n.searchNoSources)) + : const ScheduleSearchEmptyState() + : (context) => ScheduleEmptyState( + onNewEvent: canCreateEvent ? onNewEvent : null, + onNewTask: canCreateTask ? onNewTask : null, + ), + ), + }, + ); + final intent = navigationIntent; + if (mode == ScheduleViewMode.month || mode == ScheduleViewMode.year) { + final adjacent = intent?.cause == ScheduleNavigationCause.adjacentPeriod + ? intent + : null; + return BusyMaxDirectionalSwitcher( + generation: adjacent?.generation ?? 0, + direction: adjacent?.direction ?? 0, + child: modeView, + ); + } + return modeView; } } @@ -3049,9 +3044,11 @@ class _HorizontalSchedulePager extends StatelessWidget { class _ScheduleTaskDetailsOverlay extends StatefulWidget { const _ScheduleTaskDetailsOverlay({ + super.key, required this.child, required this.target, required this.onClose, + required this.onWindowCloseRequested, required this.onDirtyChanged, required this.onMutationCommitted, }); @@ -3059,8 +3056,9 @@ class _ScheduleTaskDetailsOverlay extends StatefulWidget { final Widget child; final _TaskDetailsTarget? target; final VoidCallback onClose; + final BusyMaxWindowCloseHandler onWindowCloseRequested; final ValueChanged onDirtyChanged; - final VoidCallback onMutationCommitted; + final ValueChanged onMutationCommitted; @override State<_ScheduleTaskDetailsOverlay> createState() => @@ -3068,20 +3066,38 @@ class _ScheduleTaskDetailsOverlay extends StatefulWidget { } class _ScheduleTaskDetailsOverlayState - extends State<_ScheduleTaskDetailsOverlay> { + extends State<_ScheduleTaskDetailsOverlay> + with SingleTickerProviderStateMixin { final _modalFocusNode = FocusNode(debugLabel: 'scheduleTaskDetails'); FocusNode? _previousFocus; + late final AnimationController _controller = AnimationController( + vsync: this, + animationBehavior: AnimationBehavior.preserve, + value: widget.target == null ? 0 : 1, + ); + _TaskDetailsTarget? _displayedTarget; + bool _exiting = false; + bool _disableAnimations = false; @override void initState() { super.initState(); if (widget.target != null) { + _displayedTarget = widget.target; _previousFocus = FocusManager.instance.primaryFocus; - WidgetsBinding.instance.addPostFrameCallback((_) { - if (mounted) { - _modalFocusNode.requestFocus(); - } - }); + _requestEditorFocus(); + } + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + final disabled = MediaQuery.disableAnimationsOf(context); + if (disabled != _disableAnimations) { + _disableAnimations = disabled; + if (disabled) { + _controller.value = _exiting || widget.target == null ? 0 : 1; + } } } @@ -3089,81 +3105,132 @@ class _ScheduleTaskDetailsOverlayState void didUpdateWidget(covariant _ScheduleTaskDetailsOverlay oldWidget) { super.didUpdateWidget(oldWidget); if (oldWidget.target == null && widget.target != null) { + _displayedTarget = widget.target; + _exiting = false; _previousFocus = FocusManager.instance.primaryFocus; - WidgetsBinding.instance.addPostFrameCallback((_) { - if (mounted) { - _modalFocusNode.requestFocus(); - } - }); + _requestEditorFocus(); + present(); + } else if (oldWidget.target != widget.target && widget.target != null) { + _displayedTarget = widget.target; + _exiting = false; + _requestEditorFocus(); + present(); } else if (oldWidget.target != null && widget.target == null) { + _displayedTarget = null; + _exiting = false; final previousFocus = _previousFocus; _previousFocus = null; WidgetsBinding.instance.addPostFrameCallback((_) { - if (mounted && (previousFocus?.context?.mounted ?? false)) { + if (mounted && + widget.target == null && + (previousFocus?.context?.mounted ?? false)) { previousFocus!.requestFocus(); } }); } } + void _requestEditorFocus() { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted && widget.target != null && !_exiting) { + _modalFocusNode.requestFocus(); + } + }); + } + + void present() { + _exiting = false; + if (_disableAnimations) { + _controller.value = 1; + return; + } + final distance = (1 - _controller.value).abs(); + if (distance == 0) return; + _controller.animateTo( + 1, + duration: BusyMaxMotion.taskEditorOpen * distance, + curve: BusyMaxMotion.presentationCurve, + ); + } + + Future dismiss() async { + if (_displayedTarget == null) return; + if (mounted) setState(() => _exiting = true); + if (_disableAnimations) { + _controller.value = 0; + return; + } + final distance = _controller.value.abs(); + if (distance == 0) return; + try { + await _controller + .animateBack( + 0, + duration: BusyMaxMotion.taskEditorClose * distance, + curve: BusyMaxMotion.presentationCurve, + ) + .orCancel; + } on TickerCanceled { + // A new editor request reversed the dismissal from its current value. + } + } + @override void dispose() { + _controller.dispose(); _modalFocusNode.dispose(); super.dispose(); } @override Widget build(BuildContext context) { - final target = widget.target; - if (target == null) { - return widget.child; - } - return Stack( - children: [ - ExcludeFocus(child: ExcludeSemantics(child: widget.child)), - ModalBarrier( - color: busyMaxModalBarrierColor(context), - dismissible: false, - ), - BlockSemantics( - child: Semantics( - scopesRoute: true, - namesRoute: true, - label: context.l10n.editTask, - explicitChildNodes: true, - child: BusyMaxModalShortcutBoundary( - child: Shortcuts( - shortcuts: const { - BusyMaxShortcutActivators.dismiss: DismissIntent(), - }, - child: Actions( - actions: { - DismissIntent: CallbackAction( - onInvoke: (_) { - widget.onClose(); - return null; - }, - ), + final target = _displayedTarget ?? widget.target; + final modalActive = target != null; + final editor = target == null + ? const SizedBox.shrink() + : BlockSemantics( + child: Semantics( + scopesRoute: true, + namesRoute: true, + label: context.l10n.editTask, + explicitChildNodes: true, + child: BusyMaxModalShortcutBoundary( + child: Shortcuts( + shortcuts: const { + BusyMaxShortcutActivators.dismiss: DismissIntent(), }, - child: FocusTraversalGroup( - policy: WidgetOrderTraversalPolicy(), - child: Focus( - autofocus: true, - focusNode: _modalFocusNode, - child: Center( - child: BusyMaxModalEditorSurface( - maxWidth: BusyMaxSizes.compactDetailsWidth, - maxHeight: 760, - child: TaskDetailsPane( - key: ValueKey(target), - accountId: target.accountId, - taskListId: target.taskListId, - taskId: target.taskId, - onClose: widget.onClose, - onDirtyChanged: widget.onDirtyChanged, - onTaskMutationCommitted: (_) => - widget.onMutationCommitted(), - dialogBarrierColor: Colors.transparent, + child: Actions( + actions: { + DismissIntent: CallbackAction( + onInvoke: (_) { + widget.onClose(); + return null; + }, + ), + }, + child: FocusTraversalGroup( + policy: WidgetOrderTraversalPolicy(), + child: Focus( + autofocus: true, + focusNode: _modalFocusNode, + child: Center( + child: BusyMaxModalEditorSurface( + maxWidth: BusyMaxSizes.compactDetailsWidth, + maxHeight: 760, + child: BusyMaxWindowCloseGuard( + onCloseRequested: widget.onWindowCloseRequested, + child: TaskDetailsPane( + key: ValueKey(target), + accountId: target.accountId, + taskListId: target.taskListId, + taskId: target.taskId, + onClose: widget.onClose, + onDirtyChanged: widget.onDirtyChanged, + onTaskMutationCommitted: + widget.onMutationCommitted, + dialogBarrierColor: Colors.transparent, + ), + ), ), ), ), @@ -3172,9 +3239,42 @@ class _ScheduleTaskDetailsOverlayState ), ), ), + ); + return AnimatedBuilder( + animation: _controller, + child: widget.child, + builder: (context, child) => Stack( + children: [ + ExcludeFocus( + excluding: modalActive, + child: ExcludeSemantics(excluding: modalActive, child: child!), ), - ), - ], + if (modalActive) ...[ + ModalBarrier( + color: busyMaxModalBarrierColor(context).withValues( + alpha: busyMaxModalBarrierColor(context).a * _controller.value, + ), + dismissible: false, + ), + IgnorePointer( + ignoring: _exiting, + child: ExcludeFocus( + excluding: _exiting, + child: ExcludeSemantics( + excluding: _exiting, + child: Opacity( + opacity: _controller.value, + child: Transform.scale( + scale: .985 + .015 * _controller.value, + child: editor, + ), + ), + ), + ), + ), + ], + ], + ), ); } } diff --git a/lib/src/features/settings/presentation/account_removal_dialog.dart b/lib/src/features/settings/presentation/account_removal_dialog.dart index b5fb2211..456d74ea 100644 --- a/lib/src/features/settings/presentation/account_removal_dialog.dart +++ b/lib/src/features/settings/presentation/account_removal_dialog.dart @@ -4,7 +4,6 @@ import 'package:yaru/yaru.dart'; import '../../../app/busymax_design.dart'; import '../../../app/busymax_dialogs.dart'; import '../../../l10n/l10n.dart'; -import '../../../platform/linux_header_bar_service.dart'; @immutable class AccountRemovalOptions { @@ -17,11 +16,9 @@ Future showBusyMaxAccountRemovalDialog( BuildContext context, { required String accountLabel, required bool canRevokeGoogleAuthorization, - LinuxHeaderBarService? headerBarService, }) { return showBusyMaxModalDialog( context, - headerBarService: headerBarService, barrierDismissible: false, builder: (dialogContext) => _AccountRemovalDialog( accountLabel: accountLabel, @@ -73,18 +70,33 @@ class _AccountRemovalDialogState extends State<_AccountRemovalDialog> { Text(l10n.removeAccountConfirmation), if (widget.canRevokeGoogleAuthorization) ...[ const SizedBox(height: BusyMaxSpacing.lg), - YaruCheckboxListTile( - key: const Key('revoke-google-authorization'), - value: _revokeGoogleAuthorization, - onChanged: (value) { - setState(() => _revokeGoogleAuthorization = value ?? false); - }, - title: Text(l10n.revokeGoogleAccess), - subtitle: Text(l10n.revokeGoogleAccessDescription), - shape: const RoundedRectangleBorder(), + BusyMaxYaruFocusBorder( + borderStrokeAlign: BorderSide.strokeAlignInside, + builder: (context, rowFocusNode) => YaruCheckboxListTile( + key: const Key('revoke-google-authorization'), + value: _revokeGoogleAuthorization, + onChanged: _onRevokeGoogleAuthorizationChanged, + focusNode: rowFocusNode, + control: BusyMaxYaruFocusBorder( + builder: (context, controlFocusNode) => YaruCheckbox( + value: _revokeGoogleAuthorization, + onChanged: _onRevokeGoogleAuthorizationChanged, + focusNode: controlFocusNode, + hasFocusBorder: false, + ), + ), + title: Text(l10n.revokeGoogleAccess), + subtitle: Text(l10n.revokeGoogleAccessDescription), + shape: const RoundedRectangleBorder(), + hasFocusBorder: false, + ), ), ], ], ); } + + void _onRevokeGoogleAuthorizationChanged(bool? value) { + setState(() => _revokeGoogleAuthorization = value ?? false); + } } diff --git a/lib/src/features/settings/presentation/settings_screen.dart b/lib/src/features/settings/presentation/settings_screen.dart index f4ceecd1..4b36f309 100644 --- a/lib/src/features/settings/presentation/settings_screen.dart +++ b/lib/src/features/settings/presentation/settings_screen.dart @@ -1,7 +1,6 @@ import 'package:busymax/src/l10n/time_format_scope.dart'; import 'package:busymax/src/l10n/week_preferences_scope.dart'; import 'dart:async'; -import 'dart:io'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; @@ -13,10 +12,13 @@ import '../../../app/busymax_about_dialog.dart'; import '../../../app/busymax_yaru_theme.dart'; import '../../../app/app_bootstrap.dart'; import '../../../app/busymax_design.dart'; +import '../../../app/common/busymax_motion_widgets.dart'; import '../../../app/busymax_dialogs.dart'; -import '../../../app/busymax_glyphs.dart'; import '../../../app/busymax_keyboard_shortcuts_dialog.dart'; import '../../../app/busymax_layout.dart'; +import '../../../app/busymax_shortcuts.dart'; +import '../../../app/linux/linux_page_frame.dart'; +import '../../../app/linux/linux_header_style.dart'; import '../../../core/logging/redacting_logger.dart'; import '../../../dav/auth/dav_account_dialogs.dart'; import '../../../dav/presentation/nextcloud_collection_dialog.dart'; @@ -27,9 +29,8 @@ import '../../../dav/storage/dav_settings_repository.dart'; 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/common/desktop_services.dart'; -import '../../../platform/linux_header_bar_provider.dart'; +import '../../../platform/gtk_header_icon_service.dart'; import '../../../webcal/webcal_subscription_service.dart'; import '../../../webcal/webcal_uri.dart'; import 'package:busymax/src/providers/busy_provider.dart'; @@ -42,7 +43,10 @@ import '../../connectivity/network_connectivity_service.dart'; import '../../diagnostics/presentation/diagnostics_screen.dart'; import '../../feedback/presentation/feedback_dialog.dart'; import '../../sync/sync_auth_error.dart'; +import '../../task_lists/data/task_lists_repository.dart'; import '../../tasks/presentation/desktop_date_time_fields.dart'; +import '../../tasks/domain/task_capabilities.dart'; +import '../../schedule/presentation/schedule_toolbar.dart'; import 'account_removal_dialog.dart'; import 'launch_at_login_refresh.dart'; @@ -60,10 +64,6 @@ class SettingsScreen extends ConsumerStatefulWidget { class _SettingsScreenState extends ConsumerState { late var _page = widget.initialPage; - late final LinuxHeaderBarSession _headerBarSession; - StreamSubscription? _headerBarActions; - var _headerBarReady = false; - var _nativeHeaderBarAvailable = false; BusyProvider? _connectingProvider; DavCancellationToken? _davCancellation; final _removingAccountIds = {}; @@ -76,19 +76,12 @@ class _SettingsScreenState extends ConsumerState { _autostartRefresh = LaunchAtLoginRefreshObserver( () => ref.invalidate(launchAtLoginStateProvider), ); - _headerBarSession = ref.read(linuxHeaderBarServiceProvider).claimSession(); - _headerBarActions = _headerBarSession.actions.listen( - _handleHeaderBarAction, - ); - unawaited(_initializeHeaderBar()); } @override void dispose() { _autostartRefresh.dispose(); _davCancellation?.cancel(); - _headerBarSession.dispose(); - unawaited(_headerBarActions?.cancel()); super.dispose(); } @@ -105,12 +98,19 @@ class _SettingsScreenState extends ConsumerState { final selectedAccount = ref.watch(selectedAccountProvider); final accounts = ref.watch(accountManagementStreamProvider).valueOrNull ?? const []; + final allAccounts = + ref.watch(accountsStreamProvider).valueOrNull ?? + const []; final davCollections = ref.watch(davCollectionsStreamProvider).valueOrNull ?? const []; final davConflicts = ref.watch(davConflictsStreamProvider).valueOrNull ?? const []; final calendarSources = ref.watch(calendarSourcesStreamProvider).valueOrNull ?? const []; + final taskLists = _page == SettingsPage.schedule + ? ref.watch(scheduleTaskListsProvider).valueOrNull ?? + const [] + : const []; final subscriptions = ref.watch(webCalSubscriptionsProvider).valueOrNull ?? const []; final config = ref.watch(buildConfigProvider); @@ -121,6 +121,30 @@ class _SettingsScreenState extends ConsumerState { final themeController = ref.read(busyMaxThemeControllerProvider); final l10n = context.l10n; final title = _settingsPageLabel(context, _page); + final hasSyncEligibleAccounts = allAccounts.any( + (account) => account.isSyncEligible, + ); + final writableCalendars = writableCalendarSources(calendarSources); + final writableTaskLists = [ + for (final list in taskLists) + if (!list.pendingDelete && + _canCreateTasksInList(ref, allAccounts, list)) + list, + ]; + final calendarOptions = { + for (final source in writableCalendars) + CreationDestination( + accountId: source.accountId, + id: source.id, + ): '${source.summary} · ${_settingsAccountLabel(allAccounts, source.accountId)}', + }; + final taskListOptions = { + for (final list in writableTaskLists) + CreationDestination( + accountId: list.accountId, + id: list.id, + ): '${list.title} · ${_settingsAccountLabel(allAccounts, list.accountId)}', + }; final pageBody = switch (_page) { SettingsPage.accounts => _AccountManagementSection( @@ -177,10 +201,39 @@ class _SettingsScreenState extends ConsumerState { onUnsubscribe: (subscription) => unawaited(_unsubscribe(subscription)), ), SettingsPage.schedule => _SettingsPageLayout( - title: l10n.scheduleSettings, children: [ + BusyMaxGroupedList( + title: l10n.newEventsAndTasks, + titleStyle: _settingsSectionHeaderStyle(context), + filled: true, + children: [ + BusyMaxComboRow( + title: l10n.defaultCalendar, + leading: const Icon(YaruIcons.calendar), + values: [null, ...calendarOptions.keys], + selected: calendarOptions.containsKey(settings.defaultCalendar) + ? settings.defaultCalendar + : null, + labelFor: (value) => + value == null ? l10n.lastUsed : calendarOptions[value]!, + onSelected: settingsController.setDefaultCalendar, + ), + BusyMaxComboRow( + title: l10n.defaultTaskList, + leading: const Icon(YaruIcons.checkmark), + values: [null, ...taskListOptions.keys], + selected: taskListOptions.containsKey(settings.defaultTaskList) + ? settings.defaultTaskList + : null, + labelFor: (value) => + value == null ? l10n.lastUsed : taskListOptions[value]!, + onSelected: settingsController.setDefaultTaskList, + ), + ], + ), BusyMaxGroupedList( title: l10n.scheduleDisplaySettings, + titleStyle: _settingsSectionHeaderStyle(context), description: l10n.scheduleDisplayHoursDescription, filled: true, children: [ @@ -228,7 +281,6 @@ class _SettingsScreenState extends ConsumerState { ], ), SettingsPage.system => _SettingsPageLayout( - title: l10n.settingsSystem, children: [ BusyMaxGroupedList( filled: true, @@ -316,7 +368,6 @@ class _SettingsScreenState extends ConsumerState { ], ), SettingsPage.notifications => _SettingsPageLayout( - title: l10n.notifications, children: [ BusyMaxGroupedList( filled: true, @@ -393,7 +444,6 @@ class _SettingsScreenState extends ConsumerState { ], ), SettingsPage.privacy => _SettingsPageLayout( - title: l10n.privacy, children: [ BusyMaxGroupedList( filled: true, @@ -409,98 +459,102 @@ class _SettingsScreenState extends ConsumerState { ], ), SettingsPage.diagnostics => _SettingsPageLayout( - title: l10n.diagnostics, children: [ BusyMaxGroupedList( key: const ValueKey('diagnostics-full-resync-section'), title: l10n.sync, + titleStyle: _settingsSectionHeaderStyle(context), description: l10n.forceFullResyncDescription, filled: true, children: [ BusyMaxActionRow( title: l10n.forceFullResync, leading: const Icon(YaruIcons.sync), - enabled: accounts.isNotEmpty, - onTap: accounts.isEmpty + enabled: hasSyncEligibleAccounts, + onTap: !hasSyncEligibleAccounts ? null - : () => _forceFullResync(context, ref, accounts), + : () => _forceFullResync(context, ref), ), ], ), const SizedBox(height: BusyMaxSpacing.lg), - const DiagnosticsPanel(scrollable: false), + DiagnosticsPanel( + scrollable: false, + sectionHeaderStyle: _settingsSectionHeaderStyle(context), + ), ], ), }; - return Scaffold( - backgroundColor: BusyMaxSurfaceColors.of(context).window, - body: LayoutBuilder( - builder: (context, constraints) { - final showSidebar = BusyMaxLayoutRules.showSettingsSidebar( - constraints.maxWidth, - ); - _updateSettingsHeaderBar( - context, - title, - settings: settings, - showSidebar: showSidebar, - ); - final content = Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - if (_showFallbackHeader) - _SettingsFallbackHeader(title: title, onBack: _goBack), - if (ref.watch(appSettingsPersistenceFailedProvider)) - MaterialBanner( - content: Text(l10n.settingsSaveFailed), - actions: [ - TextButton( - onPressed: () => - unawaited(settingsController.retrySave()), - child: Text(l10n.retry), + return CallbackShortcuts( + bindings: {BusyMaxShortcutActivators.back: _goBack}, + child: Focus( + autofocus: true, + child: Scaffold( + backgroundColor: BusyMaxSurfaceColors.of(context).window, + body: LayoutBuilder( + builder: (context, constraints) { + final showSidebar = BusyMaxLayoutRules.showSettingsSidebar( + constraints.maxWidth, + ); + final content = Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (ref.watch(appSettingsPersistenceFailedProvider)) + MaterialBanner( + content: Text(l10n.settingsSaveFailed), + actions: [ + BusyMaxPushButton.standard( + onPressed: () => + unawaited(settingsController.retrySave()), + child: Text(l10n.retry), + ), + ], + ), + if (!showSidebar) + Padding( + padding: const EdgeInsets.fromLTRB( + BusyMaxSpacing.lg, + BusyMaxSpacing.md, + BusyMaxSpacing.lg, + 0, + ), + child: _SettingsPageSelector( + selected: _page, + onSelected: _selectPage, + ), + ), + Expanded( + child: BusyMaxClamp( + maxWidth: 760, + margin: EdgeInsets.zero, + padding: const EdgeInsets.fromLTRB(16, 12, 16, 24), + child: BusyMaxKeyedCrossfade( + transitionKey: _page, + child: pageBody, + ), ), - ], - ), - if (!showSidebar) - Padding( - padding: const EdgeInsets.fromLTRB( - BusyMaxSpacing.lg, - BusyMaxSpacing.md, - BusyMaxSpacing.lg, - 0, - ), - child: _SettingsPageSelector( - selected: _page, - onSelected: _selectPage, ), + ], + ); + return LinuxPageFrame( + header: _SettingsHeader( + title: title, + onBack: _goBack, + onMenuSelected: _handleMenuAction, ), - Expanded( - child: BusyMaxClamp( - maxWidth: 760, - margin: EdgeInsets.zero, - padding: const EdgeInsets.fromLTRB(16, 12, 16, 24), - child: pageBody, - ), - ), - ], - ); - if (!showSidebar) { - return content; - } - return Row( - children: [ - SizedBox( - width: BusyMaxSizes.sidebarWidth, - child: _SettingsSidebar( + body: content, + sidebarHeader: const BusyMaxLinuxBrandHeader(), + sidebarBody: _SettingsSidebar( selected: _page, onSelected: _selectPage, ), - ), - Expanded(child: content), - ], - ); - }, + sidebarAvailable: showSidebar, + sidebarExpanded: true, + ); + }, + ), + ), ), ); } @@ -528,7 +582,6 @@ class _SettingsScreenState extends ConsumerState { 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 { @@ -548,7 +601,6 @@ class _SettingsScreenState extends ConsumerState { 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 { @@ -565,7 +617,6 @@ class _SettingsScreenState extends ConsumerState { message: context.l10n.unsubscribeCalendarConfirmation, confirmLabel: context.l10n.unsubscribe, destructive: true, - headerBarService: ref.read(linuxHeaderBarServiceProvider), ); if (!confirmed || !mounted) return; await _runSubscriptionOperation(subscription.id, () async { @@ -618,74 +669,23 @@ class _SettingsScreenState extends ConsumerState { } } - bool get _showFallbackHeader { - if (!Platform.isLinux) { - return true; - } - return _headerBarReady && !_nativeHeaderBarAvailable; - } - - Future _initializeHeaderBar() async { - await _headerBarSession.initialize(); - if (!mounted) { - return; - } - setState(() { - _headerBarReady = true; - _nativeHeaderBarAvailable = _headerBarSession.isAvailable; - }); - if (_headerBarSession.isAvailable) { - unawaited( - _headerBarSession.setOnboardingControls( - visible: false, - canGoBack: false, - canContinue: false, - backLabel: '', - continueLabel: '', - force: true, - ), - ); - } - } - - void _handleHeaderBarAction(BusyMaxHeaderBarAction action) { - if (!_headerBarSession.isCurrent) { - return; - } - if (action == BusyMaxHeaderBarAction.back) { - _goBack(); - return; - } - if (action == BusyMaxHeaderBarAction.settings) { - _selectPage(SettingsPage.system); - return; - } - if (action == BusyMaxHeaderBarAction.keyboardShortcuts) { - unawaited( - showBusyMaxKeyboardShortcutsDialog( - context, - headerBarService: ref.read(linuxHeaderBarServiceProvider), - ), - ); - return; - } - if (action == BusyMaxHeaderBarAction.reportIssue) { - unawaited( - showBusyMaxFeedbackDialog( - context, - submissionService: ref.read(feedbackSubmissionServiceProvider), - headerBarService: ref.read(linuxHeaderBarServiceProvider), - ), - ); - return; - } - if (action == BusyMaxHeaderBarAction.aboutBusyMax) { - unawaited( - showBusyMaxAboutDialog( - context, - headerBarService: ref.read(linuxHeaderBarServiceProvider), - ), - ); + void _handleMenuAction(ScheduleToolbarMenuAction action) { + switch (action) { + case ScheduleToolbarMenuAction.refresh: + return; + case ScheduleToolbarMenuAction.settings: + _selectPage(SettingsPage.system); + case ScheduleToolbarMenuAction.keyboardShortcuts: + unawaited(showBusyMaxKeyboardShortcutsDialog(context)); + case ScheduleToolbarMenuAction.reportIssue: + unawaited( + showBusyMaxFeedbackDialog( + context, + submissionService: ref.read(feedbackSubmissionServiceProvider), + ), + ); + case ScheduleToolbarMenuAction.about: + unawaited(showBusyMaxAboutDialog(context)); } } @@ -705,8 +705,11 @@ class _SettingsScreenState extends ConsumerState { setState(() => _page = page); } final router = GoRouter.maybeOf(context); - final uri = router?.state.uri; - if (router == null || uri == null || uri.path != '/settings') { + if (router == null) { + return; + } + final uri = GoRouterState.of(context).uri; + if (uri.path != '/settings') { return; } final routePage = uri.queryParameters['page']; @@ -727,40 +730,6 @@ class _SettingsScreenState extends ConsumerState { ); } - void _updateSettingsHeaderBar( - BuildContext context, - String title, { - required AppSettings settings, - required bool showSidebar, - }) { - if (!_nativeHeaderBarAvailable) { - return; - } - WidgetsBinding.instance.addPostFrameCallback((_) { - if (!mounted) { - return; - } - unawaited( - _headerBarSession.updateState( - BusyMaxHeaderBarState( - title: title, - viewMode: settings.scheduleViewMode, - canRefresh: false, - canCreateEvent: false, - canCreateTask: false, - searchActive: false, - searchQuery: '', - canShowSidebar: showSidebar, - sidebarVisible: showSidebar, - navigationVisible: false, - scheduleControlsVisible: false, - backVisible: true, - ), - ), - ); - }); - } - Future _connectAccount( BusyProvider provider, { AccountEntity? reconnecting, @@ -774,14 +743,12 @@ class _SettingsScreenState extends ConsumerState { appleInput = await showAppleICloudCredentialDialog( context, fixedEmail: reconnecting?.email ?? reconnecting?.providerAccountId, - headerBarService: ref.read(linuxHeaderBarServiceProvider), ); if (appleInput == null || !mounted) return; } else if (provider == BusyProvider.nextcloud) { nextcloudServer = await showNextcloudServerDialog( context, initialServer: reconnecting?.authority, - headerBarService: ref.read(linuxHeaderBarServiceProvider), ); if (nextcloudServer == null || !mounted) return; } @@ -890,7 +857,6 @@ class _SettingsScreenState extends ConsumerState { accountLabel: account.displayLabel, canRevokeGoogleAuthorization: account.provider == BusyProvider.google && account.isSignedIn, - headerBarService: ref.read(linuxHeaderBarServiceProvider), ); if (!context.mounted || options == null) { return; @@ -938,18 +904,21 @@ class _SettingsScreenState extends ConsumerState { } } - Future _forceFullResync( - BuildContext context, - WidgetRef ref, - List accounts, - ) async { - if (accounts.isEmpty) { - return; - } - + Future _forceFullResync(BuildContext context, WidgetRef ref) async { try { + final repository = ref.read(accountsRepositoryProvider); + final accounts = await repository.listSyncEligibleAccounts(); + if (accounts.isEmpty) { + return; + } final runSync = ref.read(signedInSyncRunnerProvider); for (final account in accounts) { + final stillEligible = (await repository.listSyncEligibleAccounts()).any( + (current) => current.id == account.id, + ); + if (!stillEligible) { + continue; + } await runSync(account.id, true); } if (context.mounted) { @@ -972,6 +941,18 @@ class _SettingsScreenState extends ConsumerState { Future _refreshCollections(AccountEntity account) async { try { + final current = await ref + .read(accountsRepositoryProvider) + .accountById(account.id); + if (current?.isSyncEligible != true) { + if (current?.needsReconnect == true && mounted) { + _showMessage( + context, + context.l10n.syncFailed(accountReconnectRequiredSyncMessage), + ); + } + return; + } await ref.read(signedInSyncRunnerProvider)(account.id, true); if (mounted) _showMessage(context, context.l10n.syncComplete); } on Object catch (error) { @@ -1028,9 +1009,7 @@ class _SettingsScreenState extends ConsumerState { await ref .read(davConflictResolutionServiceProvider) .resolve(conflict.id, resolution); - await ref - .read(accountSyncOperationsProvider) - .syncAccount(conflict.accountId, full: false); + await ref.read(signedInSyncRunnerProvider)(conflict.accountId, false); } on Object catch (error) { _settingsLogger.warning('DAV conflict resolution failed: $error'); if (mounted) { @@ -1055,6 +1034,7 @@ class _SettingsSidebar extends StatelessWidget { @override Widget build(BuildContext context) { return BusyMaxSidebarSurface( + showEndBorder: false, child: BusyMaxSidebarNavigation( children: [ for (final page in SettingsPage.values) @@ -1128,65 +1108,95 @@ class _SettingsPageSelector extends StatelessWidget { } } -class _SettingsFallbackHeader extends StatelessWidget { - const _SettingsFallbackHeader({required this.title, required this.onBack}); +class _SettingsHeader extends StatelessWidget { + const _SettingsHeader({ + required this.title, + required this.onBack, + required this.onMenuSelected, + }); final String title; final VoidCallback onBack; + final ValueChanged onMenuSelected; @override Widget build(BuildContext context) { - return SizedBox( - height: BusyMaxSizes.toolbarHeight, - child: Row( + return BusyMaxLinuxHeaderLayout( + leading: BusyMaxLinuxHeaderControlGroup( children: [ - const SizedBox(width: BusyMaxSpacing.sm), - YaruIconButton( + BusyMaxLinuxHeaderIconButton( + key: const ValueKey('settings-header-back-button'), tooltip: MaterialLocalizations.of(context).backButtonTooltip, - icon: Icon(BusyMaxGlyphs.backFor(Directionality.of(context))), + icon: BusyMaxLinuxHeaderIcon.back, onPressed: onBack, ), - const SizedBox(width: BusyMaxSpacing.sm), - Expanded( - child: Text( - title, - textAlign: TextAlign.center, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: busyMaxHeaderTitleStyle(context), - ), + ], + ), + title: BusyMaxLinuxHeaderTitle( + title, + key: const ValueKey('settings-header-title'), + ), + trailing: BusyMaxLinuxHeaderControlGroup( + children: [ + BusyMaxMainMenuButton( + settingsSelected: true, + onSelected: onMenuSelected, ), - const SizedBox(width: BusyMaxSizes.headerIconButton), - const SizedBox(width: BusyMaxSpacing.md), ], ), ); } } +TextStyle _settingsSectionHeaderStyle(BuildContext context) { + final theme = Theme.of(context); + return (theme.textTheme.titleSmall ?? const TextStyle()).copyWith( + color: theme.colorScheme.onSurface, + fontWeight: FontWeight.bold, + ); +} + +String _settingsAccountLabel(List accounts, String accountId) { + for (final account in accounts) { + if (account.id == accountId) return account.displayLabel; + } + return accountId; +} + +bool _canCreateTasksInList( + WidgetRef ref, + List accounts, + TaskListEntity list, +) { + for (final account in accounts) { + if (account.id != list.accountId || !account.isTaskCapable) continue; + if (account.provider == BusyProvider.nextcloud) { + return ref + .watch( + davTaskCollectionCapabilitiesProvider(( + accountId: list.accountId, + taskListId: list.id, + )), + ) + .valueOrNull + ?.canCreateTasks == + true; + } + return adapterDefaultTaskCapabilities(account.provider).canCreateTasks; + } + return false; +} + class _SettingsPageLayout extends StatelessWidget { - const _SettingsPageLayout({required this.title, required this.children}); + const _SettingsPageLayout({required this.children}); - final String title; final List children; @override Widget build(BuildContext context) { return Column( crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Semantics( - header: true, - child: Text( - key: const ValueKey('settings-page-heading'), - title, - style: Theme.of( - context, - ).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.w600), - ), - ), - ...children, - ], + children: children, ); } } @@ -1333,7 +1343,6 @@ class _AccountManagementSection extends StatelessWidget { final l10n = context.l10n; final connecting = connectingProvider != null; return _SettingsPageLayout( - title: l10n.accounts, children: [ BusyMaxGroupedList( filled: true, @@ -1386,8 +1395,9 @@ class _AccountManagementSection extends StatelessWidget { ? null : () => onReconnect(account), onRefreshCollections: - account.provider == BusyProvider.appleICloud || - account.provider == BusyProvider.nextcloud + account.isSyncEligible && + (account.provider == BusyProvider.appleICloud || + account.provider == BusyProvider.nextcloud) ? () => onRefreshCollections(account) : null, onRemoveAccount: () => onRemoveAccount(account), @@ -1487,7 +1497,7 @@ class _AccountSettingsGroup extends StatelessWidget { child: Text( key: ValueKey('settings-account-heading-${account.id}'), _accountProviderLabel(context, account.provider), - style: busyMaxSectionHeaderStyle(context), + style: _settingsSectionHeaderStyle(context), ), ), const SizedBox(height: BusyMaxSpacing.xs), @@ -1515,8 +1525,8 @@ class _AccountSettingsGroup extends StatelessWidget { TextStyle? _settingsAccountSubsectionStyle(BuildContext context) { final theme = Theme.of(context); return theme.textTheme.bodySmall?.copyWith( - color: theme.colorScheme.onSurfaceVariant, - fontWeight: FontWeight.w600, + color: theme.colorScheme.onSurface, + fontWeight: FontWeight.bold, ); } @@ -1674,24 +1684,32 @@ class _CalendarSettingsRow extends StatelessWidget { ), _CalendarSettingsSwitchCell( message: l10n.showInSchedule, - child: YaruSwitch( - key: ValueKey('settings-calendar-schedule-${source.id}'), - value: source.selected && !source.hidden, - onChanged: source.hidden - ? null - : (selected) => onSelected(source, selected), + child: BusyMaxYaruFocusBorder( + builder: (context, focusNode) => YaruSwitch( + key: ValueKey('settings-calendar-schedule-${source.id}'), + value: source.selected && !source.hidden, + focusNode: focusNode, + hasFocusBorder: false, + onChanged: source.hidden + ? null + : (selected) => onSelected(source, selected), + ), ), ), if (showsProviderVisibility) _CalendarSettingsSwitchCell( message: l10n.visibility, - child: YaruSwitch( - key: ValueKey('settings-calendar-provider-${source.id}'), - value: !source.hidden, - onChanged: canChangeProviderVisibility - ? (visible) => - onProviderVisibilityChanged(source, visible) - : null, + child: BusyMaxYaruFocusBorder( + builder: (context, focusNode) => YaruSwitch( + key: ValueKey('settings-calendar-provider-${source.id}'), + value: !source.hidden, + focusNode: focusNode, + hasFocusBorder: false, + onChanged: canChangeProviderVisibility + ? (visible) => + onProviderVisibilityChanged(source, visible) + : null, + ), ), ), ], @@ -1730,6 +1748,7 @@ class _CalendarImportCard extends StatelessWidget { Widget build(BuildContext context) { return BusyMaxGroupedList( title: context.l10n.calendarImport, + titleStyle: _settingsSectionHeaderStyle(context), description: context.l10n.calendarImportDescription, filled: true, children: [ @@ -1771,6 +1790,7 @@ class _CalendarSubscriptionsCard extends StatelessWidget { children: [ BusyMaxGroupedList( title: l10n.calendarSubscriptions, + titleStyle: _settingsSectionHeaderStyle(context), description: l10n.calendarSubscriptionsDescription, filled: true, children: [ @@ -1958,40 +1978,52 @@ class _WebCalAddDialogState extends State<_WebCalAddDialog> { ), ], 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), + BusyMaxGroupedList( + filled: true, + children: [ + YaruListTile.square( + title: TextField( + key: const ValueKey('subscription-url'), + controller: _urlController, + autofocus: true, + decoration: busyMaxGroupedTextFieldDecoration( + context, + labelText: l10n.subscriptionUrl, + errorText: + _urlController.text.trim().isNotEmpty && + normalized == null + ? l10n.subscriptionUrlInvalid + : null, + ).copyWith(helperText: l10n.subscriptionUrlHelp), + ), + ), + YaruListTile.square( + title: TextField( + controller: _nameController, + decoration: busyMaxGroupedTextFieldDecoration( + context, + labelText: l10n.subscriptionName, + ), + ), + ), + YaruListTile.square( + title: TextField( + controller: _colorController, + decoration: busyMaxGroupedTextFieldDecoration( + context, + labelText: l10n.subscriptionColor, + errorText: _colorValid ? null : l10n.subscriptionColorInvalid, + ).copyWith(helperText: l10n.subscriptionColorHelp), + ), + ), + 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( @@ -2020,7 +2052,6 @@ Future showAddCalendarSubscriptionFlow( }) async { final input = await showBusyMaxModalDialog<_WebCalAddInput>( context, - headerBarService: ref.read(linuxHeaderBarServiceProvider), barrierDismissible: false, builder: (dialogContext) => _WebCalAddDialog(initialUrl: initialUrl), ); @@ -2125,7 +2156,7 @@ class _AccountManagementCard extends StatelessWidget { YaruIcons.trash, color: Theme.of(context).colorScheme.error, ), - destructive: true, + enabled: !removing, onTap: removing ? null : onRemoveAccount, ), ], diff --git a/lib/src/features/sync/all_accounts_sync_scheduler.dart b/lib/src/features/sync/all_accounts_sync_scheduler.dart index 5082043f..6bce7b7d 100644 --- a/lib/src/features/sync/all_accounts_sync_scheduler.dart +++ b/lib/src/features/sync/all_accounts_sync_scheduler.dart @@ -92,12 +92,17 @@ Future runAllSyncEligibleAccountSync({ required Future Function(String accountId) syncAccount, required Future Function(Object error) onSyncFailure, Future Function(String accountId, Object error)? onAccountSyncFailure, + bool rethrowFirstFailure = false, }) async { final accounts = await listSyncEligibleAccounts(); + Object? firstFailure; + StackTrace? firstFailureStackTrace; for (final account in accounts) { try { await syncAccount(account.id); - } on Object catch (error) { + } on Object catch (error, stackTrace) { + firstFailure ??= error; + firstFailureStackTrace ??= stackTrace; try { await onAccountSyncFailure?.call(account.id, error); } on Object { @@ -107,4 +112,7 @@ Future runAllSyncEligibleAccountSync({ await onSyncFailure(error); } } + if (rethrowFirstFailure && firstFailure != null) { + Error.throwWithStackTrace(firstFailure, firstFailureStackTrace!); + } } diff --git a/lib/src/features/sync/calendar_pending_ops_replayer.dart b/lib/src/features/sync/calendar_pending_ops_replayer.dart index 6ff3dd3d..48f518f4 100644 --- a/lib/src/features/sync/calendar_pending_ops_replayer.dart +++ b/lib/src/features/sync/calendar_pending_ops_replayer.dart @@ -11,6 +11,7 @@ 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 '../../core/http/request_dispatch_exception.dart'; import '../../db/app_database.dart'; import '../../google_calendar/google_calendar_errors.dart'; import '../../google_calendar/google_calendar_mapper.dart'; @@ -77,13 +78,26 @@ class CalendarPendingOpsReplayer { var applied = 0; for (final originalOp in ops) { - final op = await _readOp(originalOp.id); + var op = await _readOp(originalOp.id); if (op == null || !_isCalendarOp(op)) { continue; } if (op.dependsOnOpId != null && await _opExists(op.dependsOnOpId!)) { continue; } + if (_isCalendarCreation(op)) { + if (op.state == 'in_progress') { + await _blockUnknownCalendarCreation( + op, + 'The app stopped before the creation response was recorded.', + ); + continue; + } + if (op.state == 'recovery_required') continue; + if (!await _claimCalendarCreation(op)) continue; + op = await _readOp(op.id); + if (op == null) continue; + } if (_copyConfirmationMissing(op)) { await _blockOp( op, @@ -97,11 +111,28 @@ class CalendarPendingOpsReplayer { await _replay(op); await _acknowledge(op); applied += 1; + } on RequestNotDispatchedException catch (error) { + await _scheduleRetry( + op, + error.code, + 'The calendar creation request was not sent and can be retried.', + ); } on GoogleCalendarApiError catch (error) { if (_isSuccessfulMissingDelete(op, error.statusCode)) { await _applyDeleteSideEffect(op); await _database.pendingOpsDao.deleteOp(op.id); applied += 1; + } else if (error.statusCode == 412 && + (_operationType(op) == 'event.patch' || + _operationType(op) == 'event.delete')) { + const message = + 'The remote event changed before the pending write was applied.'; + if (await _blockOp(op, 'conflict', message)) { + await _onConflictBlocked?.call(message); + } + } else if (_isCalendarCreation(op) && + (error.statusCode == 408 || error.statusCode >= 500)) { + await _blockUnknownCalendarCreation(op, error.message); } else if (_isRetryableStatus(error.statusCode)) { await _scheduleRetry(op, error.code, error.message); } else { @@ -112,6 +143,9 @@ class CalendarPendingOpsReplayer { await _applyDeleteSideEffect(op); await _database.pendingOpsDao.deleteOp(op.id); applied += 1; + } else if (_isCalendarCreation(op) && + (error.statusCode == 408 || error.statusCode >= 500)) { + await _blockUnknownCalendarCreation(op, error.message); } else if (_isRetryableStatus(error.statusCode)) { await _scheduleRetry(op, error.code, error.message); } else { @@ -120,11 +154,15 @@ class CalendarPendingOpsReplayer { } on _PendingOpBlocked { continue; } on Object catch (error) { - await _scheduleRetry( - op, - error.runtimeType.toString(), - error.toString(), - ); + if (_isCalendarCreation(op)) { + await _blockUnknownCalendarCreation(op, error.toString()); + } else { + await _scheduleRetry( + op, + error.runtimeType.toString(), + error.toString(), + ); + } } } @@ -234,10 +272,23 @@ class CalendarPendingOpsReplayer { await _blockOp(op, 'unsupported_calendar_operation', _operationType(op)); throw const _PendingOpBlocked(); } - final source = await _client.createCalendar( - _calendarMutation(_request(op)), + final request = _request(op); + final source = await _client.createCalendar(_calendarMutation(request)); + final followUp = _client.provider == BusyProvider.google + ? { + if (request['backgroundColor'] != null) + 'backgroundColor': request['backgroundColor'], + if (request['foregroundColor'] != null) + 'foregroundColor': request['foregroundColor'], + if (request['colorId'] != null) 'colorId': request['colorId'], + calendarMutationScopeKey: calendarMutationScopePersonal, + } + : const {}; + await _replaceLocalCalendar( + op, + source, + followUpRequest: followUp.length > 1 ? followUp : null, ); - await _replaceLocalCalendar(op, source); } Future _deleteCalendar(PendingOp op) async { @@ -420,22 +471,29 @@ class CalendarPendingOpsReplayer { final local = await _localEvent(op); final request = _request(op); final recurringScope = request[calendarEventRecurringScopeKey]?.toString(); + final providerEventId = await _providerEventId(op, local); + final checkedVersion = await _ensureNoEventConflict(op, local, request); if (recurringScope == 'thisAndFuture') { - await _patchGoogleFollowingEvents(op, local: local, request: request); + await _patchGoogleFollowingEvents( + op, + local: local, + request: request, + checkedVersion: checkedVersion, + ); 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, - ); + final master = + checkedVersion ?? + await _client.getEvent( + calendarId: providerCalendarId, + eventId: providerEventId, + ); mutationRequest = _seriesRequestForMaster( request, master, @@ -460,8 +518,12 @@ class CalendarPendingOpsReplayer { fallbackTimeZone: await _fallbackTimeZone(op, local: local), ), guestUpdatePolicy: _guestUpdatePolicy(request), + ifMatch: _client.provider == BusyProvider.google + ? checkedVersion?.etagOrChangeKey + : null, ); if (recurringScope == 'entireSeries') { + await _database.transaction(() => _rebaseDependentEventEdits(op, event)); await _repository.upsertEvent(accountId: _accountId, event: event); await _markRecurringRowsSynced(op, local); return; @@ -479,14 +541,8 @@ class CalendarPendingOpsReplayer { PendingOp completedOp, CalendarEventDto serverEvent, ) async { - final dependents = - await (_database.select(_database.pendingOps)..where( - (row) => - row.accountId.equals(_accountId) & - row.dependsOnOpId.equals(completedOp.id), - )) - .get(); - if (dependents.isEmpty) { + final descendants = await _eventMutationDescendants(completedOp); + if (descendants.isEmpty) { return false; } @@ -494,14 +550,27 @@ class CalendarPendingOpsReplayer { _request(completedOp), _client.provider, ); + if (_client.provider == BusyProvider.microsoft && + acknowledgedFields.contains(_microsoftLocationStateField)) { + acknowledgedFields.add('location'); + } final serverSnapshot = _semanticSnapshot( _client.provider, serverEvent.rawJson, ); - for (final dependent in dependents) { + for (final descendant in descendants) { + final descendantResourceId = await _pendingEventResourceId(descendant); + if (descendant.providerCalendarId != serverEvent.providerCalendarId || + descendantResourceId != serverEvent.providerEventId) { + // This is only an execution dependency. An occurrence response must + // never become the acknowledged baseline for its recurring master (or + // vice versa). + continue; + } + final wholeEventBoundary = _operationType(descendant) == 'event.delete'; final baseline = _eventBaselineSnapshot( _client.provider, - dependent.baselineRawJson ?? '{}', + descendant.baselineRawJson ?? '{}', ); // Keep the original timestamp and untouched fields so a provider edit to // a different field is still detected by the dependent operation. @@ -510,11 +579,14 @@ class CalendarPendingOpsReplayer { } await (_database.update( _database.pendingOps, - )..where((row) => row.id.equals(dependent.id))).write( + )..where((row) => row.id.equals(descendant.id))).write( PendingOpsCompanion( baselineRawJson: Value( jsonEncode({calendarEventSemanticBaselineKey: baseline}), ), + baselineUpdatedUtc: wholeEventBoundary + ? Value(serverEvent.updatedAtServer) + : const Value.absent(), updatedAtUtc: Value(_nowUtc().toIso8601String()), ), ); @@ -522,22 +594,73 @@ class CalendarPendingOpsReplayer { return true; } + Future> _eventMutationDescendants(PendingOp ancestor) async { + final operations = + await (_database.select(_database.pendingOps)..where( + (row) => + row.accountId.equals(_accountId) & + row.entityType.equals('event'), + )) + .get(); + final descendantIds = {ancestor.id}; + var expanded = true; + while (expanded) { + expanded = false; + for (final operation in operations) { + if (descendantIds.contains(operation.id) || + !descendantIds.contains(operation.dependsOnOpId)) { + continue; + } + descendantIds.add(operation.id); + expanded = true; + } + } + return operations + .where( + (operation) => + operation.id != ancestor.id && + descendantIds.contains(operation.id), + ) + .toList(growable: false); + } + + Future _pendingEventResourceId(PendingOp operation) async { + final target = _request( + operation, + )[calendarEventTargetProviderIdKey]?.toString().trim(); + if (target != null && target.isNotEmpty) return target; + final eventId = operation.eventId; + if (eventId == null) return operation.localTempId; + final local = await (_database.select( + _database.calendarEvents, + )..where((row) => row.id.equals(eventId))).getSingleOrNull(); + return local?.providerEventId ?? operation.localTempId; + } + Future _deleteEvent(PendingOp op) async { final providerCalendarId = _require(op.providerCalendarId, 'calendarId'); final local = await _localEvent(op); final request = _request(op); + final providerEventId = await _providerEventId(op, local); + final checkedVersion = await _ensureEventUnchanged(op, local, 'delete'); if (request[calendarEventRecurringScopeKey]?.toString() == 'thisAndFuture') { - await _deleteGoogleFollowingEvents(op, local: local, request: request); + await _deleteGoogleFollowingEvents( + op, + local: local, + request: request, + checkedVersion: checkedVersion, + ); 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), + ifMatch: _client.provider == BusyProvider.google + ? checkedVersion?.etagOrChangeKey + : null, ); await _applyDeleteSideEffect(op); } @@ -613,6 +736,7 @@ class CalendarPendingOpsReplayer { PendingOp op, { required CalendarEvent local, required Map request, + required CalendarEventDto? checkedVersion, }) async { if (_client.provider != BusyProvider.google) { await _blockOp( @@ -627,10 +751,9 @@ class CalendarPendingOpsReplayer { request[calendarEventTargetProviderIdKey]?.toString(), 'recurringEventId', ); - final currentMaster = await _client.getEvent( - calendarId: calendarId, - eventId: masterId, - ); + final currentMaster = + checkedVersion ?? + await _client.getEvent(calendarId: calendarId, eventId: masterId); final snapshotValue = request[_googleSplitMasterRawKey]; late final CalendarEventDto originalMaster; if (snapshotValue is Map) { @@ -676,6 +799,7 @@ class CalendarPendingOpsReplayer { fallbackTimeZone: await _fallbackTimeZone(op, local: local), ), guestUpdatePolicy: _guestUpdatePolicy(request), + ifMatch: checkedVersion?.etagOrChangeKey, ); if (request.containsKey('location')) { await LocationResolutionRepository( @@ -745,6 +869,7 @@ class CalendarPendingOpsReplayer { eventId: masterId, mutation: CalendarEventMutation(recurrence: trimmedRecurrence), guestUpdatePolicy: _guestUpdatePolicy(request), + ifMatch: checkedVersion?.etagOrChangeKey, ); final splitRequest = { @@ -816,6 +941,7 @@ class CalendarPendingOpsReplayer { PendingOp op, { required CalendarEvent local, required Map request, + required CalendarEventDto? checkedVersion, }) async { if (_client.provider != BusyProvider.google) { await _blockOp( @@ -830,10 +956,9 @@ class CalendarPendingOpsReplayer { request[calendarEventTargetProviderIdKey]?.toString(), 'recurringEventId', ); - final master = await _client.getEvent( - calendarId: calendarId, - eventId: masterId, - ); + final master = + checkedVersion ?? + await _client.getEvent(calendarId: calendarId, eventId: masterId); final masterStart = _dtoStart(master); if (masterStart == null) { throw StateError('The recurring master start is unavailable.'); @@ -847,6 +972,7 @@ class CalendarPendingOpsReplayer { calendarId: calendarId, eventId: masterId, guestUpdatePolicy: _guestUpdatePolicy(request), + ifMatch: checkedVersion?.etagOrChangeKey, ); await LocationResolutionRepository(_database).removeGoogleSeriesSource( accountId: local.accountId, @@ -888,6 +1014,7 @@ class CalendarPendingOpsReplayer { ), ), guestUpdatePolicy: _guestUpdatePolicy(request), + ifMatch: checkedVersion?.etagOrChangeKey, ); await _markRecurringRowsSynced(op, local); } @@ -994,11 +1121,31 @@ class CalendarPendingOpsReplayer { ); await _database.transaction(() async { - await _repository.upsertEvent(accountId: _accountId, event: serverEvent); + final descendants = await _sameEventMutationDescendants( + creation: op, + temporaryEventId: tempEventId, + temporaryProviderEventId: tempProviderEventId, + serverEvent: serverEvent, + ); + for (final descendant in descendants) { + await (_database.update( + _database.pendingOps, + )..where((row) => row.id.equals(descendant.id))).write( + PendingOpsCompanion( + baselineUpdatedUtc: Value(serverEvent.updatedAtServer), + baselineRawJson: Value(jsonEncode(serverEvent.rawJson)), + updatedAtUtc: Value(_nowUtc().toIso8601String()), + ), + ); + } if (tempEventId != null) { final old = await (_database.select( _database.calendarEvents, )..where((r) => r.id.equals(tempEventId))).getSingleOrNull(); + await _repository.upsertEvent( + accountId: _accountId, + event: serverEvent, + ); final replacement = await (_database.select( _database.calendarEvents, )..where((r) => r.id.equals(serverEventId))).getSingle(); @@ -1034,7 +1181,17 @@ class CalendarPendingOpsReplayer { old.providerRecurringEventId == null) { await resolutions.reconcileGoogleSeriesMaster(replacement); } + if (descendants.isNotEmpty) { + await (_database.update(_database.calendarEvents) + ..where((row) => row.id.equals(serverEventId))) + .write(_pendingLocalEventProjection(old)); + } } + } else { + await _repository.upsertEvent( + accountId: _accountId, + event: serverEvent, + ); } await _removeMovedSeriesSourceRows(op); await _confirmDependentCopyDeletes(op, serverEventId); @@ -1059,10 +1216,69 @@ class CalendarPendingOpsReplayer { }); } + Future> _sameEventMutationDescendants({ + required PendingOp creation, + required String? temporaryEventId, + required String? temporaryProviderEventId, + required CalendarEventDto serverEvent, + }) async { + if (temporaryEventId == null) return const []; + return (await _eventMutationDescendants(creation)) + .where((operation) { + if (operation.eventId != temporaryEventId) { + return false; + } + final target = _request( + operation, + )[calendarEventTargetProviderIdKey]?.toString().trim(); + return target == null || + target.isEmpty || + target == temporaryProviderEventId || + target == serverEvent.providerEventId; + }) + .toList(growable: false); + } + + CalendarEventsCompanion _pendingLocalEventProjection(CalendarEvent local) { + return CalendarEventsCompanion( + title: Value(local.title), + description: Value(local.description), + location: Value(local.location), + locationLatitude: Value(local.locationLatitude), + locationLongitude: Value(local.locationLongitude), + allDay: Value(local.allDay), + startDate: Value(local.startDate), + startDateTime: Value(local.startDateTime), + startTimeZone: Value(local.startTimeZone), + endDate: Value(local.endDate), + endDateTime: Value(local.endDateTime), + endTimeZone: Value(local.endTimeZone), + recurrenceJson: Value(local.recurrenceJson), + remindersJson: Value(local.remindersJson), + attendeesJson: Value(local.attendeesJson), + categoriesJson: Value(local.categoriesJson), + organizerJson: Value(local.organizerJson), + creatorJson: Value(local.creatorJson), + colorId: Value(local.colorId), + colorHex: Value(local.colorHex), + visibility: Value(local.visibility), + transparencyOrShowAs: Value(local.transparencyOrShowAs), + conferenceJson: Value(local.conferenceJson), + attachmentsJson: Value(local.attachmentsJson), + isCancelled: Value(local.isCancelled), + isDeleted: Value(local.isDeleted), + rawJson: Value(local.rawJson), + createdAtLocal: Value(local.createdAtLocal), + updatedAtLocal: Value(local.updatedAtLocal), + syncStatus: const Value('pending'), + ); + } + Future _replaceLocalCalendar( PendingOp op, - CalendarSourceDto serverSource, - ) async { + CalendarSourceDto serverSource, { + Map? followUpRequest, + }) async { final temporarySourceId = op.calendarSourceId; final temporaryProviderCalendarId = op.localTempId ?? op.providerCalendarId; final localSource = temporarySourceId == null @@ -1148,6 +1364,17 @@ class CalendarPendingOpsReplayer { oldProviderCalendarId: temporaryProviderCalendarId, newProviderCalendarId: serverSource.providerCalendarId, ); + if (followUpRequest != null) { + await _queueCalendarCreationFollowUp( + completedCreate: op, + serverSourceId: serverSourceId, + serverSource: serverSource, + request: followUpRequest, + ); + } + // Identity persistence and creation acknowledgement are one commit. A + // crash after this point cannot replay the creation POST. + await _database.pendingOpsDao.deleteOp(op.id); if (temporarySourceId != null && temporarySourceId != serverSourceId) { await (_database.delete( _database.calendarSources, @@ -1163,6 +1390,55 @@ class CalendarPendingOpsReplayer { } } + Future _queueCalendarCreationFollowUp({ + required PendingOp completedCreate, + required String serverSourceId, + required CalendarSourceDto serverSource, + required Map request, + }) async { + final existing = + await (_database.select(_database.pendingOps)..where( + (row) => + row.accountId.equals(_accountId) & + row.calendarSourceId.equals(serverSourceId) & + row.operationType.equals('calendar.patch'), + )) + .get(); + for (final operation in existing) { + final current = _request(operation); + if (current[calendarMutationScopeKey] != calendarMutationScopePersonal) { + continue; + } + await (_database.update( + _database.pendingOps, + )..where((row) => row.id.equals(operation.id))).write( + PendingOpsCompanion( + requestJson: Value(jsonEncode({...current, ...request})), + dependsOnOpId: const Value(null), + updatedAtUtc: Value(_nowUtc().toIso8601String()), + ), + ); + return; + } + final now = _nowUtc().toIso8601String(); + await _database.pendingOpsDao.enqueue( + PendingOpsCompanion.insert( + id: '${completedCreate.id}:calendar-list-follow-up', + accountId: _accountId, + provider: Value(serverSource.provider.storageValue), + entityType: 'calendar', + operation: 'patch', + operationType: const Value('calendar.patch'), + calendarSourceId: Value(serverSourceId), + providerCalendarId: Value(serverSource.providerCalendarId), + requestJson: jsonEncode(request), + baselineRawJson: Value(jsonEncode(serverSource.rawJson)), + createdAtUtc: now, + updatedAtUtc: now, + ), + ); + } + Future _rewriteCalendarPendingReferences({ required String? oldSourceId, required String newSourceId, @@ -1334,14 +1610,14 @@ class CalendarPendingOpsReplayer { ); } - Future _ensureNoEventConflict( + Future _ensureNoEventConflict( PendingOp op, CalendarEvent local, Map request, ) async { final baselineUpdatedUtc = _parseUtc(op.baselineUpdatedUtc); if (baselineUpdatedUtc == null) { - return; + return null; } final current = await _client.getEvent( calendarId: op.providerCalendarId ?? local.providerCalendarId, @@ -1350,7 +1626,7 @@ class CalendarPendingOpsReplayer { final currentUpdatedUtc = _parseUtc(current.updatedAtServer); if (currentUpdatedUtc == null || !currentUpdatedUtc.isAfter(baselineUpdatedUtc)) { - return; + return current; } final baseline = _eventBaselineSnapshot( _client.provider, @@ -1364,27 +1640,46 @@ class CalendarPendingOpsReplayer { _client.provider, ); if (changed.isEmpty) { - return; + return current; } await _blockConflict( op, 'Remote event changed fields: ${changed.toList()..sort()}', ); + return null; } - Future _ensureEventUnchanged( + Future _ensureEventUnchanged( PendingOp op, CalendarEvent local, String action, ) async { final baselineUpdatedUtc = _parseUtc(op.baselineUpdatedUtc); if (baselineUpdatedUtc == null) { - return; + return null; } final current = await _client.getEvent( calendarId: op.providerCalendarId ?? local.providerCalendarId, eventId: await _providerEventId(op, local), ); + final encodedBaseline = op.baselineRawJson ?? local.baselineRawJson ?? '{}'; + final rawBaseline = _jsonObject(encodedBaseline); + final semanticBaseline = rawBaseline[calendarEventSemanticBaselineKey]; + if (semanticBaseline is Map) { + final baseline = semanticBaseline.cast(); + final remote = _semanticSnapshot(_client.provider, current.rawJson); + final changed = { + for (final field in {...baseline.keys, ...remote.keys}) + if (!_deepEquals(baseline[field], remote[field])) field, + }; + if (changed.isNotEmpty) { + await _blockConflict( + op, + 'Remote event changed fields since local $action was queued: ' + '${changed.toList()..sort()}', + ); + } + } final currentUpdatedUtc = _parseUtc(current.updatedAtServer); if (currentUpdatedUtc != null && currentUpdatedUtc.isAfter(baselineUpdatedUtc)) { @@ -1393,6 +1688,7 @@ class CalendarPendingOpsReplayer { 'Remote event changed since local $action was queued.', ); } + return current; } Set _changedSemanticFields( @@ -1476,6 +1772,7 @@ class CalendarPendingOpsReplayer { 'recurrenceJson': raw['recurrence'], 'remindersJson': raw['reminders'], 'attendeesJson': raw['attendees'], + 'attachmentsJson': raw['attachments'], 'colorId': raw['colorId'], 'visibility': raw['visibility'], 'transparencyOrShowAs': raw['transparency'], @@ -1703,6 +2000,38 @@ class CalendarPendingOpsReplayer { return statusCode == 429 || statusCode >= 500; } + bool _isCalendarCreation(PendingOp op) => + _operationType(op) == 'calendar.create'; + + Future _claimCalendarCreation(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 _blockUnknownCalendarCreation(PendingOp op, String detail) { + return _database.pendingOpsDao.updateAttempt( + id: op.id, + attemptCount: op.attemptCount + 1, + nextAttemptAtUtc: DateTime.utc(9999, 12, 31), + lastErrorCode: 'calendar_creation_outcome_unknown', + lastErrorMessage: + 'The provider may already have created this calendar. Automatic ' + 'replay was stopped to avoid a duplicate. $detail', + state: 'recovery_required', + ); + } + Future _scheduleRetry( PendingOp op, String errorCode, @@ -1725,6 +2054,7 @@ class CalendarPendingOpsReplayer { nextAttemptAtUtc: nextAttempt, lastErrorCode: errorCode, lastErrorMessage: errorMessage, + state: _isCalendarCreation(op) ? 'retry' : null, ); } @@ -1765,6 +2095,7 @@ class CalendarPendingOpsReplayer { nextAttemptAtUtc: nextAttemptAtUtc, lastErrorCode: errorCode, lastErrorMessage: errorMessage, + state: _isCalendarCreation(op) ? 'failed' : null, ); return true; } diff --git a/lib/src/features/sync/calendar_sync_engine.dart b/lib/src/features/sync/calendar_sync_engine.dart index 7b84345a..e1c9be26 100644 --- a/lib/src/features/sync/calendar_sync_engine.dart +++ b/lib/src/features/sync/calendar_sync_engine.dart @@ -184,7 +184,8 @@ class CalendarSyncEngine { preservePendingLocalChanges: true, ); final recurringMasterId = event.providerRecurringEventId; - if (provider == BusyProvider.google && + if ((provider == BusyProvider.google || + provider == BusyProvider.microsoft) && recurringMasterId != null && recurringMasterId.isNotEmpty) { expandedRecurringMasterIds.add(recurringMasterId); @@ -243,9 +244,25 @@ class CalendarSyncEngine { } } - if (provider == BusyProvider.google) { - await _repository.markGoogleRecurringMastersDeleted( + if (provider == BusyProvider.google || provider == BusyProvider.microsoft) { + // Expanded Google synchronization and Microsoft calendar views return + // instances but omit their recurring masters. Retain the authoritative + // master snapshots separately so later whole-series mutations have a + // real conflict baseline instead of borrowing one from an occurrence. + for (final recurringMasterId in expandedRecurringMasterIds) { + final master = await _client.getEvent( + calendarId: providerCalendarId, + eventId: recurringMasterId, + ); + await _repository.upsertEvent( + accountId: _accountId, + event: master, + preservePendingLocalChanges: true, + ); + } + await _repository.markExpandedRecurringMastersDeleted( accountId: _accountId, + provider: provider, providerCalendarId: providerCalendarId, providerRecurringEventIds: expandedRecurringMasterIds, ); diff --git a/lib/src/features/sync/conflict_detector.dart b/lib/src/features/sync/conflict_detector.dart index e429d57e..cb545897 100644 --- a/lib/src/features/sync/conflict_detector.dart +++ b/lib/src/features/sync/conflict_detector.dart @@ -14,10 +14,12 @@ class ConflictDetector { required Map currentServerJson, required DateTime? baselineUpdatedUtc, required DateTime? currentUpdatedUtc, + bool compareWithoutRevision = false, }) { - if (baselineUpdatedUtc == null || - currentUpdatedUtc == null || - !currentUpdatedUtc.isAfter(baselineUpdatedUtc)) { + if (!compareWithoutRevision && + (baselineUpdatedUtc == null || + currentUpdatedUtc == null || + !currentUpdatedUtc.isAfter(baselineUpdatedUtc))) { return PendingMutationConflict( entityType: entityType, entityId: entityId, diff --git a/lib/src/features/sync/pending_op_resolution_service.dart b/lib/src/features/sync/pending_op_resolution_service.dart index a3231f2a..986f2c71 100644 --- a/lib/src/features/sync/pending_op_resolution_service.dart +++ b/lib/src/features/sync/pending_op_resolution_service.dart @@ -6,6 +6,7 @@ import '../../calendar_providers/cloud_calendar_client.dart'; import '../../dav/mutation/dav_pending_operation_selection.dart'; import '../../dav/mutation/dav_pending_operations.dart'; import '../../db/app_database.dart'; +import '../accounts/data/accounts_repository.dart'; import '../calendar/data/calendar_repository.dart'; import '../notifications/notification_schedule_service.dart'; import '../task_lists/data/task_lists_repository.dart'; @@ -15,6 +16,7 @@ import '../tasks/domain/task_remote_error.dart'; import '../tasks/domain/task_checklist_item.dart'; import '../tasks/domain/task_remote_models.dart'; import 'pending_ops_replay_coordinator.dart'; +import 'sync_auth_error.dart'; class PendingOpResolutionService { PendingOpResolutionService({ @@ -48,8 +50,8 @@ class PendingOpResolutionService { final op = await _database.pendingOpsDao.getOp(opId); if (op == null) return; _requireOwnedOperation(op); - if (op.state == 'recovery_required' && - _isTaskCreationOperation(op.operation)) { + await _requireAccountSyncEligible(); + if (op.state == 'recovery_required' && _isCreationOperation(op)) { throw StateError( 'This creation cannot be retried safely because the provider may ' 'already have created the item. Check the provider, then discard this ' @@ -67,10 +69,12 @@ class PendingOpResolutionService { await _syncAfterResolution(op); } - bool _isTaskCreationOperation(String operation) { - return operation == 'create_task_list' || - operation == 'create_task' || - operation == 'create_task_checklist_item'; + bool _isCreationOperation(PendingOp op) { + return op.operation == 'create_task_list' || + op.operation == 'create_task' || + op.operation == 'create_task_checklist_item' || + op.operationType == 'calendar.create' || + (op.entityType == 'calendar' && op.operation == 'create'); } Future discard(String opId) async { @@ -79,6 +83,7 @@ class PendingOpResolutionService { return; } _requireOwnedOperation(op); + await _requireAccountSyncEligible(); if (isDavPendingOperation(op)) { final partiallyCompletedMove = isDavPartiallyCompletedMove(op); @@ -133,11 +138,338 @@ class PendingOpResolutionService { if (current.operation == 'create_task_checklist_item') { return _discardUncertainChecklistCreation(current); } + if (current.operation == 'patch_task_checklist_item' || + current.operation == 'delete_task_checklist_item') { + return _discardChecklistMutation(current); + } + if (current.operation == 'create_task') { + return _discardTaskCreation(current); + } + if (current.operation == 'create_task_list') { + return _discardTaskListCreation(current); + } + final discardedIds = current.operation == 'move_task' + ? await _dependentTaskOperationIds(current.id) + : {current.id}; + if (current.operation == 'move_task') { + return _discardMoveAndDependents(current, discardedIds); + } final syncAfterDiscard = await _refreshOrRemoveLocalState(current); - await _database.pendingOpsDao.deleteOp(current.id); + await (_database.delete( + _database.pendingOps, + )..where((row) => row.id.isIn(discardedIds))).go(); return syncAfterDiscard; } + Future _discardTaskCreation(PendingOp snapshot) async { + final temporaryTaskId = snapshot.localTempId ?? snapshot.taskId; + if (snapshot.taskListId == null || + temporaryTaskId == null || + temporaryTaskId.isEmpty) { + throw StateError('The task creation recovery operation is incomplete.'); + } + + var discarded = false; + await _database.transaction(() async { + final current = await _database.pendingOpsDao.getOp(snapshot.id); + if (current == null) return; + if (current.accountId != _accountId || + current.operation != 'create_task' || + current.taskListId != snapshot.taskListId || + (current.localTempId ?? current.taskId) != temporaryTaskId) { + throw StateError('The task creation recovery operation changed.'); + } + + final operations = await (_database.select( + _database.pendingOps, + )..where((row) => row.accountId.equals(_accountId))).get(); + final discardedIds = {current.id}; + final discardedTaskIds = {temporaryTaskId}; + var expanded = true; + while (expanded) { + expanded = false; + for (final operation in operations) { + if (discardedIds.contains(operation.id)) continue; + final targetsDiscardedTask = + (operation.entityType == 'task' || + operation.entityType == 'task_checklist_item') && + (discardedTaskIds.contains(operation.taskId) || + discardedTaskIds.contains(operation.localTempId)); + if (!targetsDiscardedTask && + !discardedIds.contains(operation.dependsOnOpId)) { + continue; + } + discardedIds.add(operation.id); + if (operation.operation == 'create_task') { + final createdTaskId = operation.localTempId ?? operation.taskId; + if (createdTaskId != null && discardedTaskIds.add(createdTaskId)) { + expanded = true; + } + } + expanded = true; + } + } + + 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.isIn(discardedTaskIds), + )) + .go(); + discarded = true; + }); + return discarded; + } + + Future _discardTaskListCreation(PendingOp snapshot) async { + final temporaryTaskListId = snapshot.localTempId ?? snapshot.taskListId; + if (temporaryTaskListId == null || temporaryTaskListId.isEmpty) { + throw StateError( + 'The task-list creation recovery operation is incomplete.', + ); + } + + final operations = await (_database.select( + _database.pendingOps, + )..where((row) => row.accountId.equals(_accountId))).get(); + final discardedIds = _taskListCreationDiscardedOperationIds( + operations, + rootOperationId: snapshot.id, + temporaryTaskListId: temporaryTaskListId, + ); + final locallyCreatedTaskIds = { + for (final operation in operations) + if (discardedIds.contains(operation.id) && + operation.operation == 'create_task') + operation.localTempId ?? operation.taskId, + }..remove(null); + final discardedMovesByTask = >{}; + for (final operation in operations) { + final taskId = operation.taskId; + if (!discardedIds.contains(operation.id) || + operation.operation != 'move_task' || + taskId == null || + locallyCreatedTaskIds.contains(taskId)) { + continue; + } + discardedMovesByTask.putIfAbsent(taskId, () => []).add(operation); + } + final movedServerTasks = {}; + for (final entry in discardedMovesByTask.entries) { + final moves = entry.value; + final entersTemporaryList = moves.any( + (move) => _destinationTaskListId(move) == temporaryTaskListId, + ); + if (!entersTemporaryList) continue; + moves.sort((left, right) { + final created = left.createdAtUtc.compareTo(right.createdAtUtc); + return created != 0 ? created : left.id.compareTo(right.id); + }); + final destinations = { + temporaryTaskListId, + for (final move in moves) + if (_destinationTaskListId(move) != null) + _destinationTaskListId(move)!, + }; + final sources = { + for (final move in moves) + if (move.taskListId != null && + move.taskListId != temporaryTaskListId && + !destinations.contains(move.taskListId)) + move.taskListId!, + for (final move in moves) + if (move.taskListId != null && + move.taskListId != temporaryTaskListId && + destinations.contains(move.taskListId)) + move.taskListId!, + }.toList(); + TaskDto? serverTask; + String? serverTaskListId; + for (final sourceTaskListId in sources) { + try { + serverTask = await _requiredTaskClient.getTask( + taskListId: sourceTaskListId, + taskId: entry.key, + ); + serverTaskListId = sourceTaskListId; + break; + } on TaskRemoteError catch (error) { + if (error.statusCode != 404) rethrow; + } + } + movedServerTasks[entry.key] = _MovedTaskRestoration( + serverTaskListId: serverTaskListId, + serverTask: serverTask, + affectedTaskListIds: {...destinations, ...sources}, + ); + } + + var discarded = false; + await _database.transaction(() async { + final current = await _database.pendingOpsDao.getOp(snapshot.id); + if (current == null) return; + if (current.accountId != _accountId || + current.operation != 'create_task_list' || + (current.localTempId ?? current.taskListId) != temporaryTaskListId) { + throw StateError('The task-list creation recovery operation changed.'); + } + + final currentOperations = await (_database.select( + _database.pendingOps, + )..where((row) => row.accountId.equals(_accountId))).get(); + final currentDiscardedIds = _taskListCreationDiscardedOperationIds( + currentOperations, + rootOperationId: current.id, + temporaryTaskListId: temporaryTaskListId, + ); + if (discardedIds.length != currentDiscardedIds.length || + !discardedIds.containsAll(currentDiscardedIds)) { + throw StateError('The task-list creation recovery operation changed.'); + } + + await (_database.delete( + _database.pendingOps, + )..where((row) => row.id.isIn(currentDiscardedIds))).go(); + if (locallyCreatedTaskIds.isNotEmpty) { + await (_database.delete(_database.tasks)..where( + (row) => + row.accountId.equals(_accountId) & + row.id.isIn(locallyCreatedTaskIds.cast()), + )) + .go(); + } + for (final MapEntry(key: taskId, value: restoration) + in movedServerTasks.entries) { + await (_database.delete(_database.tasks)..where( + (row) => + row.accountId.equals(_accountId) & + row.id.equals(taskId) & + row.taskListId.isIn(restoration.affectedTaskListIds), + )) + .go(); + final serverTask = restoration.serverTask; + final serverTaskListId = restoration.serverTaskListId; + if (serverTask != null && serverTaskListId != null) { + await _database.tasksDao.upsertTask( + taskFromDto(_accountId, serverTaskListId, serverTask, _now()), + ); + } + } + await _database.taskListsDao.deleteTaskList( + _accountId, + temporaryTaskListId, + ); + discarded = true; + }); + return discarded; + } + + Set _taskListCreationDiscardedOperationIds( + List operations, { + required String rootOperationId, + required String temporaryTaskListId, + }) { + final discardedIds = {rootOperationId}; + var expanded = true; + while (expanded) { + expanded = false; + for (final operation in operations) { + if (discardedIds.contains(operation.id)) continue; + final targetsDiscardedTaskList = + operation.taskListId == temporaryTaskListId || + operation.localTempId == temporaryTaskListId || + (operation.operation == 'move_task' && + _destinationTaskListId(operation) == temporaryTaskListId); + if (!targetsDiscardedTaskList && + !discardedIds.contains(operation.dependsOnOpId)) { + continue; + } + discardedIds.add(operation.id); + expanded = true; + } + } + return discardedIds; + } + + Future> _dependentTaskOperationIds(String rootId) async { + final operations = await (_database.select( + _database.pendingOps, + )..where((row) => row.accountId.equals(_accountId))).get(); + final discarded = {rootId}; + var changed = true; + while (changed) { + changed = false; + for (final operation in operations) { + if (discarded.contains(operation.id) || + !discarded.contains(operation.dependsOnOpId)) { + continue; + } + discarded.add(operation.id); + changed = true; + } + } + return discarded; + } + + Future _discardMoveAndDependents( + PendingOp operation, + Set discardedIds, + ) async { + final sourceTaskListId = operation.taskListId!; + final taskId = operation.taskId!; + final discardedOperations = await (_database.select( + _database.pendingOps, + )..where((row) => row.id.isIn(discardedIds))).get(); + final destinationTaskListIds = + { + for (final discarded in discardedOperations) + if (discarded.operation == 'move_task') + _destinationTaskListId(discarded), + }..removeWhere( + (destination) => + destination == null || destination == sourceTaskListId, + ); + TaskDto? serverTask; + try { + serverTask = await _requiredTaskClient.getTask( + taskListId: sourceTaskListId, + taskId: taskId, + ); + } on TaskRemoteError catch (error) { + if (error.statusCode != 404) rethrow; + } + + await _database.transaction(() async { + if (serverTask == null) { + await _database.tasksDao.deleteTask( + _accountId, + sourceTaskListId, + taskId, + ); + } else { + await _database.tasksDao.upsertTask( + taskFromDto(_accountId, sourceTaskListId, serverTask, _now()), + ); + } + if (destinationTaskListIds.isNotEmpty) { + await (_database.delete(_database.tasks)..where( + (row) => + row.accountId.equals(_accountId) & + row.id.equals(taskId) & + row.taskListId.isIn(destinationTaskListIds.cast()), + )) + .go(); + } + await (_database.delete( + _database.pendingOps, + )..where((row) => row.id.isIn(discardedIds))).go(); + }); + return true; + } + Future _discardUncertainChecklistCreation(PendingOp snapshot) async { final taskListId = snapshot.taskListId; final parentTaskId = snapshot.taskId; @@ -261,6 +593,114 @@ class PendingOpResolutionService { return discarded; } + Future _discardChecklistMutation(PendingOp snapshot) async { + final taskListId = snapshot.taskListId; + final parentTaskId = snapshot.taskId; + final checklistItemId = _checklistItemId(snapshot); + if (taskListId == null || + parentTaskId == null || + checklistItemId == null || + checklistItemId.isEmpty) { + throw StateError('The checklist recovery operation is incomplete.'); + } + + final serverItems = []; + var parentMissing = false; + String? pageToken; + try { + do { + final page = await _requiredChecklistClient.listChecklistItemsPage( + taskListId: taskListId, + taskId: parentTaskId, + pageToken: pageToken, + ); + serverItems.addAll(page.items); + pageToken = page.nextPageToken; + } while (pageToken != null && pageToken.isNotEmpty); + } on TaskRemoteError catch (error) { + if (error.statusCode != 404) rethrow; + parentMissing = true; + } + + var discarded = false; + await _database.transaction(() async { + final current = await _database.pendingOpsDao.getOp(snapshot.id); + if (current == null) return; + if (current.accountId != _accountId || + (current.operation != 'patch_task_checklist_item' && + current.operation != 'delete_task_checklist_item') || + current.operation != snapshot.operation || + current.taskListId != taskListId || + current.taskId != parentTaskId || + _checklistItemId(current) != checklistItemId) { + throw StateError('The checklist recovery operation changed.'); + } + + final operations = + await (_database.select(_database.pendingOps) + ..where( + (row) => + row.accountId.equals(_accountId) & + row.entityType.equals('task_checklist_item') & + row.taskListId.equals(taskListId) & + row.taskId.equals(parentTaskId), + ) + ..orderBy([ + (row) => OrderingTerm.asc(row.createdAtUtc), + (row) => OrderingTerm.asc(row.updatedAtUtc), + (row) => OrderingTerm.asc(row.id), + ])) + .get(); + final remaining = [ + for (final operation in operations) + if (operation.id != current.id) operation, + ]; + + if (parentMissing) { + await _database.tasksDao.deleteTask( + _accountId, + taskListId, + parentTaskId, + ); + } else { + final task = + await (_database.select(_database.tasks)..where( + (row) => + row.accountId.equals(_accountId) & + row.taskListId.equals(taskListId) & + row.id.equals(parentTaskId), + )) + .getSingleOrNull(); + if (task != null) { + final merged = mergeTaskChecklistProjection( + serverItems: serverItems, + localItems: decodeTaskChecklistItems( + task.microsoftChecklistItemsJson, + ), + pendingOperations: remaining, + ); + await (_database.update(_database.tasks)..where( + (row) => + row.accountId.equals(_accountId) & + row.taskListId.equals(taskListId) & + row.id.equals(parentTaskId), + )) + .write( + TasksCompanion( + microsoftChecklistItemsJson: Value( + encodeTaskChecklistItems(merged), + ), + updatedLocalAtUtc: Value(_now()), + ), + ); + } + } + await _database.pendingOpsDao.deleteOp(current.id); + discarded = true; + }); + return discarded; + } + Future _refreshOrRemoveLocalState(PendingOp op) async { if (op.entityType == 'calendar') { final repository = CalendarRepository(database: _database, now: _nowUtc); @@ -318,6 +758,18 @@ class PendingOpResolutionService { await _syncTasks(); } + Future _requireAccountSyncEligible() async { + final account = await AccountsRepository( + database: _database, + ).accountById(_accountId); + if (account?.isSyncEligible == true) { + return; + } + throw AccountNotSyncEligibleException( + needsReconnect: account?.needsReconnect == true, + ); + } + Future _rebuildNotificationSchedule() async { await NotificationScheduleService( database: _database, @@ -434,3 +886,15 @@ class PendingOpResolutionService { String _now() => _nowUtc().toIso8601String(); } + +class _MovedTaskRestoration { + const _MovedTaskRestoration({ + required this.serverTaskListId, + required this.serverTask, + required this.affectedTaskListIds, + }); + + final String? serverTaskListId; + final TaskDto? serverTask; + final Set affectedTaskListIds; +} diff --git a/lib/src/features/sync/pending_ops_replayer.dart b/lib/src/features/sync/pending_ops_replayer.dart index 977ba95b..dec1bf6a 100644 --- a/lib/src/features/sync/pending_ops_replayer.dart +++ b/lib/src/features/sync/pending_ops_replayer.dart @@ -1,6 +1,7 @@ import 'dart:convert'; import 'dart:math'; +import 'package:collection/collection.dart'; import 'package:drift/drift.dart'; import '../../core/http/request_dispatch_exception.dart'; @@ -193,9 +194,12 @@ class PendingOpsReplayer { ); final tempId = op.localTempId ?? op.taskListId; if (tempId == null) { - await _database.taskListsDao.upsertTaskList( - taskListFromDto(_accountId, dto, _now()), - ); + await _database.transaction(() async { + await _database.taskListsDao.upsertTaskList( + taskListFromDto(_accountId, dto, _now()), + ); + await _database.pendingOpsDao.deleteOp(op.id); + }); return; } @@ -241,6 +245,8 @@ class PendingOpsReplayer { completedCreateOpId: op.id, ); await _database.taskListsDao.deleteTaskList(_accountId, tempId); + // Commit the server identity and creation acknowledgement together. + await _database.pendingOpsDao.deleteOp(op.id); }); await notifyCollectionIdReplacement(_onTaskListIdReplaced, tempId, dto.id); } @@ -302,13 +308,25 @@ class PendingOpsReplayer { PendingOp completedOp, TaskListDto serverList, ) async { - final dependents = - await (_database.select(_database.pendingOps)..where( - (row) => - row.accountId.equals(_accountId) & - row.dependsOnOpId.equals(completedOp.id), - )) - .get(); + final operations = await (_database.select( + _database.pendingOps, + )..where((row) => row.accountId.equals(_accountId))).get(); + final dependencyIds = {completedOp.id}; + final dependents = []; + var foundDependent = true; + while (foundDependent) { + foundDependent = false; + for (final candidate in operations) { + if (dependencyIds.contains(candidate.id) || + !dependencyIds.contains(candidate.dependsOnOpId) || + candidate.entityType != 'task_list') { + continue; + } + dependencyIds.add(candidate.id); + dependents.add(candidate); + foundDependent = true; + } + } if (dependents.isEmpty) return false; final acknowledged = _normalizeTaskListConflictSnapshot( @@ -327,11 +345,21 @@ class PendingOpsReplayer { dependentRequest[_childTaskConflictBaselineKey] = dependent.baselineUpdatedUtc; } - final baseline = _normalizeTaskListConflictSnapshot( - _jsonObject(dependent.baselineRawJson ?? '{}'), - ); - for (final field in acknowledged.keys) { - baseline[field] = serverSnapshot[field]; + final initializesServerBaseline = + completedOp.operation == 'create_task_list'; + final baseline = initializesServerBaseline + ? Map.from(serverSnapshot) + : _normalizeTaskListConflictSnapshot( + _jsonObject(dependent.baselineRawJson ?? '{}'), + ); + if (!initializesServerBaseline) { + for (final field in acknowledged.keys) { + if (serverSnapshot.containsKey(field)) { + baseline[field] = serverSnapshot[field]; + } else { + baseline.remove(field); + } + } } await (_database.update( _database.pendingOps, @@ -339,9 +367,10 @@ class PendingOpsReplayer { PendingOpsCompanion( requestJson: Value(jsonEncode(dependentRequest)), baselineRawJson: Value(jsonEncode(baseline)), - baselineUpdatedUtc: serverList.updated == null + baselineUpdatedUtc: + !initializesServerBaseline && serverList.updated == null ? const Value.absent() - : Value(serverList.updated!.toUtc().toIso8601String()), + : Value(serverList.updated?.toUtc().toIso8601String()), updatedAtUtc: Value(_now()), ), ); @@ -367,9 +396,13 @@ class PendingOpsReplayer { ); final tempId = op.localTempId ?? op.taskId; if (tempId == null) { - await _database.tasksDao.upsertTask( - taskFromDto(_accountId, op.taskListId!, dto, _now()), - ); + await _database.transaction(() async { + await _rebasePendingListDeletionChildBaselines(op, serverTask: dto); + await _database.tasksDao.upsertTask( + taskFromDto(_accountId, op.taskListId!, dto, _now()), + ); + await _database.pendingOpsDao.deleteOp(op.id); + }); return; } @@ -404,6 +437,10 @@ class PendingOpsReplayer { Future _applyTaskEditResult(PendingOp op, TaskDto serverTask) async { await _database.transaction(() async { final hasDependent = await _rebaseDependentTaskMutations(op, serverTask); + await _rebasePendingListDeletionChildBaselines( + op, + serverTask: serverTask, + ); if (hasDependent) { return; } @@ -417,38 +454,52 @@ class PendingOpsReplayer { PendingOp completedOp, TaskDto serverTask, ) async { - final dependents = - await (_database.select(_database.pendingOps)..where( - (row) => - row.accountId.equals(_accountId) & - row.dependsOnOpId.equals(completedOp.id), - )) - .get(); + final operations = await (_database.select( + _database.pendingOps, + )..where((row) => row.accountId.equals(_accountId))).get(); + final dependencyIds = {completedOp.id}; + final dependents = []; + var foundDependent = true; + while (foundDependent) { + foundDependent = false; + for (final candidate in operations) { + if (dependencyIds.contains(candidate.id) || + !dependencyIds.contains(candidate.dependsOnOpId) || + !_mutatesSameTask(completedOp, candidate)) { + continue; + } + dependencyIds.add(candidate.id); + dependents.add(candidate); + foundDependent = true; + } + } if (dependents.isEmpty) { return false; } - final acknowledgedFields = _request(completedOp).keys; + final serverSnapshot = _normalizeTaskConflictSnapshot(serverTask.rawJson); for (final dependent in dependents) { - final baseline = _normalizeTaskConflictSnapshot( + final initializesServerBaseline = + completedOp.operation == 'create_task' && + dependent.baselineUpdatedUtc == null; + final baseline = _rebaseAcknowledgedTaskSnapshot( + completedOp, _jsonObject(dependent.baselineRawJson ?? '{}'), + serverSnapshot, + initialize: initializesServerBaseline, ); - final serverSnapshot = _normalizeTaskConflictSnapshot(serverTask.rawJson); final usesWholeTaskConflictBoundary = dependent.operation == 'move_task' || dependent.operation == 'delete_task'; - // Keep the original timestamp and untouched fields so a provider edit to - // a different field is still detected by a dependent field-level edit. - for (final field in acknowledgedFields) { - baseline[field] = serverSnapshot[field]; - } await (_database.update( _database.pendingOps, )..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 + // Creation supplies the first server boundary for local-only edits. + // Whole-task checks must also advance past an acknowledged write. + baselineUpdatedUtc: + initializesServerBaseline || usesWholeTaskConflictBoundary ? Value(serverTask.updated?.toUtc().toIso8601String()) : const Value.absent(), updatedAtUtc: Value(_now()), @@ -458,10 +509,191 @@ class PendingOpsReplayer { return true; } + Map _rebaseAcknowledgedTaskSnapshot( + PendingOp completedOp, + Map originalBaseline, + Map normalizedServerSnapshot, { + bool initialize = false, + }) { + if (initialize) { + return Map.from(normalizedServerSnapshot); + } + + final completedRequest = _request(completedOp); + final requestedFields = + completedOp.operation == 'create_task' && + completedRequest['body'] is Map + ? (completedRequest['body'] as Map).keys.map((key) => key.toString()) + : completedRequest.keys; + final acknowledgedFields = { + for (final field in requestedFields) + if (!_taskOperationArgumentFields.contains(field)) field, + }; + final baseline = _normalizeTaskConflictSnapshot(originalBaseline); + + void acknowledgeProviderFields(Iterable fields) { + for (final field in fields) { + if (baseline.containsKey(field) || + normalizedServerSnapshot.containsKey(field)) { + acknowledgedFields.add(field); + } + } + } + + if (acknowledgedFields.contains('status')) { + acknowledgeProviderFields({ + 'completed', + 'microsoftCompletedDateTime', + 'microsoftCompletedTimeZone', + }); + } + if (acknowledgedFields.contains('due') || + acknowledgedFields.contains('microsoftDueDateTime')) { + acknowledgeProviderFields({ + 'due', + 'microsoftDueDateTime', + 'microsoftDueTimeZone', + }); + } + if (completedOp.operation == 'move_task') { + acknowledgedFields.addAll({'parent', 'position'}); + } + + // Keep untouched fields at their original values so an independent + // remote change remains visible to a later whole-task check. Missing + // acknowledged values mean the provider cleared/omitted that field; do + // not manufacture null aliases from another provider's schema. + for (final field in acknowledgedFields) { + if (normalizedServerSnapshot.containsKey(field)) { + baseline[field] = normalizedServerSnapshot[field]; + } else { + baseline.remove(field); + } + } + return baseline; + } + + Future _rebasePendingListDeletionChildBaselines( + PendingOp completedOp, { + TaskDto? serverTask, + }) async { + final sourceListId = completedOp.taskListId; + final taskId = serverTask?.id ?? completedOp.taskId; + if (sourceListId == null || taskId == null) return; + + final request = _request(completedOp); + final destinationListId = completedOp.operation == 'move_task' + ? request['destinationTasklist']?.toString() ?? sourceListId + : sourceListId; + final listDeletes = + await (_database.select(_database.pendingOps)..where( + (row) => + row.accountId.equals(_accountId) & + row.entityType.equals('task_list') & + row.operation.equals('delete_task_list'), + )) + .get(); + + for (final listDelete in listDeletes) { + final deleteRequest = _request(listDelete); + final encoded = deleteRequest[_childTaskConflictBaselinesKey]; + if (encoded is! Map) continue; + final baselines = { + for (final entry in encoded.entries) entry.key.toString(): entry.value, + }; + var changed = false; + + void removeTask() { + changed = baselines.remove(taskId) != null || changed; + final localId = completedOp.localTempId; + if (localId != null) { + changed = baselines.remove(localId) != null || changed; + } + } + + void putServerTask({required bool initialize}) { + final task = serverTask; + if (task == null) return; + final existing = baselines[taskId]; + if (!initialize && existing is! Map) return; + final existingEntry = existing is Map + ? existing.cast() + : const {}; + final baselineRaw = existingEntry['rawJson']; + final normalizedServer = _normalizeTaskConflictSnapshot(task.rawJson); + final rebasedRaw = initialize || baselineRaw is! Map + ? Map.from(normalizedServer) + : _rebaseAcknowledgedTaskSnapshot( + completedOp, + baselineRaw.cast(), + normalizedServer, + ); + baselines[taskId] = { + 'updatedUtc': task.updated?.toUtc().toIso8601String(), + 'rawJson': rebasedRaw, + }; + changed = true; + } + + final deletingSource = listDelete.taskListId == sourceListId; + final deletingDestination = listDelete.taskListId == destinationListId; + switch (completedOp.operation) { + case 'create_task': + if (deletingSource) putServerTask(initialize: true); + break; + case 'patch_task': + case 'update_task': + if (deletingSource) putServerTask(initialize: false); + break; + case 'delete_task': + if (deletingSource) removeTask(); + break; + case 'move_task': + if (sourceListId == destinationListId) { + if (deletingSource) putServerTask(initialize: false); + } else { + if (deletingSource) removeTask(); + if (deletingDestination) putServerTask(initialize: true); + } + break; + } + if (!changed) continue; + deleteRequest[_childTaskConflictBaselinesKey] = baselines; + await (_database.update( + _database.pendingOps, + )..where((row) => row.id.equals(listDelete.id))).write( + PendingOpsCompanion( + requestJson: Value(jsonEncode(deleteRequest)), + updatedAtUtc: Value(_now()), + ), + ); + } + } + + bool _mutatesSameTask(PendingOp completed, PendingOp dependent) { + final identities = { + if (completed.taskId != null) completed.taskId!, + if (completed.localTempId != null) completed.localTempId!, + }; + if (dependent.entityType == 'task_checklist_item') { + return identities.contains(dependent.taskId); + } + return dependent.entityType == 'task' && + (identities.contains(dependent.taskId) || + identities.contains(dependent.localTempId)); + } + Future _deleteTask(PendingOp op) async { await _ensureTaskUnchanged(op, 'delete'); await _apiClient.deleteTask(taskListId: op.taskListId!, taskId: op.taskId!); - await _database.tasksDao.deleteTask(_accountId, op.taskListId!, op.taskId!); + await _database.transaction(() async { + await _rebasePendingListDeletionChildBaselines(op); + await _database.tasksDao.deleteTask( + _accountId, + op.taskListId!, + op.taskId!, + ); + }); } Future _moveTask(PendingOp op) async { @@ -478,10 +710,12 @@ class PendingOpsReplayer { ); await _database.transaction(() async { final hasDependent = await _rebaseDependentTaskMutations(op, dto); + await _rebasePendingListDeletionChildBaselines(op, serverTask: dto); if (hasDependent) { // The repository has already moved the visible projection to the tail // of the local chain. Applying this intermediate response would move // it backwards and erase later local edits. + await _preserveDependentMoveProjection(op, targetTaskListId); return; } await _database.tasksDao.upsertTask( @@ -497,6 +731,76 @@ class PendingOpsReplayer { }); } + Future _preserveDependentMoveProjection( + PendingOp op, + String targetTaskListId, + ) async { + final projectionTaskListId = await _dependentTaskProjectionListId( + op, + targetTaskListId, + ); + if (projectionTaskListId == op.taskListId) return; + final sourceTask = + await (_database.select(_database.tasks)..where( + (row) => + row.accountId.equals(_accountId) & + row.taskListId.equals(op.taskListId!) & + row.id.equals(op.taskId!), + )) + .getSingleOrNull(); + if (sourceTask == null) return; + final destinationTask = + await (_database.select(_database.tasks)..where( + (row) => + row.accountId.equals(_accountId) & + row.taskListId.equals(projectionTaskListId) & + row.id.equals(op.taskId!), + )) + .getSingleOrNull(); + if (destinationTask == null) { + await (_database.update(_database.tasks)..where( + (row) => + row.accountId.equals(_accountId) & + row.taskListId.equals(op.taskListId!) & + row.id.equals(op.taskId!), + )) + .write(TasksCompanion(taskListId: Value(projectionTaskListId))); + return; + } + await _database.tasksDao.deleteTask(_accountId, op.taskListId!, op.taskId!); + } + + Future _dependentTaskProjectionListId( + PendingOp completedOp, + String initialTaskListId, + ) async { + final operations = await (_database.select( + _database.pendingOps, + )..where((row) => row.accountId.equals(_accountId))).get(); + final dependencyIds = {completedOp.id}; + var projectionTaskListId = initialTaskListId; + var foundDependent = true; + while (foundDependent) { + foundDependent = false; + for (final candidate in operations) { + if (dependencyIds.contains(candidate.id) || + !dependencyIds.contains(candidate.dependsOnOpId) || + !_mutatesSameTask(completedOp, candidate)) { + continue; + } + dependencyIds.add(candidate.id); + foundDependent = true; + final request = _request(candidate); + projectionTaskListId = candidate.operation == 'move_task' + ? request['destinationTasklist']?.toString() ?? + candidate.taskListId ?? + projectionTaskListId + : candidate.taskListId ?? projectionTaskListId; + } + } + return projectionTaskListId; + } + Future _clearCompleted(PendingOp op) async { await _ensureNoCompletedTaskConflict(op); await _apiClient.clearCompletedTasks(op.taskListId!); @@ -526,6 +830,8 @@ class PendingOpsReplayer { newValue: item.id, ); } + // Commit the server identity and creation acknowledgement together. + await _database.pendingOpsDao.deleteOp(op.id); }); } @@ -614,9 +920,21 @@ class PendingOpsReplayer { ? -1 : items.indexWhere((candidate) => candidate.id == oldItemId); if (index < 0) { - items.add(item); + final pendingDelete = oldItemId == null + ? false + : await _hasPendingChecklistDelete( + parentTaskId: parentTaskId, + checklistItemId: oldItemId, + ); + if (!pendingDelete) items.add(item); } else { - items[index] = item; + // Retain the temporary identity long enough for a local action that + // started before this acknowledgement to resolve the new server item. + var replacement = item.withLocalIdentityAlias(oldItemId); + for (final alias in items[index].localIdentityAliases) { + replacement = replacement.withLocalIdentityAlias(alias); + } + items[index] = replacement; } await (_database.update(_database.tasks)..where( (row) => @@ -705,14 +1023,39 @@ class PendingOpsReplayer { row.id.equals(tempTaskId), )) .getSingleOrNull(); + final createOperation = await _database.pendingOpsDao.getOp( + completedCreateOpId, + ); + if (createOperation == null) { + throw StateError('The task creation operation is unavailable.'); + } + final hasDependent = await _rebaseDependentTaskMutations( + createOperation, + serverTask, + ); + await _rebasePendingListDeletionChildBaselines( + createOperation, + serverTask: serverTask, + ); + // A queued cross-list move has already relocated the temporary row. + // Keep that visible projection in place while replacing its identity. + final projectionTaskListId = localTask?.taskListId ?? taskListId; await _database.tasksDao.upsertTask( - taskFromDto(_accountId, taskListId, serverTask, _now()), + taskFromDto(_accountId, projectionTaskListId, serverTask, _now()), ); - if (localTask?.microsoftChecklistItemsJson != null) { + if (localTask != null && hasDependent) { await (_database.update(_database.tasks)..where( (row) => row.accountId.equals(_accountId) & - row.taskListId.equals(taskListId) & + row.taskListId.equals(projectionTaskListId) & + row.id.equals(serverTask.id), + )) + .write(_pendingLocalTaskProjection(localTask)); + } else if (localTask?.microsoftChecklistItemsJson != null) { + await (_database.update(_database.tasks)..where( + (row) => + row.accountId.equals(_accountId) & + row.taskListId.equals(projectionTaskListId) & row.id.equals(serverTask.id), )) .write( @@ -747,9 +1090,47 @@ class PendingOpsReplayer { localTask?.taskListId ?? taskListId, tempTaskId, ); + // Commit the server identity and creation acknowledgement together. + await _database.pendingOpsDao.deleteOp(completedCreateOpId); }); } + TasksCompanion _pendingLocalTaskProjection(Task local) { + return TasksCompanion( + title: Value(local.title), + parent: Value(local.parent), + position: Value(local.position), + notes: Value(local.notes), + status: Value(local.status), + dueUtc: Value(local.dueUtc), + completedUtc: Value(local.completedUtc), + providerStatus: Value(local.providerStatus), + bodyContent: Value(local.bodyContent), + bodyContentType: Value(local.bodyContentType), + microsoftDueDateTime: Value(local.microsoftDueDateTime), + microsoftDueTimeZone: Value(local.microsoftDueTimeZone), + microsoftStartDateTime: Value(local.microsoftStartDateTime), + microsoftStartTimeZone: Value(local.microsoftStartTimeZone), + microsoftReminderDateTime: Value(local.microsoftReminderDateTime), + microsoftReminderTimeZone: Value(local.microsoftReminderTimeZone), + microsoftIsReminderOn: Value(local.microsoftIsReminderOn), + microsoftCompletedDateTime: Value(local.microsoftCompletedDateTime), + microsoftCompletedTimeZone: Value(local.microsoftCompletedTimeZone), + microsoftChecklistItemsJson: Value(local.microsoftChecklistItemsJson), + recurrenceJson: Value(local.recurrenceJson), + importance: Value(local.importance), + categoriesJson: Value(local.categoriesJson), + deleted: Value(local.deleted), + hidden: Value(local.hidden), + localDirty: const Value(true), + pendingDelete: Value(local.pendingDelete), + pendingMove: Value(local.pendingMove), + localCreated: const Value(false), + createdLocalAtUtc: Value(local.createdLocalAtUtc), + updatedLocalAtUtc: Value(local.updatedLocalAtUtc), + ); + } + Future _replacePendingReference( String oldValue, String newValue, { @@ -815,11 +1196,14 @@ class PendingOpsReplayer { if (op.operation == 'delete_task' && op.taskListId != null && op.taskId != null) { - await _database.tasksDao.deleteTask( - _accountId, - op.taskListId!, - op.taskId!, - ); + await _database.transaction(() async { + await _rebasePendingListDeletionChildBaselines(op); + await _database.tasksDao.deleteTask( + _accountId, + op.taskListId!, + op.taskId!, + ); + }); } if (op.operation == 'delete_task_checklist_item' && op.taskListId != null && @@ -858,6 +1242,25 @@ class PendingOpsReplayer { ); } + Future _hasPendingChecklistDelete({ + required String parentTaskId, + required String checklistItemId, + }) async { + final operations = + await (_database.select(_database.pendingOps)..where( + (row) => + row.accountId.equals(_accountId) & + row.entityType.equals('task_checklist_item') & + row.taskId.equals(parentTaskId) & + row.operation.equals('delete_task_checklist_item'), + )) + .get(); + return operations.any( + (operation) => + _request(operation)['checklistItemId']?.toString() == checklistItemId, + ); + } + Future _blockOp( PendingOp op, String errorCode, @@ -951,9 +1354,13 @@ class PendingOpsReplayer { Map pendingFields, ) async { final baselineUpdatedUtc = _parseUtc(op.baselineUpdatedUtc); - if (baselineUpdatedUtc == null || op.taskListId == null) { + if (op.taskListId == null) { return; } + final compareWithoutRevision = + baselineUpdatedUtc == null && + _apiClient is RevisionlessTaskListConflictClient; + if (baselineUpdatedUtc == null && !compareWithoutRevision) return; final local = await (_database.select(_database.taskLists)..where( @@ -982,6 +1389,7 @@ class PendingOpsReplayer { currentServerJson: normalizedCurrent, baselineUpdatedUtc: baselineUpdatedUtc, currentUpdatedUtc: current.updated, + compareWithoutRevision: compareWithoutRevision, ); if (conflict.hasConflict) { await _blockConflict( @@ -1062,12 +1470,27 @@ class PendingOpsReplayer { Future _ensureTaskListUnchanged(PendingOp op, String action) async { final baselineUpdatedUtc = _parseUtc(op.baselineUpdatedUtc); - if (baselineUpdatedUtc == null || op.taskListId == null) { + if (op.taskListId == null) { return; } + final compareWithoutRevision = + baselineUpdatedUtc == null && + _apiClient is RevisionlessTaskListConflictClient; + if (baselineUpdatedUtc == null && !compareWithoutRevision) return; final current = await _apiClient.getTaskList(op.taskListId!); - if (_remoteChangedAfterBaseline(current.updated, baselineUpdatedUtc)) { + final baselineRawJson = op.baselineRawJson; + final contentChanged = + compareWithoutRevision && + baselineRawJson != null && + !const DeepCollectionEquality().equals( + _semanticTaskListConflictSnapshot(_jsonObject(baselineRawJson)), + _semanticTaskListConflictSnapshot(current.rawJson), + ); + final revisionChanged = + baselineUpdatedUtc != null && + _remoteChangedAfterBaseline(current.updated, baselineUpdatedUtc); + if (contentChanged || revisionChanged) { await _blockConflict( op, 'Remote task list changed since local $action was queued.', @@ -1087,7 +1510,18 @@ class PendingOpsReplayer { taskListId: op.taskListId!, taskId: op.taskId!, ); - if (_remoteChangedAfterBaseline(current.updated, baselineUpdatedUtc)) { + final baselineRawJson = op.baselineRawJson; + final contentChanged = + baselineRawJson != null && + !const DeepCollectionEquality().equals( + _semanticTaskConflictSnapshot(_jsonObject(baselineRawJson)), + _semanticTaskConflictSnapshot(current.rawJson), + ); + final revisionChanged = _remoteChangedAfterBaseline( + current.updated, + baselineUpdatedUtc, + ); + if (contentChanged || revisionChanged) { await _blockConflict( op, 'Remote task changed since local $action was queued.', @@ -1100,9 +1534,21 @@ class PendingOpsReplayer { String action, ) async { final request = _request(op); + if (request.containsKey(_childTaskConflictBaselinesKey)) { + await _ensureChildTaskSnapshotsUnchanged(op, action, request); + return; + } final childBaseline = request[_childTaskConflictBaselineKey]?.toString(); final baselineUpdatedUtc = _parseUtc(childBaseline) ?? _parseUtc(op.baselineUpdatedUtc); + if (baselineUpdatedUtc == null && + _apiClient is RevisionlessTaskListConflictClient) { + await _blockConflict( + op, + 'Remote tasks in this list cannot be verified against the local ' + '$action baseline.', + ); + } if (baselineUpdatedUtc == null || op.taskListId == null) { return; } @@ -1133,6 +1579,142 @@ class PendingOpsReplayer { } while (pageToken != null && pageToken.isNotEmpty); } + Future _ensureChildTaskSnapshotsUnchanged( + PendingOp op, + String action, + Map request, + ) async { + if (op.taskListId == null) return; + final encodedBaselines = request[_childTaskConflictBaselinesKey]; + if (encodedBaselines is! Map) return; + final baselines = >{ + for (final entry in encodedBaselines.entries) + if (entry.value is Map) + entry.key.toString(): (entry.value as Map).cast(), + }; + final currentTasks = {}; + String? pageToken; + do { + final page = await _apiClient.listTasksPage( + taskListId: op.taskListId!, + maxResults: 100, + pageToken: pageToken, + showCompleted: true, + showDeleted: true, + showHidden: true, + showAssigned: true, + ); + for (final task in page.items) { + currentTasks[task.id] = task; + } + pageToken = page.nextPageToken; + } while (pageToken != null && pageToken.isNotEmpty); + + var changed = baselines.length != currentTasks.length; + if (!changed) { + for (final entry in baselines.entries) { + final current = currentTasks[entry.key]; + if (current == null) { + changed = true; + break; + } + final baselineUpdatedUtc = _parseUtc( + entry.value['updatedUtc']?.toString(), + ); + final baselineRaw = entry.value['rawJson']; + final revisionChanged = + baselineUpdatedUtc != null && + _remoteChangedAfterBaseline(current.updated, baselineUpdatedUtc); + final contentChanged = + baselineRaw is Map && + !const DeepCollectionEquality().equals( + _semanticTaskConflictSnapshot( + baselineRaw.cast(), + ), + _semanticTaskConflictSnapshot(current.rawJson), + ); + if (revisionChanged || contentChanged) { + changed = true; + break; + } + } + } + if (changed) { + await _blockConflict( + op, + 'Remote task in list changed since local $action was queued.', + ); + } + } + + Map _semanticTaskConflictSnapshot( + Map snapshot, + ) { + final semantic = Map.from( + _normalizeTaskConflictSnapshot(snapshot), + ); + semantic.removeWhere( + (key, _) => + _taskRevisionFields.contains(key) || + _taskOperationArgumentFields.contains(key) || + key.startsWith('@odata.') || + key.startsWith('_busymax'), + ); + if (semantic.containsKey('notes')) semantic.remove('body'); + if (semantic.containsKey('microsoftDueDateTime')) { + semantic.remove('dueDateTime'); + } + if (semantic.containsKey('microsoftStartDateTime')) { + semantic.remove('startDateTime'); + } + if (semantic.containsKey('microsoftReminderDateTime')) { + semantic.remove('reminderDateTime'); + } + if (semantic.containsKey('microsoftCompletedDateTime')) { + semantic.remove('completedDateTime'); + } + if (semantic.containsKey('microsoftIsReminderOn')) { + semantic.remove('isReminderOn'); + } + return _canonicalConflictMap(semantic); + } + + Map _semanticTaskListConflictSnapshot( + Map snapshot, + ) { + final semantic = Map.from( + _normalizeTaskListConflictSnapshot(snapshot), + ); + semantic.removeWhere( + (key, _) => + _taskListRevisionFields.contains(key) || + key.startsWith('@odata.') || + key.startsWith('_busymax'), + ); + if (semantic.containsKey('title')) semantic.remove('displayName'); + return _canonicalConflictMap(semantic); + } + + Map _canonicalConflictMap(Map value) { + final result = {}; + for (final entry in value.entries) { + final canonical = _canonicalConflictValue(entry.value); + if (canonical != null) result[entry.key] = canonical; + } + return result; + } + + Object? _canonicalConflictValue(Object? value) { + if (value is Map) { + final nested = _canonicalConflictMap(value.cast()); + return nested.isEmpty ? null : nested; + } + if (value is List) { + return [for (final item in value) _canonicalConflictValue(item)]; + } + return value; + } + Future _ensureNoCompletedTaskConflict(PendingOp op) async { final baselineUpdatedUtc = _parseUtc(op.baselineUpdatedUtc); if (baselineUpdatedUtc == null || op.taskListId == null) { @@ -1190,6 +1772,30 @@ class PendingOpsReplayer { const _childTaskConflictBaselineKey = '_busymaxChildTaskConflictBaselineUpdatedUtc'; +const _childTaskConflictBaselinesKey = '_busymaxChildTaskConflictBaselines'; + +const _taskRevisionFields = { + 'etag', + '@odata.etag', + 'updated', + 'lastModifiedDateTime', + 'bodyLastModifiedDateTime', +}; + +const _taskOperationArgumentFields = { + 'previous', + 'destinationTasklist', + 'taskListId', + 'tasklist', + 'checklistItemId', +}; + +const _taskListRevisionFields = { + 'etag', + '@odata.etag', + 'updated', + 'lastModifiedDateTime', +}; const _pendingOpReferenceKeys = { 'id', diff --git a/lib/src/features/sync/sync_auth_error.dart b/lib/src/features/sync/sync_auth_error.dart index 691f75f3..a4ebb468 100644 --- a/lib/src/features/sync/sync_auth_error.dart +++ b/lib/src/features/sync/sync_auth_error.dart @@ -1,4 +1,5 @@ import '../../core/logging/redacting_logger.dart'; +import '../../core/http/request_dispatch_exception.dart'; import 'package:busymax/src/core/auth/oauth_models.dart'; import 'package:flutter/services.dart'; import '../connectivity/network_connectivity_service.dart'; @@ -6,24 +7,52 @@ import '../connectivity/network_connectivity_service.dart'; const accountReconnectRequiredSyncMessage = 'This account needs to be reconnected.'; const accountReconnectRequiredActionLabel = 'Reconnect this account'; +const syncTemporarilyUnavailableMessage = + 'Synchronization is temporarily unavailable.'; + +/// Raised when an application-facing sync or recovery entry point observes +/// that the account is no longer eligible at dispatch time. +class AccountNotSyncEligibleException implements Exception { + const AccountNotSyncEligibleException({required this.needsReconnect}); + + final bool needsReconnect; + + @override + String toString() => 'The account is not eligible for synchronization.'; +} bool isMissingOAuthTokenError(Object error) { - return (error is OAuthException && - (error.code == 'OAuthMissingToken' || - error.code == 'MicrosoftOAuthMissingToken')) || - (error is PlatformException && - error.code == 'android/auth-interaction-required'); + final effectiveError = resolveEffectiveSyncFailure(error); + return (effectiveError is OAuthException && + (effectiveError.code == 'OAuthMissingToken' || + effectiveError.code == 'OAuthMissingRefreshToken' || + effectiveError.code == 'MicrosoftOAuthMissingToken' || + effectiveError.code == 'MicrosoftOAuthMissingRefreshToken')) || + (effectiveError is PlatformException && + effectiveError.code == 'android/auth-interaction-required'); } String syncFailureMessage( Object error, { String networkUnavailableMessage = 'No network connection is available.', }) { - if (error is NetworkUnavailableException) { + final effectiveError = resolveEffectiveSyncFailure(error); + if (effectiveError is NetworkUnavailableException) { return networkUnavailableMessage; } - if (isMissingOAuthTokenError(error)) { + if (effectiveError is AccountNotSyncEligibleException) { + return effectiveError.needsReconnect + ? accountReconnectRequiredSyncMessage + : syncTemporarilyUnavailableMessage; + } + if (isMissingOAuthTokenError(effectiveError) || + (effectiveError is OAuthRefreshException && + effectiveError.oauthError == 'invalid_grant')) { return accountReconnectRequiredSyncMessage; } - return redactForLog(error); + if (effectiveError is OAuthRefreshException || + effectiveError is RequestNotDispatchedException) { + return syncTemporarilyUnavailableMessage; + } + return redactForLog(effectiveError); } diff --git a/lib/src/features/sync/sync_failure_notification_policy.dart b/lib/src/features/sync/sync_failure_notification_policy.dart index 35636b74..6ac5faef 100644 --- a/lib/src/features/sync/sync_failure_notification_policy.dart +++ b/lib/src/features/sync/sync_failure_notification_policy.dart @@ -4,6 +4,7 @@ import 'dart:io'; import 'package:http/http.dart' as http; import '../../core/auth/oauth_models.dart'; +import '../../core/http/request_dispatch_exception.dart'; import '../../dav/dav_errors.dart'; import '../../dav/sync/dav_account_sync_engine.dart'; import '../../google_calendar/google_calendar_errors.dart'; @@ -24,45 +25,59 @@ enum SyncFailureNotificationDisposition { SyncFailureNotificationDisposition syncFailureNotificationDisposition( Object error, ) { - if (error is DavAccountSyncException) { - return _aggregateDavFailures(error.failures); + final effectiveError = resolveEffectiveSyncFailure(error); + if (effectiveError is DavAccountSyncException) { + return _aggregateDavFailures(effectiveError.failures); } - if (error is DavException) { - return _davDisposition(error); + if (effectiveError is AccountNotSyncEligibleException) { + return effectiveError.needsReconnect + ? SyncFailureNotificationDisposition.reconnectRequired + : SyncFailureNotificationDisposition.temporarilyUnavailable; } - if (error is NetworkUnavailableException || - error is TimeoutException || - error is SocketException || - error is http.ClientException) { + if (effectiveError is DavException) { + return _davDisposition(effectiveError); + } + if (effectiveError is NetworkUnavailableException || + effectiveError is TimeoutException || + effectiveError is SocketException || + effectiveError is http.ClientException) { return SyncFailureNotificationDisposition.suppressed; } - if (error is TaskRemoteError) { - if (error.retryable) { + if (effectiveError is TaskRemoteError) { + if (effectiveError.retryable) { return SyncFailureNotificationDisposition.suppressed; } - return _httpDisposition(error.statusCode); + return _httpDisposition(effectiveError.statusCode); } - if (error is GoogleCalendarApiError) { - return _httpDisposition(error.statusCode); + if (effectiveError is GoogleCalendarApiError) { + return _httpDisposition(effectiveError.statusCode); } - if (error is MicrosoftCalendarApiError) { - return _httpDisposition(error.statusCode); + if (effectiveError is MicrosoftCalendarApiError) { + return _httpDisposition(effectiveError.statusCode); } - if (error is MicrosoftTodoApiError) { - return _httpDisposition(error.statusCode); + if (effectiveError is MicrosoftTodoApiError) { + return _httpDisposition(effectiveError.statusCode); } - if (error is OAuthRefreshException) { - return _httpDisposition( - error.statusCode, - badRequestRequiresReconnect: true, - ); + if (effectiveError is OAuthRefreshException) { + if (effectiveError.oauthError == 'invalid_grant') { + return SyncFailureNotificationDisposition.reconnectRequired; + } + return _oauthRefreshHttpDisposition(effectiveError.statusCode); } - if (isMissingOAuthTokenError(error)) { + if (isMissingOAuthTokenError(effectiveError)) { return SyncFailureNotificationDisposition.reconnectRequired; } - if (error is OAuthException && - error.code.toLowerCase().contains('refreshfailed')) { - return SyncFailureNotificationDisposition.reconnectRequired; + return SyncFailureNotificationDisposition.temporarilyUnavailable; +} + +SyncFailureNotificationDisposition _oauthRefreshHttpDisposition( + int statusCode, +) { + if (statusCode == HttpStatus.requestTimeout || + statusCode == 425 || + statusCode == HttpStatus.tooManyRequests || + statusCode >= HttpStatus.internalServerError) { + return SyncFailureNotificationDisposition.suppressed; } return SyncFailureNotificationDisposition.temporarilyUnavailable; } @@ -112,12 +127,8 @@ SyncFailureNotificationDisposition _davDisposition(DavException error) { }; } -SyncFailureNotificationDisposition _httpDisposition( - int statusCode, { - bool badRequestRequiresReconnect = false, -}) { - if (statusCode == HttpStatus.unauthorized || - (badRequestRequiresReconnect && statusCode == HttpStatus.badRequest)) { +SyncFailureNotificationDisposition _httpDisposition(int statusCode) { + if (statusCode == HttpStatus.unauthorized) { return SyncFailureNotificationDisposition.reconnectRequired; } if (statusCode == HttpStatus.forbidden) { 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 f0ce6976..f1df6cb5 100644 --- a/lib/src/features/task_lists/data/task_lists_repository.dart +++ b/lib/src/features/task_lists/data/task_lists_repository.dart @@ -262,6 +262,9 @@ class TaskListsRepository { } await _database.transaction(() async { final current = await _baselineRow(id); + final childTaskBaselines = current?.updatedUtc == null + ? await _childTaskConflictBaselines(id) + : null; await _updateLocalList( id, TaskListsCompanion( @@ -273,7 +276,10 @@ class TaskListsRepository { await _enqueue( operation: 'delete_task_list', taskListId: id, - request: const {}, + request: { + if (childTaskBaselines != null) + _childTaskConflictBaselinesKey: childTaskBaselines, + }, baselineUpdatedUtc: current?.updatedUtc, baselineRawJson: current?.rawJson, createdAtUtc: now, @@ -389,6 +395,27 @@ class TaskListsRepository { .getSingleOrNull(); } + Future> _childTaskConflictBaselines( + String taskListId, + ) async { + final tasks = + await (_database.select(_database.tasks)..where( + (row) => + row.accountId.equals(_accountId) & + row.taskListId.equals(taskListId) & + row.localCreated.equals(false) & + row.serverMissing.equals(false), + )) + .get(); + return { + for (final task in tasks) + task.id: { + 'updatedUtc': task.updatedUtc, + 'rawJson': jsonDecode(task.rawJson), + }, + }; + } + Future _taskListCreationMode() async { final account = await (_database.select( _database.accounts, @@ -419,6 +446,8 @@ const _taskListMutationOperations = { 'delete_task_list', }; +const _childTaskConflictBaselinesKey = '_busymaxChildTaskConflictBaselines'; + TaskListsCompanion taskListFromDto( String accountId, TaskListDto dto, diff --git a/lib/src/features/tasks/data/tasks_repository.dart b/lib/src/features/tasks/data/tasks_repository.dart index f7d5b3f1..9d1bf223 100644 --- a/lib/src/features/tasks/data/tasks_repository.dart +++ b/lib/src/features/tasks/data/tasks_repository.dart @@ -409,6 +409,7 @@ class TasksRepository { TaskRemoteClient? apiClient, void Function()? onMutationQueued, Future Function()? onNotificationScheduleChanged, + Future Function()? beforeChecklistDeleteTransaction, Uuid uuid = const Uuid(), DateTime Function()? nowUtc, }) : _database = database, @@ -416,6 +417,7 @@ class TasksRepository { _apiClient = apiClient, _onMutationQueued = onMutationQueued, _onNotificationScheduleChanged = onNotificationScheduleChanged, + _beforeChecklistDeleteTransaction = beforeChecklistDeleteTransaction, _uuid = uuid, _nowUtc = nowUtc ?? (() => DateTime.now().toUtc()); @@ -424,6 +426,7 @@ class TasksRepository { final TaskRemoteClient? _apiClient; final void Function()? _onMutationQueued; final Future Function()? _onNotificationScheduleChanged; + final Future Function()? _beforeChecklistDeleteTransaction; final Uuid _uuid; final DateTime Function() _nowUtc; @@ -560,7 +563,7 @@ class TasksRepository { }); } - Future createTask(String taskListId, TaskCreateInput input) async { + Future createTask(String taskListId, TaskCreateInput input) async { final taskList = await _requiredTaskList(taskListId); if (taskList.davCollectionId != null) { return _createDavTask(taskList, input); @@ -591,6 +594,9 @@ class TasksRepository { ), ); await _patchLocalTask(taskListId, localId, fields, now); + final parentCreate = input.parentTaskId == null + ? null + : await _pendingTaskCreate(input.parentTaskId!); final createOperationId = await _enqueue( operation: 'create_task', taskListId: taskListId, @@ -603,6 +609,7 @@ class TasksRepository { 'previous': input.previousSiblingTaskId, }, createdAtUtc: now, + dependsOnOpId: parentCreate?.id, ); if (input.parentTaskId != null) { final moveCreatedAt = DateTime.parse( @@ -624,6 +631,7 @@ class TasksRepository { }); await _rebuildTaskNotifications(); _onMutationQueued?.call(); + return localId; } Future createSubtask({ @@ -669,34 +677,52 @@ class TasksRepository { final items = List.of( decodeTaskChecklistItems(task.microsoftChecklistItemsJson), ); - final index = items.indexWhere((item) => item.id == checklistItemId); + final index = items.indexWhere( + (item) => item.matchesIdentity(checklistItemId), + ); if (index < 0) { throw StateError('The checklist subtask is unavailable.'); } final now = _now(); - final raw = items[index].toJson(); - if (normalizedTitle != null) raw['displayName'] = normalizedTitle; - if (completed != null) { - raw['isChecked'] = completed; - if (completed) { - raw['checkedDateTime'] = now; - } else { - raw.remove('checkedDateTime'); - } - } - items[index] = TaskChecklistItemEntity.fromJson(raw); final body = { if (normalizedTitle != null) 'displayName': normalizedTitle, if (completed != null) 'isChecked': completed, }; await _database.transaction(() async { - await _writeChecklistProjection(taskListId, parentTaskId, items, now); + final currentTask = await _requiredTask(taskListId, parentTaskId); + final currentItems = List.of( + decodeTaskChecklistItems(currentTask.microsoftChecklistItemsJson), + ); + final currentIndex = currentItems.indexWhere( + (item) => item.matchesIdentity(checklistItemId), + ); + if (currentIndex < 0) { + throw StateError('The checklist subtask is unavailable.'); + } + final currentItemId = currentItems[currentIndex].id; + final raw = currentItems[currentIndex].toJson(); + if (normalizedTitle != null) raw['displayName'] = normalizedTitle; + if (completed != null) { + raw['isChecked'] = completed; + if (completed) { + raw['checkedDateTime'] = now; + } else { + raw.remove('checkedDateTime'); + } + } + currentItems[currentIndex] = TaskChecklistItemEntity.fromJson(raw); + await _writeChecklistProjection( + taskListId, + parentTaskId, + currentItems, + now, + ); await _enqueueChecklistOperation( operation: 'patch_task_checklist_item', taskListId: taskListId, parentTaskId: parentTaskId, - checklistItemId: checklistItemId, - request: {'checklistItemId': checklistItemId, 'body': body}, + checklistItemId: currentItemId, + request: {'checklistItemId': currentItemId, 'body': body}, createdAtUtc: now, ); }); @@ -711,34 +737,46 @@ class TasksRepository { if (_apiClient is! TaskChecklistRemoteClient) { throw UnsupportedError('This provider does not use checklist subtasks.'); } - final task = await _requiredTask(taskListId, parentTaskId); - final items = decodeTaskChecklistItems(task.microsoftChecklistItemsJson); - if (!items.any((item) => item.id == checklistItemId)) { - throw StateError('The checklist subtask is unavailable.'); - } final now = _now(); - final pendingCreate = await _pendingChecklistCreate( - taskListId, - parentTaskId, - checklistItemId, - ); + await _beforeChecklistDeleteTransaction?.call(); await _database.transaction(() async { + final currentTask = await _requiredTask(taskListId, parentTaskId); + final currentItems = decodeTaskChecklistItems( + currentTask.microsoftChecklistItemsJson, + ); + final currentItem = currentItems.firstWhereOrNull( + (item) => item.matchesIdentity(checklistItemId), + ); + if (currentItem == null) { + throw StateError('The checklist subtask is unavailable.'); + } + final currentItemId = currentItem.id; await _writeChecklistProjection(taskListId, parentTaskId, [ - for (final item in items) - if (item.id != checklistItemId) item, + for (final item in currentItems) + if (item.id != currentItemId) item, ], now); - if (pendingCreate != null) { - await _deleteChecklistOperationChain( + final pendingCreate = await _pendingChecklistCreate( + taskListId, + parentTaskId, + currentItemId, + ); + var cancelled = false; + if (pendingCreate != null && + pendingCreate.state == 'pending' && + pendingCreate.attemptCount == 0) { + cancelled = await _deleteChecklistOperationChain( + create: pendingCreate, parentTaskId: parentTaskId, - checklistItemId: checklistItemId, + checklistItemId: currentItemId, ); - } else { + } + if (!cancelled) { await _enqueueChecklistOperation( operation: 'delete_task_checklist_item', taskListId: taskListId, parentTaskId: parentTaskId, - checklistItemId: checklistItemId, - request: {'checklistItemId': checklistItemId}, + checklistItemId: currentItemId, + request: {'checklistItemId': currentItemId}, createdAtUtc: now, ); } @@ -999,7 +1037,10 @@ class TasksRepository { ); } - Future _createDavTask(TaskList taskList, TaskCreateInput input) async { + Future _createDavTask( + TaskList taskList, + TaskCreateInput input, + ) async { final collectionId = taskList.davCollectionId!; final fields = Map.from(input.toFields()); await _ensureDavCreateAllowed(taskList, fields); @@ -1067,6 +1108,7 @@ class TasksRepository { }); await _rebuildTaskNotifications(); _onMutationQueued?.call(); + return localId; } Future<({String localId, String uid, String lastOperationId})> @@ -2449,10 +2491,22 @@ class TasksRepository { ); } - Future _deleteChecklistOperationChain({ + Future _deleteChecklistOperationChain({ + required PendingOp create, required String parentTaskId, required String checklistItemId, }) async { + final deletedCreate = + await (_database.delete(_database.pendingOps)..where( + (row) => + row.id.equals(create.id) & + row.state.equals(create.state) & + row.attemptCount.equals(create.attemptCount) & + row.requestJson.equals(create.requestJson) & + row.updatedAtUtc.equals(create.updatedAtUtc), + )) + .go(); + if (deletedCreate != 1) return false; final operations = await (_database.select(_database.pendingOps)..where( (row) => @@ -2462,10 +2516,12 @@ class TasksRepository { )) .get(); for (final operation in operations) { - if (_checklistItemIdFromOperation(operation) == checklistItemId) { + if (operation.id != create.id && + _checklistItemIdFromOperation(operation) == checklistItemId) { await _database.pendingOpsDao.deleteOp(operation.id); } } + return true; } Future _enqueue({ @@ -2620,12 +2676,20 @@ class TasksRepository { changed = true; } } + final discardedLocalTaskIds = { + localTaskId, + for (final operation in operations) + if (discardedIds.contains(operation.id) && + operation.operation == 'create_task') + operation.localTempId ?? operation.taskId, + }..remove(null); 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), + row.accountId.equals(_accountId) & + row.id.isIn(discardedLocalTaskIds.cast()), )) .go(); } @@ -2796,10 +2860,16 @@ List mergeTaskChecklistProjection({ required Iterable localItems, required Iterable pendingOperations, }) { - final items = { - for (final item in serverItems.map(taskChecklistItemFromDto)) item.id: item, - }; final localById = {for (final item in localItems) item.id: item}; + final items = {}; + for (final serverItem in serverItems.map(taskChecklistItemFromDto)) { + var merged = serverItem; + for (final alias + in localById[serverItem.id]?.localIdentityAliases ?? const {}) { + merged = merged.withLocalIdentityAlias(alias); + } + items[serverItem.id] = merged; + } for (final operation in pendingOperations) { final itemId = _checklistItemIdFromOperation(operation); if (itemId == null) continue; diff --git a/lib/src/features/tasks/domain/task_checklist_item.dart b/lib/src/features/tasks/domain/task_checklist_item.dart index 2031024d..66adb201 100644 --- a/lib/src/features/tasks/domain/task_checklist_item.dart +++ b/lib/src/features/tasks/domain/task_checklist_item.dart @@ -1,5 +1,7 @@ import 'dart:convert'; +const taskChecklistLocalIdentityAliasesKey = '_busymaxLocalIdentityAliases'; + class TaskChecklistItemEntity { const TaskChecklistItemEntity({ required this.id, @@ -29,6 +31,29 @@ class TaskChecklistItemEntity { final Map rawJson; Map toJson() => Map.from(rawJson); + + Set get localIdentityAliases { + final aliases = rawJson[taskChecklistLocalIdentityAliasesKey]; + return { + if (aliases is List) + for (final alias in aliases) + if (alias != null && alias.toString().isNotEmpty) alias.toString(), + }; + } + + bool matchesIdentity(String candidate) { + if (id == candidate) return true; + return localIdentityAliases.contains(candidate); + } + + TaskChecklistItemEntity withLocalIdentityAlias(String? alias) { + if (alias == null || alias.isEmpty || alias == id) return this; + final aliases = {...localIdentityAliases, alias}; + return TaskChecklistItemEntity.fromJson({ + ...rawJson, + taskChecklistLocalIdentityAliasesKey: aliases.toList(growable: false), + }); + } } List decodeTaskChecklistItems(String? source) { diff --git a/lib/src/features/tasks/domain/task_mutation_result.dart b/lib/src/features/tasks/domain/task_mutation_result.dart new file mode 100644 index 00000000..a8aff645 --- /dev/null +++ b/lib/src/features/tasks/domain/task_mutation_result.dart @@ -0,0 +1,39 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; + +enum TaskMutationKind { + updated, + deleted, + duplicated, + createdSubtask, + completion, + checklistUpdated, + checklistDeleted, + reordered, + moved, +} + +@immutable +class TaskMutationResult { + const TaskMutationResult({ + required this.kind, + required this.accountId, + required this.taskListId, + required this.taskId, + this.checklistItemId, + this.createdTaskId, + this.completed, + }); + + final TaskMutationKind kind; + final String accountId; + final String taskListId; + final String taskId; + final String? checklistItemId; + final String? createdTaskId; + final bool? completed; +} + +typedef TaskMutationCommittedCallback = + FutureOr Function(TaskMutationResult result); diff --git a/lib/src/features/tasks/domain/task_remote_client.dart b/lib/src/features/tasks/domain/task_remote_client.dart index bb1a933c..a848d118 100644 --- a/lib/src/features/tasks/domain/task_remote_client.dart +++ b/lib/src/features/tasks/domain/task_remote_client.dart @@ -73,6 +73,10 @@ abstract interface class TaskConflictSnapshotNormalizer { ); } +/// Marks providers whose task-list resources have no server revision field. +/// Conflict checks for these lists compare stored and current snapshots. +abstract interface class RevisionlessTaskListConflictClient {} + /// Child-item boundary used by providers whose subtasks are not task /// resources. Microsoft Graph models Microsoft To Do steps as checklistItem /// children of a todoTask. diff --git a/lib/src/features/tasks/presentation/ical_task_fields_editor.dart b/lib/src/features/tasks/presentation/ical_task_fields_editor.dart index d47a802f..6687c261 100644 --- a/lib/src/features/tasks/presentation/ical_task_fields_editor.dart +++ b/lib/src/features/tasks/presentation/ical_task_fields_editor.dart @@ -12,7 +12,6 @@ import '../../../app/busymax_dialogs.dart'; import '../../../app/app_bootstrap.dart'; import '../../../dav/ical/ical_task_alarm.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'; @@ -33,7 +32,6 @@ class IcalTaskFieldsEditor extends ConsumerStatefulWidget { required this.onChanged, this.useNativeDatePicker = false, this.dialogBarrierColor, - this.headerBarService, this.savedLocation, this.savedPoint, this.savedIdentity, @@ -46,7 +44,6 @@ class IcalTaskFieldsEditor extends ConsumerStatefulWidget { final ValueChanged onChanged; final bool useNativeDatePicker; final Color? dialogBarrierColor; - final LinuxHeaderBarService? headerBarService; final String? savedLocation; final GeographicPoint? savedPoint; final LocationItemIdentity? savedIdentity; @@ -639,7 +636,6 @@ class _IcalTaskFieldsEditorState extends ConsumerState { return showBusyMaxModalEditorDialog( context, barrierColor: widget.dialogBarrierColor, - headerBarService: widget.headerBarService, maxWidth: 520, maxHeight: 680, builder: (context) => _TaskReminderDialog( @@ -676,7 +672,6 @@ class _IcalTaskFieldsEditorState extends ConsumerState { timeZone: widget.draft.microsoftStartTimeZone, useNativeDatePicker: widget.useNativeDatePicker, barrierColor: widget.dialogBarrierColor, - headerBarService: widget.headerBarService, ); if (result == null || !mounted) return; widget.onChanged( diff --git a/lib/src/features/tasks/presentation/new_task_dialog.dart b/lib/src/features/tasks/presentation/new_task_dialog.dart index acc47990..3cc98886 100644 --- a/lib/src/features/tasks/presentation/new_task_dialog.dart +++ b/lib/src/features/tasks/presentation/new_task_dialog.dart @@ -7,8 +7,6 @@ import '../../../app/app_bootstrap.dart'; 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'; @@ -43,11 +41,9 @@ Future showBusyMaxNewTaskDialog( required String? initialAccountId, required String? initialListId, DateTime? initialDueUtc, - LinuxHeaderBarService? headerBarService, }) { return showBusyMaxModalEditorDialog( context, - headerBarService: headerBarService, maxWidth: 640, maxHeight: 760, builder: (dialogContext) => UncontrolledProviderScope( @@ -165,7 +161,6 @@ class _NewTaskEditorPanelState extends ConsumerState { isCreate: true, confirmTaskSwitch: false, useNativeDatePicker: widget.useNativeDatePicker, - headerBarService: ref.read(linuxHeaderBarServiceProvider), categorySuggestions: categorySuggestions, canSaveDraft: (draft) => draft.taskListId.isNotEmpty, onDraftChanged: (draft) { diff --git a/lib/src/features/tasks/presentation/task_details_editor.dart b/lib/src/features/tasks/presentation/task_details_editor.dart index 35a23906..a3e17f9f 100644 --- a/lib/src/features/tasks/presentation/task_details_editor.dart +++ b/lib/src/features/tasks/presentation/task_details_editor.dart @@ -13,7 +13,6 @@ import '../../../google_tasks/api/google_tasks_json.dart'; import '../../../l10n/l10n.dart'; import '../../maps/domain/location_result.dart'; import '../../maps/application/external_location_launcher.dart'; -import '../../../platform/linux_header_bar_service.dart'; import 'package:busymax/src/features/tasks/domain/task_capabilities.dart'; import '../../task_lists/data/task_lists_repository.dart'; import '../data/tasks_repository.dart'; @@ -61,7 +60,6 @@ class TaskDetailsEditor extends StatefulWidget { this.confirmTaskSwitch = true, this.useNativeDatePicker = false, this.dialogBarrierColor, - this.headerBarService, this.canSaveDraft, this.isCreate = false, this.externalLocationLauncher = const ExternalLocationLauncher(), @@ -111,7 +109,6 @@ class TaskDetailsEditor extends StatefulWidget { final bool confirmTaskSwitch; final bool useNativeDatePicker; final Color? dialogBarrierColor; - final LinuxHeaderBarService? headerBarService; final bool Function(TaskDetailsDraft draft)? canSaveDraft; final bool isCreate; final ExternalLocationLauncher externalLocationLauncher; @@ -205,7 +202,7 @@ class _TaskDetailsEditorState extends State { }, }, child: Focus( - autofocus: true, + autofocus: !widget.isCreate, focusNode: _shortcutFocusNode, onKeyEvent: _handleEditorKeyEvent, child: Column( @@ -230,6 +227,7 @@ class _TaskDetailsEditorState extends State { YaruListTile.square( title: TextField( controller: _titleController, + autofocus: widget.isCreate, enabled: _canWrite, decoration: busyMaxGroupedTextFieldDecoration( context, @@ -347,7 +345,6 @@ class _TaskDetailsEditorState extends State { enabled: _canWrite, useNativeDatePicker: widget.useNativeDatePicker, dialogBarrierColor: widget.dialogBarrierColor, - headerBarService: widget.headerBarService, onChanged: _updateDraft, ), if (widget.capabilities.supportsReminderDateTime && @@ -454,7 +451,6 @@ class _TaskDetailsEditorState extends State { ], ), ], - const SizedBox(height: BusyMaxSpacing.lg), ], ), ), @@ -614,7 +610,6 @@ class _TaskDetailsEditorState extends State { final choice = await showBusyMaxModalDialog<_RelatedReminderChoice>( context, barrierColor: widget.dialogBarrierColor, - headerBarService: widget.headerBarService, barrierDismissible: false, builder: (dialogContext) => BusyMaxDialogShell( title: dialogContext.l10n.relatedRemindersTitle, @@ -918,7 +913,6 @@ class _TaskDetailsEditorState extends State { confirmLabel: context.l10n.discardChangesAction, destructive: true, barrierColor: widget.dialogBarrierColor, - headerBarService: widget.headerBarService, ); if (!discard || !mounted) { return; @@ -941,7 +935,6 @@ class _TaskDetailsEditorState extends State { confirmLabel: context.l10n.discardChangesAction, destructive: true, barrierColor: widget.dialogBarrierColor, - headerBarService: widget.headerBarService, ); if (!mounted) { return; @@ -1015,13 +1008,20 @@ class _TaskDetailsEditorState extends State { trailing: Row( mainAxisSize: MainAxisSize.min, children: [ - YaruCheckbox( - value: subtask.completed, - onChanged: !canToggle - ? null - : (value) => unawaited( - widget.onSubtaskCompletionChanged!(subtask, value ?? false), - ), + BusyMaxYaruFocusBorder( + builder: (context, focusNode) => YaruCheckbox( + value: subtask.completed, + focusNode: focusNode, + hasFocusBorder: false, + onChanged: !canToggle + ? null + : (value) => unawaited( + widget.onSubtaskCompletionChanged!( + subtask, + value ?? false, + ), + ), + ), ), if (task != null) Icon(BusyMaxGlyphs.chevronForwardFor(Directionality.of(context))) @@ -1069,7 +1069,6 @@ class _TaskDetailsEditorState extends State { actionLabel: context.l10n.save, initialValue: subtask.title, barrierColor: widget.dialogBarrierColor, - headerBarService: widget.headerBarService, ); final normalized = title?.trim(); if (normalized == null || @@ -1086,7 +1085,6 @@ class _TaskDetailsEditorState extends State { confirmLabel: context.l10n.delete, destructive: true, barrierColor: widget.dialogBarrierColor, - headerBarService: widget.headerBarService, ); if (confirmed) { await widget.onChecklistSubtaskDeleted?.call(subtask); @@ -1102,7 +1100,6 @@ class _TaskDetailsEditorState extends State { label: context.l10n.title, actionLabel: context.l10n.create, barrierColor: widget.dialogBarrierColor, - headerBarService: widget.headerBarService, ); if (title == null || title.trim().isEmpty) { return; @@ -1128,7 +1125,6 @@ class _TaskDetailsEditorState extends State { confirmLabel: context.l10n.delete, destructive: true, barrierColor: widget.dialogBarrierColor, - headerBarService: widget.headerBarService, ); if (confirmed) { await widget.onDelete(); diff --git a/lib/src/features/tasks/presentation/task_details_pane.dart b/lib/src/features/tasks/presentation/task_details_pane.dart index 4d83b6c7..fa4f99e7 100644 --- a/lib/src/features/tasks/presentation/task_details_pane.dart +++ b/lib/src/features/tasks/presentation/task_details_pane.dart @@ -8,23 +8,24 @@ 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'; import '../../schedule/presentation/schedule_item_exporter.dart'; import '../../maps/application/external_location_launcher.dart'; import '../../sync/sync_auth_error.dart'; +import '../../sync/sync_failure_notification_policy.dart'; import '../../task_lists/data/task_lists_repository.dart'; import '../data/tasks_repository.dart'; +import '../domain/task_mutation_result.dart'; import 'task_details_draft.dart'; import 'task_details_editor.dart'; +export '../domain/task_mutation_result.dart'; + typedef TasksRepositoryForAccount = TasksRepository Function(String accountId); typedef TaskListsRepositoryForAccount = TaskListsRepository Function(String accountId); -typedef TaskMutationCommittedCallback = - FutureOr Function(String accountId); class TaskDetailsPane extends ConsumerStatefulWidget { const TaskDetailsPane({ @@ -287,9 +288,25 @@ class _TaskDetailsPaneState extends ConsumerState { Future _refreshTask(TasksRepository repository, TaskEntity task) async { try { + final account = await ref + .read(accountsRepositoryProvider) + .accountById(task.accountId); + if (account?.isSyncEligible != true) { + if (account?.needsReconnect == true && mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + context.l10n.refreshFailed(accountReconnectRequiredSyncMessage), + ), + ), + ); + } + return; + } await repository.refreshTask(task.taskListId, task.id); } on Object catch (error) { - if (isMissingOAuthTokenError(error)) { + if (syncFailureNotificationDisposition(error) == + SyncFailureNotificationDisposition.reconnectRequired) { try { await ref .read(authRepositoryProvider) @@ -342,7 +359,27 @@ class _TaskDetailsPaneState extends ConsumerState { mutated = true; } if (mutated) { - await widget.onTaskMutationCommitted?.call(task.accountId); + final moving = draft.taskListId != task.taskListId; + final patchedStatus = patch['status']?.toString().toLowerCase(); + final originalCompleted = task.status?.toLowerCase() == 'completed'; + final bool? completed = patchedStatus == null + ? null + : (patchedStatus == 'completed') == originalCompleted + ? null + : patchedStatus == 'completed'; + await widget.onTaskMutationCommitted?.call( + TaskMutationResult( + kind: moving + ? TaskMutationKind.moved + : completed != null + ? TaskMutationKind.completion + : TaskMutationKind.updated, + accountId: task.accountId, + taskListId: moving ? task.taskListId : draft.taskListId, + taskId: task.id, + completed: completed, + ), + ); } } @@ -363,9 +400,7 @@ class _TaskDetailsPaneState extends ConsumerState { localTimeZone: localTimeZone, categorySuggestions: categorySuggestions, accountLabel: _accountEditorLabel(context, account), - onRefresh: () { - unawaited(_refreshTask(repository, task)); - }, + onRefresh: () => unawaited(_refreshTask(repository, task)), onSave: (draft, patch) => _saveDraft(repository, task, draft, patch), hierarchy: hierarchy, onCreateSubtask: (title) => _createSubtask(repository, task, title), @@ -384,7 +419,14 @@ class _TaskDetailsPaneState extends ConsumerState { onExport: () => _exportTask(repository, task), onDelete: () async { await repository.deleteTask(task.taskListId, task.id); - await widget.onTaskMutationCommitted?.call(task.accountId); + await widget.onTaskMutationCommitted?.call( + TaskMutationResult( + kind: TaskMutationKind.deleted, + accountId: task.accountId, + taskListId: task.taskListId, + taskId: task.id, + ), + ); widget.onClose?.call(); }, onCancel: () => widget.onClose?.call(), @@ -393,7 +435,6 @@ class _TaskDetailsPaneState extends ConsumerState { onDirtyChanged: _setEditorDirty, externalLocationLauncher: widget.externalLocationLauncher, dialogBarrierColor: widget.dialogBarrierColor, - headerBarService: ref.read(linuxHeaderBarServiceProvider), ); } @@ -408,7 +449,14 @@ class _TaskDetailsPaneState extends ConsumerState { parentTaskId: task.id, title: title, ); - await widget.onTaskMutationCommitted?.call(task.accountId); + await widget.onTaskMutationCommitted?.call( + TaskMutationResult( + kind: TaskMutationKind.createdSubtask, + accountId: task.accountId, + taskListId: task.taskListId, + taskId: task.id, + ), + ); } on Object catch (error) { _showTaskMutationError(error); } @@ -449,7 +497,19 @@ class _TaskDetailsPaneState extends ConsumerState { completed: completed, ); } - await widget.onTaskMutationCommitted?.call(parent.accountId); + final affectedTask = subtask.task; + await widget.onTaskMutationCommitted?.call( + TaskMutationResult( + kind: TaskMutationKind.completion, + accountId: parent.accountId, + taskListId: affectedTask?.taskListId ?? parent.taskListId, + taskId: affectedTask?.id ?? parent.id, + checklistItemId: subtask.kind == TaskSubtaskKind.checklistItem + ? subtask.id + : null, + completed: completed, + ), + ); } on Object catch (error) { _showTaskMutationError(error); } @@ -468,7 +528,15 @@ class _TaskDetailsPaneState extends ConsumerState { checklistItemId: subtask.id, title: title, ); - await widget.onTaskMutationCommitted?.call(parent.accountId); + await widget.onTaskMutationCommitted?.call( + TaskMutationResult( + kind: TaskMutationKind.checklistUpdated, + accountId: parent.accountId, + taskListId: parent.taskListId, + taskId: parent.id, + checklistItemId: subtask.id, + ), + ); } on Object catch (error) { _showTaskMutationError(error); } @@ -485,7 +553,15 @@ class _TaskDetailsPaneState extends ConsumerState { parentTaskId: parent.id, checklistItemId: subtask.id, ); - await widget.onTaskMutationCommitted?.call(parent.accountId); + await widget.onTaskMutationCommitted?.call( + TaskMutationResult( + kind: TaskMutationKind.checklistDeleted, + accountId: parent.accountId, + taskListId: parent.taskListId, + taskId: parent.id, + checklistItemId: subtask.id, + ), + ); } on Object catch (error) { _showTaskMutationError(error); } @@ -511,7 +587,14 @@ class _TaskDetailsPaneState extends ConsumerState { await repository.moveTask( TaskMoveInput(sourceTaskListId: task.taskListId, taskId: task.id), ); - await widget.onTaskMutationCommitted?.call(task.accountId); + await widget.onTaskMutationCommitted?.call( + TaskMutationResult( + kind: TaskMutationKind.reordered, + accountId: task.accountId, + taskListId: task.taskListId, + taskId: task.id, + ), + ); } Future _duplicateTask( @@ -519,8 +602,19 @@ class _TaskDetailsPaneState extends ConsumerState { TaskEntity task, ) async { try { - await repository.duplicateTask(task.taskListId, task.id); - await widget.onTaskMutationCommitted?.call(task.accountId); + final duplicateId = await repository.duplicateTask( + task.taskListId, + task.id, + ); + await widget.onTaskMutationCommitted?.call( + TaskMutationResult( + kind: TaskMutationKind.duplicated, + accountId: task.accountId, + taskListId: task.taskListId, + taskId: task.id, + createdTaskId: duplicateId, + ), + ); if (!mounted) return; ScaffoldMessenger.of( context, diff --git a/lib/src/google_calendar/google_calendar_api_client.dart b/lib/src/google_calendar/google_calendar_api_client.dart index e43a1f41..d0fc2f59 100644 --- a/lib/src/google_calendar/google_calendar_api_client.dart +++ b/lib/src/google_calendar/google_calendar_api_client.dart @@ -76,12 +76,9 @@ class GoogleCalendarApiClient _uri('/calendar/v3/calendars'), body: googleCalendarMutationToJson(mutation), ); - final calendarId = json['id']?.toString(); - if (calendarId != null && - calendarId.isNotEmpty && - _hasCalendarListColor(mutation)) { - return _updateCalendarListColor(calendarId, mutation); - } + // Calendar-list color is a separate resource mutation. The replay layer + // persists this acknowledged identity before queueing that follow-up, so a + // color failure can never cause another calendar creation POST. return googleCalendarSourceFromJson(json); } @@ -245,6 +242,7 @@ class GoogleCalendarApiClient required CalendarEventMutation mutation, CalendarGuestUpdatePolicy guestUpdatePolicy = CalendarGuestUpdatePolicy.send, + String? ifMatch, }) async { final json = await _requestJson( 'PATCH', @@ -256,6 +254,7 @@ class GoogleCalendarApiClient }, ), body: googleEventMutationToJson(mutation), + headers: {if (ifMatch != null) 'If-Match': ifMatch}, ); return googleCalendarEventFromJson(calendarId, json); } @@ -287,6 +286,7 @@ class GoogleCalendarApiClient required String eventId, CalendarGuestUpdatePolicy guestUpdatePolicy = CalendarGuestUpdatePolicy.send, + String? ifMatch, }) { return _requestEmpty( 'DELETE', @@ -294,6 +294,7 @@ class GoogleCalendarApiClient '/calendar/v3/calendars/${_enc(calendarId)}/events/${_enc(eventId)}', query: {'sendUpdates': _googleGuestUpdatePolicy(guestUpdatePolicy)}, ), + headers: {if (ifMatch != null) 'If-Match': ifMatch}, ); } @@ -614,8 +615,12 @@ class GoogleCalendarApiClient ); } - Future _requestEmpty(String method, Uri uri) async { - final response = await _send(method, uri); + Future _requestEmpty( + String method, + Uri uri, { + Map headers = const {}, + }) async { + final response = await _send(method, uri, headers: headers); if (response.statusCode < 200 || response.statusCode >= 300) { throw GoogleCalendarApiError.fromResponse(response); } @@ -625,8 +630,9 @@ class GoogleCalendarApiClient String method, Uri uri, { Map? body, + Map headers = const {}, }) async { - final response = await _send(method, uri, body: body); + final response = await _send(method, uri, body: body, headers: headers); if (response.statusCode < 200 || response.statusCode >= 300) { throw GoogleCalendarApiError.fromResponse(response); } @@ -640,30 +646,36 @@ class GoogleCalendarApiClient String method, Uri uri, { Map? body, + Map headers = const {}, bool retried = false, }) async { final authorizationHeaderProvider = _authorizationHeaderProvider; - final headers = { + final requestHeaders = { 'Accept': 'application/json', if (body != null) 'Content-Type': 'application/json', if (authorizationHeaderProvider != null) 'Authorization': await authorizationHeaderProvider(), + ...headers, }; final encodedBody = body == null ? null : jsonEncode(body); final response = switch (method) { - 'GET' => await _httpClient.get(uri, headers: headers), + 'GET' => await _httpClient.get(uri, headers: requestHeaders), 'POST' => await _httpClient.post( uri, - headers: headers, + headers: requestHeaders, body: encodedBody, ), 'PATCH' => await _httpClient.patch( uri, - headers: headers, + headers: requestHeaders, + body: encodedBody, + ), + 'PUT' => await _httpClient.put( + uri, + headers: requestHeaders, body: encodedBody, ), - 'PUT' => await _httpClient.put(uri, headers: headers, body: encodedBody), - 'DELETE' => await _httpClient.delete(uri, headers: headers), + 'DELETE' => await _httpClient.delete(uri, headers: requestHeaders), _ => throw ArgumentError.value(method, 'method', 'Unsupported method'), }; final unauthorizedRefreshProvider = _unauthorizedRefreshProvider; @@ -671,7 +683,7 @@ class GoogleCalendarApiClient !retried && unauthorizedRefreshProvider != null) { await unauthorizedRefreshProvider(); - return _send(method, uri, body: body, retried: true); + return _send(method, uri, body: body, headers: headers, retried: true); } return response; } diff --git a/lib/src/google_tasks/oauth/oauth_service.dart b/lib/src/google_tasks/oauth/oauth_service.dart index 715e0d6a..c6a9a57c 100644 --- a/lib/src/google_tasks/oauth/oauth_service.dart +++ b/lib/src/google_tasks/oauth/oauth_service.dart @@ -55,6 +55,7 @@ class OAuthService implements OAuthGateway { final DateTime Function() _nowUtc; final Duration _authorizationRevocationTimeout; final RedactingLogger _logger = RedactingLogger(Logger('OAuthService')); + final Map _credentialGenerations = {}; @override Future get activeAccountId => _tokenStore.readActiveAccountId(); @@ -107,6 +108,12 @@ class OAuthService implements OAuthGateway { 'No OAuth token is available for this account.', ); } + if (!tokenSet.canRefresh) { + throw const OAuthException( + 'OAuthMissingRefreshToken', + 'No refresh token is available for this account.', + ); + } if (tokenSet.expiresWithin(const Duration(seconds: 60), _nowUtc())) { return refreshTokenForAccount(accountId); } @@ -131,16 +138,31 @@ class OAuthService implements OAuthGateway { authorizationEndpoint: Uri.parse(_config.oauthAuthorizationEndpoint), clientId: _config.googleOAuthClientId, scope: googleBusyMaxOAuthScope, - extraAuthorizationParameters: const {'include_granted_scopes': 'true'}, + extraAuthorizationParameters: const { + 'access_type': 'offline', + 'prompt': 'consent', + 'include_granted_scopes': 'true', + }, loginHint: loginHint, ); - final tokenSet = await exchangeAuthorizationCode( + var tokenSet = await exchangeAuthorizationCode( code: result.callback.code, codeVerifier: result.codeVerifier, redirectUri: result.redirectUri, fallbackScopeText: result.callback.scope, ); final accountId = deriveAccountId(tokenSet); + if (!tokenSet.canRefresh) { + final previous = await _readTokenSet(accountId); + if (previous?.canRefresh == true) { + tokenSet = tokenSet.copyWith(refreshToken: previous!.refreshToken); + } else { + throw const OAuthException( + 'OAuthMissingRefreshToken', + 'Google did not provide a refresh token. Try connecting again.', + ); + } + } await _tokenStore.saveOAuthTokenSet( accountId, BusyProvider.google, @@ -211,35 +233,35 @@ class OAuthService implements OAuthGateway { Future refreshActiveToken() async { final accountId = await _tokenStore.readActiveAccountId(); if (accountId == null) { - throw const OAuthException('OAuthRefreshFailed', 'No active account.'); + throw const OAuthException('OAuthMissingToken', 'No active account.'); } return refreshTokenForAccount(accountId); } Future refreshTokenForAccount(String accountId) async { + final generation = _credentialGenerations[accountId] ?? 0; final current = await _readTokenSet(accountId); if (current == null || !current.canRefresh) { throw const OAuthException( - 'OAuthRefreshFailed', + 'OAuthMissingRefreshToken', 'No refresh token is available.', ); } - try { - final refreshed = await refreshToken(current); - await _tokenStore.saveOAuthTokenSet( - accountId, - BusyProvider.google, - refreshed, + final refreshed = await refreshToken(current); + if ((_credentialGenerations[accountId] ?? 0) != generation) { + throw const OAuthException( + 'OAuthRefreshCancelled', + 'The account was removed while its credential was refreshing.', ); - return refreshed; - } on OAuthException catch (error) { - if (error is OAuthRefreshException && error.statusCode == 400) { - await _clearAccountAfterInvalidRefresh(accountId); - } - rethrow; } + await _tokenStore.saveOAuthTokenSet( + accountId, + BusyProvider.google, + refreshed, + ); + return refreshed; } Future refreshToken(OAuthTokenSet current) async { @@ -271,10 +293,17 @@ class OAuthService implements OAuthGateway { ); if (response.statusCode < 200 || response.statusCode >= 300) { + final details = _tokenEndpointFailureDetails(response.body); throw OAuthRefreshException( 'OAuthRefreshFailed', - _tokenEndpointFailureMessage(operation: 'refresh', response: response), + _tokenEndpointFailureMessage( + operation: 'refresh', + response: response, + details: details, + ), statusCode: response.statusCode, + oauthError: details?.oauthError, + oauthErrorDescription: details?.oauthErrorDescription, ); } @@ -338,6 +367,7 @@ class OAuthService implements OAuthGateway { final targetAccountId = accountId ?? await _tokenStore.readActiveAccountId(); if (targetAccountId != null) { + _invalidateCredentialWrites(targetAccountId); await _tokenStore.deleteCredential(targetAccountId); } if (targetAccountId == null || @@ -346,12 +376,9 @@ class OAuthService implements OAuthGateway { } } - Future _clearAccountAfterInvalidRefresh(String accountId) async { - final active = await _tokenStore.readActiveAccountId(); - await _tokenStore.deleteCredential(accountId); - if (active == accountId) { - await _tokenStore.clearActiveAccount(); - } + void _invalidateCredentialWrites(String accountId) { + _credentialGenerations[accountId] = + (_credentialGenerations[accountId] ?? 0) + 1; } Future _readTokenSet(String accountId) async { @@ -486,7 +513,7 @@ void _validateTokenRefreshParameters({ } if (refreshToken == null || refreshToken.trim().isEmpty) { throw const OAuthException( - 'OAuthRefreshFailed', + 'OAuthMissingRefreshToken', 'No refresh token is available.', ); } @@ -524,21 +551,22 @@ String _clientIdSuffix(String clientId) { String _tokenEndpointFailureMessage({ required String operation, required http.Response response, + _TokenEndpointFailureDetails? details, }) { - final details = _tokenEndpointFailureDetails(response.body); - if (details == null || details.text.isEmpty) { + final safeDetails = details ?? _tokenEndpointFailureDetails(response.body); + if (safeDetails == null || safeDetails.text.isEmpty) { return 'Google token $operation failed with HTTP ${response.statusCode}.'; } - if (_isMissingClientSecretError(details)) { + if (_isMissingClientSecretError(safeDetails)) { return 'This Google Desktop OAuth client requires a client secret. Re-run ' 'BusyMax with GOOGLE_OAUTH_CLIENT_SECRET set from the same Desktop ' 'OAuth client credentials.'; } - final statusPrefix = details.isJson + final statusPrefix = safeDetails.isJson ? 'Google token $operation failed' : 'Google token $operation failed with HTTP ${response.statusCode}'; - return '$statusPrefix: ${details.text}.'; + return '$statusPrefix: ${safeDetails.text}.'; } _TokenEndpointFailureDetails? _tokenEndpointFailureDetails(String body) { @@ -553,20 +581,36 @@ _TokenEndpointFailureDetails? _tokenEndpointFailureDetails(String body) { final error = redactForLog(decoded['error']).trim(); final description = redactForLog(decoded['error_description']).trim(); if (error.isNotEmpty && description.isNotEmpty) { - return _TokenEndpointFailureDetails('$error - $description', true); + return _TokenEndpointFailureDetails( + '$error - $description', + isJson: true, + oauthError: error, + oauthErrorDescription: description, + ); } if (error.isNotEmpty) { - return _TokenEndpointFailureDetails(error, true); + return _TokenEndpointFailureDetails( + error, + isJson: true, + oauthError: error, + ); } if (description.isNotEmpty) { - return _TokenEndpointFailureDetails(description, true); + return _TokenEndpointFailureDetails( + description, + isJson: true, + oauthErrorDescription: description, + ); } } } on FormatException { - return _TokenEndpointFailureDetails(redactForLog(trimmedBody), false); + return _TokenEndpointFailureDetails( + redactForLog(trimmedBody), + isJson: false, + ); } - return _TokenEndpointFailureDetails(redactForLog(trimmedBody), false); + return _TokenEndpointFailureDetails(redactForLog(trimmedBody), isJson: false); } bool _isMissingClientSecretError(_TokenEndpointFailureDetails details) { @@ -577,8 +621,15 @@ bool _isMissingClientSecretError(_TokenEndpointFailureDetails details) { } class _TokenEndpointFailureDetails { - const _TokenEndpointFailureDetails(this.text, this.isJson); + const _TokenEndpointFailureDetails( + this.text, { + required this.isJson, + this.oauthError, + this.oauthErrorDescription, + }); final String text; final bool isJson; + final String? oauthError; + final String? oauthErrorDescription; } diff --git a/lib/src/ical/ical_import_service.dart b/lib/src/ical/ical_import_service.dart index 3c6f72da..92a80864 100644 --- a/lib/src/ical/ical_import_service.dart +++ b/lib/src/ical/ical_import_service.dart @@ -312,13 +312,20 @@ _PreparedImportDraft _prepareDraft( } final resolver = IcalTimeZoneResolver.fromDocument(semantic); final providerTimeZones = IcalTimeZoneResolver.system(); + DateTime? resolvedStartInstant; + DateTime? resolvedEndInstant; try { + if (startValue.kind != IcalTemporalKind.date) { + resolvedStartInstant = resolver.toUtc(startValue); + } if (startValue.kind == IcalTemporalKind.tzidDateTime) { - resolver.toUtc(startValue); providerTimeZones.toUtc(startValue); } + final semanticEnd = master.end; + if (semanticEnd != null && semanticEnd.kind != IcalTemporalKind.date) { + resolvedEndInstant = resolver.toUtc(semanticEnd); + } if (master.end?.kind == IcalTemporalKind.tzidDateTime) { - resolver.toUtc(master.end!); providerTimeZones.toUtc(master.end!); } } on DavException catch (error) { @@ -358,7 +365,14 @@ _PreparedImportDraft _prepareDraft( 'Zero-duration date-time events cannot be represented by BusyMax.', ); } - if (!end.isAfter(start)) { + final positiveRange = allDay + ? end.isAfter(start) + : endValue != null + ? resolvedStartInstant != null && + resolvedEndInstant != null && + resolvedEndInstant.isAfter(resolvedStartInstant) + : master.duration != null && master.duration!.duration > Duration.zero; + if (!positiveRange) { return const _PreparedImportDraft.unsupported( 'The event has a non-positive duration.', ); diff --git a/lib/src/microsoft_calendar/microsoft_calendar_api_client.dart b/lib/src/microsoft_calendar/microsoft_calendar_api_client.dart index b8d5e342..894dbfdf 100644 --- a/lib/src/microsoft_calendar/microsoft_calendar_api_client.dart +++ b/lib/src/microsoft_calendar/microsoft_calendar_api_client.dart @@ -159,6 +159,7 @@ class MicrosoftCalendarApiClient implements CloudCalendarClient { required CalendarEventMutation mutation, CalendarGuestUpdatePolicy guestUpdatePolicy = CalendarGuestUpdatePolicy.send, + String? ifMatch, }) async { final json = await _requestJson( 'PATCH', @@ -174,6 +175,7 @@ class MicrosoftCalendarApiClient implements CloudCalendarClient { required String eventId, CalendarGuestUpdatePolicy guestUpdatePolicy = CalendarGuestUpdatePolicy.send, + String? ifMatch, }) { return _requestEmpty( 'DELETE', diff --git a/lib/src/microsoft_todo/api/microsoft_todo_task_remote_client.dart b/lib/src/microsoft_todo/api/microsoft_todo_task_remote_client.dart index 9a5d4002..94a9c142 100644 --- a/lib/src/microsoft_todo/api/microsoft_todo_task_remote_client.dart +++ b/lib/src/microsoft_todo/api/microsoft_todo_task_remote_client.dart @@ -9,7 +9,8 @@ class MicrosoftTodoTaskRemoteClient implements TaskRemoteClient, TaskChecklistRemoteClient, - TaskConflictSnapshotNormalizer { + TaskConflictSnapshotNormalizer, + RevisionlessTaskListConflictClient { MicrosoftTodoTaskRemoteClient({ required MicrosoftTodoApiClient client, required String defaultTimeZone, @@ -30,7 +31,8 @@ class MicrosoftTodoTaskRemoteClient if (!normalized.containsKey('notes') && snapshot.containsKey('body')) { final body = snapshot['body']; final content = body is Map ? body['content']?.toString() ?? '' : ''; - normalized['notes'] = _htmlToPlainText(content); + final contentType = body is Map ? body['contentType']?.toString() : null; + normalized['notes'] = _microsoftBodyToPlainText(content, contentType); } _putMicrosoftDateTimeAliases( normalized, @@ -511,7 +513,10 @@ TaskDto _taskDto(MicrosoftTodoTaskDto dto) { etag: dto.etag, title: dto.title ?? '', updated: _parseUtc(dto.lastModifiedDateTime), - notes: _htmlToPlainText(dto.body?.content ?? ''), + notes: _microsoftBodyToPlainText( + dto.body?.content ?? '', + dto.body?.contentType, + ), status: dto.status == 'completed' ? 'completed' : 'needsAction', completed: null, due: null, @@ -557,6 +562,12 @@ String _htmlToPlainText(String value) { .trim(); } +String _microsoftBodyToPlainText(String content, String? contentType) { + return contentType?.toLowerCase() == 'html' + ? _htmlToPlainText(content) + : content; +} + String _htmlEscape(String text) { return text .replaceAll('&', '&') diff --git a/lib/src/microsoft_todo/oauth/microsoft_oauth_service.dart b/lib/src/microsoft_todo/oauth/microsoft_oauth_service.dart index 58971c1a..d4f2000e 100644 --- a/lib/src/microsoft_todo/oauth/microsoft_oauth_service.dart +++ b/lib/src/microsoft_todo/oauth/microsoft_oauth_service.dart @@ -52,6 +52,7 @@ class MicrosoftOAuthService implements MicrosoftOAuthGateway { final RedactingLogger _logger = RedactingLogger( Logger('MicrosoftOAuthService'), ); + final Map _credentialGenerations = {}; Future signIn() async { final clientId = _config.microsoftOAuthClientId.trim(); @@ -186,15 +187,22 @@ class MicrosoftOAuthService implements MicrosoftOAuthGateway { } Future refreshTokenForAccount(String accountId) async { + final generation = _credentialGenerations[accountId] ?? 0; final current = await _readTokenSet(accountId); if (current == null || !current.canRefresh) { throw const OAuthException( - 'MicrosoftOAuthRefreshFailed', + 'MicrosoftOAuthMissingRefreshToken', 'No Microsoft refresh token is available.', ); } final refreshed = await refreshToken(current); + if ((_credentialGenerations[accountId] ?? 0) != generation) { + throw const OAuthException( + 'MicrosoftOAuthRefreshCancelled', + 'The account was removed while its credential was refreshing.', + ); + } await _tokenStore.saveOAuthTokenSet( accountId, BusyProvider.microsoft, @@ -230,10 +238,13 @@ class MicrosoftOAuthService implements MicrosoftOAuthGateway { }, ); if (response.statusCode < 200 || response.statusCode >= 300) { + final details = _tokenEndpointFailureDetails(response.body); throw OAuthRefreshException( 'MicrosoftOAuthRefreshFailed', - _tokenEndpointFailureMessage('refresh', response), + _tokenEndpointFailureMessage('refresh', response, details: details), statusCode: response.statusCode, + oauthError: details?.oauthError, + oauthErrorDescription: details?.oauthErrorDescription, ); } @@ -249,6 +260,8 @@ class MicrosoftOAuthService implements MicrosoftOAuthGateway { @override Future signOutAccount(String accountId) async { + _credentialGenerations[accountId] = + (_credentialGenerations[accountId] ?? 0) + 1; await _tokenStore.deleteCredential(accountId); if (await _tokenStore.readActiveAccountId() == accountId) { await _tokenStore.clearActiveAccount(); @@ -350,7 +363,7 @@ void _validateTokenRefreshParameters({ } if (refreshToken == null || refreshToken.trim().isEmpty) { throw const OAuthException( - 'MicrosoftOAuthRefreshFailed', + 'MicrosoftOAuthMissingRefreshToken', 'No Microsoft refresh token is available.', ); } @@ -380,16 +393,20 @@ String _clientIdSuffix(String clientId) { return '...${trimmed.substring(trimmed.length - suffixLength)}'; } -String _tokenEndpointFailureMessage(String operation, http.Response response) { - final details = _tokenEndpointFailureDetails(response.body); - if (details == null || details.isEmpty) { +String _tokenEndpointFailureMessage( + String operation, + http.Response response, { + _TokenEndpointFailureDetails? details, +}) { + final safeDetails = details ?? _tokenEndpointFailureDetails(response.body); + if (safeDetails == null || safeDetails.text.isEmpty) { return 'Microsoft token $operation failed with HTTP ' '${response.statusCode}.'; } - return 'Microsoft token $operation failed: $details.'; + return 'Microsoft token $operation failed: ${safeDetails.text}.'; } -String? _tokenEndpointFailureDetails(String body) { +_TokenEndpointFailureDetails? _tokenEndpointFailureDetails(String body) { final trimmedBody = body.trim(); if (trimmedBody.isEmpty) { return null; @@ -401,17 +418,36 @@ String? _tokenEndpointFailureDetails(String body) { final error = redactForLog(json['error']).trim(); final description = redactForLog(json['error_description']).trim(); if (error.isNotEmpty && description.isNotEmpty) { - return '$error - $description'; + return _TokenEndpointFailureDetails( + '$error - $description', + oauthError: error, + oauthErrorDescription: description, + ); } if (error.isNotEmpty) { - return error; + return _TokenEndpointFailureDetails(error, oauthError: error); } if (description.isNotEmpty) { - return description; + return _TokenEndpointFailureDetails( + description, + oauthErrorDescription: description, + ); } } } on FormatException { - return redactForLog(trimmedBody); + return _TokenEndpointFailureDetails(redactForLog(trimmedBody)); } - return redactForLog(trimmedBody); + return _TokenEndpointFailureDetails(redactForLog(trimmedBody)); +} + +class _TokenEndpointFailureDetails { + const _TokenEndpointFailureDetails( + this.text, { + this.oauthError, + this.oauthErrorDescription, + }); + + final String text; + final String? oauthError; + final String? oauthErrorDescription; } diff --git a/lib/src/platform/gtk_animation_settings_service.dart b/lib/src/platform/gtk_animation_settings_service.dart new file mode 100644 index 00000000..f360e766 --- /dev/null +++ b/lib/src/platform/gtk_animation_settings_service.dart @@ -0,0 +1,60 @@ +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +/// Bridges GTK's `gtk-enable-animations` setting into Flutter. +class GtkAnimationSettingsService { + const GtkAnimationSettingsService({ + MethodChannel channel = _settingsChannel, + EventChannel events = _eventsChannel, + }) : _channel = channel, + _events = events; + + static const _settingsChannel = MethodChannel( + 'io.busystack.busymax/gtk_settings', + ); + static const _eventsChannel = EventChannel( + 'io.busystack.busymax/gtk_animation_settings', + ); + + final MethodChannel _channel; + final EventChannel _events; + + Future getAnimationsEnabled() async { + try { + return await _channel.invokeMethod('getGtkAnimationsEnabled'); + } on MissingPluginException { + return null; + } on PlatformException { + return null; + } + } + + Stream watchAnimationsEnabled() async* { + try { + await for (final value in _events.receiveBroadcastStream()) { + yield value is bool ? value : null; + } + } on MissingPluginException { + yield null; + } on PlatformException { + yield null; + } + } +} + +final initialGtkAnimationsEnabledProvider = Provider((ref) => null); + +final gtkAnimationsEnabledProvider = StreamProvider((ref) async* { + var previous = ref.watch(initialGtkAnimationsEnabledProvider); + yield previous; + // Linux bootstrap supplies a concrete initial value only when the native + // bridge exists. Avoid activating an EventChannel on widget-test and + // non-Linux engines where no plugin is registered. + if (previous == null) return; + await for (final value + in const GtkAnimationSettingsService().watchAnimationsEnabled()) { + if (value == previous) continue; + previous = value; + yield value; + } +}); diff --git a/lib/src/platform/gtk_font_service.dart b/lib/src/platform/gtk_font_service.dart index 79c35338..e4a167fe 100644 --- a/lib/src/platform/gtk_font_service.dart +++ b/lib/src/platform/gtk_font_service.dart @@ -137,6 +137,112 @@ final gtkFontSettingsProvider = StreamProvider((ref) { ); }); +@immutable +final class GtkSearchEntryStateStyle { + const GtkSearchEntryStateStyle({ + required this.background, + required this.foreground, + required this.borderTop, + required this.borderRight, + required this.borderBottom, + required this.borderLeft, + required this.borderColor, + required this.primaryIconForeground, + required this.primaryIconForegroundRtl, + required this.secondaryIconForeground, + required this.secondaryIconForegroundRtl, + required this.radius, + this.hasInnerFocus = false, + this.innerFocusColor = const Color(0x00000000), + this.innerFocusWidth = 0, + }); + + final Color background; + final Color foreground; + final double borderTop; + final double borderRight; + final double borderBottom; + final double borderLeft; + final Color borderColor; + + final Color primaryIconForeground; + final Color primaryIconForegroundRtl; + final Color secondaryIconForeground; + final Color secondaryIconForegroundRtl; + final double radius; + final bool hasInnerFocus; + final Color innerFocusColor; + final double innerFocusWidth; + + @override + bool operator ==(Object other) { + return identical(this, other) || + other is GtkSearchEntryStateStyle && + other.background == background && + other.foreground == foreground && + other.borderTop == borderTop && + other.borderRight == borderRight && + other.borderBottom == borderBottom && + other.borderLeft == borderLeft && + other.borderColor == borderColor && + other.primaryIconForeground == primaryIconForeground && + other.primaryIconForegroundRtl == primaryIconForegroundRtl && + other.secondaryIconForeground == secondaryIconForeground && + other.secondaryIconForegroundRtl == secondaryIconForegroundRtl && + other.radius == radius && + other.hasInnerFocus == hasInnerFocus && + other.innerFocusColor == innerFocusColor && + other.innerFocusWidth == innerFocusWidth; + } + + @override + int get hashCode => Object.hash( + background, + foreground, + borderTop, + borderRight, + borderBottom, + borderLeft, + borderColor, + primaryIconForeground, + primaryIconForegroundRtl, + secondaryIconForeground, + secondaryIconForegroundRtl, + radius, + hasInnerFocus, + innerFocusColor, + innerFocusWidth, + ); +} + +@immutable +final class GtkSearchEntryTheme { + const GtkSearchEntryTheme({ + required this.normal, + required this.focused, + required this.backdrop, + required this.backdropFocused, + }); + + final GtkSearchEntryStateStyle normal; + final GtkSearchEntryStateStyle focused; + final GtkSearchEntryStateStyle backdrop; + final GtkSearchEntryStateStyle backdropFocused; + + @override + bool operator ==(Object other) { + return identical(this, other) || + other is GtkSearchEntryTheme && + other.normal == normal && + other.focused == focused && + other.backdrop == backdrop && + other.backdropFocused == backdropFocused; + } + + @override + int get hashCode => Object.hash(normal, focused, backdrop, backdropFocused); +} + @immutable class GtkThemeColors { const GtkThemeColors({ @@ -166,6 +272,7 @@ class GtkThemeColors { this.floatingBorder, this.sidebarBorder, this.shade, + this.searchEntry, }); final Brightness brightness; @@ -194,6 +301,7 @@ class GtkThemeColors { final Color? floatingBorder; final Color? sidebarBorder; final Color? shade; + final GtkSearchEntryTheme? searchEntry; @override bool operator ==(Object other) { @@ -224,7 +332,8 @@ class GtkThemeColors { other.cardShade == cardShade && other.floatingBorder == floatingBorder && other.sidebarBorder == sidebarBorder && - other.shade == shade; + other.shade == shade && + other.searchEntry == searchEntry; } @override @@ -255,6 +364,7 @@ class GtkThemeColors { floatingBorder, sidebarBorder, shade, + searchEntry, ]); } @@ -344,9 +454,98 @@ GtkThemeColors? _parseThemeColors(Object? value) { floatingBorder: _parseColor(value['floatingBorder']), sidebarBorder: _parseColor(value['sidebarBorder']), shade: _parseColor(value['shade']), + searchEntry: _parseSearchEntryTheme(value['searchEntry']), ); } +GtkSearchEntryTheme? _parseSearchEntryTheme(Object? value) { + if (value is! Map) { + return null; + } + final normal = _parseSearchEntryStateStyle(value['normal']); + final focused = _parseSearchEntryStateStyle(value['focused']); + final backdrop = _parseSearchEntryStateStyle(value['backdrop']); + final backdropFocused = _parseSearchEntryStateStyle(value['backdropFocused']); + if (normal == null || + focused == null || + backdrop == null || + backdropFocused == null) { + return null; + } + return GtkSearchEntryTheme( + normal: normal, + focused: focused, + backdrop: backdrop, + backdropFocused: backdropFocused, + ); +} + +GtkSearchEntryStateStyle? _parseSearchEntryStateStyle(Object? value) { + if (value is! Map) { + return null; + } + final background = _parseColor(value['background']); + final foreground = _parseColor(value['foreground']); + final borderColor = _parseColor(value['borderColor']); + final primaryIconForeground = _parseColor(value['primaryIconForeground']); + final primaryIconForegroundRtl = _parseColor( + value['primaryIconForegroundRtl'], + ); + final secondaryIconForeground = _parseColor(value['secondaryIconForeground']); + final secondaryIconForegroundRtl = _parseColor( + value['secondaryIconForegroundRtl'], + ); + final borderTop = _parseNonNegativeDouble(value['borderTop']); + final borderRight = _parseNonNegativeDouble(value['borderRight']); + final borderBottom = _parseNonNegativeDouble(value['borderBottom']); + final borderLeft = _parseNonNegativeDouble(value['borderLeft']); + final radius = _parseNonNegativeDouble(value['radius']); + final hasInnerFocus = value['hasInnerFocus'] is bool + ? value['hasInnerFocus'] as bool + : false; + final innerFocusColor = _parseColor(value['innerFocusColor']); + final innerFocusWidth = _parseNonNegativeDouble(value['innerFocusWidth']); + if (background == null || + foreground == null || + borderColor == null || + primaryIconForeground == null || + primaryIconForegroundRtl == null || + secondaryIconForeground == null || + secondaryIconForegroundRtl == null || + borderTop == null || + borderRight == null || + borderBottom == null || + borderLeft == null || + radius == null) { + return null; + } + return GtkSearchEntryStateStyle( + background: background, + foreground: foreground, + borderTop: borderTop, + borderRight: borderRight, + borderBottom: borderBottom, + borderLeft: borderLeft, + borderColor: borderColor, + primaryIconForeground: primaryIconForeground, + primaryIconForegroundRtl: primaryIconForegroundRtl, + secondaryIconForeground: secondaryIconForeground, + secondaryIconForegroundRtl: secondaryIconForegroundRtl, + radius: radius, + hasInnerFocus: hasInnerFocus, + innerFocusColor: innerFocusColor ?? const Color(0x00000000), + innerFocusWidth: innerFocusWidth ?? 0, + ); +} + +double? _parseNonNegativeDouble(Object? value) { + if (value is! num) { + return null; + } + final parsed = value.toDouble(); + return parsed.isFinite && parsed >= 0 ? parsed : null; +} + Color? _parseColor(Object? value) { if (value is! String) { return null; diff --git a/lib/src/platform/gtk_header_icon_service.dart b/lib/src/platform/gtk_header_icon_service.dart new file mode 100644 index 00000000..54e84c27 --- /dev/null +++ b/lib/src/platform/gtk_header_icon_service.dart @@ -0,0 +1,334 @@ +import 'dart:async'; +import 'dart:collection'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +const gtkHeaderIconsChannelName = 'io.busystack.busymax/gtk_header_icons'; +const gtkHeaderIconsChangedChannelName = + 'io.busystack.busymax/gtk_header_icons_changed'; + +@immutable +final class GtkHeaderIconAsset { + const GtkHeaderIconAsset({ + required this.bytes, + required this.resolvedName, + required this.scale, + required this.pixelWidth, + required this.pixelHeight, + }); + + final Uint8List bytes; + final String resolvedName; + final int scale; + final int pixelWidth; + final int pixelHeight; +} + +enum BusyMaxLinuxHeaderIcon { + back, + sidebar, + today, + previous, + next, + search, + searchEntryFind, + searchClear, + mainMenu, + create, + refresh, + viewDay, + viewWeek, + viewMonth, + viewYear, + viewAgenda, + viewMenuArrow, + filter, + close, +} + +extension BusyMaxLinuxHeaderIconNames on BusyMaxLinuxHeaderIcon { + List get gtkNames => switch (this) { + BusyMaxLinuxHeaderIcon.back => const ['go-previous-symbolic'], + BusyMaxLinuxHeaderIcon.sidebar => const ['sidebar-show-symbolic'], + BusyMaxLinuxHeaderIcon.today => const [ + 'today-symbolic', + 'x-office-calendar-symbolic', + ], + BusyMaxLinuxHeaderIcon.previous => const ['go-previous-symbolic'], + BusyMaxLinuxHeaderIcon.next => const ['go-next-symbolic'], + BusyMaxLinuxHeaderIcon.search => const ['system-search-symbolic'], + BusyMaxLinuxHeaderIcon.searchEntryFind => const ['edit-find-symbolic'], + BusyMaxLinuxHeaderIcon.searchClear => const ['edit-clear-symbolic'], + BusyMaxLinuxHeaderIcon.mainMenu => const ['open-menu-symbolic'], + BusyMaxLinuxHeaderIcon.create => const ['list-add-symbolic'], + BusyMaxLinuxHeaderIcon.refresh => const ['view-refresh-symbolic'], + BusyMaxLinuxHeaderIcon.viewDay => const ['view-continuous-symbolic'], + BusyMaxLinuxHeaderIcon.viewWeek => const [ + 'calendar-week-symbolic', + 'x-office-calendar-symbolic', + ], + BusyMaxLinuxHeaderIcon.viewMonth => const [ + 'calendar-month-symbolic', + 'x-office-calendar-symbolic', + ], + BusyMaxLinuxHeaderIcon.viewYear => const ['view-app-grid-symbolic'], + BusyMaxLinuxHeaderIcon.viewAgenda => const ['view-list-symbolic'], + BusyMaxLinuxHeaderIcon.viewMenuArrow => const ['pan-down-symbolic'], + BusyMaxLinuxHeaderIcon.filter => const ['view-filter-symbolic'], + BusyMaxLinuxHeaderIcon.close => const ['window-close-symbolic'], + }; + + bool get directionSensitive => switch (this) { + BusyMaxLinuxHeaderIcon.back || + BusyMaxLinuxHeaderIcon.sidebar || + BusyMaxLinuxHeaderIcon.previous || + BusyMaxLinuxHeaderIcon.next => true, + _ => false, + }; + + bool get allowMissing => this == BusyMaxLinuxHeaderIcon.filter; +} + +final class GtkHeaderIconCatalog { + GtkHeaderIconCatalog( + Map assets, { + required this.revision, + }) : assets = UnmodifiableMapView(assets); + + GtkHeaderIconCatalog.empty() : assets = const {}, revision = 0; + + final Map assets; + final int revision; + + int? get scale => assets.values.firstOrNull?.scale; + + GtkHeaderIconAsset? assetFor( + BusyMaxLinuxHeaderIcon icon, + TextDirection direction, + ) => assets[_catalogKey(icon, direction)]; +} + +typedef GtkHeaderIconBatchLoader = + Future Function(List> requests); + +/// Cached Linux application-header artwork supplied by GTK's active icon +/// theme. A replacement catalog is published only after its full batch loads. +class GtkHeaderIconService extends ChangeNotifier { + GtkHeaderIconService({ + MethodChannel methodChannel = const MethodChannel( + gtkHeaderIconsChannelName, + ), + EventChannel changedEvents = const EventChannel( + gtkHeaderIconsChangedChannelName, + ), + }) : _loadBatch = ((requests) => + methodChannel.invokeMethod('loadIcons', requests)), + _changedEvents = changedEvents.receiveBroadcastStream(); + + @visibleForTesting + GtkHeaderIconService.testing({ + GtkHeaderIconCatalog? initialCatalog, + GtkHeaderIconBatchLoader? loadBatch, + Stream? changedEvents, + }) : _catalog = initialCatalog ?? GtkHeaderIconCatalog.empty(), + _loadBatch = loadBatch, + _changedEvents = changedEvents; + + GtkHeaderIconService.fallback() : _loadBatch = null, _changedEvents = null; + + static final fallbackInstance = GtkHeaderIconService.fallback(); + + final GtkHeaderIconBatchLoader? _loadBatch; + final Stream? _changedEvents; + GtkHeaderIconCatalog _catalog = GtkHeaderIconCatalog.empty(); + StreamSubscription? _subscription; + bool _reloadRunning = false; + int? _queuedRevision; + int _highestSeenRevision = 0; + int _reloadCount = 0; + bool _disposed = false; + + GtkHeaderIconCatalog get catalog => _catalog; + + @visibleForTesting + int get reloadCount => _reloadCount; + + Future initialize({ + Duration timeout = const Duration(seconds: 5), + }) async { + final loadBatch = _loadBatch; + if (loadBatch == null) return false; + _subscription ??= _changedEvents?.listen( + _handleInvalidation, + onError: (Object error, StackTrace stackTrace) { + debugPrint('GTK header-icon invalidation stream failed: $error'); + }, + ); + _reloadRunning = true; + try { + var targetRevision = 0; + while (true) { + final loaded = await _loadCatalog(targetRevision).timeout(timeout); + if (_disposed) return false; + _catalog = loaded; + notifyListeners(); + final queued = _queuedRevision; + _queuedRevision = null; + if (queued == null || queued <= targetRevision) { + return loaded.assets.isNotEmpty; + } + targetRevision = queued; + } + } on Object catch (error) { + debugPrint('GTK header-icon catalog preload failed: $error'); + return false; + } finally { + _reloadRunning = false; + } + } + + void _handleInvalidation(Object? event) { + final revision = switch (event) { + {'revision': final int value} => value, + _ => _highestSeenRevision + 1, + }; + if (revision <= _highestSeenRevision) return; + _highestSeenRevision = revision; + if (_reloadRunning) { + _queuedRevision = revision; + return; + } + unawaited(_reloadFromInvalidation(revision)); + } + + Future _reloadFromInvalidation(int revision) async { + _reloadRunning = true; + var targetRevision = revision; + try { + while (!_disposed) { + try { + final replacement = await _loadCatalog(targetRevision); + if (_disposed) return; + _catalog = replacement; + notifyListeners(); + } on Object catch (error) { + debugPrint('GTK header-icon catalog refresh failed: $error'); + } + final queued = _queuedRevision; + _queuedRevision = null; + if (queued == null || queued <= targetRevision) break; + targetRevision = queued; + } + } finally { + _reloadRunning = false; + } + } + + Future _loadCatalog(int revision) async { + final loadBatch = _loadBatch; + if (loadBatch == null) return GtkHeaderIconCatalog.empty(); + _reloadCount += 1; + final response = await loadBatch(_requests()); + if (response is! Map) { + throw const FormatException('GTK header-icon response was not a map.'); + } + final assets = {}; + for (final entry in response.entries) { + final key = entry.key; + final value = entry.value; + if (key is! String || value is! Map) continue; + final bytes = value['bytes']; + final resolvedName = value['resolvedName']; + final scale = value['scale']; + final pixelWidth = value['pixelWidth']; + final pixelHeight = value['pixelHeight']; + if (bytes is! Uint8List || + resolvedName is! String || + scale is! int || + pixelWidth is! int || + pixelHeight is! int || + bytes.isEmpty || + scale < 1 || + pixelWidth < 1 || + pixelHeight < 1) { + continue; + } + assets[key] = GtkHeaderIconAsset( + bytes: bytes, + resolvedName: resolvedName, + scale: scale, + pixelWidth: pixelWidth, + pixelHeight: pixelHeight, + ); + } + if (assets.isEmpty) { + throw const FormatException('GTK returned no usable header icons.'); + } + return GtkHeaderIconCatalog(assets, revision: revision); + } + + static List> _requests() { + return [ + for (final icon in BusyMaxLinuxHeaderIcon.values) + for (final direction + in icon.directionSensitive + ? TextDirection.values + : const [TextDirection.ltr]) + { + 'key': _catalogKey(icon, direction), + 'names': icon.gtkNames, + 'direction': direction == TextDirection.rtl ? 'rtl' : 'ltr', + 'allowMissing': icon.allowMissing, + }, + ]; + } + + @override + void dispose() { + _disposed = true; + unawaited(_subscription?.cancel()); + _subscription = null; + super.dispose(); + } +} + +String _catalogKey(BusyMaxLinuxHeaderIcon icon, TextDirection direction) { + final effectiveDirection = icon.directionSensitive + ? direction + : TextDirection.ltr; + return '${icon.name}.${effectiveDirection.name}'; +} + +final gtkHeaderIconServiceProvider = Provider((ref) { + final service = GtkHeaderIconService.fallback(); + ref.onDispose(service.dispose); + return service; +}); + +class GtkHeaderIconScope extends InheritedNotifier { + const GtkHeaderIconScope({ + super.key, + required GtkHeaderIconService service, + required super.child, + }) : super(notifier: service); + + static GtkHeaderIconService of(BuildContext context) { + return context + .dependOnInheritedWidgetOfExactType() + ?.notifier ?? + GtkHeaderIconService.fallbackInstance; + } +} + +String resolvedNativeHeaderIconName( + BuildContext context, + BusyMaxLinuxHeaderIcon icon, +) { + final asset = GtkHeaderIconScope.of( + context, + ).catalog.assetFor(icon, Directionality.of(context)); + return asset?.resolvedName ?? icon.gtkNames.first; +} diff --git a/lib/src/platform/gtk_window_preferences_service.dart b/lib/src/platform/gtk_window_preferences_service.dart new file mode 100644 index 00000000..481f72ce --- /dev/null +++ b/lib/src/platform/gtk_window_preferences_service.dart @@ -0,0 +1,188 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +enum GtkTitlebarAction { none, minimize, toggleMaximize, menu, lower } + +@immutable +final class GtkDecorationLayout { + const GtkDecorationLayout({required this.left, required this.right}); + + factory GtkDecorationLayout.parse(String? value) { + final sides = (value ?? 'menu:minimize,maximize,close').split(':'); + final left = sides.isEmpty ? '' : sides.first; + final right = sides.length < 2 ? '' : sides[1]; + return GtkDecorationLayout( + left: _parseControls(left), + right: _parseControls(right), + ); + } + + final List left; + final List right; + + @override + bool operator ==(Object other) => + other is GtkDecorationLayout && + listEquals(other.left, left) && + listEquals(other.right, right); + + @override + int get hashCode => Object.hash(Object.hashAll(left), Object.hashAll(right)); + + static List _parseControls(String source) { + return List.unmodifiable( + source + .split(',') + .map( + (token) => switch (token.trim()) { + 'menu' => GtkWindowDecorationElement.fallbackApplicationMenu, + 'icon' => GtkWindowDecorationElement.windowIcon, + 'minimize' => GtkWindowDecorationElement.minimize, + 'maximize' => GtkWindowDecorationElement.maximize, + 'close' => GtkWindowDecorationElement.close, + _ => null, + }, + ) + .whereType(), + ); + } +} + +/// An element from GTK 3's physical `gtk-decoration-layout` setting. +/// +/// The fallback application menu is intentionally distinct from +/// [GtkTitlebarAction.menu], which requests the window-manager menu. +enum GtkWindowDecorationElement { + fallbackApplicationMenu, + windowIcon, + minimize, + maximize, + close, +} + +@immutable +final class GtkWindowPreferences { + const GtkWindowPreferences({ + required this.decorationLayout, + required this.doubleClick, + required this.middleClick, + required this.rightClick, + }); + + factory GtkWindowPreferences.fromMessage(Map message) { + return GtkWindowPreferences( + decorationLayout: GtkDecorationLayout.parse( + message['decorationLayout'] as String?, + ), + doubleClick: _parseAction(message['doubleClick'] as String?), + middleClick: _parseAction(message['middleClick'] as String?), + rightClick: _parseAction(message['rightClick'] as String?), + ); + } + + factory GtkWindowPreferences.defaults() => GtkWindowPreferences( + decorationLayout: GtkDecorationLayout.parse(null), + doubleClick: GtkTitlebarAction.toggleMaximize, + middleClick: GtkTitlebarAction.none, + rightClick: GtkTitlebarAction.menu, + ); + + final GtkDecorationLayout decorationLayout; + final GtkTitlebarAction doubleClick; + final GtkTitlebarAction middleClick; + final GtkTitlebarAction rightClick; + + @override + bool operator ==(Object other) => + other is GtkWindowPreferences && + other.decorationLayout == decorationLayout && + other.doubleClick == doubleClick && + other.middleClick == middleClick && + other.rightClick == rightClick; + + @override + int get hashCode => + Object.hash(decorationLayout, doubleClick, middleClick, rightClick); + + static GtkTitlebarAction _parseAction(String? value) => switch (value) { + 'minimize' => GtkTitlebarAction.minimize, + 'toggle-maximize' => GtkTitlebarAction.toggleMaximize, + 'menu' => GtkTitlebarAction.menu, + 'lower' => GtkTitlebarAction.lower, + _ => GtkTitlebarAction.none, + }; +} + +class GtkWindowPreferencesService { + const GtkWindowPreferencesService({ + MethodChannel channel = const MethodChannel( + 'io.busystack.busymax/gtk_settings', + ), + EventChannel events = const EventChannel( + 'io.busystack.busymax/gtk_window_preferences', + ), + }) : _channel = channel, + _events = events; + + final MethodChannel _channel; + final EventChannel _events; + + Future load() async { + try { + final value = await _channel.invokeMapMethod( + 'getGtkWindowPreferences', + ); + return value == null ? null : GtkWindowPreferences.fromMessage(value); + } on MissingPluginException { + return null; + } on PlatformException { + return null; + } + } + + Stream watch() async* { + try { + await for (final value in _events.receiveBroadcastStream()) { + if (value is Map) { + yield GtkWindowPreferences.fromMessage(value); + } + } + } on MissingPluginException { + yield null; + } on PlatformException { + yield null; + } + } + + Future lowerWindow() async { + try { + await _channel.invokeMethod('lowerWindow'); + } on MissingPluginException { + // Tests and non-Linux compositions do not expose the GTK bridge. + } on PlatformException { + // The compositor may refuse lowering; leave the window unchanged. + } + } +} + +final initialGtkWindowPreferencesProvider = Provider( + (ref) => null, +); + +final gtkWindowPreferencesProvider = StreamProvider(( + ref, +) async* { + final initial = ref.watch(initialGtkWindowPreferencesProvider); + var previous = initial ?? GtkWindowPreferences.defaults(); + yield previous; + // Linux bootstrap supplies a concrete initial value only when the native + // bridge exists. Avoid activating an EventChannel on widget-test and + // non-Linux engines where no plugin is registered. + if (initial == null) return; + await for (final update in const GtkWindowPreferencesService().watch()) { + if (update == null || update == previous) continue; + previous = update; + yield update; + } +}); diff --git a/lib/src/platform/linux_header_bar_configuration_synchronizer.dart b/lib/src/platform/linux_header_bar_configuration_synchronizer.dart deleted file mode 100644 index 04e11d02..00000000 --- a/lib/src/platform/linux_header_bar_configuration_synchronizer.dart +++ /dev/null @@ -1,158 +0,0 @@ -import 'dart:async'; - -import 'package:flutter/scheduler.dart'; -import 'package:flutter/widgets.dart'; - -import 'linux_header_bar_service.dart'; - -@immutable -final class BusyMaxHeaderBarConfiguration { - const BusyMaxHeaderBarConfiguration({ - required this.labels, - required this.sidebarWidth, - required this.textDirection, - required this.theme, - }); - - final BusyMaxHeaderBarLabels labels; - final double sidebarWidth; - final TextDirection textDirection; - final BusyMaxHeaderBarTheme theme; - - @override - bool operator ==(Object other) { - return identical(this, other) || - other is BusyMaxHeaderBarConfiguration && - labels == other.labels && - sidebarWidth == other.sidebarWidth && - textDirection == other.textDirection && - theme == other.theme; - } - - @override - int get hashCode => Object.hash(labels, sidebarWidth, textDirection, theme); -} - -typedef BusyMaxHeaderBarConfigurationApplier = - Future Function(BusyMaxHeaderBarConfiguration configuration); -typedef BusyMaxAfterFrameScheduler = void Function(VoidCallback callback); -typedef BusyMaxHeaderBarConfigurationErrorReporter = - void Function(Object error, StackTrace stackTrace); - -/// Coalesces build-time native chrome updates and applies them serially. -/// -/// A route or theme change can rebuild the application several times in one -/// frame. Only the latest configuration from that frame reaches GTK, while an -/// update requested during an in-flight platform call is queued behind it. -final class BusyMaxHeaderBarConfigurationSynchronizer { - BusyMaxHeaderBarConfigurationSynchronizer(LinuxHeaderBarService service) - : this._( - apply: (configuration) async { - await service.initialize(); - await service.setLocalizedLabels(configuration.labels); - await service.setSidebarWidth(configuration.sidebarWidth); - await service.setTextDirection(configuration.textDirection); - await service.setTheme(configuration.theme); - }, - scheduleAfterFrame: _afterCurrentFrame, - reportError: _reportApplyFailure, - ); - - @visibleForTesting - BusyMaxHeaderBarConfigurationSynchronizer.forTesting({ - required BusyMaxHeaderBarConfigurationApplier apply, - required BusyMaxAfterFrameScheduler scheduleAfterFrame, - BusyMaxHeaderBarConfigurationErrorReporter? reportError, - }) : this._( - apply: apply, - scheduleAfterFrame: scheduleAfterFrame, - reportError: reportError ?? _reportApplyFailure, - ); - - BusyMaxHeaderBarConfigurationSynchronizer._({ - required BusyMaxHeaderBarConfigurationApplier apply, - required BusyMaxAfterFrameScheduler scheduleAfterFrame, - required BusyMaxHeaderBarConfigurationErrorReporter reportError, - }) : _apply = apply, - _scheduleAfterFrame = scheduleAfterFrame, - _reportError = reportError; - - final BusyMaxHeaderBarConfigurationApplier _apply; - final BusyMaxAfterFrameScheduler _scheduleAfterFrame; - final BusyMaxHeaderBarConfigurationErrorReporter _reportError; - - BusyMaxHeaderBarConfiguration? _requested; - Future _tail = Future.value(); - var _revision = 0; - var _disposed = false; - - void schedule(BusyMaxHeaderBarConfiguration configuration) { - if (_disposed || _requested == configuration) { - return; - } - _requested = configuration; - final revision = ++_revision; - _scheduleAfterFrame(() { - if (_disposed || revision != _revision) { - return; - } - _tail = _tail.then((_) => _applySafely(configuration, revision)); - }); - } - - @visibleForTesting - Future get settled => _tail; - - void dispose() { - _disposed = true; - _revision += 1; - } - - Future _applyIfCurrent( - BusyMaxHeaderBarConfiguration configuration, - int revision, - ) async { - if (_disposed || revision != _revision) { - return; - } - await _apply(configuration); - } - - Future _applySafely( - BusyMaxHeaderBarConfiguration configuration, - int revision, - ) async { - try { - await _applyIfCurrent(configuration, revision); - } catch (error, stackTrace) { - // A platform-channel failure must not poison the serial queue. Clear the - // deduplication token only when this remains the newest request so an - // equivalent configuration can be retried on a later rebuild. - if (!_disposed && revision == _revision && _requested == configuration) { - _requested = null; - } - try { - _reportError(error, stackTrace); - } catch (reportingError, reportingStackTrace) { - _reportApplyFailure(reportingError, reportingStackTrace); - } - } - } - - static void _reportApplyFailure(Object error, StackTrace stackTrace) { - FlutterError.reportError( - FlutterErrorDetails( - exception: error, - stack: stackTrace, - library: 'BusyMax Linux header bar', - context: ErrorDescription( - 'while applying native header-bar configuration', - ), - ), - ); - } - - static void _afterCurrentFrame(VoidCallback callback) { - SchedulerBinding.instance.addPostFrameCallback((_) => callback()); - } -} diff --git a/lib/src/platform/linux_header_bar_provider.dart b/lib/src/platform/linux_header_bar_provider.dart deleted file mode 100644 index 52e9b442..00000000 --- a/lib/src/platform/linux_header_bar_provider.dart +++ /dev/null @@ -1,14 +0,0 @@ -import 'package:flutter_riverpod/flutter_riverpod.dart'; - -import 'linux_header_bar_service.dart'; - -/// Application-scoped owner of the native Linux header-bar bridge. -/// -/// Keeping the provider beside the platform service lets shared presentation -/// infrastructure resolve the bridge without depending on the application's -/// composition root. -final linuxHeaderBarServiceProvider = Provider((ref) { - final service = LinuxHeaderBarService(); - ref.onDispose(service.dispose); - return service; -}); diff --git a/lib/src/platform/linux_header_bar_service.dart b/lib/src/platform/linux_header_bar_service.dart deleted file mode 100644 index acf1dbd7..00000000 --- a/lib/src/platform/linux_header_bar_service.dart +++ /dev/null @@ -1,1088 +0,0 @@ -import 'dart:async'; -import 'dart:io'; - -import 'package:flutter/services.dart'; -import 'package:flutter/widgets.dart'; - -import '../schedule/schedule_view_mode.dart'; - -const int busyMaxOnboardingContentMaxWidth = 480; - -enum BusyMaxHeaderBarAction { - back, - continueSetup, - sidebarToggle, - today, - previous, - next, - viewModeDay, - viewModeWeek, - viewModeMonth, - viewModeYear, - viewModeAgenda, - search, - createEvent, - createTask, - refresh, - settings, - keyboardShortcuts, - reportIssue, - aboutBusyMax, -} - -sealed class BusyMaxHeaderBarSearchEvent { - const BusyMaxHeaderBarSearchEvent(); -} - -@immutable -final class BusyMaxHeaderBarSearchQueryChanged - extends BusyMaxHeaderBarSearchEvent { - const BusyMaxHeaderBarSearchQueryChanged(this.query); - - final String query; -} - -@immutable -final class BusyMaxHeaderBarSearchFocusChanged - extends BusyMaxHeaderBarSearchEvent { - const BusyMaxHeaderBarSearchFocusChanged(this.focused); - - final bool focused; -} - -@immutable -final class BusyMaxHeaderBarSearchCleared extends BusyMaxHeaderBarSearchEvent { - const BusyMaxHeaderBarSearchCleared(); -} - -@immutable -final class BusyMaxHeaderBarSearchEscapePressed - extends BusyMaxHeaderBarSearchEvent { - const BusyMaxHeaderBarSearchEscapePressed(); -} - -@immutable -class BusyMaxHeaderBarLabels { - const BusyMaxHeaderBarLabels({ - required this.today, - required this.day, - required this.week, - required this.month, - required this.year, - required this.agenda, - required this.search, - required this.create, - required this.createEvent, - required this.createTask, - required this.refresh, - required this.menu, - required this.previous, - required this.next, - required this.showSidebarPanel, - required this.hideSidebarPanel, - required this.back, - required this.settings, - required this.keyboardShortcuts, - required this.reportIssue, - required this.aboutBusyMax, - this.todayShortcut = '', - this.dayShortcut = '', - this.weekShortcut = '', - this.monthShortcut = '', - this.yearShortcut = '', - this.agendaShortcut = '', - this.searchShortcut = '', - this.sidebarShortcut = '', - this.createEventShortcut = '', - this.createTaskShortcut = '', - this.previousShortcut = '', - this.nextShortcut = '', - this.settingsShortcut = '', - this.keyboardShortcutsShortcut = '', - }); - - final String today; - final String day; - final String week; - final String month; - final String year; - final String agenda; - final String search; - final String create; - final String createEvent; - final String createTask; - final String refresh; - final String menu; - final String previous; - final String next; - final String showSidebarPanel; - final String hideSidebarPanel; - final String back; - final String settings; - final String keyboardShortcuts; - final String reportIssue; - final String aboutBusyMax; - final String todayShortcut; - final String dayShortcut; - final String weekShortcut; - final String monthShortcut; - final String yearShortcut; - final String agendaShortcut; - final String searchShortcut; - final String sidebarShortcut; - final String createEventShortcut; - final String createTaskShortcut; - final String previousShortcut; - final String nextShortcut; - final String settingsShortcut; - final String keyboardShortcutsShortcut; - - Map toJson() { - return { - 'today': today, - 'day': day, - 'week': week, - 'month': month, - 'year': year, - 'agenda': agenda, - 'search': search, - 'create': create, - 'createEvent': createEvent, - 'createTask': createTask, - 'refresh': refresh, - 'menu': menu, - 'previous': previous, - 'next': next, - 'showSidebarPanel': showSidebarPanel, - 'hideSidebarPanel': hideSidebarPanel, - 'back': back, - 'settings': settings, - 'keyboardShortcuts': keyboardShortcuts, - 'reportIssue': reportIssue, - 'aboutBusyMax': aboutBusyMax, - 'todayShortcut': todayShortcut, - 'dayShortcut': dayShortcut, - 'weekShortcut': weekShortcut, - 'monthShortcut': monthShortcut, - 'yearShortcut': yearShortcut, - 'agendaShortcut': agendaShortcut, - 'searchShortcut': searchShortcut, - 'sidebarShortcut': sidebarShortcut, - 'createEventShortcut': createEventShortcut, - 'createTaskShortcut': createTaskShortcut, - 'previousShortcut': previousShortcut, - 'nextShortcut': nextShortcut, - 'settingsShortcut': settingsShortcut, - 'keyboardShortcutsShortcut': keyboardShortcutsShortcut, - }; - } - - @override - bool operator ==(Object other) { - return identical(this, other) || - other is BusyMaxHeaderBarLabels && - today == other.today && - day == other.day && - week == other.week && - month == other.month && - year == other.year && - agenda == other.agenda && - search == other.search && - create == other.create && - createEvent == other.createEvent && - createTask == other.createTask && - refresh == other.refresh && - menu == other.menu && - previous == other.previous && - next == other.next && - showSidebarPanel == other.showSidebarPanel && - hideSidebarPanel == other.hideSidebarPanel && - back == other.back && - settings == other.settings && - keyboardShortcuts == other.keyboardShortcuts && - reportIssue == other.reportIssue && - aboutBusyMax == other.aboutBusyMax && - todayShortcut == other.todayShortcut && - dayShortcut == other.dayShortcut && - weekShortcut == other.weekShortcut && - monthShortcut == other.monthShortcut && - yearShortcut == other.yearShortcut && - agendaShortcut == other.agendaShortcut && - searchShortcut == other.searchShortcut && - sidebarShortcut == other.sidebarShortcut && - createEventShortcut == other.createEventShortcut && - createTaskShortcut == other.createTaskShortcut && - previousShortcut == other.previousShortcut && - nextShortcut == other.nextShortcut && - settingsShortcut == other.settingsShortcut && - keyboardShortcutsShortcut == other.keyboardShortcutsShortcut; - } - - @override - int get hashCode => Object.hashAll([ - today, - day, - week, - month, - year, - agenda, - search, - create, - createEvent, - createTask, - refresh, - menu, - previous, - next, - showSidebarPanel, - hideSidebarPanel, - back, - settings, - keyboardShortcuts, - reportIssue, - aboutBusyMax, - todayShortcut, - dayShortcut, - weekShortcut, - monthShortcut, - yearShortcut, - agendaShortcut, - searchShortcut, - sidebarShortcut, - createEventShortcut, - createTaskShortcut, - previousShortcut, - nextShortcut, - settingsShortcut, - keyboardShortcutsShortcut, - ]); -} - -@immutable -class BusyMaxHeaderBarTooltipTheme { - const BusyMaxHeaderBarTooltipTheme({ - required this.backgroundColor, - required this.foregroundColor, - required this.borderColor, - required this.borderRadius, - required this.fontSize, - required this.horizontalPadding, - required this.verticalPadding, - required this.minimumHeight, - }); - - final Color backgroundColor; - final Color foregroundColor; - final Color borderColor; - final double borderRadius; - final double fontSize; - final double horizontalPadding; - final double verticalPadding; - final double minimumHeight; - - Map toJson() => { - 'backgroundColor': busyMaxCssColor(backgroundColor), - 'foregroundColor': busyMaxCssColor(foregroundColor), - 'borderColor': busyMaxCssColor(borderColor), - 'borderRadius': borderRadius, - 'fontSize': fontSize, - 'horizontalPadding': horizontalPadding, - 'verticalPadding': verticalPadding, - 'minimumHeight': minimumHeight, - }; - - @override - bool operator ==(Object other) { - return identical(this, other) || - other is BusyMaxHeaderBarTooltipTheme && - backgroundColor == other.backgroundColor && - foregroundColor == other.foregroundColor && - borderColor == other.borderColor && - borderRadius == other.borderRadius && - fontSize == other.fontSize && - horizontalPadding == other.horizontalPadding && - verticalPadding == other.verticalPadding && - minimumHeight == other.minimumHeight; - } - - @override - int get hashCode => Object.hash( - backgroundColor, - foregroundColor, - borderColor, - borderRadius, - fontSize, - horizontalPadding, - verticalPadding, - minimumHeight, - ); -} - -@immutable -class BusyMaxHeaderBarTheme { - const BusyMaxHeaderBarTheme({ - required this.preferDark, - required this.highContrast, - required this.windowBackgroundColor, - required this.backgroundColor, - required this.sidebarBackgroundColor, - required this.foregroundColor, - required this.sidebarBorderColor, - required this.dialogBackgroundColor, - required this.dialogOutlineColor, - required this.modalBarrierColor, - required this.tooltip, - }); - - final bool preferDark; - final bool highContrast; - final Color windowBackgroundColor; - final Color backgroundColor; - final Color sidebarBackgroundColor; - final Color foregroundColor; - final Color sidebarBorderColor; - final Color dialogBackgroundColor; - final Color dialogOutlineColor; - final Color modalBarrierColor; - final BusyMaxHeaderBarTooltipTheme tooltip; - - Map toJson() { - return { - 'preferDark': preferDark, - 'highContrast': highContrast, - 'windowBackgroundColor': busyMaxCssColor(windowBackgroundColor), - 'backgroundColor': busyMaxCssColor(backgroundColor), - 'sidebarBackgroundColor': busyMaxCssColor(sidebarBackgroundColor), - 'foregroundColor': busyMaxCssColor(foregroundColor), - 'sidebarBorderColor': busyMaxCssColor(sidebarBorderColor), - 'dialogBackgroundColor': busyMaxCssColor(dialogBackgroundColor), - 'dialogOutlineColor': busyMaxCssColor(dialogOutlineColor), - 'modalBarrierColor': busyMaxCssColor(modalBarrierColor), - 'tooltip': tooltip.toJson(), - }; - } - - @override - bool operator ==(Object other) { - return identical(this, other) || - other is BusyMaxHeaderBarTheme && - other.preferDark == preferDark && - other.highContrast == highContrast && - other.windowBackgroundColor == windowBackgroundColor && - other.backgroundColor == backgroundColor && - other.sidebarBackgroundColor == sidebarBackgroundColor && - other.foregroundColor == foregroundColor && - other.sidebarBorderColor == sidebarBorderColor && - other.dialogBackgroundColor == dialogBackgroundColor && - other.dialogOutlineColor == dialogOutlineColor && - other.modalBarrierColor == modalBarrierColor && - other.tooltip == tooltip; - } - - @override - int get hashCode => Object.hashAll([ - preferDark, - highContrast, - windowBackgroundColor, - backgroundColor, - sidebarBackgroundColor, - foregroundColor, - sidebarBorderColor, - dialogBackgroundColor, - dialogOutlineColor, - modalBarrierColor, - tooltip, - ]); -} - -/// The complete, screen-owned presentation state of the native header bar. -/// -/// Configuration that is shared across screens, such as localized labels, -/// theme colors, and sidebar width, is intentionally managed separately. -@immutable -class BusyMaxHeaderBarState { - const BusyMaxHeaderBarState({ - required this.title, - required this.viewMode, - required this.canRefresh, - required this.canCreateEvent, - required this.canCreateTask, - required this.searchActive, - required this.searchQuery, - required this.canShowSidebar, - required this.sidebarVisible, - required this.navigationVisible, - required this.scheduleControlsVisible, - required this.backVisible, - }); - - static const int schemaVersion = 3; - - final String title; - final ScheduleViewMode viewMode; - final bool canRefresh; - final bool canCreateEvent; - final bool canCreateTask; - final bool searchActive; - final String searchQuery; - - bool get canCreate => canCreateEvent || canCreateTask; - - /// Whether the current layout can present a sidebar. - /// - /// This is distinct from [sidebarVisible], which represents the user's - /// current expanded/collapsed choice when a sidebar can be presented. - final bool canShowSidebar; - final bool sidebarVisible; - final bool navigationVisible; - final bool scheduleControlsVisible; - final bool backVisible; - - Map toJson() { - return { - 'schemaVersion': schemaVersion, - 'title': title, - 'viewMode': viewMode.name, - 'canRefresh': canRefresh, - 'canCreateEvent': canCreateEvent, - 'canCreateTask': canCreateTask, - 'searchActive': searchActive, - 'searchQuery': searchQuery, - 'canShowSidebar': canShowSidebar, - 'sidebarVisible': sidebarVisible, - 'navigationVisible': navigationVisible, - 'scheduleControlsVisible': scheduleControlsVisible, - 'backVisible': backVisible, - }; - } - - BusyMaxHeaderBarState copyWith({ - String? title, - ScheduleViewMode? viewMode, - bool? canRefresh, - bool? canCreateEvent, - bool? canCreateTask, - bool? searchActive, - String? searchQuery, - bool? canShowSidebar, - bool? sidebarVisible, - bool? navigationVisible, - bool? scheduleControlsVisible, - bool? backVisible, - }) { - return BusyMaxHeaderBarState( - title: title ?? this.title, - viewMode: viewMode ?? this.viewMode, - canRefresh: canRefresh ?? this.canRefresh, - canCreateEvent: canCreateEvent ?? this.canCreateEvent, - canCreateTask: canCreateTask ?? this.canCreateTask, - searchActive: searchActive ?? this.searchActive, - searchQuery: searchQuery ?? this.searchQuery, - canShowSidebar: canShowSidebar ?? this.canShowSidebar, - sidebarVisible: sidebarVisible ?? this.sidebarVisible, - navigationVisible: navigationVisible ?? this.navigationVisible, - scheduleControlsVisible: - scheduleControlsVisible ?? this.scheduleControlsVisible, - backVisible: backVisible ?? this.backVisible, - ); - } - - @override - bool operator ==(Object other) { - return identical(this, other) || - other is BusyMaxHeaderBarState && - title == other.title && - viewMode == other.viewMode && - canRefresh == other.canRefresh && - canCreateEvent == other.canCreateEvent && - canCreateTask == other.canCreateTask && - searchActive == other.searchActive && - searchQuery == other.searchQuery && - canShowSidebar == other.canShowSidebar && - sidebarVisible == other.sidebarVisible && - navigationVisible == other.navigationVisible && - scheduleControlsVisible == other.scheduleControlsVisible && - backVisible == other.backVisible; - } - - @override - int get hashCode => Object.hash( - title, - viewMode, - canRefresh, - canCreateEvent, - canCreateTask, - searchActive, - searchQuery, - canShowSidebar, - sidebarVisible, - navigationVisible, - scheduleControlsVisible, - backVisible, - ); -} - -class LinuxHeaderBarService { - LinuxHeaderBarService({ - MethodChannel channel = const MethodChannel( - 'io.busystack.busymax/headerbar', - ), - bool? isLinux, - }) : _channel = channel, - _isLinux = isLinux ?? Platform.isLinux; - - final MethodChannel _channel; - final bool _isLinux; - final _sessions = []; - - Future? _initialization; - bool _available = false; - bool _disposed = false; - _BusyMaxOnboardingControlsState? _onboardingControls; - ({bool visible, int shadeDepth})? _modalBarrierState; - double? _sidebarWidth; - TextDirection? _textDirection; - BusyMaxHeaderBarLabels? _labels; - BusyMaxHeaderBarTheme? _theme; - BusyMaxHeaderBarState? _state; - - bool get isAvailable => _available; - - /// Returns a route-owned session for native header state and actions. - /// - /// Claiming a new session immediately supersedes the previous one. This is - /// important during route transitions, when the outgoing screen remains - /// mounted and can still receive asynchronous rebuilds. - LinuxHeaderBarSession claimSession() { - if (_disposed) { - throw StateError('Cannot claim a session from a disposed service.'); - } - final session = LinuxHeaderBarSession._(this); - _sessions.add(session); - return session; - } - - /// Initializes the native bridge once and shares the in-flight result. - Future initialize() { - return _initialization ??= _initialize(); - } - - Future _initialize() async { - if (_disposed) { - return; - } - _channel.setMethodCallHandler(handleNativeMethodCall); - if (!_isLinux) { - _available = false; - return; - } - try { - final available = - await _channel.invokeMethod('initialize') ?? false; - if (!_disposed) { - _available = available; - } - } on MissingPluginException { - if (!_disposed) { - _available = false; - } - } on PlatformException { - if (!_disposed) { - _available = false; - } - } - } - - /// Applies all screen-owned header state in one native transaction. - /// - /// Equal state is not sent twice. Session ownership is checked before this - /// method is called so inactive routes cannot mutate the native-state cache. - Future _applyState( - BusyMaxHeaderBarState state, { - bool force = false, - }) async { - if (!_available) { - return; - } - if (!force && _state == state) { - return; - } - _state = state; - await _invokeIfAvailable('setState', state.toJson()); - } - - Future setLocalizedLabels(BusyMaxHeaderBarLabels labels) async { - if (!_available) { - return; - } - if (_labels == labels) { - return; - } - _labels = labels; - await _invokeIfAvailable('setLocalizedLabels', labels.toJson()); - } - - Future setSidebarWidth(double value) async { - if (!_available) { - return; - } - if (_sidebarWidth == value) { - return; - } - _sidebarWidth = value; - await _invokeIfAvailable('setSidebarWidth', value); - } - - Future setTextDirection(TextDirection value) async { - if (!_available || _textDirection == value) { - return; - } - _textDirection = value; - await _invokeIfAvailable('setTextDirection', value.name); - } - - Future _setOnboardingControls({ - required bool visible, - required bool canGoBack, - required bool canContinue, - required String backLabel, - required String continueLabel, - required int contentWidth, - bool force = false, - }) async { - if (!_available) { - return; - } - final state = _BusyMaxOnboardingControlsState( - visible: visible, - canGoBack: canGoBack, - canContinue: canContinue, - backLabel: backLabel, - continueLabel: continueLabel, - contentWidth: contentWidth, - ); - if (!force && _onboardingControls == state) { - return; - } - _onboardingControls = state; - await _invokeIfAvailable('setOnboardingControls', state.toJson()); - } - - LinuxHeaderBarSession? get _activeSession { - return _sessions.isEmpty ? null : _sessions.last; - } - - bool _isCurrentSession(LinuxHeaderBarSession session) { - return identical(_activeSession, session); - } - - void _releaseSession(LinuxHeaderBarSession session) { - final wasActive = _isCurrentSession(session); - _sessions.remove(session); - if (wasActive) { - final activeSession = _activeSession; - if (activeSession != null) { - unawaited(activeSession._restore()); - } - } - } - - Future setModalBarrierState({ - required bool visible, - required int shadeDepth, - }) async { - if (!_available) { - return; - } - final effectiveShadeDepth = visible ? (shadeDepth < 0 ? 0 : shadeDepth) : 0; - final state = (visible: visible, shadeDepth: effectiveShadeDepth); - if (_modalBarrierState == state) { - return; - } - _modalBarrierState = state; - await _invokeIfAvailable('setModalBarrierState', { - 'visible': state.visible, - 'shadeDepth': state.shadeDepth, - }); - } - - Future setModalBarrierVisible(bool value) { - return setModalBarrierState(visible: value, shadeDepth: value ? 1 : 0); - } - - Future setModalBarrierDepth(int value) { - final depth = value < 0 ? 0 : value; - return setModalBarrierState(visible: depth > 0, shadeDepth: depth); - } - - Future setTheme(BusyMaxHeaderBarTheme theme) async { - if (!_available) { - return; - } - if (_theme == theme) { - return; - } - _theme = theme; - await _invokeIfAvailable('setTheme', theme.toJson()); - } - - @visibleForTesting - Future handleNativeMethodCall(MethodCall call) async { - final searchEvent = _searchEventForCall(call); - if (searchEvent != null) { - _activeSession?._dispatchSearchEvent(searchEvent); - return null; - } - final action = _actionForMethod(call.method); - if (action != null) { - _activeSession?._dispatch(action); - } - return null; - } - - Future _invokeIfAvailable(String method, Object? arguments) async { - if (_disposed || !_available) { - return; - } - try { - await _channel.invokeMethod(method, arguments); - } on MissingPluginException { - _available = false; - } on PlatformException { - _available = false; - } - } - - Future _showCreateMenu(LinuxHeaderBarSession session) async { - if (_disposed || !_available || !_isCurrentSession(session)) { - return false; - } - try { - return await _channel.invokeMethod('showCreateMenu') ?? false; - } on MissingPluginException { - _available = false; - } on PlatformException { - _available = false; - } - return false; - } - - Future _focusSearch(LinuxHeaderBarSession session) async { - if (_disposed || !_available || !_isCurrentSession(session)) { - return false; - } - try { - return await _channel.invokeMethod('focusSearch') ?? false; - } on MissingPluginException { - _available = false; - } on PlatformException { - _available = false; - } - return false; - } - - Future _focusContent(LinuxHeaderBarSession session) async { - if (_disposed || !_available || !_isCurrentSession(session)) { - return false; - } - try { - return await _channel.invokeMethod('focusContent') ?? false; - } on MissingPluginException { - _available = false; - } on PlatformException { - _available = false; - } - return false; - } - - BusyMaxHeaderBarSearchEvent? _searchEventForCall(MethodCall call) { - return switch ((call.method, call.arguments)) { - ('searchQueryChanged', final String query) => - BusyMaxHeaderBarSearchQueryChanged(query), - ('searchFocusChanged', final bool focused) => - BusyMaxHeaderBarSearchFocusChanged(focused), - ('searchCleared', _) => const BusyMaxHeaderBarSearchCleared(), - ('searchEscapePressed', _) => const BusyMaxHeaderBarSearchEscapePressed(), - _ => null, - }; - } - - BusyMaxHeaderBarAction? _actionForMethod(String method) { - return switch (method) { - 'back' => BusyMaxHeaderBarAction.back, - 'continueSetup' => BusyMaxHeaderBarAction.continueSetup, - 'sidebarToggle' => BusyMaxHeaderBarAction.sidebarToggle, - 'today' => BusyMaxHeaderBarAction.today, - 'previous' => BusyMaxHeaderBarAction.previous, - 'next' => BusyMaxHeaderBarAction.next, - 'viewModeDay' => BusyMaxHeaderBarAction.viewModeDay, - 'viewModeWeek' => BusyMaxHeaderBarAction.viewModeWeek, - 'viewModeMonth' => BusyMaxHeaderBarAction.viewModeMonth, - 'viewModeYear' => BusyMaxHeaderBarAction.viewModeYear, - 'viewModeAgenda' => BusyMaxHeaderBarAction.viewModeAgenda, - 'search' => BusyMaxHeaderBarAction.search, - 'createEvent' => BusyMaxHeaderBarAction.createEvent, - 'createTask' => BusyMaxHeaderBarAction.createTask, - 'refresh' => BusyMaxHeaderBarAction.refresh, - 'settings' => BusyMaxHeaderBarAction.settings, - 'keyboardShortcuts' => BusyMaxHeaderBarAction.keyboardShortcuts, - 'reportIssue' => BusyMaxHeaderBarAction.reportIssue, - 'aboutBusyMax' => BusyMaxHeaderBarAction.aboutBusyMax, - _ => null, - }; - } - - void dispose() { - if (_disposed) { - return; - } - _disposed = true; - _available = false; - for (final session in _sessions.toList()) { - session._disposeFromService(); - } - _sessions.clear(); - _channel.setMethodCallHandler(null); - } -} - -/// Exclusive route-level access to native header state and actions. -/// -/// Global concerns such as theme, labels, and modal barriers remain on -/// [LinuxHeaderBarService]. Route presentation state goes through this session -/// so that a transitioning-out screen cannot overwrite its successor. -class LinuxHeaderBarSession { - LinuxHeaderBarSession._(this._service); - - final LinuxHeaderBarService _service; - final _actions = StreamController.broadcast(); - final _searchEvents = - StreamController.broadcast(); - bool _disposed = false; - BusyMaxHeaderBarState? _state; - int _stateRevision = 0; - _BusyMaxOnboardingControlsState? _onboardingControls; - int _onboardingRevision = 0; - - bool get isCurrent => !_disposed && _service._isCurrentSession(this); - - bool get isAvailable => !_disposed && _service.isAvailable; - - Stream get actions => _actions.stream; - - Stream get searchEvents => _searchEvents.stream; - - Future initialize() => _service.initialize(); - - Future updateState( - BusyMaxHeaderBarState state, { - bool force = false, - }) async { - if (_disposed) { - return; - } - _state = state; - final revision = ++_stateRevision; - await initialize(); - if (!isCurrent || revision != _stateRevision) { - return; - } - await _service._applyState(state, force: force); - } - - /// Opens the route-owned native Create popover when it is available. - /// - /// The current-session check prevents an outgoing route from opening UI in - /// a header that has already been claimed by its successor. - Future showCreateMenu() async { - if (_disposed) { - return false; - } - await initialize(); - if (!isCurrent) { - return false; - } - return _service._showCreateMenu(this); - } - - /// Focuses the route-owned native search entry when it is active. - Future focusSearch() async { - if (_disposed) { - return false; - } - await initialize(); - if (!isCurrent) { - return false; - } - return _service._focusSearch(this); - } - - /// Returns GTK keyboard focus to the Flutter filter controls. - Future focusContent() async { - if (_disposed) { - return false; - } - await initialize(); - if (!isCurrent) { - return false; - } - return _service._focusContent(this); - } - - Future setOnboardingControls({ - required bool visible, - required bool canGoBack, - required bool canContinue, - required String backLabel, - required String continueLabel, - int contentWidth = busyMaxOnboardingContentMaxWidth, - bool force = false, - }) async { - if (_disposed) { - return; - } - final state = _BusyMaxOnboardingControlsState( - visible: visible, - canGoBack: canGoBack, - canContinue: canContinue, - backLabel: backLabel, - continueLabel: continueLabel, - contentWidth: contentWidth, - ); - _onboardingControls = state; - final revision = ++_onboardingRevision; - await initialize(); - if (!isCurrent || revision != _onboardingRevision) { - return; - } - await _service._setOnboardingControls( - visible: state.visible, - canGoBack: state.canGoBack, - canContinue: state.canContinue, - backLabel: state.backLabel, - continueLabel: state.continueLabel, - contentWidth: state.contentWidth, - force: force, - ); - } - - Future _restore() async { - await initialize(); - if (!isCurrent) { - return; - } - final state = _state; - if (state != null) { - await _service._applyState(state, force: true); - } - if (!isCurrent) { - return; - } - final onboardingControls = _onboardingControls; - if (onboardingControls != null) { - await _service._setOnboardingControls( - visible: onboardingControls.visible, - canGoBack: onboardingControls.canGoBack, - canContinue: onboardingControls.canContinue, - backLabel: onboardingControls.backLabel, - continueLabel: onboardingControls.continueLabel, - contentWidth: onboardingControls.contentWidth, - force: true, - ); - } - } - - void _dispatch(BusyMaxHeaderBarAction action) { - if (isCurrent && !_actions.isClosed) { - _actions.add(action); - } - } - - void _dispatchSearchEvent(BusyMaxHeaderBarSearchEvent event) { - if (isCurrent && !_searchEvents.isClosed) { - _searchEvents.add(event); - } - } - - void dispose() { - if (_disposed) { - return; - } - _disposed = true; - _service._releaseSession(this); - unawaited(_actions.close()); - unawaited(_searchEvents.close()); - } - - void _disposeFromService() { - if (_disposed) { - return; - } - _disposed = true; - unawaited(_actions.close()); - unawaited(_searchEvents.close()); - } -} - -@immutable -class _BusyMaxOnboardingControlsState { - const _BusyMaxOnboardingControlsState({ - required this.visible, - required this.canGoBack, - required this.canContinue, - required this.backLabel, - required this.continueLabel, - required this.contentWidth, - }); - - final bool visible; - final bool canGoBack; - final bool canContinue; - final String backLabel; - final String continueLabel; - final int contentWidth; - - Map toJson() { - return { - 'visible': visible, - 'canGoBack': canGoBack, - 'canContinue': canContinue, - 'backLabel': backLabel, - 'continueLabel': continueLabel, - 'contentWidth': contentWidth, - }; - } - - @override - bool operator ==(Object other) { - return identical(this, other) || - other is _BusyMaxOnboardingControlsState && - visible == other.visible && - canGoBack == other.canGoBack && - canContinue == other.canContinue && - backLabel == other.backLabel && - continueLabel == other.continueLabel && - contentWidth == other.contentWidth; - } - - @override - int get hashCode => Object.hash( - visible, - canGoBack, - canContinue, - backLabel, - continueLabel, - contentWidth, - ); -} - -String busyMaxCssColor(Color color) { - final rgb = color.toARGB32() & 0x00ffffff; - if (color.a >= 1) { - return '#${rgb.toRadixString(16).padLeft(6, '0').toUpperCase()}'; - } - final red = (rgb >> 16) & 0xff; - final green = (rgb >> 8) & 0xff; - final blue = rgb & 0xff; - return 'rgba($red,$green,$blue,${color.a.toStringAsFixed(2)})'; -} diff --git a/lib/src/platform/native_dialog_service.dart b/lib/src/platform/native_dialog_service.dart index f4c34f92..e28a2461 100644 --- a/lib/src/platform/native_dialog_service.dart +++ b/lib/src/platform/native_dialog_service.dart @@ -1,7 +1,7 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; -import 'linux_header_bar_service.dart'; +import 'native_style.dart'; @visibleForTesting const nativeDialogChannelName = 'busymax/native_dialogs'; diff --git a/lib/src/platform/native_style.dart b/lib/src/platform/native_style.dart new file mode 100644 index 00000000..e5185f88 --- /dev/null +++ b/lib/src/platform/native_style.dart @@ -0,0 +1,118 @@ +import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; + +@immutable +final class BusyMaxNativeSurfaceTheme { + const BusyMaxNativeSurfaceTheme({ + required this.highContrast, + required this.windowBackgroundColor, + required this.dialogBackgroundColor, + required this.dialogOutlineColor, + required this.tooltipBackgroundColor, + required this.tooltipForegroundColor, + required this.tooltipBorderColor, + required this.tooltipRadius, + required this.tooltipFontSize, + required this.tooltipHorizontalPadding, + required this.tooltipVerticalPadding, + required this.tooltipMinimumHeight, + }); + + final bool highContrast; + final Color windowBackgroundColor; + final Color dialogBackgroundColor; + final Color dialogOutlineColor; + final Color tooltipBackgroundColor; + final Color tooltipForegroundColor; + final Color tooltipBorderColor; + final double tooltipRadius; + final double tooltipFontSize; + final double tooltipHorizontalPadding; + final double tooltipVerticalPadding; + final double tooltipMinimumHeight; + + Map toMessage() => { + 'highContrast': highContrast, + 'windowBackgroundColor': busyMaxCssColor(windowBackgroundColor), + 'dialogBackgroundColor': busyMaxCssColor(dialogBackgroundColor), + 'dialogOutlineColor': busyMaxCssColor(dialogOutlineColor), + 'tooltipBackgroundColor': busyMaxCssColor(tooltipBackgroundColor), + 'tooltipForegroundColor': busyMaxCssColor(tooltipForegroundColor), + 'tooltipBorderColor': busyMaxCssColor(tooltipBorderColor), + 'tooltipRadius': tooltipRadius, + 'tooltipFontSize': tooltipFontSize, + 'tooltipHorizontalPadding': tooltipHorizontalPadding, + 'tooltipVerticalPadding': tooltipVerticalPadding, + 'tooltipMinimumHeight': tooltipMinimumHeight, + }; + + @override + bool operator ==(Object other) => + other is BusyMaxNativeSurfaceTheme && + other.highContrast == highContrast && + other.windowBackgroundColor == windowBackgroundColor && + other.dialogBackgroundColor == dialogBackgroundColor && + other.dialogOutlineColor == dialogOutlineColor && + other.tooltipBackgroundColor == tooltipBackgroundColor && + other.tooltipForegroundColor == tooltipForegroundColor && + other.tooltipBorderColor == tooltipBorderColor && + other.tooltipRadius == tooltipRadius && + other.tooltipFontSize == tooltipFontSize && + other.tooltipHorizontalPadding == tooltipHorizontalPadding && + other.tooltipVerticalPadding == tooltipVerticalPadding && + other.tooltipMinimumHeight == tooltipMinimumHeight; + + @override + int get hashCode => Object.hashAll([ + highContrast, + windowBackgroundColor, + dialogBackgroundColor, + dialogOutlineColor, + tooltipBackgroundColor, + tooltipForegroundColor, + tooltipBorderColor, + tooltipRadius, + tooltipFontSize, + tooltipHorizontalPadding, + tooltipVerticalPadding, + tooltipMinimumHeight, + ]); +} + +/// Applies semantic colors only to retained native surfaces. +/// +/// Flutter owns the main header and sidebar. This bridge remains for GTK +/// dialogs, native tooltips, the window backing surface, and decorations. +final class NativeSurfaceStyleService { + const NativeSurfaceStyleService({ + MethodChannel channel = const MethodChannel( + 'io.busystack.busymax/gtk_settings', + ), + }) : _channel = channel; + + final MethodChannel _channel; + + Future setTheme(BusyMaxNativeSurfaceTheme theme) async { + try { + await _channel.invokeMethod( + 'setNativeSurfaceTheme', + theme.toMessage(), + ); + } on MissingPluginException { + // Lightweight tests and non-Linux compositions have no GTK bridge. + } on PlatformException { + // Native styling is advisory; Flutter remains fully themed. + } + } +} + +String busyMaxCssColor(Color color) { + final rgb = color.toARGB32() & 0x00ffffff; + if (color.a >= 1) { + return '#${rgb.toRadixString(16).padLeft(6, '0').toUpperCase()}'; + } + final red = (rgb >> 16) & 0xff; + final green = (rgb >> 8) & 0xff; + final blue = rgb & 0xff; + return 'rgba($red,$green,$blue,${color.a.toStringAsFixed(2)})'; +} diff --git a/lib/src/schedule/schedule_event_rescheduling.dart b/lib/src/schedule/schedule_event_rescheduling.dart index bb19160e..4332a861 100644 --- a/lib/src/schedule/schedule_event_rescheduling.dart +++ b/lib/src/schedule/schedule_event_rescheduling.dart @@ -221,7 +221,7 @@ final class ScheduleReschedulingCoordinator { } } RecurringEventMutationScope? scope; - if (detail.providerRecurringEventId != null) { + if (detail.requiresRecurringMutationScope) { scope = await chooseScope(detail, eventSupportsThisAndFollowing(detail)); if (scope == null || !active()) return ScheduleRescheduleResult.cancelled; } diff --git a/lib/src/schedule/schedule_navigation_intent.dart b/lib/src/schedule/schedule_navigation_intent.dart new file mode 100644 index 00000000..959cc96b --- /dev/null +++ b/lib/src/schedule/schedule_navigation_intent.dart @@ -0,0 +1,18 @@ +import 'package:flutter/foundation.dart'; + +enum ScheduleNavigationCause { adjacentPeriod, today, dateSelection } + +@immutable +class ScheduleNavigationIntent { + const ScheduleNavigationIntent({ + required this.cause, + required this.target, + required this.direction, + required this.generation, + }); + + final ScheduleNavigationCause cause; + final DateTime target; + final int direction; + final int generation; +} diff --git a/lib/src/schedule/schedule_repository.dart b/lib/src/schedule/schedule_repository.dart index 807d9287..b67a4295 100644 --- a/lib/src/schedule/schedule_repository.dart +++ b/lib/src/schedule/schedule_repository.dart @@ -457,6 +457,7 @@ class ScheduleRepository { if (!ignoreDateRange && !_intersects(range, start, end)) { continue; } + final detail = CalendarEventDetail.fromRow(event); items.add( CalendarScheduleItem( id: event.id, @@ -464,10 +465,8 @@ class ScheduleRepository { provider: provider, sourceId: event.calendarSourceId, providerCalendarId: event.providerCalendarId, - providerRecurringEventId: event.providerRecurringEventId, - timingBaseline: EventTimingBaseline.fromDetail( - CalendarEventDetail.fromRow(event), - ), + providerRecurringEventId: detail.recurringMutationSeriesId, + timingBaseline: EventTimingBaseline.fromDetail(detail), title: event.title, allDay: event.allDay, start: start, diff --git a/lib/src/schedule/task_list_mutation_intent.dart b/lib/src/schedule/task_list_mutation_intent.dart new file mode 100644 index 00000000..6221fb54 --- /dev/null +++ b/lib/src/schedule/task_list_mutation_intent.dart @@ -0,0 +1,26 @@ +import 'package:flutter/foundation.dart'; + +enum TaskListMutationPresentation { insertion, removal, completion } + +@immutable +class TaskListMutationIntent { + const TaskListMutationIntent({ + required this.presentation, + required this.accountId, + required this.taskListId, + required this.taskId, + required this.generation, + this.checklistItemId, + this.completed, + }); + + final TaskListMutationPresentation presentation; + final String accountId; + final String taskListId; + final String taskId; + final String? checklistItemId; + final bool? completed; + final int generation; + + String get taskKey => '$accountId\u0000$taskListId\u0000$taskId'; +} diff --git a/lib/src/ui/common/schedule/schedule_interactions.dart b/lib/src/ui/common/schedule/schedule_interactions.dart index cfbd54aa..e2dfddd3 100644 --- a/lib/src/ui/common/schedule/schedule_interactions.dart +++ b/lib/src/ui/common/schedule/schedule_interactions.dart @@ -246,6 +246,7 @@ class ScheduleInteractionRegionState extends State ScheduleInterval? _preview; Timer? _scroll; bool _saving = false; + bool _moveCursorActive = false; bool get active => _token != null; bool get canMove => widget.onReschedule != null && !active; ScheduleInterval? get preview => _preview; @@ -314,6 +315,7 @@ class ScheduleInteractionRegionState extends State _token = data.token = Object(); _drag = data; _action = action; + _moveCursorActive = action == ScheduleTimingAction.move; _anchor = data.grab; _pointer = data.pointerDown; _preview = data.original; @@ -401,6 +403,7 @@ class ScheduleInteractionRegionState extends State cancel(); return; } + _clearMoveCursor(token: _token); _saving = true; final token = _token; final request = ScheduleRescheduleRequest( @@ -422,7 +425,15 @@ class ScheduleInteractionRegionState extends State /// Drag-end is cleanup only. It must never act as an accepted drop. void dragEnded(ScheduleEventDragData data) { - if (!_saving && identical(data.token, _token)) cancel(); + if (!identical(data.token, _token)) return; + _clearMoveCursor(token: data.token); + if (!_saving) cancel(); + } + + void _clearMoveCursor({required Object? token}) { + if (!identical(token, _token) || !_moveCursorActive) return; + _moveCursorActive = false; + if (mounted) _notify(); } void cancel() { @@ -433,6 +444,7 @@ class ScheduleInteractionRegionState extends State _pointer = null; _preview = null; _saving = false; + _moveCursorActive = false; if (mounted) _notify(); } @@ -510,6 +522,20 @@ class ScheduleInteractionRegionState extends State ), ), ), + Positioned.fill( + child: ListenableBuilder( + listenable: changes, + builder: (context, _) { + if (!_moveCursorActive) return const SizedBox.shrink(); + return const MouseRegion( + cursor: SystemMouseCursors.move, + opaque: false, + hitTestBehavior: HitTestBehavior.translucent, + child: SizedBox.expand(), + ); + }, + ), + ), ], ), ); @@ -755,7 +781,7 @@ class _ScheduleEventInteractionState extends State { if (region.owns(_data)) region.cancel(); }, child: MouseRegion( - cursor: SystemMouseCursors.move, + cursor: MouseCursor.defer, child: Stack( fit: widget.dateOnly ? StackFit.loose : StackFit.expand, children: [ diff --git a/lib/src/ui/windows/windows_desktop_runtime.dart b/lib/src/ui/windows/windows_desktop_runtime.dart index eb5ff0fb..bdf1c100 100644 --- a/lib/src/ui/windows/windows_desktop_runtime.dart +++ b/lib/src/ui/windows/windows_desktop_runtime.dart @@ -44,10 +44,10 @@ class WindowsDesktopRuntime extends ConsumerStatefulWidget { @override ConsumerState createState() => - _WindowsDesktopRuntimeState(); + WindowsDesktopRuntimeState(); } -class _WindowsDesktopRuntimeState extends ConsumerState { +class WindowsDesktopRuntimeState extends ConsumerState { DesktopTrayService? _tray; BusyMaxTrayPresentationFormatter? _formatter; Timer? _refreshTimer; @@ -135,7 +135,7 @@ class _WindowsDesktopRuntimeState extends ConsumerState { widget.trayServiceFactory?.call(_loadFormattedPresentation) ?? WindowsTrayService( loadPresentation: _loadFormattedPresentation, - onCommand: _handleCommand, + onCommand: handleTrayCommand, onUnavailable: (errorCode) async { ref.read(desktopTrayDiagnosticProvider.notifier).state = errorCode ?? 'tray-unavailable'; @@ -202,7 +202,8 @@ class _WindowsDesktopRuntimeState extends ConsumerState { return _formatter!.format(presentation); } - Future _handleCommand(WindowsTrayCommand command) async { + @visibleForTesting + Future handleTrayCommand(WindowsTrayCommand command) async { final window = ref.read(desktopWindowServiceProvider); switch (command) { case WindowsTrayCommand.open: @@ -237,7 +238,7 @@ class _WindowsDesktopRuntimeState extends ConsumerState { .read(desktopNavigationServiceProvider) .open(DesktopNavigationDestination.schedule); case WindowsTrayCommand.synchronize: - await ref.read(allAccountsSyncRunnerProvider)(); + await ref.read(syncSchedulerProvider).runNow(); await _tray?.refresh(); case WindowsTrayCommand.settings: await window.showWindow(); diff --git a/lib/src/ui/windows/windows_diagnostics_dialog.dart b/lib/src/ui/windows/windows_diagnostics_dialog.dart index d47ce86e..6138285b 100644 --- a/lib/src/ui/windows/windows_diagnostics_dialog.dart +++ b/lib/src/ui/windows/windows_diagnostics_dialog.dart @@ -11,6 +11,7 @@ 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 '../../features/sync/sync_auth_error.dart'; import '../common/busymax_glyph.dart'; import 'windows_busymax_glyphs.dart'; @@ -284,10 +285,9 @@ class WindowsDavConflictReview extends ConsumerWidget { ); if (resolution == null || !context.mounted) return; final service = ref.read(davConflictResolutionServiceProvider); - final sync = ref.read(accountSyncOperationsProvider); try { await service.resolve(conflict.id, resolution); - await sync.syncAccount(conflict.accountId, full: false); + await ref.read(signedInSyncRunnerProvider)(conflict.accountId, false); } on Object catch (error) { if (!context.mounted) return; await _showResolutionFailure(context, error); @@ -300,7 +300,12 @@ class WindowsDavConflictReview extends ConsumerWidget { context: context, builder: (dialogContext) => ContentDialog( title: Text(l10n.conflictResolutionFailed), - content: Text(redactForLog('$error')), + content: Text( + syncFailureMessage( + error, + networkUnavailableMessage: l10n.networkOfflineTryAgain, + ), + ), actions: [ FilledButton( onPressed: () => Navigator.pop(dialogContext), @@ -377,7 +382,16 @@ class _PendingOperationTile extends StatelessWidget { await _showResult(context, AppLocalizations.of(context).retryCompleted); } } on Object catch (error) { - if (context.mounted) await _showResult(context, redactForLog('$error')); + if (context.mounted) { + final l10n = AppLocalizations.of(context); + await _showResult( + context, + syncFailureMessage( + error, + networkUnavailableMessage: l10n.networkOfflineTryAgain, + ), + ); + } } } @@ -410,7 +424,15 @@ class _PendingOperationTile extends StatelessWidget { ); } } on Object catch (error) { - if (context.mounted) await _showResult(context, redactForLog('$error')); + if (context.mounted) { + await _showResult( + context, + syncFailureMessage( + error, + networkUnavailableMessage: l10n.networkOfflineTryAgain, + ), + ); + } } } diff --git a/lib/src/ui/windows/windows_event_editor_dialog.dart b/lib/src/ui/windows/windows_event_editor_dialog.dart index 71370e4e..ff216b91 100644 --- a/lib/src/ui/windows/windows_event_editor_dialog.dart +++ b/lib/src/ui/windows/windows_event_editor_dialog.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'windows_time_picker.dart'; import '../../features/calendar/domain/event_property_policy.dart'; import 'package:fluent_ui/fluent_ui.dart'; @@ -78,11 +80,20 @@ Future showWindowsEventEditorDialog( final categories = TextEditingController( text: originalDraft?.categories.join(', '), ); + final settings = ref.read(appSettingsControllerProvider); var selectedSource = sources.firstWhere( (source) => source.id == (originalDraft?.sourceId ?? initialSourceId) && source.accountId == (originalDraft?.accountId ?? initialAccountId), - orElse: () => sources.first, + orElse: () => + preferredCreationDestination( + sources, + selected: settings.defaultCalendar, + lastUsed: settings.lastUsedCalendar, + destinationOf: (source) => + CreationDestination(accountId: source.accountId, id: source.id), + ) ?? + sources.first, ); final requestedStart = initialStart == null ? null @@ -1132,6 +1143,16 @@ Future showWindowsEventEditorDialog( draft, guestUpdatePolicy: guestUpdatePolicy, ); + unawaited( + ref + .read(appSettingsControllerProvider.notifier) + .rememberCalendar( + CreationDestination( + accountId: draft.accountId, + id: draft.sourceId, + ), + ), + ); } else { await repository.updateLocalEvent( draft, diff --git a/lib/src/ui/windows/windows_schedule_day_week_view.dart b/lib/src/ui/windows/windows_schedule_day_week_view.dart index 94ecb0d1..09b51a7e 100644 --- a/lib/src/ui/windows/windows_schedule_day_week_view.dart +++ b/lib/src/ui/windows/windows_schedule_day_week_view.dart @@ -1,6 +1,7 @@ import 'package:busymax/src/ui/common/schedule/clock_hours_painter.dart'; import 'package:busymax/src/l10n/time_format_scope.dart'; import 'dart:math' as math; +import 'dart:ui' as ui; import 'package:fluent_ui/fluent_ui.dart'; import 'package:infinite_calendar_view/infinite_calendar_view.dart' as icv; @@ -10,6 +11,8 @@ import '../../../l10n/generated/app_localizations.dart'; import '../../schedule/schedule_event_rescheduling.dart'; import '../../schedule/schedule_item.dart'; import '../../schedule/schedule_projection.dart'; +import '../../schedule/schedule_navigation_intent.dart'; +import '../../app/common/busymax_design_values.dart'; import '../common/schedule/schedule_interactions.dart'; import '../common/schedule/schedule_planner_events.dart'; import '../common/schedule/schedule_preview_label.dart'; @@ -31,6 +34,7 @@ class WindowsScheduleDayWeekView extends StatefulWidget { this.onTaskCompletionChanged, this.dayStartMinute = 420, this.dayEndMinute = 1320, + this.navigationIntent, }); final DateTime initialDate; final int daysShowed; @@ -44,6 +48,7 @@ class WindowsScheduleDayWeekView extends StatefulWidget { final void Function(TaskScheduleItem, bool)? onTaskCompletionChanged; final int dayStartMinute; final int dayEndMinute; + final ScheduleNavigationIntent? navigationIntent; @override State createState() => _WindowsScheduleDayWeekViewState(); @@ -63,9 +68,19 @@ class _WindowsScheduleDayWeekViewState final _interaction = GlobalKey(); double _heightPerMinute = .9; double _allDayHeight = 82; + int? _activeNavigationGeneration; + bool _disableAnimations = false; + @override void didChangeDependencies() { super.didChangeDependencies(); + final disabled = MediaQuery.disableAnimationsOf(context); + if (disabled != _disableAnimations) { + _disableAnimations = disabled; + if (disabled && _activeNavigationGeneration != null) { + _settleNavigation(widget.navigationIntent); + } + } _reload(); } @@ -81,11 +96,18 @@ class _WindowsScheduleDayWeekViewState _plannerOrigin = widget.initialDate; return; } - WidgetsBinding.instance.addPostFrameCallback((_) { - if (mounted && !(_interaction.currentState?.active ?? false)) { - _planner.currentState?.jumpToDate(widget.initialDate); - } - }); + final intent = widget.navigationIntent; + if (intent != null && + intent.cause == ScheduleNavigationCause.adjacentPeriod && + intent.generation != oldWidget.navigationIntent?.generation) { + _animateToDate(widget.initialDate, intent); + } else { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted && !(_interaction.currentState?.active ?? false)) { + _planner.currentState?.jumpToDate(widget.initialDate); + } + }); + } } } @@ -137,7 +159,9 @@ class _WindowsScheduleDayWeekViewState daySeparationWidth: 1, dayEventsArranger: const SchedulePlannerEventArranger(), onDayChange: (date) { - if (plannerKey == _planner) widget.onVisibleDateChanged(date); + if (plannerKey == _planner && _activeNavigationGeneration == null) { + widget.onVisibleDateChanged(date); + } }, daysHeaderParam: icv.DaysHeaderParam( daysHeaderHeight: header, @@ -240,53 +264,63 @@ class _WindowsScheduleDayWeekViewState onZoomChange: (height) => setState(() => _heightPerMinute = height), ), ); - return ScheduleInteractionRegion( - key: _interaction, - coordinates: SchedulePlannerCoordinates( - key: _planner, - headerHeight: header, - allDayHeight: bar, - ), - onReschedule: widget.onReschedule, - onRangeCreated: widget.onRangeCreated, - onEmptySlot: widget.onEmptySlot, - previewColor: accent, - previewBuilder: (context, interval, allDay) => Padding( - padding: const EdgeInsets.all(4), - child: Text( - schedulePreviewLabel(context, interval, allDay), - style: theme.typography.caption, - maxLines: 2, - overflow: TextOverflow.ellipsis, + return NotificationListener( + onNotification: (notification) { + if (notification.metrics.axis == Axis.horizontal && + notification.dragDetails != null) { + _interruptNavigation(); + } + return false; + }, + child: ScheduleInteractionRegion( + key: _interaction, + coordinates: SchedulePlannerCoordinates( + key: _planner, + headerHeight: header, + allDayHeight: bar, ), - ), - child: Stack( - children: [ - planner, - if (hasAllDay) - Positioned( - top: header + bar - 8, - left: gutterWidth, - right: 0, - child: Center( - child: ScheduleInteractionBlocker( - child: MouseRegion( - cursor: SystemMouseCursors.resizeUpDown, - child: GestureDetector( - key: const ValueKey('windows-all-day-resize'), - behavior: HitTestBehavior.opaque, - onVerticalDragUpdate: (details) => setState( - () => _allDayHeight = (_allDayHeight + details.delta.dy) - .clamp(82.0, 260.0), - ), - child: SizedBox( - width: 48, - height: 16, - child: Center( - child: Container( - width: 24, - height: 3, - color: theme.inactiveColor, + onReschedule: widget.onReschedule, + onRangeCreated: widget.onRangeCreated, + onEmptySlot: widget.onEmptySlot, + previewColor: accent, + previewBuilder: (context, interval, allDay) => Padding( + padding: const EdgeInsets.all(4), + child: Text( + schedulePreviewLabel(context, interval, allDay), + style: theme.typography.caption, + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ), + child: Stack( + children: [ + planner, + if (hasAllDay) + Positioned( + top: header + bar - 8, + left: gutterWidth, + right: 0, + child: Center( + child: ScheduleInteractionBlocker( + child: MouseRegion( + cursor: SystemMouseCursors.resizeUpDown, + child: GestureDetector( + key: const ValueKey('windows-all-day-resize'), + behavior: HitTestBehavior.opaque, + onVerticalDragUpdate: (details) => setState( + () => + _allDayHeight = (_allDayHeight + details.delta.dy) + .clamp(82.0, 260.0), + ), + child: SizedBox( + width: 48, + height: 16, + child: Center( + child: Container( + width: 24, + height: 3, + color: theme.inactiveColor, + ), ), ), ), @@ -294,12 +328,80 @@ class _WindowsScheduleDayWeekViewState ), ), ), - ), - ], + ], + ), ), ); } + void _animateToDate(DateTime date, ScheduleNavigationIntent intent) { + WidgetsBinding.instance.addPostFrameCallback((_) async { + if (!mounted || (_interaction.currentState?.active ?? false)) return; + final planner = _planner.currentState; + if (planner == null || !planner.mainHorizontalController.hasClients) { + return; + } + _activeNavigationGeneration = intent.generation; + final difference = _calendarDayDifference(planner.initialDate, date); + final index = planner.widget.textDirection == ui.TextDirection.rtl + ? -difference + : difference; + final target = planner.dayWidthCalculator.offsetForIndex(index); + if (_disableAnimations) { + planner.mainHorizontalController.jumpTo(target); + _finishNavigation(intent.generation); + return; + } + final distance = (target - planner.mainHorizontalController.offset).abs(); + final page = planner.dayWidth * widget.daysShowed; + final fraction = page <= 0 ? 1.0 : (distance / page).clamp(0.0, 1.0); + if (fraction == 0) { + _finishNavigation(intent.generation); + return; + } + try { + await planner.mainHorizontalController.animateTo( + target, + duration: BusyMaxMotion.calendarPeriod * fraction, + curve: BusyMaxMotion.presentationCurve, + ); + } finally { + _finishNavigation(intent.generation); + } + }); + } + + void _settleNavigation(ScheduleNavigationIntent? intent) { + if (intent == null) return; + final planner = _planner.currentState; + if (planner == null || !planner.mainHorizontalController.hasClients) return; + final difference = _calendarDayDifference( + planner.initialDate, + widget.initialDate, + ); + final index = planner.widget.textDirection == ui.TextDirection.rtl + ? -difference + : difference; + planner.mainHorizontalController.jumpTo( + planner.dayWidthCalculator.offsetForIndex(index), + ); + _finishNavigation(intent.generation); + } + + void _interruptNavigation() { + if (_activeNavigationGeneration == null) return; + final controller = _planner.currentState?.mainHorizontalController; + if (controller?.hasClients ?? false) { + controller!.jumpTo(controller.offset); + } + _activeNavigationGeneration = null; + } + + void _finishNavigation(int generation) { + if (!mounted || _activeNavigationGeneration != generation) return; + _activeNavigationGeneration = null; + } + Widget _tile(icv.Event event, double height, double width, String locale) { final group = schedulePlannerGroup(event); final item = schedulePlannerItem(event); @@ -409,3 +511,9 @@ class _WindowsPlannerTile extends StatelessWidget { bool _sameDay(DateTime a, DateTime b) => a.year == b.year && a.month == b.month && a.day == b.day; + +int _calendarDayDifference(DateTime origin, DateTime target) => DateTime.utc( + target.year, + target.month, + target.day, +).difference(DateTime.utc(origin.year, origin.month, origin.day)).inDays; diff --git a/lib/src/ui/windows/windows_schedule_page.dart b/lib/src/ui/windows/windows_schedule_page.dart index 0a2d4c99..c40bb11c 100644 --- a/lib/src/ui/windows/windows_schedule_page.dart +++ b/lib/src/ui/windows/windows_schedule_page.dart @@ -13,6 +13,8 @@ import '../../../l10n/generated/app_localizations.dart'; import '../../app/app_bootstrap.dart'; import '../../app/busymax_shortcuts.dart'; import '../../app/common/busymax_design_values.dart'; +import '../../app/common/busymax_motion_widgets.dart'; +import '../../app/common/busymax_mutation_list.dart'; import '../../calendar_providers/calendar_mutation.dart'; import '../../features/accounts/data/accounts_repository.dart'; import '../../features/calendar/data/calendar_repository.dart'; @@ -28,12 +30,15 @@ import '../../features/schedule/application/saved_schedule_location.dart'; import '../../schedule/schedule_event_rescheduling.dart'; import '../../features/calendar/domain/event_timing_policy.dart'; import '../../features/tasks/data/tasks_repository.dart'; +import '../../features/tasks/domain/task_mutation_result.dart'; import '../common/schedule/schedule_interactions.dart'; import '../common/schedule/schedule_preview_label.dart'; import 'windows_schedule_day_week_view.dart'; import '../../schedule/schedule_range.dart'; +import '../../schedule/schedule_navigation_intent.dart'; import '../../schedule/schedule_source_visibility.dart'; import '../../schedule/schedule_view_mode.dart'; +import '../../schedule/task_list_mutation_intent.dart'; import '../common/busymax_glyph.dart'; import 'windows_busymax_glyphs.dart'; import 'windows_calendar_activation_flows.dart'; @@ -73,15 +78,22 @@ class _WindowsSchedulePageState extends ConsumerState { var _refreshRevision = 0; String? _taskListsKey; Future>? _taskListsFuture; - Object? _itemsKey; - Future>? _itemsFuture; + Object? _normalItemsKey; + Future>? _normalItemsFuture; + Object? _searchItemsKey; + Future>? _searchItemsFuture; var _sourcePaneCollapsed = false; + var _sourcePaneTransitionGeneration = 0; Timer? _searchDebounce; ScheduleWorkspaceCommand? _pendingCommand; bool _resolvingCommand = false; bool _commandRefreshPending = false; CalendarSourceEntity? _creationCalendar; ScheduleTaskListKey? _creationTaskList; + var _navigationGeneration = 0; + ScheduleNavigationIntent? _navigationIntent; + var _taskMutationGeneration = 0; + TaskListMutationIntent? _taskMutationIntent; @override void initState() { @@ -149,7 +161,8 @@ class _WindowsSchedulePageState extends ConsumerState { _initialSearchCriteria = _initialSearchCriteria?.copyWith( firstWeekday: firstWeekday, ); - _itemsKey = null; + _normalItemsKey = null; + _searchItemsKey = null; } Future _revealPendingCommand() async { @@ -210,8 +223,10 @@ class _WindowsSchedulePageState extends ConsumerState { _refreshRevision += 1; _taskListsKey = null; _taskListsFuture = null; - _itemsKey = null; - _itemsFuture = null; + _normalItemsKey = null; + _normalItemsFuture = null; + _searchItemsKey = null; + _searchItemsFuture = null; }); } @@ -289,15 +304,76 @@ class _WindowsSchedulePageState extends ConsumerState { }) { _latestVisibility = visibility; if (_searchActive && _searchCriteria == null) _initializeSearch(); - _creationCalendar = writableCalendarSources(sources) + final settings = ref.read(appSettingsControllerProvider); + final allCalendars = writableCalendarSources(sources); + final visibleCalendars = allCalendars .where( (source) => visibility.visibleCalendarSourceIds.contains(source.id), ) - .firstOrNull; - _creationTaskList = visibility.visibleTaskListKeys.firstOrNull; + .toList(); + _creationCalendar = + preferredCreationDestination( + allCalendars, + selected: settings.defaultCalendar, + lastUsed: settings.lastUsedCalendar, + destinationOf: (source) => + CreationDestination(accountId: source.accountId, id: source.id), + ) ?? + visibleCalendars.firstOrNull ?? + allCalendars.firstOrNull; + final preferredTaskList = preferredCreationDestination( + taskLists.where((list) => !list.pendingDelete), + selected: settings.defaultTaskList, + lastUsed: settings.lastUsedTaskList, + destinationOf: (list) => + CreationDestination(accountId: list.accountId, id: list.id), + ); + _creationTaskList = preferredTaskList == null + ? visibility.visibleTaskListKeys.firstOrNull + : ScheduleTaskListKey( + accountId: preferredTaskList.accountId, + taskListId: preferredTaskList.id, + ); final l10n = AppLocalizations.of(context); final locale = Localizations.localeOf(context).toLanguageTag(); final range = _rangeForMode(); + final presentationMode = _mode; + final presentationDate = _selectedDate; + final presentationFirstWeekday = _firstWeekday; + final presentationNavigationIntent = _navigationIntent; + final presentationMutationIntent = _taskMutationIntent; + final searchCriteria = _searchCriteria; + final searchQuery = _query; + final presentation = _searchActive && searchCriteria != null + ? _buildSearchPresentation( + future: _searchItemsFor( + range: range, + accounts: accounts, + criteria: searchCriteria, + query: searchQuery, + ), + criteria: searchCriteria, + query: searchQuery, + locale: locale, + mutationIntent: presentationMutationIntent, + ) + : _buildNormalPresentation( + future: _normalItemsFor( + range: range, + accounts: accounts, + visibility: visibility, + mode: presentationMode, + agendaTaskLimit: _agendaTaskLimit, + ), + mode: presentationMode, + selectedDate: presentationDate, + firstWeekday: presentationFirstWeekday, + range: range, + locale: locale, + sources: sources, + navigationIntent: presentationNavigationIntent, + mutationIntent: presentationMutationIntent, + ); return LayoutBuilder( builder: (context, constraints) { final canShowSourcePane = BusyMaxBreakpoints.showsSourcePane( @@ -324,7 +400,10 @@ class _WindowsSchedulePageState extends ConsumerState { BusyMaxShortcutActivators.search: _focusSearch, BusyMaxShortcutActivators.sidebar: () { if (canShowSourcePane) { - setState(() => _sourcePaneCollapsed = !showSourcePane); + setState(() { + _sourcePaneCollapsed = !showSourcePane; + _sourcePaneTransitionGeneration += 1; + }); } else { unawaited( _showSourcesDialog( @@ -408,7 +487,10 @@ class _WindowsSchedulePageState extends ConsumerState { ), onPressed: () { if (canShowSourcePane) { - setState(() => _sourcePaneCollapsed = false); + setState(() { + _sourcePaneCollapsed = false; + _sourcePaneTransitionGeneration += 1; + }); } else { unawaited( _showSourcesDialog( @@ -471,13 +553,23 @@ class _WindowsSchedulePageState extends ConsumerState { ), content: Row( children: [ - if (showSourcePane) ...[ - SizedBox( - width: BusyMaxDimensions.sourcePaneWidth, - child: sourcePane, + BusyMaxHorizontalReveal( + visible: showSourcePane, + width: BusyMaxDimensions.sourcePaneWidth + 1, + transitionGeneration: _sourcePaneTransitionGeneration, + child: Row( + children: [ + SizedBox( + width: BusyMaxDimensions.sourcePaneWidth, + child: BusyMaxBinaryPresentation( + alternateActive: _searchActive, + child: sourcePane, + ), + ), + const Divider(direction: Axis.vertical, size: 1), + ], ), - const Divider(direction: Axis.vertical), - ], + ), Expanded( child: Column( children: [ @@ -545,77 +637,9 @@ class _WindowsSchedulePageState extends ConsumerState { ), ), Expanded( - child: FutureBuilder>( - future: _itemsFor( - range: range, - accounts: accounts, - visibility: visibility, - ), - builder: (context, snapshot) { - if (snapshot.connectionState != - ConnectionState.done && - (_searchActive || !snapshot.hasData)) { - return const Center(child: ProgressRing()); - } - if (snapshot.hasError) { - return Center( - child: InfoBar( - title: Text(l10n.scheduleUnavailable), - severity: InfoBarSeverity.error, - action: Button( - onPressed: _reload, - child: Text(l10n.retry), - ), - ), - ); - } - final items = snapshot.data ?? const []; - if (items.isEmpty && _searchActive) { - return _WindowsScheduleEmptyState( - searching: _searchActive, - noVisibleSources: - _searchCriteria?.hasSources == false, - ); - } - if (_searchActive) { - return _AgendaList( - key: const ValueKey('windows-search-results'), - items: items, - locale: locale, - onOpen: _showItemDetails, - searchCriteria: _searchCriteria, - searchQuery: _query, - ); - } - return _ScheduleModeView( - firstWeekday: _firstWeekday, - mode: _mode, - selectedDate: _selectedDate, - range: range, - items: items, - locale: locale, - onOpen: _showItemDetails, - onSelectDate: _openDay, - onLoadMoreAgenda: _loadMoreAgenda, - onVisibleDateChanged: _selectDate, - onEmptySlot: (start) => - unawaited(_createEvent(start: start)), - onRangeCreated: - writableCalendarSources(sources).isEmpty - ? null - : (interval) => unawaited( - _createEvent(interval: interval), - ), - onReschedule: _rescheduleEvent, - onTaskCompletionChanged: _setTaskCompleted, - dayStartMinute: ref - .read(appSettingsControllerProvider) - .scheduleDayStartMinute, - dayEndMinute: ref - .read(appSettingsControllerProvider) - .scheduleDayEndMinute, - ); - }, + child: BusyMaxBinaryPresentation( + alternateActive: _searchActive, + child: presentation, ), ), ], @@ -630,6 +654,105 @@ class _WindowsSchedulePageState extends ConsumerState { ); } + Widget _buildSearchPresentation({ + required Future> future, + required ScheduleSearchCriteria criteria, + required String query, + required String locale, + required TaskListMutationIntent? mutationIntent, + }) => FutureBuilder>( + key: const ValueKey('windows-search-presentation'), + future: future, + builder: (context, snapshot) { + if (snapshot.connectionState != ConnectionState.done && + !snapshot.hasData) { + return const Center(child: ProgressRing()); + } + if (snapshot.hasError) { + return _scheduleLoadError(context); + } + return _AgendaList( + key: const ValueKey('windows-search-results'), + items: snapshot.data ?? const [], + locale: locale, + onOpen: _showItemDetails, + searchCriteria: criteria, + searchQuery: query, + taskMutationIntent: mutationIntent, + onTaskMutationConsumed: _consumeTaskMutation, + emptyBuilder: (context) => _WindowsScheduleEmptyState( + searching: true, + noVisibleSources: !criteria.hasSources, + ), + ); + }, + ); + + Widget _buildNormalPresentation({ + required Future> future, + required ScheduleViewMode mode, + required DateTime selectedDate, + required int firstWeekday, + required ScheduleRange range, + required String locale, + required List sources, + required ScheduleNavigationIntent? navigationIntent, + required TaskListMutationIntent? mutationIntent, + }) => FutureBuilder>( + key: const ValueKey('windows-normal-presentation'), + future: future, + builder: (context, snapshot) { + if (snapshot.connectionState != ConnectionState.done && + !snapshot.hasData) { + return const Center(child: ProgressRing()); + } + if (snapshot.hasError) { + return _scheduleLoadError(context); + } + return BusyMaxKeyedCrossfade( + transitionKey: mode, + child: _ScheduleModeView( + firstWeekday: firstWeekday, + mode: mode, + selectedDate: selectedDate, + range: range, + items: snapshot.data ?? const [], + locale: locale, + onOpen: _showItemDetails, + onSelectDate: _openDay, + onLoadMoreAgenda: _loadMoreAgenda, + onVisibleDateChanged: _selectDate, + navigationIntent: navigationIntent, + taskMutationIntent: mutationIntent, + onTaskMutationConsumed: _consumeTaskMutation, + onEmptySlot: (start) => unawaited(_createEvent(start: start)), + onRangeCreated: writableCalendarSources(sources).isEmpty + ? null + : (interval) => unawaited(_createEvent(interval: interval)), + onReschedule: _rescheduleEvent, + onTaskCompletionChanged: _setTaskCompleted, + dayStartMinute: ref + .read(appSettingsControllerProvider) + .scheduleDayStartMinute, + dayEndMinute: ref + .read(appSettingsControllerProvider) + .scheduleDayEndMinute, + ), + ); + }, + ); + + Widget _scheduleLoadError(BuildContext context) => Center( + child: InfoBar( + title: Text(AppLocalizations.of(context).scheduleUnavailable), + severity: InfoBarSeverity.error, + action: Button( + onPressed: _reload, + child: Text(AppLocalizations.of(context).retry), + ), + ), + ); + Future _createEvent({ DateTime? start, ScheduleInterval? interval, @@ -703,30 +826,104 @@ class _WindowsSchedulePageState extends ConsumerState { } Future _setTaskCompleted(TaskScheduleItem item, bool completed) async { - await ref - .read(tasksRepositoryForAccountProvider(item.accountId)) - .patchTask( - item.sourceId, - item.id, - TaskPatchInput({ - 'status': completed ? 'completed' : 'needsAction', - 'completed': completed - ? DateTime.now().toUtc().toIso8601String() - : null, - }), - ); - if (mounted) _reload(); + final intent = TaskListMutationIntent( + presentation: TaskListMutationPresentation.completion, + accountId: item.accountId, + taskListId: item.sourceId, + taskId: item.id, + completed: completed, + generation: ++_taskMutationGeneration, + ); + setState(() => _taskMutationIntent = intent); + try { + await ref + .read(tasksRepositoryForAccountProvider(item.accountId)) + .patchTask( + item.sourceId, + item.id, + TaskPatchInput({ + 'status': completed ? 'completed' : 'needsAction', + 'completed': completed + ? DateTime.now().toUtc().toIso8601String() + : null, + }), + ); + if (mounted) _reload(); + } on Object { + if (mounted && _taskMutationIntent?.generation == intent.generation) { + setState(() => _taskMutationIntent = null); + } + rethrow; + } } Future _createTask() async { - final changed = await showWindowsTaskEditorDialog( + final result = await showWindowsTaskEditorDialog( context, ref, initialDate: _selectedDate, initialAccountId: _creationTaskList?.accountId, initialTaskListId: _creationTaskList?.taskListId, ); - if (changed && mounted) _reload(); + if (result != null && mounted) { + setState(() { + _taskMutationIntent = TaskListMutationIntent( + presentation: TaskListMutationPresentation.insertion, + accountId: result.accountId, + taskListId: result.taskListId, + taskId: result.taskId, + generation: ++_taskMutationGeneration, + ); + }); + _reload(); + } + } + + void _consumeTaskMutation(TaskListMutationIntent intent) { + if (!mounted) return; + if (_taskMutationIntent?.generation == intent.generation) { + setState(() => _taskMutationIntent = null); + } + } + + void _beginDetailsMutation(TaskMutationResult result) { + if (!mounted) return; + final presentation = switch (result.kind) { + TaskMutationKind.deleted || + TaskMutationKind.moved => TaskListMutationPresentation.removal, + TaskMutationKind.duplicated => TaskListMutationPresentation.insertion, + _ => TaskListMutationPresentation.completion, + }; + final taskId = result.createdTaskId ?? result.taskId; + final current = _taskMutationIntent; + if (current?.presentation == presentation && + current?.accountId == result.accountId && + current?.taskListId == result.taskListId && + current?.taskId == taskId && + current?.checklistItemId == result.checklistItemId && + current?.completed == result.completed) { + return; + } + setState(() { + _taskMutationIntent = TaskListMutationIntent( + presentation: presentation, + accountId: result.accountId, + taskListId: result.taskListId, + taskId: taskId, + checklistItemId: result.checklistItemId, + completed: result.completed, + generation: ++_taskMutationGeneration, + ); + }); + } + + void _commitDetailsMutation(TaskMutationResult result) { + _beginDetailsMutation(result); + if (mounted) _reload(); + } + + void _failDetailsMutation() { + if (mounted) setState(() => _taskMutationIntent = null); } void _initializeSearch() { @@ -838,10 +1035,12 @@ class _WindowsSchedulePageState extends ConsumerState { return _taskListsFuture!; } - Future> _itemsFor({ + Future> _normalItemsFor({ required ScheduleRange range, required List accounts, required ScheduleSourceVisibility visibility, + required ScheduleViewMode mode, + required int agendaTaskLimit, }) { final calendarIds = visibility.visibleCalendarSourceIds.toList()..sort(); final taskKeys = @@ -852,19 +1051,19 @@ class _WindowsSchedulePageState extends ConsumerState { final accountIds = [for (final account in accounts) account.id]..sort(); final normalKey = [ _refreshRevision, - _mode.name, + mode.name, range.start.toIso8601String(), range.end.toIso8601String(), - _query, accountIds.join(','), calendarIds.join(','), taskKeys.join(','), ].join('|'); - final key = (normalKey, _searchActive, _searchCriteria); - if (_itemsKey == key && _itemsFuture != null) return _itemsFuture!; - _itemsKey = key; + if (_normalItemsKey == normalKey && _normalItemsFuture != null) { + return _normalItemsFuture!; + } + _normalItemsKey = normalKey; final filters = ScheduleFilters( - query: _query, + query: '', accountIds: accountIds.toSet(), sourceIds: visibility.visibleCalendarSourceIds, taskListKeys: visibility.visibleTaskListKeys, @@ -873,35 +1072,63 @@ class _WindowsSchedulePageState extends ConsumerState { includeCalendarEvents: true, includeTasks: true, taskCompletion: ScheduleTaskCompletion.all, - showNoDateTasks: _mode == ScheduleViewMode.agenda, + showNoDateTasks: mode == ScheduleViewMode.agenda, ); - _itemsFuture = _searchActive && _searchCriteria != null - ? ref - .read(scheduleRepositoryProvider) - .listItems( - range: _searchCriteria!.range ?? range, - filters: _searchCriteria!.filters(_query), - ) - : _loadItems(range, filters); - return _itemsFuture!; + _normalItemsFuture = _loadItems( + range, + filters, + mode: mode, + agendaTaskLimit: agendaTaskLimit, + ); + return _normalItemsFuture!; + } + + Future> _searchItemsFor({ + required ScheduleRange range, + required List accounts, + required ScheduleSearchCriteria criteria, + required String query, + }) { + final accountIds = [for (final account in accounts) account.id]..sort(); + final key = ( + _refreshRevision, + range.start, + range.end, + accountIds.join(','), + criteria, + query, + ); + if (_searchItemsKey == key && _searchItemsFuture != null) { + return _searchItemsFuture!; + } + _searchItemsKey = key; + _searchItemsFuture = ref + .read(scheduleRepositoryProvider) + .listItems( + range: criteria.range ?? range, + filters: criteria.filters(query), + ); + return _searchItemsFuture!; } Future> _loadItems( ScheduleRange range, - ScheduleFilters filters, - ) async { + ScheduleFilters filters, { + required ScheduleViewMode mode, + required int agendaTaskLimit, + }) async { final repository = ref.read(scheduleRepositoryProvider); final items = [ ...await repository.listItems(range: range, filters: filters), ]; - if (_mode == ScheduleViewMode.agenda) { + if (mode == ScheduleViewMode.agenda) { final overdue = await repository.listOverdueTasks( before: range.start, - limit: _agendaTaskLimit, + limit: agendaTaskLimit, filters: filters, ); final noDate = await repository.listNoDateTasks( - limit: _agendaTaskLimit, + limit: agendaTaskLimit, filters: filters, ); items.addAll(overdue.items); @@ -963,8 +1190,9 @@ class _WindowsSchedulePageState extends ConsumerState { void _selectDate(DateTime date) { setState(() { + _navigationIntent = null; _selectedDate = _dateOnly(date); - _itemsKey = null; + _normalItemsKey = null; }); } @@ -997,14 +1225,24 @@ class _WindowsSchedulePageState extends ConsumerState { ), ScheduleViewMode.agenda => _selectedDate, }; - _selectDate(next); + setState(() { + _selectedDate = _dateOnly(next); + _normalItemsKey = null; + _navigationIntent = ScheduleNavigationIntent( + cause: ScheduleNavigationCause.adjacentPeriod, + target: _selectedDate, + direction: direction, + generation: ++_navigationGeneration, + ); + }); } void _setMode(ScheduleViewMode mode) { if (_mode == mode) return; setState(() { + _navigationIntent = null; _mode = mode; - _itemsKey = null; + _normalItemsKey = null; }); unawaited( ref @@ -1017,7 +1255,7 @@ class _WindowsSchedulePageState extends ConsumerState { setState(() { _agendaDays += 30; _agendaTaskLimit += 100; - _itemsKey = null; + _normalItemsKey = null; }); } @@ -1354,7 +1592,14 @@ class _WindowsSchedulePageState extends ConsumerState { eventId: item.id, ); } else if (item is TaskScheduleItem) { - changed = await showWindowsTaskDetailsDialog(currentContext, ref, item); + changed = await showWindowsTaskDetailsDialog( + currentContext, + ref, + item, + onTaskMutationStarted: _beginDetailsMutation, + onTaskMutationCommitted: _commitDetailsMutation, + onTaskMutationFailed: _failDetailsMutation, + ); } else { return; } @@ -1643,6 +1888,9 @@ class _ScheduleModeView extends StatelessWidget { required this.onTaskCompletionChanged, required this.dayStartMinute, required this.dayEndMinute, + this.navigationIntent, + this.taskMutationIntent, + this.onTaskMutationConsumed, }); final ScheduleViewMode mode; @@ -1661,48 +1909,69 @@ class _ScheduleModeView extends StatelessWidget { final void Function(TaskScheduleItem, bool) onTaskCompletionChanged; final int dayStartMinute; final int dayEndMinute; + final ScheduleNavigationIntent? navigationIntent; + final TaskListMutationIntent? taskMutationIntent; + final ValueChanged? onTaskMutationConsumed; @override - Widget build(BuildContext context) => switch (mode) { - ScheduleViewMode.day || ScheduleViewMode.week => WindowsScheduleDayWeekView( - key: ValueKey('windows-${mode.name}-planner'), - initialDate: mode == ScheduleViewMode.day ? selectedDate : range.start, - daysShowed: mode == ScheduleViewMode.day ? 1 : 7, - items: items, - onOpen: onOpen, - onSelectDate: onSelectDate, - onVisibleDateChanged: onVisibleDateChanged, - onEmptySlot: onEmptySlot, - onRangeCreated: onRangeCreated, - onReschedule: onReschedule, - onTaskCompletionChanged: onTaskCompletionChanged, - dayStartMinute: dayStartMinute, - dayEndMinute: dayEndMinute, - ), - ScheduleViewMode.month => WindowsScheduleMonthView( - firstWeekday: firstWeekday, - onReschedule: onReschedule, - selectedDate: selectedDate, - range: range, - items: items, - locale: locale, - onOpen: onOpen, - onSelectDate: onSelectDate, - ), - ScheduleViewMode.year => WindowsScheduleYearView( - firstWeekday: firstWeekday, - selectedDate: selectedDate, - items: items, - locale: locale, - onSelectDate: onSelectDate, - ), - ScheduleViewMode.agenda => _AgendaList( - items: items, - locale: locale, - onOpen: onOpen, - onLoadMore: onLoadMoreAgenda, - ), - }; + Widget build(BuildContext context) { + final view = switch (mode) { + ScheduleViewMode.day || + ScheduleViewMode.week => WindowsScheduleDayWeekView( + key: ValueKey('windows-${mode.name}-planner'), + initialDate: mode == ScheduleViewMode.day ? selectedDate : range.start, + daysShowed: mode == ScheduleViewMode.day ? 1 : 7, + items: items, + onOpen: onOpen, + onSelectDate: onSelectDate, + onVisibleDateChanged: onVisibleDateChanged, + onEmptySlot: onEmptySlot, + onRangeCreated: onRangeCreated, + onReschedule: onReschedule, + onTaskCompletionChanged: onTaskCompletionChanged, + dayStartMinute: dayStartMinute, + dayEndMinute: dayEndMinute, + navigationIntent: navigationIntent, + ), + ScheduleViewMode.month => WindowsScheduleMonthView( + firstWeekday: firstWeekday, + onReschedule: onReschedule, + selectedDate: selectedDate, + range: range, + items: items, + locale: locale, + onOpen: onOpen, + onSelectDate: onSelectDate, + ), + ScheduleViewMode.year => WindowsScheduleYearView( + firstWeekday: firstWeekday, + selectedDate: selectedDate, + items: items, + locale: locale, + onSelectDate: onSelectDate, + ), + ScheduleViewMode.agenda => _AgendaList( + items: items, + locale: locale, + onOpen: onOpen, + onLoadMore: onLoadMoreAgenda, + taskMutationIntent: taskMutationIntent, + onTaskMutationConsumed: onTaskMutationConsumed, + ), + }; + final intent = navigationIntent; + if (mode == ScheduleViewMode.month || mode == ScheduleViewMode.year) { + final adjacent = intent?.cause == ScheduleNavigationCause.adjacentPeriod + ? intent + : null; + return BusyMaxDirectionalSwitcher( + generation: adjacent?.generation ?? 0, + direction: adjacent?.direction ?? 0, + child: view, + ); + } + return view; + } } class _AgendaList extends StatelessWidget { @@ -1714,6 +1983,9 @@ class _AgendaList extends StatelessWidget { required this.locale, required this.onOpen, this.onLoadMore, + this.taskMutationIntent, + this.onTaskMutationConsumed, + this.emptyBuilder, }); final ScheduleSearchCriteria? searchCriteria; @@ -1722,6 +1994,9 @@ class _AgendaList extends StatelessWidget { final String locale; final ValueChanged onOpen; final VoidCallback? onLoadMore; + final TaskListMutationIntent? taskMutationIntent; + final ValueChanged? onTaskMutationConsumed; + final WidgetBuilder? emptyBuilder; @override Widget build(BuildContext context) { @@ -1729,76 +2004,93 @@ class _AgendaList extends StatelessWidget { ? items : (List.of(items) ..sort(compareScheduleSearchResultPresentation)); - final rows = []; - if (agendaItems.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 agendaItems) { - final displayDate = searchCriteria == null - ? item.start - : scheduleSearchResultDisplayDate(item); - final day = displayDate == null ? null : _dateOnly(displayDate); - if (day != previousDay) { - rows.add( - Padding( - padding: const EdgeInsetsDirectional.fromSTEB(4, 14, 4, 6), - child: Text( - day == null - ? AppLocalizations.of(context).noDate - : DateFormat.yMMMMEEEEd(locale).format(day), - style: FluentTheme.of(context).typography.subtitle, + return BusyMaxMutationList( + items: agendaItems, + mutation: taskMutationIntent, + identityOf: (item) => item is TaskScheduleItem + ? '${item.accountId}\u0000${item.sourceId}\u0000${item.id}' + : 'event\u0000${item.accountId}\u0000${item.sourceId}\u0000${item.id}', + mutationApplied: (item, mutation) => + item is! TaskScheduleItem || + mutation.completed == null || + item.completed == mutation.completed, + onMutationConsumed: onTaskMutationConsumed, + emptyBuilder: + emptyBuilder ?? + (context) => + Center(child: Text(AppLocalizations.of(context).noEventsOrTasks)), + padding: const EdgeInsetsDirectional.fromSTEB(20, 0, 20, 24), + footer: onLoadMore == null + ? null + : Padding( + padding: const EdgeInsets.only(top: 12), + child: Button( + onPressed: onLoadMore, + child: Text(AppLocalizations.of(context).windowsAgendaLoadMore), + ), ), - ), - ); - previousDay = day; - noDateShown = day == null; - } else if (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, + itemBuilder: (context, item, index) { + final displayDate = searchCriteria == null + ? item.start + : scheduleSearchResultDisplayDate(item); + final day = displayDate == null ? null : _dateOnly(displayDate); + final previousDisplayDate = index == 0 || index > agendaItems.length + ? null + : searchCriteria == null + ? agendaItems[index - 1].start + : scheduleSearchResultDisplayDate(agendaItems[index - 1]); + final previousDay = previousDisplayDate == null + ? null + : _dateOnly(previousDisplayDate); + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (index == 0 || day != previousDay) + 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, + ), + ), + _ScheduleItemCard( + item: item, + searchCriteria: searchCriteria, + searchQuery: searchQuery, + locale: locale, + animateCompletion: _animatesTaskCompletion(item), + completedOverride: item is TaskScheduleItem + ? _displayedCompletion(item) + : null, + onPressed: () => onOpen(item), ), - ), + ], ); - noDateShown = true; - } - rows.add( - _ScheduleItemCard( - item: item, - searchCriteria: searchCriteria, - searchQuery: searchQuery, - 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, + }, ); } + + bool _animatesTaskCompletion(ScheduleItem item) { + final intent = taskMutationIntent; + return item is TaskScheduleItem && + intent?.presentation == TaskListMutationPresentation.completion && + intent?.taskKey == + '${item.accountId}\u0000${item.sourceId}\u0000${item.id}' && + intent?.completed != null; + } + + bool _displayedCompletion(TaskScheduleItem task) { + final intent = taskMutationIntent; + if (intent?.presentation == TaskListMutationPresentation.completion && + intent?.taskKey == + '${task.accountId}\u0000${task.sourceId}\u0000${task.id}' && + intent?.completed != null) { + return intent!.completed!; + } + return task.completed; + } } class WindowsScheduleMonthView extends StatelessWidget { @@ -2273,6 +2565,8 @@ class _ScheduleItemCard extends StatelessWidget { required this.item, required this.locale, required this.onPressed, + this.animateCompletion = false, + this.completedOverride, }); final ScheduleSearchCriteria? searchCriteria; @@ -2280,10 +2574,13 @@ class _ScheduleItemCard extends StatelessWidget { final ScheduleItem item; final String locale; final VoidCallback onPressed; + final bool animateCompletion; + final bool? completedOverride; @override Widget build(BuildContext context) { final task = item is TaskScheduleItem ? item as TaskScheduleItem : null; + final completed = completedOverride ?? task?.completed == true; return Padding( padding: const EdgeInsets.only(bottom: 8), child: Card( @@ -2295,11 +2592,21 @@ class _ScheduleItemCard extends StatelessWidget { : BusyMaxGlyph.calendar, ), ), - title: Text( - item.title, - style: task?.completed == true - ? const TextStyle(decoration: TextDecoration.lineThrough) - : null, + title: AnimatedDefaultTextStyle( + duration: + MediaQuery.disableAnimationsOf(context) || !animateCompletion + ? Duration.zero + : BusyMaxMotion.taskCompletion, + curve: BusyMaxMotion.presentationCurve, + style: + (FluentTheme.of(context).typography.body ?? const TextStyle()) + .copyWith( + decoration: completed ? TextDecoration.lineThrough : null, + color: completed + ? FluentTheme.of(context).inactiveColor + : null, + ), + child: Text(item.title), ), subtitle: Text( searchCriteria != null diff --git a/lib/src/ui/windows/windows_task_details_dialog.dart b/lib/src/ui/windows/windows_task_details_dialog.dart index 99c41c9e..4c628871 100644 --- a/lib/src/ui/windows/windows_task_details_dialog.dart +++ b/lib/src/ui/windows/windows_task_details_dialog.dart @@ -12,6 +12,7 @@ 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/domain/task_mutation_result.dart'; import '../../features/tasks/data/tasks_repository.dart'; import '../../features/tasks/presentation/task_details_draft.dart'; import '../../features/schedule/presentation/schedule_item_exporter.dart'; @@ -34,6 +35,9 @@ Future showWindowsTaskDetailsDialog( const ExternalLocationLauncher( platform: _windowsExternalLocationPlatform, ), + TaskMutationCommittedCallback? onTaskMutationStarted, + TaskMutationCommittedCallback? onTaskMutationCommitted, + VoidCallback? onTaskMutationFailed, }) async { final repository = ref.read( tasksRepositoryForAccountProvider(task.accountId), @@ -181,6 +185,14 @@ Future showWindowsTaskDetailsDialog( !currentDraft().hasSameValues(originalDraft) || subtaskTitle.text.isNotEmpty || (!capabilities.supportsTaskStatus && completed != task.completed); + void report( + TaskMutationCommittedCallback? callback, + TaskMutationResult result, + ) { + if (callback == null) return; + Future.sync(() async => callback(result)).ignore(); + } + await showDialog( context: context, barrierDismissible: false, @@ -710,6 +722,23 @@ Future showWindowsTaskDetailsDialog( onChanged: enabled ? (value) async { setState(() => busy = true); + final affectedTask = subtask.task; + final mutation = TaskMutationResult( + kind: TaskMutationKind.completion, + accountId: task.accountId, + taskListId: + affectedTask?.taskListId ?? + original.taskListId, + taskId: + affectedTask?.id ?? original.id, + checklistItemId: + subtask.kind == + TaskSubtaskKind.checklistItem + ? subtask.id + : null, + completed: value ?? false, + ); + report(onTaskMutationStarted, mutation); try { if (subtask.kind == TaskSubtaskKind.task) { @@ -738,6 +767,10 @@ Future showWindowsTaskDetailsDialog( completed: value ?? false, ); } + report( + onTaskMutationCommitted, + mutation, + ); hierarchy = await repository .watchTaskHierarchy( original.taskListId, @@ -750,6 +783,7 @@ Future showWindowsTaskDetailsDialog( error = null; }); } on Object catch (_) { + onTaskMutationFailed?.call(); setState(() { busy = false; error = l10n.operationFailed; @@ -975,9 +1009,20 @@ Future showWindowsTaskDetailsDialog( ? () async { setState(() => busy = true); try { - await repository.duplicateTask( - original.taskListId, - original.id, + final duplicateId = await repository + .duplicateTask( + original.taskListId, + original.id, + ); + report( + onTaskMutationCommitted, + TaskMutationResult( + kind: TaskMutationKind.duplicated, + accountId: task.accountId, + taskListId: original.taskListId, + taskId: original.id, + createdTaskId: duplicateId, + ), ); setState(() { busy = false; @@ -1153,11 +1198,20 @@ Future showWindowsTaskDetailsDialog( ); if (!(confirmed ?? false)) return; setState(() => busy = true); + final mutation = TaskMutationResult( + kind: TaskMutationKind.deleted, + accountId: task.accountId, + taskListId: task.sourceId, + taskId: task.id, + ); + report(onTaskMutationStarted, mutation); try { await repository.deleteTask(task.sourceId, task.id); + report(onTaskMutationCommitted, mutation); changed = true; closeDialog(); } on Object catch (_) { + onTaskMutationFailed?.call(); setState(() { busy = false; error = l10n.operationFailed; @@ -1219,6 +1273,21 @@ Future showWindowsTaskDetailsDialog( ? DateTime.now().toUtc().toIso8601String() : null; } + final moving = + selectedTaskListId != original.taskListId; + final completionChanged = completed != task.completed; + final mutation = TaskMutationResult( + kind: moving + ? TaskMutationKind.moved + : completionChanged + ? TaskMutationKind.completion + : TaskMutationKind.updated, + accountId: task.accountId, + taskListId: original.taskListId, + taskId: task.id, + completed: completionChanged ? completed : null, + ); + report(onTaskMutationStarted, mutation); if (patch.isNotEmpty) { await repository.patchTask( task.sourceId, @@ -1238,9 +1307,11 @@ Future showWindowsTaskDetailsDialog( ), ); } + report(onTaskMutationCommitted, mutation); changed = true; closeDialog(); } on Object catch (_) { + onTaskMutationFailed?.call(); setState(() { busy = false; error = l10n.operationFailed; @@ -1274,6 +1345,9 @@ Future showWindowsTaskDetailsDialog( ref, taskToOpen, externalLocationLauncher: externalLocationLauncher, + onTaskMutationStarted: onTaskMutationStarted, + onTaskMutationCommitted: onTaskMutationCommitted, + onTaskMutationFailed: onTaskMutationFailed, ) || changed; } diff --git a/lib/src/ui/windows/windows_task_editor_dialog.dart b/lib/src/ui/windows/windows_task_editor_dialog.dart index c54aada4..847682cf 100644 --- a/lib/src/ui/windows/windows_task_editor_dialog.dart +++ b/lib/src/ui/windows/windows_task_editor_dialog.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'windows_time_picker.dart'; import 'package:busymax/src/l10n/time_format_scope.dart'; import 'package:fluent_ui/fluent_ui.dart'; @@ -19,7 +21,19 @@ import 'windows_recurrence_dialog.dart'; import '../../features/tasks/presentation/task_details_draft.dart'; import 'windows_time_zone_dialog.dart'; -Future showWindowsTaskEditorDialog( +class WindowsTaskEditorResult { + const WindowsTaskEditorResult({ + required this.accountId, + required this.taskListId, + required this.taskId, + }); + + final String accountId; + final String taskListId; + final String taskId; +} + +Future showWindowsTaskEditorDialog( BuildContext context, WidgetRef ref, { String? initialAccountId, @@ -53,7 +67,7 @@ Future showWindowsTaskEditorDialog( } } } - if (!context.mounted) return false; + if (!context.mounted) return null; if (lists.isEmpty) { await showDialog( context: context, @@ -68,7 +82,7 @@ Future showWindowsTaskEditorDialog( ], ), ); - return false; + return null; } final title = TextEditingController(); @@ -76,11 +90,20 @@ Future showWindowsTaskEditorDialog( final location = TextEditingController(); final taskUrl = TextEditingController(); final categories = TextEditingController(); + final settings = ref.read(appSettingsControllerProvider); var selectedList = lists.firstWhere( (list) => (initialAccountId == null || list.accountId == initialAccountId) && (initialTaskListId == null || list.id == initialTaskListId), - orElse: () => lists.first, + orElse: () => + preferredCreationDestination( + lists, + selected: settings.defaultTaskList, + lastUsed: settings.lastUsedTaskList, + destinationOf: (list) => + CreationDestination(accountId: list.accountId, id: list.id), + ) ?? + lists.first, ); var selectedTimeZone = ref.read(localTimeZoneProvider); DateTime? due = initialDate == null @@ -101,7 +124,7 @@ Future showWindowsTaskEditorDialog( var hideSubtasks = false; var hideCompletedSubtasks = false; var saving = false; - var saved = false; + WindowsTaskEditorResult? result; var allowPop = false; String? error; final initialListId = selectedList.id; @@ -726,7 +749,7 @@ Future showWindowsTaskEditorDialog( error = null; }); try { - await ref + final taskId = await ref .read( tasksRepositoryForAccountProvider( selectedList.accountId, @@ -739,7 +762,21 @@ Future showWindowsTaskEditorDialog( localTimeZone: selectedTimeZone, ), ); - saved = true; + unawaited( + ref + .read(appSettingsControllerProvider.notifier) + .rememberTaskList( + CreationDestination( + accountId: selectedList.accountId, + id: selectedList.id, + ), + ), + ); + result = WindowsTaskEditorResult( + accountId: selectedList.accountId, + taskListId: selectedList.id, + taskId: taskId, + ); closeDialog(); } on Object catch (_) { setState(() { @@ -767,7 +804,7 @@ Future showWindowsTaskEditorDialog( location.dispose(); taskUrl.dispose(); categories.dispose(); - return saved; + return result; } class _DateTimeField extends StatelessWidget { diff --git a/lib/src/ui/windows/windows_tasks_page.dart b/lib/src/ui/windows/windows_tasks_page.dart index 681eda72..499af6b2 100644 --- a/lib/src/ui/windows/windows_tasks_page.dart +++ b/lib/src/ui/windows/windows_tasks_page.dart @@ -8,9 +8,13 @@ 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 '../../app/common/busymax_mutation_list.dart'; +import '../../schedule/task_list_mutation_intent.dart'; import '../../schedule/schedule_filters.dart'; import '../../schedule/schedule_item.dart'; import '../../features/tasks/data/tasks_repository.dart'; +import '../../features/tasks/domain/task_mutation_result.dart'; import '../common/busymax_glyph.dart'; import 'windows_busymax_glyphs.dart'; import 'windows_task_details_dialog.dart'; @@ -35,6 +39,8 @@ class _WindowsTasksPageState extends ConsumerState { var _showCompleted = false; Timer? _searchDebounce; final _pendingCompletion = {}; + var _mutationGeneration = 0; + TaskListMutationIntent? _mutationIntent; @override void initState() { @@ -124,19 +130,78 @@ class _WindowsTasksPageState extends ConsumerState { } Future _createTask() async { - final changed = await showWindowsTaskEditorDialog( + final result = await showWindowsTaskEditorDialog( context, ref, initialAccountId: _listKey?.accountId ?? _accountId, initialTaskListId: _listKey?.taskListId, ); - if (changed && mounted) _reload(); + if (result != null && mounted) { + setState(() { + _mutationIntent = TaskListMutationIntent( + presentation: TaskListMutationPresentation.insertion, + accountId: result.accountId, + taskListId: result.taskListId, + taskId: result.taskId, + generation: ++_mutationGeneration, + ); + }); + _reload(); + } + } + + void _beginDetailsMutation(TaskMutationResult result) { + if (!mounted) return; + final presentation = switch (result.kind) { + TaskMutationKind.deleted || + TaskMutationKind.moved => TaskListMutationPresentation.removal, + TaskMutationKind.duplicated => TaskListMutationPresentation.insertion, + _ => TaskListMutationPresentation.completion, + }; + final taskId = result.createdTaskId ?? result.taskId; + final current = _mutationIntent; + if (current?.presentation == presentation && + current?.accountId == result.accountId && + current?.taskListId == result.taskListId && + current?.taskId == taskId && + current?.completed == result.completed) { + return; + } + setState(() { + _mutationIntent = TaskListMutationIntent( + presentation: presentation, + accountId: result.accountId, + taskListId: result.taskListId, + taskId: taskId, + checklistItemId: result.checklistItemId, + completed: result.completed, + generation: ++_mutationGeneration, + ); + }); + } + + void _commitDetailsMutation(TaskMutationResult result) { + _beginDetailsMutation(result); + if (mounted) _reload(); + } + + void _failDetailsMutation() { + if (mounted) setState(() => _mutationIntent = null); } Future _setCompleted(TaskScheduleItem task, bool completed) async { final key = '${task.accountId}/${task.sourceId}/${task.id}'; if (!task.capabilities.canEdit || !_pendingCompletion.add(key)) return; - setState(() {}); + setState(() { + _mutationIntent = TaskListMutationIntent( + presentation: TaskListMutationPresentation.completion, + accountId: task.accountId, + taskListId: task.sourceId, + taskId: task.id, + completed: completed, + generation: ++_mutationGeneration, + ); + }); try { await ref .read(tasksRepositoryForAccountProvider(task.accountId)) @@ -153,6 +218,7 @@ class _WindowsTasksPageState extends ConsumerState { if (mounted) _reload(); } on Object catch (_) { if (mounted) { + setState(() => _mutationIntent = null); await displayInfoBar( context, builder: (context, close) => InfoBar( @@ -172,10 +238,30 @@ class _WindowsTasksPageState extends ConsumerState { _searchDebounce?.cancel(); _searchController.clear(); _query = ''; + _mutationIntent = null; _searchFocusNode.unfocus(); _reload(); } + bool _animatesCompletion(TaskScheduleItem task) { + final intent = _mutationIntent; + return intent?.presentation == TaskListMutationPresentation.completion && + intent?.taskKey == + '${task.accountId}\u0000${task.sourceId}\u0000${task.id}' && + intent?.completed != null; + } + + bool _displayedCompletion(TaskScheduleItem task) { + final intent = _mutationIntent; + if (intent?.presentation == TaskListMutationPresentation.completion && + intent?.taskKey == + '${task.accountId}\u0000${task.sourceId}\u0000${task.id}' && + intent?.completed != null) { + return intent!.completed!; + } + return task.completed; + } + void _createTaskFromShortcut() { final focusContext = FocusManager.instance.primaryFocus?.context; if (focusContext != null && @@ -280,6 +366,7 @@ class _WindowsTasksPageState extends ConsumerState { const Duration(milliseconds: 250), () { if (!mounted) return; + _mutationIntent = null; _query = value; _reload(); }, @@ -312,6 +399,7 @@ class _WindowsTasksPageState extends ConsumerState { ), ], onChanged: (value) { + _mutationIntent = null; _accountId = value == '' ? null : value; _listKey = null; _reload(); @@ -353,6 +441,7 @@ class _WindowsTasksPageState extends ConsumerState { ), ], onChanged: (value) { + _mutationIntent = null; _listKey = value?.accountId == '' ? null : value; _reload(); }, @@ -362,6 +451,7 @@ class _WindowsTasksPageState extends ConsumerState { checked: _showCompleted, content: Text(l10n.completed), onChanged: (value) { + _mutationIntent = null; _showCompleted = value ?? false; _reload(); }, @@ -393,8 +483,21 @@ class _WindowsTasksPageState extends ConsumerState { ); } final tasks = snapshot.data ?? const []; - if (tasks.isEmpty) { - return Center( + return BusyMaxMutationList( + items: tasks, + mutation: _mutationIntent, + identityOf: (task) => + '${task.accountId}\u0000${task.sourceId}\u0000${task.id}', + mutationApplied: (task, mutation) => + mutation.completed == null || + task.completed == mutation.completed, + onMutationConsumed: (mutation) { + if (!mounted) return; + if (_mutationIntent?.generation == mutation.generation) { + setState(() => _mutationIntent = null); + } + }, + emptyBuilder: (context) => Center( child: Text( accounts.isEmpty ? l10n.signInToViewTasks @@ -404,18 +507,15 @@ class _WindowsTasksPageState extends ConsumerState { ? l10n.noTasksInList : l10n.noTasksYet, ), - ); - } - return ListView.builder( + ), padding: const EdgeInsetsDirectional.fromSTEB( 24, 0, 24, 24, ), - itemCount: tasks.length, - itemBuilder: (context, index) { - final task = tasks[index]; + itemBuilder: (context, task, index) { + final displayedCompleted = _displayedCompletion(task); final due = task.start == null ? '' : DateFormat.yMMMd(locale).format(task.start!); @@ -427,7 +527,7 @@ class _WindowsTasksPageState extends ConsumerState { child: Card( child: ListTile( leading: Checkbox( - checked: task.completed, + checked: displayedCompleted, semanticLabel: task.title, onChanged: task.capabilities.canEdit && @@ -439,13 +539,27 @@ class _WindowsTasksPageState extends ConsumerState { ) : null, ), - title: Text( - task.title, - style: task.completed - ? const TextStyle( - decoration: TextDecoration.lineThrough, - ) - : null, + title: AnimatedDefaultTextStyle( + duration: + MediaQuery.disableAnimationsOf(context) || + !_animatesCompletion(task) + ? Duration.zero + : BusyMaxMotion.taskCompletion, + curve: BusyMaxMotion.presentationCurve, + style: + (FluentTheme.of(context).typography.body ?? + const TextStyle()) + .copyWith( + decoration: displayedCompleted + ? TextDecoration.lineThrough + : null, + color: displayedCompleted + ? FluentTheme.of( + context, + ).inactiveColor + : null, + ), + child: Text(task.title), ), subtitle: Text( [ @@ -460,6 +574,9 @@ class _WindowsTasksPageState extends ConsumerState { context, ref, task, + onTaskMutationStarted: _beginDetailsMutation, + onTaskMutationCommitted: _commitDetailsMutation, + onTaskMutationFailed: _failDetailsMutation, ).then((changed) { if (changed) _reload(); }), diff --git a/linux/io.busystack.busymax.metainfo.xml b/linux/io.busystack.busymax.metainfo.xml index 8dff3ff0..9b537b76 100644 --- a/linux/io.busystack.busymax.metainfo.xml +++ b/linux/io.busystack.busymax.metainfo.xml @@ -100,6 +100,11 @@ + + +

Beta maintenance release.

+
+

Beta maintenance release.

diff --git a/linux/runner/CMakeLists.txt b/linux/runner/CMakeLists.txt index 9463469c..cb9b9dce 100644 --- a/linux/runner/CMakeLists.txt +++ b/linux/runner/CMakeLists.txt @@ -8,6 +8,9 @@ project(runner LANGUAGES CXX) # Any new source files that you add to the application should be added here. add_executable(${BINARY_NAME} "first_weekday_preference.cc" + "gtk_header_icons.cc" + "gtk_search_entry_theme.cc" + "gtk_window_preferences.cc" "main.cc" "my_application.cc" "${FLUTTER_MANAGED_DIR}/generated_plugin_registrant.cc" diff --git a/linux/runner/gtk_header_icons.cc b/linux/runner/gtk_header_icons.cc new file mode 100644 index 00000000..c81c50cf --- /dev/null +++ b/linux/runner/gtk_header_icons.cc @@ -0,0 +1,222 @@ +#include "gtk_header_icons.h" + +#include +#include + +namespace { + +GtkIconLookupFlags LookupFlags(BusyMaxGtkIconDirection direction) { + const GtkIconLookupFlags directional = + direction == BusyMaxGtkIconDirection::kRtl ? GTK_ICON_LOOKUP_DIR_RTL + : GTK_ICON_LOOKUP_DIR_LTR; + return static_cast(directional | + GTK_ICON_LOOKUP_FORCE_SYMBOLIC); +} + +std::string FirstAvailableName(GtkIconTheme* theme, + const std::vector& names) { + const auto available = std::find_if( + names.begin(), names.end(), + [theme](const std::string& name) { + return gtk_icon_theme_has_icon(theme, name.c_str()); + }); + return available == names.end() ? std::string() : *available; +} + +} // namespace + +BusyMaxGtkHeaderIcons::BusyMaxGtkHeaderIcons(GtkWidget* flutter_view, + GtkIconTheme* icon_theme) + : flutter_view_(flutter_view), injected_theme_(icon_theme) { + if (flutter_view_ != nullptr) g_object_ref(flutter_view_); + if (injected_theme_ != nullptr) g_object_ref(injected_theme_); +} + +BusyMaxGtkHeaderIcons::~BusyMaxGtkHeaderIcons() { + Stop(); + g_clear_object(&injected_theme_); + g_clear_object(&flutter_view_); +} + +std::optional BusyMaxGtkHeaderIcons::Load( + const std::vector& candidate_names, + BusyMaxGtkIconDirection direction, + int scale_override, + bool allow_missing) const { + if (candidate_names.empty()) return std::nullopt; + GtkIconTheme* theme = icon_theme_ != nullptr ? icon_theme_ : ResolveTheme(); + if (theme == nullptr) { + if (!allow_missing) { + g_warning("GTK could not resolve BusyMax header icon '%s'", + candidate_names.front().c_str()); + } + return std::nullopt; + } + + const int requested_scale = + scale_override > 0 ? scale_override : scale(); + GtkIconInfo* info = nullptr; + if (candidate_names.size() == 1) { + info = gtk_icon_theme_lookup_icon_for_scale( + theme, candidate_names.front().c_str(), + kBusyMaxGtkHeaderIconLogicalSize, requested_scale, + LookupFlags(direction)); + } else { + std::vector names; + names.reserve(candidate_names.size() + 1); + for (const std::string& name : candidate_names) { + names.push_back(name.c_str()); + } + names.push_back(nullptr); + info = gtk_icon_theme_choose_icon_for_scale( + theme, names.data(), kBusyMaxGtkHeaderIconLogicalSize, + requested_scale, LookupFlags(direction)); + } + if (info == nullptr) { + if (!allow_missing) { + g_warning("GTK could not resolve BusyMax header icon '%s'", + candidate_names.front().c_str()); + } + return std::nullopt; + } + g_autoptr(GtkIconInfo) owned_info = info; + + const GdkRGBA white = {1.0, 1.0, 1.0, 1.0}; + gboolean was_symbolic = FALSE; + g_autoptr(GError) load_error = nullptr; + g_autoptr(GdkPixbuf) pixbuf = gtk_icon_info_load_symbolic( + owned_info, &white, &white, &white, &white, &was_symbolic, + &load_error); + if (pixbuf == nullptr) { + if (load_error != nullptr) { + g_warning("Failed to load GTK header icon: %s", load_error->message); + } + return std::nullopt; + } + + gchar* encoded = nullptr; + gsize encoded_length = 0; + g_autoptr(GError) encode_error = nullptr; + if (!gdk_pixbuf_save_to_buffer(pixbuf, &encoded, &encoded_length, "png", + &encode_error, nullptr)) { + if (encode_error != nullptr) { + g_warning("Failed to encode GTK header icon: %s", + encode_error->message); + } + return std::nullopt; + } + std::vector png_bytes( + reinterpret_cast(encoded), + reinterpret_cast(encoded) + encoded_length); + g_free(encoded); + + std::string resolved_name = FirstAvailableName(theme, candidate_names); + if (resolved_name.empty()) resolved_name = candidate_names.front(); + return BusyMaxGtkHeaderIconAsset{ + std::move(png_bytes), + std::move(resolved_name), + requested_scale, + gdk_pixbuf_get_width(pixbuf), + gdk_pixbuf_get_height(pixbuf), + }; +} + +bool BusyMaxGtkHeaderIcons::Start(InvalidatedCallback callback) { + callback_ = std::move(callback); + if (flutter_view_ != nullptr && scale_changed_signal_id_ == 0) { + scale_changed_signal_id_ = g_signal_connect( + flutter_view_, "notify::scale-factor", G_CALLBACK(OnScaleChanged), + this); + } + if (flutter_view_ != nullptr && screen_changed_signal_id_ == 0) { + screen_changed_signal_id_ = g_signal_connect( + flutter_view_, "screen-changed", G_CALLBACK(OnScreenChanged), this); + } + RebindTheme(); + return icon_theme_ != nullptr; +} + +void BusyMaxGtkHeaderIcons::Stop() { + if (icon_theme_ != nullptr && theme_changed_signal_id_ != 0 && + g_signal_handler_is_connected(icon_theme_, theme_changed_signal_id_)) { + g_signal_handler_disconnect(icon_theme_, theme_changed_signal_id_); + } + theme_changed_signal_id_ = 0; + g_clear_object(&icon_theme_); + + if (flutter_view_ != nullptr && scale_changed_signal_id_ != 0 && + g_signal_handler_is_connected(flutter_view_, + scale_changed_signal_id_)) { + g_signal_handler_disconnect(flutter_view_, scale_changed_signal_id_); + } + if (flutter_view_ != nullptr && screen_changed_signal_id_ != 0 && + g_signal_handler_is_connected(flutter_view_, + screen_changed_signal_id_)) { + g_signal_handler_disconnect(flutter_view_, screen_changed_signal_id_); + } + scale_changed_signal_id_ = 0; + screen_changed_signal_id_ = 0; + callback_ = nullptr; +} + +int BusyMaxGtkHeaderIcons::scale() const { + return flutter_view_ != nullptr + ? std::max(1, gtk_widget_get_scale_factor(flutter_view_)) + : 1; +} + +gulong BusyMaxGtkHeaderIcons::theme_changed_signal_id() const { + return theme_changed_signal_id_; +} + +gulong BusyMaxGtkHeaderIcons::scale_changed_signal_id() const { + return scale_changed_signal_id_; +} + +gulong BusyMaxGtkHeaderIcons::screen_changed_signal_id() const { + return screen_changed_signal_id_; +} + +void BusyMaxGtkHeaderIcons::OnThemeChanged(GtkIconTheme*, gpointer user_data) { + static_cast(user_data)->Invalidate(); +} + +void BusyMaxGtkHeaderIcons::OnScaleChanged(GObject*, GParamSpec*, + gpointer user_data) { + static_cast(user_data)->Invalidate(); +} + +void BusyMaxGtkHeaderIcons::OnScreenChanged(GtkWidget*, GdkScreen*, + gpointer user_data) { + auto* icons = static_cast(user_data); + icons->RebindTheme(); + icons->Invalidate(); +} + +GtkIconTheme* BusyMaxGtkHeaderIcons::ResolveTheme() const { + if (injected_theme_ != nullptr) return injected_theme_; + if (flutter_view_ != nullptr) { + GdkScreen* screen = gtk_widget_get_screen(flutter_view_); + if (screen != nullptr) return gtk_icon_theme_get_for_screen(screen); + } + return gtk_icon_theme_get_default(); +} + +void BusyMaxGtkHeaderIcons::RebindTheme() { + GtkIconTheme* next_theme = ResolveTheme(); + if (next_theme == icon_theme_) return; + if (icon_theme_ != nullptr && theme_changed_signal_id_ != 0 && + g_signal_handler_is_connected(icon_theme_, theme_changed_signal_id_)) { + g_signal_handler_disconnect(icon_theme_, theme_changed_signal_id_); + } + theme_changed_signal_id_ = 0; + g_clear_object(&icon_theme_); + if (next_theme == nullptr) return; + icon_theme_ = GTK_ICON_THEME(g_object_ref(next_theme)); + theme_changed_signal_id_ = g_signal_connect( + icon_theme_, "changed", G_CALLBACK(OnThemeChanged), this); +} + +void BusyMaxGtkHeaderIcons::Invalidate() { + if (callback_) callback_(); +} diff --git a/linux/runner/gtk_header_icons.h b/linux/runner/gtk_header_icons.h new file mode 100644 index 00000000..e9dd7b84 --- /dev/null +++ b/linux/runner/gtk_header_icons.h @@ -0,0 +1,73 @@ +#ifndef RUNNER_GTK_HEADER_ICONS_H_ +#define RUNNER_GTK_HEADER_ICONS_H_ + +#include + +#include +#include +#include +#include +#include + +constexpr int kBusyMaxGtkHeaderIconLogicalSize = 16; + +enum class BusyMaxGtkIconDirection { kLtr, kRtl }; + +struct BusyMaxGtkHeaderIconAsset { + std::vector png_bytes; + std::string resolved_name; + int scale; + int pixel_width; + int pixel_height; +}; + +/// Resolves GTK icon-theme artwork and watches the configuration that makes +/// those raster results stale. Header geometry and interaction remain in Dart. +class BusyMaxGtkHeaderIcons { + public: + using InvalidatedCallback = std::function; + + explicit BusyMaxGtkHeaderIcons(GtkWidget* flutter_view, + GtkIconTheme* icon_theme = nullptr); + ~BusyMaxGtkHeaderIcons(); + + BusyMaxGtkHeaderIcons(const BusyMaxGtkHeaderIcons&) = delete; + BusyMaxGtkHeaderIcons& operator=(const BusyMaxGtkHeaderIcons&) = delete; + + std::optional Load( + const std::vector& candidate_names, + BusyMaxGtkIconDirection direction, + int scale_override = 0, + bool allow_missing = false) const; + + bool Start(InvalidatedCallback callback); + void Stop(); + + int scale() const; + gulong theme_changed_signal_id() const; + gulong scale_changed_signal_id() const; + gulong screen_changed_signal_id() const; + + private: + static void OnThemeChanged(GtkIconTheme* theme, gpointer user_data); + static void OnScaleChanged(GObject* object, + GParamSpec* specification, + gpointer user_data); + static void OnScreenChanged(GtkWidget* widget, + GdkScreen* previous_screen, + gpointer user_data); + + GtkIconTheme* ResolveTheme() const; + void RebindTheme(); + void Invalidate(); + + GtkWidget* flutter_view_; + GtkIconTheme* injected_theme_; + GtkIconTheme* icon_theme_ = nullptr; + InvalidatedCallback callback_; + gulong theme_changed_signal_id_ = 0; + gulong scale_changed_signal_id_ = 0; + gulong screen_changed_signal_id_ = 0; +}; + +#endif // RUNNER_GTK_HEADER_ICONS_H_ diff --git a/linux/runner/gtk_search_entry_theme.cc b/linux/runner/gtk_search_entry_theme.cc new file mode 100644 index 00000000..78cea2da --- /dev/null +++ b/linux/runner/gtk_search_entry_theme.cc @@ -0,0 +1,210 @@ +#include "gtk_search_entry_theme.h" + +#include +#include + +namespace { + +bool IsFiniteColor(const GdkRGBA& color) { + return std::isfinite(color.red) && std::isfinite(color.green) && + std::isfinite(color.blue) && std::isfinite(color.alpha); +} + +bool ReadColorProperty(GtkStyleContext* context, + GtkStateFlags state, + const gchar* property, + GdkRGBA* color) { + GValue value = G_VALUE_INIT; + gtk_style_context_get_property(context, property, state, &value); + const GdkRGBA* sampled = + static_cast(g_value_get_boxed(&value)); + const bool valid = sampled != nullptr && IsFiniteColor(*sampled); + if (valid) { + *color = *sampled; + } + g_value_unset(&value); + return valid; +} + +bool ReadRadius(GtkStyleContext* context, + GtkStateFlags state, + gint* radius) { + GValue value = G_VALUE_INIT; + gtk_style_context_get_property( + context, GTK_STYLE_PROPERTY_BORDER_RADIUS, state, &value); + const bool valid = G_VALUE_HOLDS_INT(&value); + if (valid) { + *radius = g_value_get_int(&value); + } + g_value_unset(&value); + return valid && *radius >= 0; +} + +} // namespace + +bool busymax_sample_gtk_search_entry_icon_foreground( + GtkStyleContext* entry_context, + GtkStateFlags state, + GtkEntryIconPosition icon_position, + GtkTextDirection direction, + GdkRGBA* color) { + if (entry_context == nullptr || color == nullptr || + (icon_position != GTK_ENTRY_ICON_PRIMARY && + icon_position != GTK_ENTRY_ICON_SECONDARY) || + (direction != GTK_TEXT_DIR_LTR && direction != GTK_TEXT_DIR_RTL)) { + return false; + } + const GtkWidgetPath* entry_path = + gtk_style_context_get_path(entry_context); + if (entry_path == nullptr) { + return false; + } + + GtkWidgetPath* image_path = gtk_widget_path_copy(entry_path); + const gint image_position = + gtk_widget_path_append_type(image_path, GTK_TYPE_IMAGE); + GtkStateFlags icon_state = static_cast( + state | (direction == GTK_TEXT_DIR_RTL ? GTK_STATE_FLAG_DIR_RTL + : GTK_STATE_FLAG_DIR_LTR)); + if (icon_position == GTK_ENTRY_ICON_PRIMARY) { + icon_state = static_cast(icon_state | + GTK_STATE_FLAG_INSENSITIVE); + } + const bool physical_left = + (direction == GTK_TEXT_DIR_LTR && + icon_position == GTK_ENTRY_ICON_PRIMARY) || + (direction == GTK_TEXT_DIR_RTL && + icon_position == GTK_ENTRY_ICON_SECONDARY); + gtk_widget_path_iter_set_object_name(image_path, image_position, "image"); + gtk_widget_path_iter_add_class( + image_path, image_position, + physical_left ? GTK_STYLE_CLASS_LEFT : GTK_STYLE_CLASS_RIGHT); + gtk_widget_path_iter_set_state(image_path, image_position, icon_state); + + GtkStyleContext* image_context = gtk_style_context_new(); + gtk_style_context_set_path(image_context, image_path); + gtk_style_context_set_parent(image_context, entry_context); + gtk_style_context_add_class( + image_context, + physical_left ? GTK_STYLE_CLASS_LEFT : GTK_STYLE_CLASS_RIGHT); + gtk_style_context_set_state(image_context, icon_state); + gtk_style_context_get_color(image_context, icon_state, color); + + const bool valid = IsFiniteColor(*color); + g_object_unref(image_context); + gtk_widget_path_free(image_path); + return valid; +} + +namespace { + +bool SampleState(GtkStyleContext* context, + GtkStateFlags state, + BusyMaxGtkSearchEntryState* result) { + gtk_style_context_set_state(context, state); + + const bool has_background = ReadColorProperty( + context, state, GTK_STYLE_PROPERTY_BACKGROUND_COLOR, + &result->background); + gtk_style_context_get_color(context, state, &result->foreground); + const bool has_foreground = IsFiniteColor(result->foreground); + const bool has_border = ReadColorProperty( + context, state, GTK_STYLE_PROPERTY_BORDER_COLOR, + &result->border_color); + gtk_style_context_get_border(context, state, &result->border_width); + const bool has_radius = ReadRadius(context, state, &result->border_radius); + result->has_inner_focus = FALSE; + result->inner_focus_color = result->border_color; + result->inner_focus_width = 0; + const bool has_primary_ltr = + busymax_sample_gtk_search_entry_icon_foreground( + context, state, GTK_ENTRY_ICON_PRIMARY, GTK_TEXT_DIR_LTR, + &result->primary_icon_foreground); + const bool has_primary_rtl = + busymax_sample_gtk_search_entry_icon_foreground( + context, state, GTK_ENTRY_ICON_PRIMARY, GTK_TEXT_DIR_RTL, + &result->primary_icon_foreground_rtl); + const bool has_secondary_ltr = + busymax_sample_gtk_search_entry_icon_foreground( + context, state, GTK_ENTRY_ICON_SECONDARY, GTK_TEXT_DIR_LTR, + &result->secondary_icon_foreground); + const bool has_secondary_rtl = + busymax_sample_gtk_search_entry_icon_foreground( + context, state, GTK_ENTRY_ICON_SECONDARY, GTK_TEXT_DIR_RTL, + &result->secondary_icon_foreground_rtl); + + return has_background && has_foreground && has_border && has_radius && + has_primary_ltr && has_primary_rtl && has_secondary_ltr && + has_secondary_rtl && result->border_width.top >= 0 && + result->border_width.right >= 0 && + result->border_width.bottom >= 0 && result->border_width.left >= 0; +} + +} // namespace + +bool busymax_gtk_theme_is_standard_yaru(const gchar* theme_name) { + if (theme_name == nullptr) { + return false; + } + g_autofree gchar* normalized = g_ascii_strdown(theme_name, -1); + const bool is_yaru = g_strcmp0(normalized, "yaru") == 0 || + g_str_has_prefix(normalized, "yaru-"); + return is_yaru && std::strstr(normalized, "highcontrast") == nullptr && + std::strstr(normalized, "high-contrast") == nullptr; +} + +void busymax_apply_yaru_search_entry_compatibility( + BusyMaxGtkSearchEntryTheme* theme) { + if (theme == nullptr) { + return; + } + + constexpr gint kBusyMaxYaruSearchEntryRadius = 9; + constexpr gint kBusyMaxYaruInnerFocusWidth = 1; + theme->normal.border_radius = kBusyMaxYaruSearchEntryRadius; + theme->focused.border_radius = kBusyMaxYaruSearchEntryRadius; + theme->backdrop.border_radius = kBusyMaxYaruSearchEntryRadius; + theme->backdrop_focused.border_radius = kBusyMaxYaruSearchEntryRadius; + + theme->normal.has_inner_focus = FALSE; + theme->normal.inner_focus_width = 0; + theme->focused.has_inner_focus = TRUE; + theme->focused.inner_focus_color = theme->focused.border_color; + theme->focused.inner_focus_width = kBusyMaxYaruInnerFocusWidth; + theme->backdrop.has_inner_focus = FALSE; + theme->backdrop.inner_focus_width = 0; + theme->backdrop_focused.has_inner_focus = FALSE; + theme->backdrop_focused.inner_focus_width = 0; +} + +bool busymax_sample_gtk_search_entry_theme( + BusyMaxGtkSearchEntryTheme* theme) { + if (theme == nullptr) { + return false; + } + + GtkWidget* search_entry = gtk_search_entry_new(); + g_object_ref_sink(search_entry); + GtkStyleContext* context = gtk_widget_get_style_context(search_entry); + const bool sampled = + SampleState(context, GTK_STATE_FLAG_NORMAL, &theme->normal) && + SampleState(context, GTK_STATE_FLAG_FOCUSED, &theme->focused) && + SampleState(context, GTK_STATE_FLAG_BACKDROP, &theme->backdrop) && + SampleState(context, + static_cast(GTK_STATE_FLAG_BACKDROP | + GTK_STATE_FLAG_FOCUSED), + &theme->backdrop_focused); + + GtkSettings* settings = gtk_settings_get_default(); + g_autofree gchar* theme_name = nullptr; + if (settings != nullptr) { + g_object_get(settings, "gtk-theme-name", &theme_name, nullptr); + } + if (sampled && busymax_gtk_theme_is_standard_yaru(theme_name)) { + busymax_apply_yaru_search_entry_compatibility(theme); + } + + gtk_widget_destroy(search_entry); + g_object_unref(search_entry); + return sampled; +} diff --git a/linux/runner/gtk_search_entry_theme.h b/linux/runner/gtk_search_entry_theme.h new file mode 100644 index 00000000..b7c06864 --- /dev/null +++ b/linux/runner/gtk_search_entry_theme.h @@ -0,0 +1,58 @@ +#ifndef RUNNER_GTK_SEARCH_ENTRY_THEME_H_ +#define RUNNER_GTK_SEARCH_ENTRY_THEME_H_ + +#include + +struct BusyMaxGtkSearchEntryState { + GdkRGBA background; + GdkRGBA foreground; + GdkRGBA border_color; + GdkRGBA primary_icon_foreground; + GdkRGBA primary_icon_foreground_rtl; + GdkRGBA secondary_icon_foreground; + GdkRGBA secondary_icon_foreground_rtl; + GtkBorder border_width; + gint border_radius; + gboolean has_inner_focus; + GdkRGBA inner_focus_color; + gint inner_focus_width; +}; + +struct BusyMaxGtkSearchEntryTheme { + BusyMaxGtkSearchEntryState normal; + BusyMaxGtkSearchEntryState focused; + BusyMaxGtkSearchEntryState backdrop; + BusyMaxGtkSearchEntryState backdrop_focused; +}; + +// Uses the same theme-name classification as BusyMax's pre-Flutter-header +// compatibility treatment. This helper is public so the production sampler +// and its native test cannot drift apart. +bool busymax_gtk_theme_is_standard_yaru(const gchar* theme_name); + +// Applies the small compatibility model needed to reproduce Yaru's native +// Search-entry geometry. Yaru keeps a normal one-pixel CSS border and adds a +// separate one-pixel inset focus stroke; it does not replace the border with a +// two-pixel stroke. This helper is public so production and native tests use +// exactly the same policy. +void busymax_apply_yaru_search_entry_compatibility( + BusyMaxGtkSearchEntryTheme* theme); + +// Samples the effective GtkSearchEntry icon foreground. The primary Find icon +// is sampled with GTK_STATE_FLAG_INSENSITIVE because GtkSearchEntry configures +// primary-icon-sensitive=FALSE. The secondary Clear icon remains sensitive. +// Logical primary/secondary positions are mapped to physical +// image.left/image.right nodes according to text direction. +bool busymax_sample_gtk_search_entry_icon_foreground( + GtkStyleContext* entry_context, + GtkStateFlags state, + GtkEntryIconPosition icon_position, + GtkTextDirection direction, + GdkRGBA* color); + +// Samples an actual GtkSearchEntry and copies all results into owned values. +// No GtkWidget or GtkStyleContext escapes this function. +bool busymax_sample_gtk_search_entry_theme( + BusyMaxGtkSearchEntryTheme* theme); + +#endif // RUNNER_GTK_SEARCH_ENTRY_THEME_H_ diff --git a/linux/runner/gtk_window_preferences.cc b/linux/runner/gtk_window_preferences.cc new file mode 100644 index 00000000..b835a9ff --- /dev/null +++ b/linux/runner/gtk_window_preferences.cc @@ -0,0 +1,111 @@ +#include "gtk_window_preferences.h" + +#include + +namespace { + +constexpr char kDefaultDecorationLayout[] = + "menu:minimize,maximize,close"; +constexpr char kDefaultDoubleClick[] = "toggle-maximize"; +constexpr char kDefaultMiddleClick[] = "none"; +constexpr char kDefaultRightClick[] = "menu"; + +std::string ValueOrDefault(const gchar* value, const char* fallback) { + return value != nullptr ? value : fallback; +} + +} // namespace + +BusyMaxGtkWindowPreferencesWatcher::BusyMaxGtkWindowPreferencesWatcher( + GtkSettings* settings) + : settings_(settings) {} + +BusyMaxGtkWindowPreferencesWatcher::~BusyMaxGtkWindowPreferencesWatcher() { + Stop(); +} + +BusyMaxGtkWindowPreferences BusyMaxGtkWindowPreferencesWatcher::Read() const { + GtkSettings* settings = ResolveSettings(); + if (settings == nullptr) return Defaults(); + + gchar* decoration_layout = nullptr; + gchar* double_click = nullptr; + gchar* middle_click = nullptr; + gchar* right_click = nullptr; + g_object_get(settings, + "gtk-decoration-layout", &decoration_layout, + "gtk-titlebar-double-click", &double_click, + "gtk-titlebar-middle-click", &middle_click, + "gtk-titlebar-right-click", &right_click, + nullptr); + BusyMaxGtkWindowPreferences preferences = { + ValueOrDefault(decoration_layout, kDefaultDecorationLayout), + ValueOrDefault(double_click, kDefaultDoubleClick), + ValueOrDefault(middle_click, kDefaultMiddleClick), + ValueOrDefault(right_click, kDefaultRightClick), + }; + g_free(decoration_layout); + g_free(double_click); + g_free(middle_click); + g_free(right_click); + return preferences; +} + +BusyMaxGtkWindowPreferences +BusyMaxGtkWindowPreferencesWatcher::Defaults() { + return { + kDefaultDecorationLayout, + kDefaultDoubleClick, + kDefaultMiddleClick, + kDefaultRightClick, + }; +} + +bool BusyMaxGtkWindowPreferencesWatcher::Start(ChangedCallback callback) { + callback_ = std::move(callback); + if (signal_ids_[0] != 0) return true; + + settings_ = ResolveSettings(); + if (settings_ == nullptr) return false; + + constexpr const char* kSignals[] = { + "notify::gtk-decoration-layout", + "notify::gtk-titlebar-double-click", + "notify::gtk-titlebar-middle-click", + "notify::gtk-titlebar-right-click", + }; + for (std::size_t index = 0; index < signal_ids_.size(); index++) { + signal_ids_[index] = + g_signal_connect(settings_, kSignals[index], + G_CALLBACK(OnSettingChanged), this); + } + return true; +} + +void BusyMaxGtkWindowPreferencesWatcher::Stop() { + if (settings_ != nullptr) { + for (gulong signal : signal_ids_) { + if (signal != 0 && g_signal_handler_is_connected(settings_, signal)) { + g_signal_handler_disconnect(settings_, signal); + } + } + } + signal_ids_ = {0, 0, 0, 0}; + callback_ = nullptr; +} + +std::array +BusyMaxGtkWindowPreferencesWatcher::signal_ids() const { + return signal_ids_; +} + +void BusyMaxGtkWindowPreferencesWatcher::OnSettingChanged( + GObject*, GParamSpec*, gpointer user_data) { + auto* watcher = + static_cast(user_data); + if (watcher->callback_) watcher->callback_(watcher->Read()); +} + +GtkSettings* BusyMaxGtkWindowPreferencesWatcher::ResolveSettings() const { + return settings_ != nullptr ? settings_ : gtk_settings_get_default(); +} diff --git a/linux/runner/gtk_window_preferences.h b/linux/runner/gtk_window_preferences.h new file mode 100644 index 00000000..60060a30 --- /dev/null +++ b/linux/runner/gtk_window_preferences.h @@ -0,0 +1,48 @@ +#ifndef RUNNER_GTK_WINDOW_PREFERENCES_H_ +#define RUNNER_GTK_WINDOW_PREFERENCES_H_ + +#include + +#include +#include +#include + +struct BusyMaxGtkWindowPreferences { + std::string decoration_layout; + std::string double_click; + std::string middle_click; + std::string right_click; +}; + +class BusyMaxGtkWindowPreferencesWatcher { + public: + using ChangedCallback = + std::function; + + explicit BusyMaxGtkWindowPreferencesWatcher(GtkSettings* settings = nullptr); + ~BusyMaxGtkWindowPreferencesWatcher(); + + BusyMaxGtkWindowPreferencesWatcher( + const BusyMaxGtkWindowPreferencesWatcher&) = delete; + BusyMaxGtkWindowPreferencesWatcher& operator=( + const BusyMaxGtkWindowPreferencesWatcher&) = delete; + + BusyMaxGtkWindowPreferences Read() const; + static BusyMaxGtkWindowPreferences Defaults(); + bool Start(ChangedCallback callback); + void Stop(); + + std::array signal_ids() const; + + private: + static void OnSettingChanged(GObject* object, + GParamSpec* specification, + gpointer user_data); + GtkSettings* ResolveSettings() const; + + GtkSettings* settings_; + ChangedCallback callback_; + std::array signal_ids_ = {0, 0, 0, 0}; +}; + +#endif // RUNNER_GTK_WINDOW_PREFERENCES_H_ diff --git a/linux/runner/my_application.cc b/linux/runner/my_application.cc index 5e57ed35..2e950081 100644 --- a/linux/runner/my_application.cc +++ b/linux/runner/my_application.cc @@ -1,6 +1,9 @@ #include "time_picker.h" #include "my_application.h" #include "first_weekday_preference.h" +#include "gtk_header_icons.h" +#include "gtk_search_entry_theme.h" +#include "gtk_window_preferences.h" #include #include @@ -22,8 +25,11 @@ constexpr char kNativeDateTimePickerChannel[] = constexpr char kNativeDialogChannel[] = "busymax/native_dialogs"; 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 kGtkHeaderIconsChannel[] = + "io.busystack.busymax/gtk_header_icons"; +constexpr char kGtkHeaderIconsChangedEventChannel[] = + "io.busystack.busymax/gtk_header_icons_changed"; constexpr char kExternalCalendarOpenChannel[] = "io.busystack.busymax/external_calendar_open"; constexpr char kExternalUriLauncherChannel[] = @@ -32,33 +38,16 @@ constexpr char kGtkFontSettingsEventChannel[] = "io.busystack.busymax/gtk_font_settings"; constexpr char kGtkThemeColorsEventChannel[] = "io.busystack.busymax/gtk_theme_colors"; +constexpr char kGtkAnimationSettingsEventChannel[] = + "io.busystack.busymax/gtk_animation_settings"; +constexpr char kGtkWindowPreferencesEventChannel[] = + "io.busystack.busymax/gtk_window_preferences"; constexpr char kFirstWeekdayEventChannel[] = "io.busystack.busymax/first_weekday"; -constexpr gint64 kHeaderBarStateSchemaVersion = 3; -constexpr gint kHeaderButtonHeight = 34; -constexpr gint kHeaderButtonSpacing = 6; -constexpr gint kHeaderCenterMaximumWidthChars = 48; -constexpr gint kHeaderOnboardingContentWidth = 480; -constexpr gint kHeaderOnboardingSideWidth = 120; -// Current libadwaita applies filter: opacity(0.5) to headerbar window-handle -// content in the backdrop state. GTK 3 cannot apply that filter here without -// also disturbing BusyMax's independently resolved header surfaces, so carry -// the same native metric through the semantic foreground instead. -constexpr gdouble kHeaderBackdropForegroundOpacity = 0.50; -constexpr gdouble kHeaderDisabledForegroundOpacity = 0.38; -constexpr gdouble kHeaderDisabledBackdropForegroundOpacity = - kHeaderDisabledForegroundOpacity * kHeaderBackdropForegroundOpacity; -constexpr gint kHeaderSidebarContentInset = kHeaderButtonSpacing; -constexpr gint kHeaderMainContentStartInset = kHeaderSidebarContentInset; constexpr gint kMainWindowDefaultWidth = 1280; constexpr gint kMainWindowDefaultHeight = 720; constexpr char kDefaultWindowBackgroundColor[] = "#2C2C2C"; -constexpr char kDefaultHeaderBarBackgroundColor[] = "#272727"; -constexpr char kDefaultHeaderBarSidebarBackgroundColor[] = "#393939"; -constexpr char kDefaultHeaderBarSidebarBorderColor[] = - "rgba(16,16,16,0.35)"; constexpr char kDefaultDialogOutlineColor[] = "rgba(255,255,255,0.07)"; -constexpr char kDefaultModalBarrierColor[] = "rgba(0,0,0,0.25)"; constexpr char kDefaultTooltipBackground[] = "rgba(0,0,0,0.8)"; constexpr char kDefaultTooltipForeground[] = "#FFFFFF"; constexpr char kDefaultTooltipBorder[] = "rgba(255,255,255,0.1)"; @@ -69,23 +58,10 @@ constexpr gdouble kDefaultTooltipVerticalPadding = 6.0; constexpr gdouble kDefaultTooltipMinimumHeight = 30.0; constexpr gdouble kTooltipBorderWidth = 1.0; // GtkTooltipWindow applies a private GtkContainer border-width of 6 px around -// its content. Compensate for it so native header hints have the same visible +// its content. Compensate for it so retained GTK hints have the same visible // border-to-text padding as Flutter tooltips. constexpr gdouble kGtkTooltipContainerInset = 6.0; -constexpr char kHeaderControlStyleClass[] = "busymax-header-control"; -constexpr char kHeaderOnboardingTextButtonStyleClass[] = - "busymax-onboarding-text-button"; constexpr char kMenuAccelAttribute[] = "accel"; -constexpr char kLtrIsolateStart[] = "\xE2\x81\xA6"; -constexpr char kBidiIsolateEnd[] = "\xE2\x81\xA9"; -constexpr char kHeaderSearchEntryStyleClass[] = - "busymax-header-search-entry"; -constexpr char kHeaderModalOpenStyleClass[] = "busymax-modal-open"; -constexpr char kHeaderModalBarrierStyleClass[] = "busymax-modal-barrier"; -constexpr char kHeaderApplicationActiveStyleClass[] = - "busymax-focus-active"; -constexpr char kHeaderApplicationBackdropStyleClass[] = - "busymax-focus-backdrop"; constexpr char kNativeDialogStyleClass[] = "busymax-native-dialog"; // Mirrors Yaru's shared window/dialog radius used by the Flutter fallback. constexpr gint kNativeDialogCornerRadius = 14; @@ -108,118 +84,49 @@ struct _MyApplication { FlMethodChannel* native_dialog_channel; FlMethodChannel* native_menu_channel; FlMethodChannel* window_channel; - FlMethodChannel* header_bar_channel; FlMethodChannel* gtk_settings_channel; + FlMethodChannel* gtk_header_icons_channel; FlMethodChannel* external_calendar_open_channel; FlMethodChannel* external_uri_launcher_channel; GQueue* pending_external_opens; gboolean external_calendar_open_ready; FlEventChannel* gtk_font_settings_event_channel; FlEventChannel* gtk_theme_colors_event_channel; + FlEventChannel* gtk_animation_settings_event_channel; + FlEventChannel* gtk_window_preferences_event_channel; + FlEventChannel* gtk_header_icons_changed_event_channel; FlEventChannel* first_weekday_event_channel; BusyMaxLinuxFirstWeekdayPreference* first_weekday_preference; + BusyMaxGtkWindowPreferencesWatcher* gtk_window_preferences; + BusyMaxGtkHeaderIcons* gtk_header_icons; gulong gtk_font_settings_signal_id; gulong gtk_theme_name_signal_id; gulong gtk_theme_dark_signal_id; + gulong gtk_animation_settings_signal_id; gboolean gtk_font_settings_listening; gboolean gtk_theme_colors_listening; + gboolean gtk_animation_settings_listening; + gboolean gtk_window_preferences_listening; + gboolean gtk_header_icons_listening; + gint64 gtk_header_icons_revision; gboolean first_weekday_listening; - GtkCssProvider* header_bar_css_provider; - gchar* header_bar_window_background_color; - gchar* header_bar_background_color; - gchar* header_bar_sidebar_background_color; - gchar* header_bar_sidebar_border_color; - gchar* header_bar_foreground_color; - gchar* header_bar_dialog_background_color; - gchar* header_bar_dialog_outline_color; - gchar* header_bar_modal_barrier_color; - gchar* header_bar_tooltip_background_color; - gchar* header_bar_tooltip_foreground_color; - gchar* header_bar_tooltip_border_color; - gdouble header_bar_tooltip_radius; - gdouble header_bar_tooltip_font_size; - gdouble header_bar_tooltip_horizontal_padding; - gdouble header_bar_tooltip_vertical_padding; - gdouble header_bar_tooltip_minimum_height; - gboolean header_bar_high_contrast; - gint header_bar_sidebar_width; - gboolean header_bar_can_show_sidebar; - gboolean header_bar_sidebar_visible; - gboolean header_bar_modal_barrier_visible; - gint header_bar_modal_barrier_shade_depth; - gboolean header_bar_theme_received; + GtkCssProvider* native_surface_css_provider; + gchar* native_surface_window_background_color; + gchar* native_surface_dialog_background_color; + gchar* native_surface_dialog_outline_color; + gchar* native_surface_tooltip_background_color; + gchar* native_surface_tooltip_foreground_color; + gchar* native_surface_tooltip_border_color; + gdouble native_surface_tooltip_radius; + gdouble native_surface_tooltip_font_size; + gdouble native_surface_tooltip_horizontal_padding; + gdouble native_surface_tooltip_vertical_padding; + gdouble native_surface_tooltip_minimum_height; + gboolean native_surface_high_contrast; + gboolean native_surface_theme_received; GtkWindow* main_window; - GtkWindow* header_focus_transient_window; GtkWidget* flutter_view; - GtkWidget* titlebar_handle; - GtkWidget* titlebar_overlay; - GtkWidget* titlebar_modal_barrier; - GtkWidget* titlebar_box; - GtkHeaderBar* header_bar; - GtkWidget* header_start_box; - GtkWidget* header_title_box; - GtkWidget* header_title_stack; - GtkWidget* onboarding_back_slot; - GtkWidget* onboarding_back_button; - GtkWidget* onboarding_continue_slot; - GtkWidget* onboarding_continue_button; - GtkWidget* header_sidebar_brand_box; - GtkWidget* header_brand_label; - GtkWidget* settings_menu_button; - GtkWidget* settings_menu; - GtkWidget* header_view_box; - GtkWidget* header_title_label; - GtkWidget* search_entry; - GtkWidget* back_button; - GtkWidget* sidebar_collapsed_toggle_button; - GtkWidget* today_button; - GtkWidget* previous_button; - GtkWidget* next_button; - GtkWidget* view_mode_button; - GtkWidget* view_mode_icon; - GtkWidget* view_mode_menu; - GtkWidget* search_button; - GtkWidget* create_button; - GtkWidget* create_menu; - GtkWidget* refresh_button; - GSimpleActionGroup* header_menu_action_group; - GSimpleAction* header_view_mode_menu_action; - GSimpleAction* header_create_event_action; - GSimpleAction* header_create_task_action; - gchar* header_view_mode; - gchar* header_title_text; - gchar* header_day_label; - gchar* header_week_label; - gchar* header_month_label; - gchar* header_year_label; - gchar* header_agenda_label; - gchar* header_show_sidebar_panel_label; - gchar* header_hide_sidebar_panel_label; - gchar* header_create_event_label; - gchar* header_create_task_label; - gchar* header_settings_label; - gchar* header_keyboard_shortcuts_label; - gchar* header_report_issue_label; - gchar* header_about_label; - gchar* header_day_shortcut; - gchar* header_week_shortcut; - gchar* header_month_shortcut; - gchar* header_year_shortcut; - gchar* header_agenda_shortcut; - gchar* header_sidebar_shortcut; - gchar* header_create_event_shortcut; - gchar* header_create_task_shortcut; - gchar* header_settings_shortcut; - gchar* header_keyboard_shortcuts_shortcut; - gchar* header_search_query; gboolean hide_on_close; - gboolean suppress_header_bar_actions; - gboolean header_search_active; - gboolean header_schedule_controls_visible; - gboolean header_navigation_visible; - gboolean header_back_visible; - gboolean header_onboarding_controls_visible; - gint header_onboarding_content_width; }; struct PendingExternalOpen { @@ -241,9 +148,6 @@ static void pending_external_open_free(gpointer data) { G_DEFINE_TYPE(MyApplication, my_application, GTK_TYPE_APPLICATION) -static void schedule_header_bar_focus_state_refresh(MyApplication* self); -static void update_header_control_visibility(MyApplication* self); - static gchar* gtk_accelerator_from_shortcut_label(const gchar* shortcut) { if (shortcut == nullptr || shortcut[0] == '\0') { return nullptr; @@ -424,16 +328,6 @@ static void update_bounded_double_arg(FlValue* args, } } -static FlValue* fl_lookup_map_arg(FlValue* args, const gchar* key) { - if (args == nullptr || fl_value_get_type(args) != FL_VALUE_TYPE_MAP) { - return nullptr; - } - FlValue* value = fl_value_lookup_string(args, key); - return value != nullptr && fl_value_get_type(value) == FL_VALUE_TYPE_MAP - ? value - : nullptr; -} - static gboolean parse_time(const gchar* value, guint* hour, guint* minute) { return busymax_time_picker::ParseCanonical(value, hour, minute); } @@ -581,7 +475,6 @@ struct NativeGroupedListStyle { }; struct NativeTimeZoneDialogState { - MyApplication* application; GtkWidget* window; GtkWidget* results; GPtrArray* options; @@ -703,21 +596,11 @@ static void native_time_zone_window_destroy_cb(GtkWidget*, gpointer user_data) { auto* state = static_cast(user_data); state->window = nullptr; - if (state->application != nullptr) { - schedule_header_bar_focus_state_refresh(state->application); - } if (g_main_loop_is_running(state->loop)) { g_main_loop_quit(state->loop); } } -static void header_focus_window_is_active_notify_cb( - GtkWindow*, - GParamSpec*, - gpointer user_data) { - schedule_header_bar_focus_state_refresh(MY_APPLICATION(user_data)); -} - static gboolean native_time_zone_present_after_parent_activation_cb( gpointer user_data) { GtkWindow* window = GTK_WINDOW(user_data); @@ -1063,8 +946,7 @@ static GtkCssProvider* create_native_grouped_list_provider( static void handle_native_time_zone_selection(FlMethodCall* method_call, FlValue* args, - GtkWindow* parent, - MyApplication* application) { + GtkWindow* parent) { const gchar* title = fl_lookup_string_arg(args, "title"); const gchar* search_placeholder = fl_lookup_string_arg(args, "searchPlaceholder"); @@ -1174,7 +1056,6 @@ static void handle_native_time_zone_selection(FlMethodCall* method_call, GMainLoop* loop = g_main_loop_new(nullptr, FALSE); NativeTimeZoneDialogState state = { - application, window, results, options, @@ -1192,16 +1073,6 @@ static void handle_native_time_zone_selection(FlMethodCall* method_call, &state); g_signal_connect(window, "destroy", G_CALLBACK(native_time_zone_window_destroy_cb), &state); - if (application != nullptr && parent == application->main_window) { - application->header_focus_transient_window = GTK_WINDOW(window); - g_object_add_weak_pointer( - G_OBJECT(window), - reinterpret_cast( - &application->header_focus_transient_window)); - g_signal_connect( - window, "notify::is-active", - G_CALLBACK(header_focus_window_is_active_notify_cb), application); - } g_signal_connect_object( parent, "notify::is-active", G_CALLBACK(native_time_zone_parent_is_active_notify_cb), window, @@ -1225,7 +1096,6 @@ static void handle_native_time_zone_selection(FlMethodCall* method_call, struct NativeDialogHandlerData { GtkWindow* window; - MyApplication* application; }; static void native_dialog_handler_data_free(gpointer user_data) { @@ -1235,11 +1105,6 @@ static void native_dialog_handler_data_free(gpointer user_data) { G_OBJECT(data->window), reinterpret_cast(&data->window)); } - if (data->application != nullptr) { - g_object_remove_weak_pointer( - G_OBJECT(data->application), - reinterpret_cast(&data->application)); - } g_free(data); } @@ -1255,30 +1120,22 @@ static void native_dialog_method_call_cb(FlMethodChannel* channel, const gchar* method = fl_method_call_get_name(method_call); if (strcmp(method, "selectTimeZone") == 0) { handle_native_time_zone_selection( - method_call, fl_method_call_get_args(method_call), parent, - data->application); + method_call, fl_method_call_get_args(method_call), parent); } else { fl_method_call_respond_not_implemented(method_call, nullptr); } } static FlMethodChannel* create_native_dialog_channel(FlView* view, - GtkWindow* window, - MyApplication* application) { + GtkWindow* window) { g_autoptr(FlStandardMethodCodec) codec = fl_standard_method_codec_new(); FlMethodChannel* channel = fl_method_channel_new( fl_engine_get_binary_messenger(fl_view_get_engine(view)), kNativeDialogChannel, FL_METHOD_CODEC(codec)); auto* data = g_new0(NativeDialogHandlerData, 1); data->window = window; - data->application = application; g_object_add_weak_pointer(G_OBJECT(window), reinterpret_cast(&data->window)); - if (application != nullptr) { - g_object_add_weak_pointer( - G_OBJECT(application), - reinterpret_cast(&data->application)); - } fl_method_channel_set_method_call_handler( channel, native_dialog_method_call_cb, data, native_dialog_handler_data_free); @@ -1289,7 +1146,7 @@ static void register_native_dialogs(MyApplication* self, FlView* view, GtkWindow* window) { self->native_dialog_channel = - create_native_dialog_channel(view, window, self); + create_native_dialog_channel(view, window); } constexpr char kNativeMenuActionNamespace[] = "busymax-native-menu"; @@ -1897,65 +1754,16 @@ static gboolean fl_method_bool_arg(FlValue* args) { : FALSE; } -static gint fl_method_int_arg(FlValue* args, gint fallback) { - return args != nullptr && fl_value_get_type(args) == FL_VALUE_TYPE_INT - ? static_cast(fl_value_get_int(args)) - : fallback; -} - -static gdouble fl_method_double_arg(FlValue* args, gdouble fallback) { - if (args == nullptr) { - return fallback; - } - switch (fl_value_get_type(args)) { - case FL_VALUE_TYPE_FLOAT: - return fl_value_get_float(args); - case FL_VALUE_TYPE_INT: - return static_cast(fl_value_get_int(args)); - default: - return fallback; - } -} - -static const gchar* fl_method_string_arg(FlValue* args) { - return args != nullptr && fl_value_get_type(args) == FL_VALUE_TYPE_STRING - ? fl_value_get_string(args) - : nullptr; -} - -static void track_header_bar_pointer(MyApplication* self, - GtkHeaderBar* header_bar) { - self->header_bar = header_bar; - g_object_add_weak_pointer(G_OBJECT(header_bar), - reinterpret_cast(&self->header_bar)); -} - -static void track_widget_pointer(GtkWidget** target, GtkWidget* widget) { - *target = widget; - g_object_add_weak_pointer(G_OBJECT(widget), - reinterpret_cast(target)); -} - -static gboolean has_header_bar(MyApplication* self) { - return self->header_bar != nullptr && GTK_IS_HEADER_BAR(self->header_bar); -} - static gboolean is_css_hex_color(const gchar* value) { - if (value == nullptr || strlen(value) != 7 || value[0] != '#') { - return FALSE; - } + if (value == nullptr || strlen(value) != 7 || value[0] != '#') return FALSE; for (int i = 1; i < 7; i++) { - if (!g_ascii_isxdigit(value[i])) { - return FALSE; - } + if (!g_ascii_isxdigit(value[i])) return FALSE; } return TRUE; } static gboolean is_css_rgba_color(const gchar* value) { - if (value == nullptr || !g_str_has_prefix(value, "rgba(")) { - return FALSE; - } + if (value == nullptr || !g_str_has_prefix(value, "rgba(")) return FALSE; gint red = -1; gint green = -1; gint blue = -1; @@ -1977,2352 +1785,258 @@ static const gchar* css_color_or(const gchar* value, const gchar* fallback) { return is_css_color_token(value) ? value : fallback; } -static gchar* modal_barrier_color_for_depth(const gchar* color, gint depth) { - GdkRGBA barrier; - if (!gdk_rgba_parse(&barrier, color)) { - return g_strdup(color); - } - const gint effective_depth = std::max(0, depth); - barrier.alpha = - 1.0 - std::pow(1.0 - barrier.alpha, effective_depth); - return gdk_rgba_to_string(&barrier); -} - -static void set_flutter_view_background_color(MyApplication* self, - const gchar* color) { - if (self->flutter_view == nullptr || !FL_IS_VIEW(self->flutter_view) || - !is_css_color_token(color)) { - return; - } - - GdkRGBA background_color; - if (!gdk_rgba_parse(&background_color, color)) { - return; - } - fl_view_set_background_color(FL_VIEW(self->flutter_view), &background_color); +static void set_css_color_field(gchar** target, const gchar* value) { + if (!is_css_color_token(value)) return; + g_free(*target); + *target = g_strdup(value); } static void set_main_flutter_view_background(MyApplication* self) { - set_flutter_view_background_color( - self, css_color_or(self->header_bar_window_background_color, - self->header_bar_background_color)); -} - -static gint header_sidebar_effective_width(MyApplication* self) { - if (!self->header_bar_can_show_sidebar || - !self->header_bar_sidebar_visible) { - return 0; + if (self->flutter_view == nullptr || !FL_IS_VIEW(self->flutter_view)) return; + const gchar* color = css_color_or( + self->native_surface_window_background_color, + kDefaultWindowBackgroundColor); + GdkRGBA background; + if (gdk_rgba_parse(&background, color)) { + fl_view_set_background_color(FL_VIEW(self->flutter_view), &background); } - return self->header_bar_sidebar_width; } static gboolean current_gtk_theme_uses_legacy_yaru_shadow() { GtkSettings* settings = gtk_settings_get_default(); - if (settings == nullptr) { - return FALSE; - } - + if (settings == nullptr) return FALSE; g_autofree gchar* theme_name = nullptr; g_object_get(settings, "gtk-theme-name", &theme_name, nullptr); - if (theme_name == nullptr) { - return FALSE; - } - - g_autofree gchar* normalized_theme = g_ascii_strdown(theme_name, -1); - const gboolean is_yaru = - g_strcmp0(normalized_theme, "yaru") == 0 || - g_str_has_prefix(normalized_theme, "yaru-"); - return is_yaru && strstr(normalized_theme, "highcontrast") == nullptr && - strstr(normalized_theme, "high-contrast") == nullptr; + return busymax_gtk_theme_is_standard_yaru(theme_name); } -static void refresh_header_bar_css(MyApplication* self) { - if (!has_header_bar(self) || - !is_css_color_token(self->header_bar_background_color)) { - return; - } - - const gchar* background_color = self->header_bar_background_color; - const gchar* window_background_color = - css_color_or(self->header_bar_window_background_color, background_color); - const gchar* sidebar_background_color = - is_css_color_token(self->header_bar_sidebar_background_color) - ? self->header_bar_sidebar_background_color - : background_color; - const gchar* sidebar_border_color = css_color_or( - self->header_bar_sidebar_border_color, - kDefaultHeaderBarSidebarBorderColor); - const gchar* foreground_color = css_color_or( - self->header_bar_foreground_color, "rgba(255,255,255,0.86)"); - const gchar* dialog_background_color = css_color_or( - self->header_bar_dialog_background_color, window_background_color); - g_autofree gchar* native_dialog_css = g_strdup_printf( +static void refresh_native_surface_css(MyApplication* self) { + const gchar* window_background = css_color_or( + self->native_surface_window_background_color, + kDefaultWindowBackgroundColor); + const gchar* dialog_background = css_color_or( + self->native_surface_dialog_background_color, window_background); + const gchar* dialog_outline = css_color_or( + self->native_surface_dialog_outline_color, kDefaultDialogOutlineColor); + const gchar* tooltip_background = css_color_or( + self->native_surface_tooltip_background_color, + kDefaultTooltipBackground); + const gchar* tooltip_foreground = css_color_or( + self->native_surface_tooltip_foreground_color, + kDefaultTooltipForeground); + const gchar* tooltip_border = css_color_or( + self->native_surface_tooltip_border_color, kDefaultTooltipBorder); + const gdouble tooltip_horizontal_padding = std::max( + 0.0, self->native_surface_tooltip_horizontal_padding - + (kGtkTooltipContainerInset - kTooltipBorderWidth)); + const gdouble tooltip_vertical_padding = std::max( + 0.0, self->native_surface_tooltip_vertical_padding - + (kGtkTooltipContainerInset - kTooltipBorderWidth)); + const gdouble tooltip_minimum_height = std::max( + 0.0, self->native_surface_tooltip_minimum_height - + kGtkTooltipContainerInset * 2 - + tooltip_vertical_padding * 2); + const gboolean legacy_yaru = !self->native_surface_high_contrast && + current_gtk_theme_uses_legacy_yaru_shadow(); + g_autofree gchar* decoration_css = legacy_yaru + ? g_strdup_printf( + "window#busymax-window.csd:not(.solid-csd):not(.maximized):" + "not(.fullscreen):not(.tiled):not(.tiled-top):not(.tiled-right):" + "not(.tiled-bottom):not(.tiled-left) > decoration {" + "box-shadow: 0 3px 9px 1px rgba(0,0,0,0.5);" + "}" + "window#busymax-window.csd:not(.solid-csd):not(.maximized):" + "not(.fullscreen):not(.tiled):not(.tiled-top):not(.tiled-right):" + "not(.tiled-bottom):not(.tiled-left) > decoration:backdrop {" + "box-shadow: 0 3px 9px 1px transparent," + "0 2px 6px 2px rgba(0,0,0,0.2);" + "}" + "messagedialog.%s.csd:not(.solid-csd):not(.maximized):" + "not(.fullscreen) > decoration," + "window.%s.%s.csd:not(.solid-csd):not(.maximized):" + "not(.fullscreen) > decoration {" + "box-shadow: 0 0 14px 2px rgba(0,0,6,0.03)," + "0 0 5px 2px rgba(0,0,6,0.10)," + "0 0 0 1px rgba(0,0,0,0.05);" + "}", + kNativeDialogStyleClass, kNativeDialogStyleClass, + kNativeTimeZoneDialogStyleClass) + : g_strdup(""); + g_autofree gchar* css = g_strdup_printf( + "window#busymax-window,window#busymax-window:backdrop {" + "background-color: %s;background-image: none;" + "}" ".%s,.%s:backdrop {" - "background-color: %s;" - "background-image: none;" + "background-color: %s;background-image: none;" "}" - ".%s headerbar," - ".%s headerbar:backdrop {" - "background-color: %s;" - "background-image: none;" - "box-shadow: none;" - "border-bottom-width: 0;" - "border-bottom-style: none;" - "border-bottom-color: transparent;" + ".%s headerbar,.%s headerbar:backdrop {" + "background-color: %s;background-image: none;box-shadow: none;" + "border-bottom-width: 0;border-bottom-style: none;" "}" ".%s .busymax-native-dialog-content," ".%s .busymax-native-dialog-content:backdrop {" - "background-color: %s;" - "background-image: none;" - "border-radius: %dpx;" + "background-color: %s;background-image: none;border-radius: %dpx;" "}" ".%s.csd:not(.solid-csd):not(.maximized):not(.fullscreen) {" - // GTK 3 has no named modern dialog-outline role. Flutter supplies the - // shared semantic token so native confirmations and in-window dialogs - // retain the same restrained inside edge. "box-shadow: inset 0 0 0 1px %s;" - "}", - kNativeDialogStyleClass, kNativeDialogStyleClass, - dialog_background_color, kNativeDialogStyleClass, - kNativeDialogStyleClass, dialog_background_color, - kNativeDialogStyleClass, kNativeDialogStyleClass, dialog_background_color, - kNativeDialogCornerRadius, kNativeDialogStyleClass, - css_color_or(self->header_bar_dialog_outline_color, - kDefaultDialogOutlineColor)); - g_autofree gchar* native_time_zone_dialog_css = g_strdup_printf( + "}" "window.%s.%s.csd:not(.solid-csd):not(.maximized):not(.fullscreen)," "window.%s.%s.csd:not(.solid-csd):not(.maximized):" "not(.fullscreen):backdrop {" - "background-color: %s;" - "background-image: none;" - "border: none;" - "border-radius: %dpx;" - "box-shadow: none;" - "}" - "window.%s.%s.csd:not(.solid-csd):not(.maximized):not(.fullscreen) " - "> decoration," - "window.%s.%s.csd:not(.solid-csd):not(.maximized):" - "not(.fullscreen) > decoration:backdrop," - "window.%s.%s.csd:not(.solid-csd):not(.maximized):" - "not(.fullscreen) > decoration-overlay," - "window.%s.%s.csd:not(.solid-csd):not(.maximized):" - "not(.fullscreen) > decoration-overlay:backdrop {" - "border: none;" - "border-radius: %dpx;" - "box-shadow: none;" + "background-color: %s;background-image: none;border: none;" + "border-radius: %dpx;box-shadow: none;" "}" "window.%s.%s .busymax-native-dialog-content," "window.%s.%s .busymax-native-dialog-content:backdrop {" - "background-color: %s;" - "background-image: none;" + "background-color: %s;background-image: none;" "border-radius: 0 0 %dpx %dpx;" - "}", - kNativeDialogStyleClass, kNativeTimeZoneDialogStyleClass, - kNativeDialogStyleClass, kNativeTimeZoneDialogStyleClass, - dialog_background_color, kNativeDialogCornerRadius, - kNativeDialogStyleClass, kNativeTimeZoneDialogStyleClass, - kNativeDialogStyleClass, kNativeTimeZoneDialogStyleClass, - kNativeDialogStyleClass, kNativeTimeZoneDialogStyleClass, - kNativeDialogStyleClass, kNativeTimeZoneDialogStyleClass, - kNativeDialogCornerRadius, kNativeDialogStyleClass, - kNativeTimeZoneDialogStyleClass, kNativeDialogStyleClass, - kNativeTimeZoneDialogStyleClass, dialog_background_color, - kNativeDialogCornerRadius, kNativeDialogCornerRadius); - // Modal blocking and visual shade depth are separate. A transparent nested - // Flutter barrier must continue blocking the native header without adding - // another black layer. - g_autofree gchar* modal_barrier_color = modal_barrier_color_for_depth( - css_color_or(self->header_bar_modal_barrier_color, - kDefaultModalBarrierColor), - self->header_bar_modal_barrier_shade_depth); - const gboolean use_legacy_yaru_compatibility = - !self->header_bar_high_contrast && - current_gtk_theme_uses_legacy_yaru_shadow(); - g_autofree gchar* native_search_geometry_css = - use_legacy_yaru_compatibility - ? g_strdup_printf( - "entry.search.%s {" - "border-radius: 9px;" - "}", - kHeaderSearchEntryStyleClass) - : g_strdup(""); - const gchar* tooltip_background = css_color_or( - self->header_bar_tooltip_background_color, kDefaultTooltipBackground); - const gchar* tooltip_foreground = css_color_or( - self->header_bar_tooltip_foreground_color, kDefaultTooltipForeground); - const gchar* tooltip_border = css_color_or( - self->header_bar_tooltip_border_color, kDefaultTooltipBorder); - const gdouble tooltip_label_horizontal_padding = std::max( - 0.0, self->header_bar_tooltip_horizontal_padding - - (kGtkTooltipContainerInset - kTooltipBorderWidth)); - const gdouble tooltip_label_vertical_padding = std::max( - 0.0, self->header_bar_tooltip_vertical_padding - - (kGtkTooltipContainerInset - kTooltipBorderWidth)); - const gdouble tooltip_label_minimum_height = std::max( - 0.0, self->header_bar_tooltip_minimum_height - - kGtkTooltipContainerInset * 2 - - tooltip_label_vertical_padding * 2); - g_autofree gchar* tooltip_css = g_strdup_printf( - "tooltip," - "tooltip.background," - "tooltip box," - "tooltip.background box {" - "margin: 0;" - "padding: 0;" - "min-width: 0;" - "min-height: 0;" + "}" + "tooltip,tooltip.background,tooltip box,tooltip.background box {" + "margin: 0;padding: 0;min-width: 0;min-height: 0;" "}" "tooltip.background {" - "background-color: %s;" - "background-image: none;" - "background-clip: padding-box;" - "border: %.2fpx solid %s;" + "background-color: %s;background-image: none;" + "background-clip: padding-box;border: %.2fpx solid %s;" "border-radius: %.2fpx;" "}" - "tooltip decoration," - "tooltip.csd decoration {" - "background-color: transparent;" - "border-radius: %.2fpx;" - "box-shadow: none;" + "tooltip decoration,tooltip.csd decoration {" + "background-color: transparent;border-radius: %.2fpx;box-shadow: none;" "}" - "tooltip * {" - "background-color: transparent;" - "color: %s;" + "tooltip * {background-color: transparent;color: %s;}" + "tooltip label,tooltip.background label {" + "margin: 0;padding: %.2fpx %.2fpx;min-width: 0;min-height: %.2fpx;" + "font-size: %.2fpx;font-weight: 400;" "}" - "tooltip label," - "tooltip.background label {" - "margin: 0;" - "padding: %.2fpx %.2fpx;" - "min-width: 0;" - "min-height: %.2fpx;" - "font-size: %.2fpx;" - "font-weight: 400;" - "}", + "%s", + window_background, kNativeDialogStyleClass, kNativeDialogStyleClass, + dialog_background, kNativeDialogStyleClass, kNativeDialogStyleClass, + dialog_background, kNativeDialogStyleClass, kNativeDialogStyleClass, + dialog_background, kNativeDialogCornerRadius, kNativeDialogStyleClass, + dialog_outline, kNativeDialogStyleClass, + kNativeTimeZoneDialogStyleClass, kNativeDialogStyleClass, + kNativeTimeZoneDialogStyleClass, dialog_background, + kNativeDialogCornerRadius, kNativeDialogStyleClass, + kNativeTimeZoneDialogStyleClass, kNativeDialogStyleClass, + kNativeTimeZoneDialogStyleClass, dialog_background, + kNativeDialogCornerRadius, kNativeDialogCornerRadius, tooltip_background, kTooltipBorderWidth, tooltip_border, - self->header_bar_tooltip_radius, - self->header_bar_tooltip_radius, tooltip_foreground, - tooltip_label_vertical_padding, tooltip_label_horizontal_padding, - tooltip_label_minimum_height, self->header_bar_tooltip_font_size); - g_autofree gchar* header_focus_css = g_strdup_printf( - ".busymax-titlebar.%s .busymax-header-brand label," - ".busymax-titlebar.%s .busymax-header-title {" - "color: %s;" - "}" - ".busymax-titlebar.%s .busymax-header-brand label," - ".busymax-titlebar.%s .busymax-header-title {" - "color: alpha(%s, %.2f);" - "}" - ".busymax-titlebar.%s " - ".busymax-header-control:not(:disabled)," - ".busymax-titlebar.%s " - "headerbar button.titlebutton:not(:disabled) {" - "color: %s;" - "-gtk-icon-effect: none;" - "}" - ".busymax-titlebar.%s " - ".busymax-header-control:not(:disabled)," - ".busymax-titlebar.%s " - "headerbar button.titlebutton:not(:disabled) {" - "color: alpha(%s, %.2f);" - "-gtk-icon-effect: none;" - "}" - ".busymax-titlebar.%s .busymax-header-control:disabled," - ".busymax-titlebar.%s headerbar button.titlebutton:disabled {" - "color: alpha(%s, %.2f);" - "-gtk-icon-effect: none;" - "}" - ".busymax-titlebar.%s .busymax-header-control:disabled," - ".busymax-titlebar.%s headerbar button.titlebutton:disabled {" - "color: alpha(%s, %.2f);" - "-gtk-icon-effect: none;" - "}", - kHeaderApplicationActiveStyleClass, - kHeaderApplicationActiveStyleClass, foreground_color, - kHeaderApplicationBackdropStyleClass, - kHeaderApplicationBackdropStyleClass, foreground_color, - kHeaderBackdropForegroundOpacity, - kHeaderApplicationActiveStyleClass, - kHeaderApplicationActiveStyleClass, foreground_color, - kHeaderApplicationBackdropStyleClass, - kHeaderApplicationBackdropStyleClass, foreground_color, - kHeaderBackdropForegroundOpacity, - kHeaderApplicationActiveStyleClass, - kHeaderApplicationActiveStyleClass, foreground_color, - kHeaderDisabledForegroundOpacity, - kHeaderApplicationBackdropStyleClass, - kHeaderApplicationBackdropStyleClass, foreground_color, - kHeaderDisabledBackdropForegroundOpacity); - g_autofree gchar* yaru_window_decoration_css = - use_legacy_yaru_compatibility - ? g_strdup_printf( - "window#busymax-window.csd:not(.solid-csd):" - "not(.maximized):not(.fullscreen):not(.tiled):" - "not(.tiled-top):not(.tiled-right):not(.tiled-bottom):" - "not(.tiled-left) > decoration {" - // Keep Yaru GTK 3's native diffuse shadow, but omit its legacy - // zero-blur outline. Current GTK 4/libadwaita Ubuntu apps use - // a much subtler edge, while Handy remains responsible for - // radius, clipping, and window-state geometry. - "box-shadow: 0 3px 9px 1px rgba(0,0,0,0.5);" - "}" - "window#busymax-window.csd:not(.solid-csd):" - "not(.maximized):not(.fullscreen):not(.tiled):" - "not(.tiled-top):not(.tiled-right):not(.tiled-bottom):" - "not(.tiled-left) > decoration:backdrop {" - "box-shadow: 0 3px 9px 1px transparent," - "0 2px 6px 2px rgba(0,0,0,0.2);" - "}" - "window#busymax-window.csd.tiled:not(.solid-csd):" - "not(.maximized):not(.fullscreen) > decoration," - "window#busymax-window.csd.tiled-top:not(.solid-csd):" - "not(.maximized):not(.fullscreen) > decoration," - "window#busymax-window.csd.tiled-right:not(.solid-csd):" - "not(.maximized):not(.fullscreen) > decoration," - "window#busymax-window.csd.tiled-bottom:not(.solid-csd):" - "not(.maximized):not(.fullscreen) > decoration," - "window#busymax-window.csd.tiled-left:not(.solid-csd):" - "not(.maximized):not(.fullscreen) > decoration {" - "box-shadow: 0 0 0 20px transparent;" - "}" - "messagedialog.%s.csd:not(.solid-csd):" - "not(.maximized):not(.fullscreen) > decoration {" - // Yaru GTK 3 adds a 65%-black zero-blur ring to message - // dialogs. Translate the current libadwaita message-dialog - // shadow to GTK 3's decoration node, leaving GTK in charge of - // every control, radius, layout, focus state, and action role. - "box-shadow: 0 0 14px 2px rgba(0,0,6,0.03)," - "0 0 5px 2px rgba(0,0,6,0.10)," - "0 0 0 1px rgba(0,0,0,0.05);" - "}" - "window.%s.%s.csd:not(.solid-csd):" - "not(.maximized):not(.fullscreen) > decoration {" - "box-shadow: 0 0 14px 2px rgba(0,0,6,0.03)," - "0 0 5px 2px rgba(0,0,6,0.10);" - "}", - kNativeDialogStyleClass, kNativeDialogStyleClass, - kNativeTimeZoneDialogStyleClass) - : g_strdup(""); - GtkWidget* header_bar = GTK_WIDGET(self->header_bar); - GtkStyleContext* context = gtk_widget_get_style_context(header_bar); - gtk_style_context_add_class(context, "busymax-flat-headerbar"); - g_autofree gchar* css = g_strdup_printf( - "window#busymax-window," - "window#busymax-window:backdrop {" - "background-color: %s;" - "background-image: none;" - "}" - "%s" - "%s" - "%s" - "%s" - "%s" - "headerbar.busymax-flat-headerbar," - "headerbar.busymax-flat-headerbar:backdrop {" - "background-color: %s;" - "background-image: none;" - "color: %s;" - "border: none;" - "box-shadow: none;" - "}" - "headerbar.busymax-flat-headerbar," - "headerbar.busymax-flat-headerbar:backdrop {" - "padding-left: 0;" - "}" - ".busymax-titlebar .busymax-header-brand {" - "background-color: %s;" - "background-image: none;" - "color: %s;" - "border-right: 1px solid %s;" - "}" - ".busymax-titlebar .busymax-header-brand label {" - "color: %s;" - "font-weight: 800;" - "}" - ".busymax-titlebar .busymax-header-brand label:backdrop {" - "color: alpha(%s, %.2f);" - "}" - ".busymax-titlebar .busymax-header-title {" - "color: %s;" - "}" - ".busymax-titlebar .busymax-header-title:backdrop {" - "color: alpha(%s, %.2f);" - "}" - // GTK themes can assign an absolute backdrop foreground directly to - // buttons, overriding the semantic foreground inherited from the - // headerbar. Keep enabled BusyMax controls and GTK-generated window - // controls on the same semantic roles and native opacity metrics in - // focused, backdrop, and disabled states. - ".busymax-titlebar " - ".busymax-header-control:not(:disabled)," - ".busymax-titlebar " - "headerbar button.titlebutton:not(:disabled) {" - "color: %s;" - "-gtk-icon-effect: none;" - "}" - ".busymax-titlebar " - ".busymax-header-control:not(:disabled):backdrop," - ".busymax-titlebar " - "headerbar button.titlebutton:not(:disabled):backdrop {" - "color: alpha(%s, %.2f);" - "-gtk-icon-effect: none;" - "}" - ".busymax-titlebar " - ".busymax-header-control:disabled," - ".busymax-titlebar " - "headerbar button.titlebutton:disabled {" - "color: alpha(%s, %.2f);" - "-gtk-icon-effect: none;" - "}" - ".busymax-titlebar " - ".busymax-header-control:disabled:backdrop," - ".busymax-titlebar " - "headerbar button.titlebutton:disabled:backdrop {" - "color: alpha(%s, %.2f);" - "-gtk-icon-effect: none;" - "}" - "%s" - // Yaru GTK 3 paints pressed and checked buttons with an absolute - // near-black image. That legacy state is incompatible with BusyMax's - // semantic header surfaces. Scope modern Yaru/libadwaita current-color - // layers to BusyMax controls so native focus and geometry remain - // GTK-owned without leaking an absolute palette color. - ".busymax-titlebar " - ".busymax-header-control:not(.suggested-action):not(:disabled) {" - "background-color: transparent;" - "background-image: none;" - "border-color: transparent;" - "box-shadow: none;" - "}" - ".busymax-titlebar " - ".busymax-header-control:not(.suggested-action):not(:disabled):hover {" - "background-color: alpha(currentColor, 0.07);" - "background-image: none;" - "}" - ".busymax-titlebar " - ".busymax-header-control:not(.suggested-action):not(:disabled):active {" - "background-color: alpha(currentColor, 0.16);" - "background-image: none;" - "}" - ".busymax-titlebar " - ".busymax-header-control:not(.suggested-action):not(:disabled):checked {" - "background-color: alpha(currentColor, 0.10);" - "background-image: none;" - "}" - ".busymax-titlebar " - ".busymax-header-control:not(.suggested-action):" - "not(:disabled):checked:hover {" - "background-color: alpha(currentColor, 0.13);" - "background-image: none;" - "}" - ".busymax-titlebar " - ".busymax-header-control:not(.suggested-action):" - "not(:disabled):checked:active {" - "background-color: alpha(currentColor, 0.19);" - "background-image: none;" - "}" - ".busymax-titlebar .busymax-onboarding-text-button," - ".busymax-titlebar .busymax-onboarding-text-button:hover," - ".busymax-titlebar .busymax-onboarding-text-button:active," - ".busymax-titlebar .busymax-onboarding-text-button:disabled {" - "min-width: 0;" - "min-height: 0;" - "padding: 0;" - "border: none;" - "background-color: transparent;" - "background-image: none;" - "box-shadow: none;" - "}" - ".busymax-titlebar .busymax-onboarding-text-button:hover label {" - "text-decoration-line: underline;" - "}" - // While a modal route is present, transient and checked control - // surfaces must not remain painted above the dimmed titlebar. The - // controls stay sensitive so GTK does not substitute disabled colors; - // the full-size input shield below owns interaction blocking. - ".busymax-titlebar.%s " - ".busymax-header-control," - ".busymax-titlebar.%s " - ".busymax-header-control:hover," - ".busymax-titlebar.%s " - ".busymax-header-control:active," - ".busymax-titlebar.%s " - ".busymax-header-control:checked," - ".busymax-titlebar.%s " - ".busymax-header-control:checked:hover," - ".busymax-titlebar.%s " - ".busymax-header-control:checked:active {" - "background-color: transparent;" - "background-image: none;" - "border-color: transparent;" - "box-shadow: none;" - "}" - ".busymax-titlebar .%s," - ".busymax-titlebar .%s:backdrop {" - "background-color: %s;" - "background-image: none;" - "}", - window_background_color, yaru_window_decoration_css, - native_dialog_css, native_time_zone_dialog_css, - native_search_geometry_css, tooltip_css, - background_color, foreground_color, - sidebar_background_color, foreground_color, sidebar_border_color, - foreground_color, foreground_color, kHeaderBackdropForegroundOpacity, - foreground_color, foreground_color, kHeaderBackdropForegroundOpacity, - foreground_color, foreground_color, kHeaderBackdropForegroundOpacity, - foreground_color, kHeaderDisabledForegroundOpacity, foreground_color, - kHeaderDisabledBackdropForegroundOpacity, - header_focus_css, - kHeaderModalOpenStyleClass, kHeaderModalOpenStyleClass, - kHeaderModalOpenStyleClass, kHeaderModalOpenStyleClass, - kHeaderModalOpenStyleClass, kHeaderModalOpenStyleClass, - kHeaderModalBarrierStyleClass, - kHeaderModalBarrierStyleClass, modal_barrier_color); + self->native_surface_tooltip_radius, + self->native_surface_tooltip_radius, tooltip_foreground, + tooltip_vertical_padding, tooltip_horizontal_padding, + tooltip_minimum_height, self->native_surface_tooltip_font_size, + decoration_css); g_autoptr(GError) error = nullptr; GtkCssProvider* provider = gtk_css_provider_new(); gtk_css_provider_load_from_data(provider, css, -1, &error); if (error != nullptr) { - g_warning("Failed to load headerbar CSS: %s", error->message); + g_warning("Failed to load native surface CSS: %s", error->message); g_object_unref(provider); return; } - - GdkScreen* screen = gtk_widget_get_screen(header_bar); - if (self->header_bar_css_provider != nullptr) { + GdkScreen* screen = gdk_screen_get_default(); + if (screen == nullptr) { + g_object_unref(provider); + return; + } + if (self->native_surface_css_provider != nullptr) { gtk_style_context_remove_provider_for_screen( - screen, GTK_STYLE_PROVIDER(self->header_bar_css_provider)); - g_clear_object(&self->header_bar_css_provider); + screen, GTK_STYLE_PROVIDER(self->native_surface_css_provider)); + g_clear_object(&self->native_surface_css_provider); } - self->header_bar_css_provider = provider; + self->native_surface_css_provider = provider; gtk_style_context_add_provider_for_screen( - screen, GTK_STYLE_PROVIDER(self->header_bar_css_provider), + screen, GTK_STYLE_PROVIDER(provider), GTK_STYLE_PROVIDER_PRIORITY_APPLICATION); } -static void set_css_color_field(gchar** target, const gchar* value) { - if (!is_css_color_token(value)) { - return; - } - g_free(*target); - *target = g_strdup(value); -} - -static void set_header_bar_theme(MyApplication* self, FlValue* args) { - if (args == nullptr || fl_value_get_type(args) != FL_VALUE_TYPE_MAP) { - return; - } - self->header_bar_theme_received = TRUE; - gboolean prefer_dark = FALSE; - if (fl_lookup_optional_bool_arg(args, "preferDark", &prefer_dark)) { - set_gtk_theme_preference(prefer_dark); - } +static void set_native_surface_theme(MyApplication* self, FlValue* args) { + if (args == nullptr || fl_value_get_type(args) != FL_VALUE_TYPE_MAP) return; + self->native_surface_theme_received = TRUE; fl_lookup_optional_bool_arg(args, "highContrast", - &self->header_bar_high_contrast); - set_css_color_field(&self->header_bar_window_background_color, - fl_lookup_string_arg(args, "windowBackgroundColor")); - set_css_color_field(&self->header_bar_background_color, - fl_lookup_string_arg(args, "backgroundColor")); - set_css_color_field(&self->header_bar_sidebar_background_color, - fl_lookup_string_arg(args, "sidebarBackgroundColor")); - set_css_color_field(&self->header_bar_sidebar_border_color, - fl_lookup_string_arg(args, "sidebarBorderColor")); - set_css_color_field(&self->header_bar_foreground_color, - fl_lookup_string_arg(args, "foregroundColor")); - set_css_color_field(&self->header_bar_dialog_background_color, - fl_lookup_string_arg(args, "dialogBackgroundColor")); - set_css_color_field(&self->header_bar_dialog_outline_color, - fl_lookup_string_arg(args, "dialogOutlineColor")); - set_css_color_field(&self->header_bar_modal_barrier_color, - fl_lookup_string_arg(args, "modalBarrierColor")); - FlValue* tooltip = fl_lookup_map_arg(args, "tooltip"); - if (tooltip != nullptr) { - set_css_color_field( - &self->header_bar_tooltip_background_color, - fl_lookup_string_arg(tooltip, "backgroundColor")); - set_css_color_field( - &self->header_bar_tooltip_foreground_color, - fl_lookup_string_arg(tooltip, "foregroundColor")); - set_css_color_field(&self->header_bar_tooltip_border_color, - fl_lookup_string_arg(tooltip, "borderColor")); - update_bounded_double_arg(tooltip, "borderRadius", 0, 64, - &self->header_bar_tooltip_radius); - update_bounded_double_arg(tooltip, "fontSize", 1, 64, - &self->header_bar_tooltip_font_size); - update_bounded_double_arg(tooltip, "horizontalPadding", 0, 64, - &self->header_bar_tooltip_horizontal_padding); - update_bounded_double_arg(tooltip, "verticalPadding", 0, 64, - &self->header_bar_tooltip_vertical_padding); - update_bounded_double_arg(tooltip, "minimumHeight", 1, 128, - &self->header_bar_tooltip_minimum_height); - } + &self->native_surface_high_contrast); + set_css_color_field( + &self->native_surface_window_background_color, + fl_lookup_string_arg(args, "windowBackgroundColor")); + set_css_color_field( + &self->native_surface_dialog_background_color, + fl_lookup_string_arg(args, "dialogBackgroundColor")); + set_css_color_field( + &self->native_surface_dialog_outline_color, + fl_lookup_string_arg(args, "dialogOutlineColor")); + set_css_color_field( + &self->native_surface_tooltip_background_color, + fl_lookup_string_arg(args, "tooltipBackgroundColor")); + set_css_color_field( + &self->native_surface_tooltip_foreground_color, + fl_lookup_string_arg(args, "tooltipForegroundColor")); + set_css_color_field( + &self->native_surface_tooltip_border_color, + fl_lookup_string_arg(args, "tooltipBorderColor")); + update_bounded_double_arg(args, "tooltipRadius", 0, 64, + &self->native_surface_tooltip_radius); + update_bounded_double_arg(args, "tooltipFontSize", 1, 64, + &self->native_surface_tooltip_font_size); + update_bounded_double_arg(args, "tooltipHorizontalPadding", 0, 64, + &self->native_surface_tooltip_horizontal_padding); + update_bounded_double_arg(args, "tooltipVerticalPadding", 0, 64, + &self->native_surface_tooltip_vertical_padding); + update_bounded_double_arg(args, "tooltipMinimumHeight", 1, 128, + &self->native_surface_tooltip_minimum_height); set_main_flutter_view_background(self); - refresh_header_bar_css(self); + refresh_native_surface_css(self); } -static void close_header_menu_button(GtkWidget* menu_button); -static void focus_flutter_view(MyApplication* self); - -static gboolean refresh_header_bar_focus_state_cb(gpointer user_data) { - MyApplication* self = MY_APPLICATION(user_data); - if (self->titlebar_handle == nullptr || - !GTK_IS_WIDGET(self->titlebar_handle)) { - return G_SOURCE_REMOVE; +static FlValue* parse_gtk_font_name(const gchar* font_name) { + if (font_name == nullptr) { + return fl_value_new_null(); } - const gboolean application_active = - (self->main_window != nullptr && - gtk_window_is_active(self->main_window)) || - (self->header_focus_transient_window != nullptr && - gtk_window_is_active(self->header_focus_transient_window)); - GtkStyleContext* context = - gtk_widget_get_style_context(self->titlebar_handle); - gtk_style_context_remove_class( - context, kHeaderApplicationActiveStyleClass); - gtk_style_context_remove_class( - context, kHeaderApplicationBackdropStyleClass); - gtk_style_context_add_class( - context, - application_active ? kHeaderApplicationActiveStyleClass - : kHeaderApplicationBackdropStyleClass); - - // The headerbar is embedded above Flutter rather than installed as - // GtkWindow's titlebar. Reset its subtree after the compositor's focus - // transfer settles so :backdrop declarations cannot remain one event late. - gtk_widget_reset_style(self->titlebar_handle); - gtk_widget_queue_draw(self->titlebar_handle); - return G_SOURCE_REMOVE; -} - -static void schedule_header_bar_focus_state_refresh(MyApplication* self) { - g_idle_add_full( - G_PRIORITY_DEFAULT_IDLE, refresh_header_bar_focus_state_cb, - g_object_ref(self), g_object_unref); -} - -static void set_header_bar_modal_barrier_state(MyApplication* self, - gboolean visible, - gint shade_depth) { - const gint effective_shade_depth = - visible ? std::max(0, shade_depth) : 0; - if (self->header_bar_modal_barrier_visible == visible && - self->header_bar_modal_barrier_shade_depth == - effective_shade_depth) { - return; - } - const gboolean shade_changed = - self->header_bar_modal_barrier_shade_depth != effective_shade_depth; - self->header_bar_modal_barrier_visible = visible; - self->header_bar_modal_barrier_shade_depth = effective_shade_depth; - if (shade_changed) { - refresh_header_bar_css(self); - } - if (self->titlebar_handle != nullptr && - GTK_IS_WIDGET(self->titlebar_handle)) { - GtkStyleContext* context = - gtk_widget_get_style_context(self->titlebar_handle); - if (visible) { - gtk_style_context_add_class(context, kHeaderModalOpenStyleClass); - } else { - gtk_style_context_remove_class(context, kHeaderModalOpenStyleClass); - } - } - if (self->titlebar_modal_barrier != nullptr && - GTK_IS_WIDGET(self->titlebar_modal_barrier)) { - gtk_widget_set_visible(self->titlebar_modal_barrier, visible); - } - if (visible) { - close_header_menu_button(self->settings_menu_button); - close_header_menu_button(self->view_mode_button); - close_header_menu_button(self->create_button); - focus_flutter_view(self); + PangoFontDescription* desc = pango_font_description_from_string(font_name); + if (desc == nullptr) { + return fl_value_new_null(); } -} -static void set_header_bar_modal_barrier_visible(MyApplication* self, - gboolean visible) { - set_header_bar_modal_barrier_state(self, visible, visible ? 1 : 0); -} + const gchar* family = pango_font_description_get_family(desc); + const gint size = pango_font_description_get_size(desc); + const double point_size = + size > 0 ? static_cast(size) / PANGO_SCALE : 0.0; -static void set_header_bar_modal_barrier_depth(MyApplication* self, - gint depth) { - const gint effective_depth = std::max(0, depth); - set_header_bar_modal_barrier_state( - self, effective_depth > 0, effective_depth); -} + FlValue* result = fl_value_new_map(); + fl_value_set_string_take( + result, "family", + fl_value_new_string(family != nullptr ? family : "")); + fl_value_set_string_take(result, "size", fl_value_new_float(point_size)); -static void clear_header_bar_pointer(MyApplication* self) { - if (self->header_bar != nullptr) { - g_object_remove_weak_pointer( - G_OBJECT(self->header_bar), - reinterpret_cast(&self->header_bar)); - self->header_bar = nullptr; - } + pango_font_description_free(desc); + return result; } -static void clear_widget_pointer(GtkWidget** target) { - if (*target != nullptr) { - g_object_remove_weak_pointer(G_OBJECT(*target), - reinterpret_cast(target)); - *target = nullptr; +static FlValue* get_gtk_font_settings() { + GtkSettings* settings = gtk_settings_get_default(); + if (settings == nullptr) { + return fl_value_new_null(); } -} -static void invoke_header_bar_action(MyApplication* self, - const gchar* action) { - if (self->header_bar_modal_barrier_visible || - self->header_bar_channel == nullptr || action == nullptr) { - return; - } - fl_method_channel_invoke_method(self->header_bar_channel, action, nullptr, - nullptr, nullptr, nullptr); + gchar* font_name = nullptr; + g_object_get(settings, "gtk-font-name", &font_name, nullptr); + FlValue* result = parse_gtk_font_name(font_name); + g_free(font_name); + return result; } -static void invoke_header_bar_string_action(MyApplication* self, - const gchar* action, - const gchar* value) { - if (self->header_bar_modal_barrier_visible || - self->header_bar_channel == nullptr || action == nullptr) { - return; +static gboolean get_gtk_animations_enabled() { + GtkSettings* settings = gtk_settings_get_default(); + gboolean enabled = TRUE; + if (settings != nullptr) { + g_object_get(settings, "gtk-enable-animations", &enabled, nullptr); } - g_autoptr(FlValue) args = fl_value_new_string(value == nullptr ? "" : value); - fl_method_channel_invoke_method(self->header_bar_channel, action, args, - nullptr, nullptr, nullptr); + return enabled; } -static void invoke_header_bar_bool_action(MyApplication* self, - const gchar* action, - gboolean value) { - if (self->header_bar_modal_barrier_visible || - self->header_bar_channel == nullptr || action == nullptr) { - return; - } - g_autoptr(FlValue) args = fl_value_new_bool(value); - fl_method_channel_invoke_method(self->header_bar_channel, action, args, - nullptr, nullptr, nullptr); -} - -enum class HeaderSearchQueryUpdateDisposition { - kAlreadyCurrent, - kPreserveNativeText, - kApplyDartSnapshot, -}; - -constexpr HeaderSearchQueryUpdateDisposition -resolve_header_search_query_update(bool queries_match, - bool native_entry_has_authority, - bool incoming_state_is_active) { - if (queries_match) { - return HeaderSearchQueryUpdateDisposition::kAlreadyCurrent; - } - return native_entry_has_authority && incoming_state_is_active - ? HeaderSearchQueryUpdateDisposition::kPreserveNativeText - : HeaderSearchQueryUpdateDisposition::kApplyDartSnapshot; -} - -static_assert( - resolve_header_search_query_update(false, true, true) == - HeaderSearchQueryUpdateDisposition::kPreserveNativeText, - "A newer focused native edit must survive a delayed Dart snapshot"); -static_assert( - resolve_header_search_query_update(false, false, true) == - HeaderSearchQueryUpdateDisposition::kApplyDartSnapshot, - "Dart owns search text while the native entry is not being edited"); -static_assert( - resolve_header_search_query_update(false, true, false) == - HeaderSearchQueryUpdateDisposition::kApplyDartSnapshot, - "Deactivation must reconcile the native entry with Dart's final query"); - -static void cache_header_search_query(MyApplication* self, - const gchar* query) { - const gchar* normalized_query = query == nullptr ? "" : query; - if (g_strcmp0(self->header_search_query, normalized_query) == 0) { - return; - } - g_free(self->header_search_query); - self->header_search_query = g_strdup(normalized_query); -} - -static void set_header_search_query(MyApplication* self, - const gchar* query, - gboolean incoming_state_is_active) { - const gchar* normalized_query = query == nullptr ? "" : query; - if (self->search_entry == nullptr || !GTK_IS_ENTRY(self->search_entry)) { - cache_header_search_query(self, normalized_query); - return; - } - const gchar* current_query = - gtk_entry_get_text(GTK_ENTRY(self->search_entry)); - const bool native_entry_has_authority = - self->header_search_active && - gtk_widget_has_focus(self->search_entry); - switch (resolve_header_search_query_update( - g_strcmp0(current_query, normalized_query) == 0, - native_entry_has_authority, incoming_state_is_active)) { - case HeaderSearchQueryUpdateDisposition::kAlreadyCurrent: - cache_header_search_query(self, normalized_query); - return; - case HeaderSearchQueryUpdateDisposition::kPreserveNativeText: - // GtkSearchEntry emits search-changed after a short delay. Dart can - // therefore publish an older mirrored snapshot after the user has - // already typed more text. While the active entry has focus, native - // text is authoritative. Do not update the cache here: a pending - // search-changed signal still needs to publish the newer native text. - return; - case HeaderSearchQueryUpdateDisposition::kApplyDartSnapshot: - break; - } - - cache_header_search_query(self, normalized_query); - const gboolean previous_suppression = self->suppress_header_bar_actions; - self->suppress_header_bar_actions = TRUE; - gtk_entry_set_text(GTK_ENTRY(self->search_entry), normalized_query); - self->suppress_header_bar_actions = previous_suppression; -} - -static void header_search_entry_search_changed_cb(GtkSearchEntry* entry, - gpointer user_data) { - MyApplication* self = MY_APPLICATION(user_data); - if (self->suppress_header_bar_actions || - self->header_bar_modal_barrier_visible || - !self->header_search_active) { - return; - } - const gchar* query = gtk_entry_get_text(GTK_ENTRY(entry)); - if (g_strcmp0(self->header_search_query, query) == 0) { - return; - } - cache_header_search_query(self, query); - invoke_header_bar_string_action(self, "searchQueryChanged", query); -} - -static gboolean header_search_entry_focus_in_cb(GtkWidget*, - GdkEventFocus*, - gpointer user_data) { - MyApplication* self = MY_APPLICATION(user_data); - if (!self->header_bar_modal_barrier_visible) { - invoke_header_bar_bool_action(self, "searchFocusChanged", TRUE); - } - return FALSE; -} - -static gboolean header_search_entry_focus_out_cb(GtkWidget*, - GdkEventFocus*, - gpointer user_data) { - MyApplication* self = MY_APPLICATION(user_data); - if (!self->header_bar_modal_barrier_visible) { - invoke_header_bar_bool_action(self, "searchFocusChanged", FALSE); - } - return FALSE; -} - -static void header_search_entry_icon_release_cb( - GtkEntry* entry, - GtkEntryIconPosition icon_position, - GdkEvent*, - gpointer user_data) { - MyApplication* self = MY_APPLICATION(user_data); - if (self->suppress_header_bar_actions || - self->header_bar_modal_barrier_visible || - !self->header_search_active || - icon_position != GTK_ENTRY_ICON_SECONDARY || - gtk_entry_get_text(entry)[0] == '\0') { - return; - } - - // GtkSearchEntry clears its native secondary icon after this signal. Cache - // the semantic result now so its delayed search-changed signal is deduped. - cache_header_search_query(self, ""); - invoke_header_bar_action(self, "searchCleared"); -} - -static void header_search_entry_stop_search_cb(GtkSearchEntry*, - gpointer user_data) { - MyApplication* self = MY_APPLICATION(user_data); - if (self->suppress_header_bar_actions || - self->header_bar_modal_barrier_visible || - !self->header_search_active) { - return; - } - invoke_header_bar_action(self, "searchEscapePressed"); -} - -static void focus_flutter_view(MyApplication* self) { - if (self->flutter_view != nullptr && GTK_IS_WIDGET(self->flutter_view)) { - gtk_widget_grab_focus(self->flutter_view); - } -} - -static void header_bar_action_clicked_cb(GtkWidget* widget, - gpointer user_data) { - MyApplication* self = MY_APPLICATION(user_data); - if (self->suppress_header_bar_actions || - self->header_bar_modal_barrier_visible) { - return; - } - const gchar* action = static_cast( - g_object_get_data(G_OBJECT(widget), "busymax-header-action")); - focus_flutter_view(self); - invoke_header_bar_action(self, action); -} - -static void connect_header_bar_action(MyApplication* self, - GtkWidget* widget, - const gchar* action) { - g_object_set_data(G_OBJECT(widget), "busymax-header-action", - const_cast(action)); - g_signal_connect(widget, "clicked", G_CALLBACK(header_bar_action_clicked_cb), - self); -} - -static void close_header_menu_button(GtkWidget* menu_button) { - if (menu_button == nullptr || !GTK_IS_MENU_BUTTON(menu_button)) { - return; - } - GtkMenu* menu = gtk_menu_button_get_popup(GTK_MENU_BUTTON(menu_button)); - if (menu != nullptr && GTK_IS_MENU(menu) && - gtk_widget_get_visible(GTK_WIDGET(menu))) { - gtk_menu_shell_deactivate(GTK_MENU_SHELL(menu)); - } - if (GTK_IS_TOGGLE_BUTTON(menu_button)) { - gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(menu_button), FALSE); - } -} - -static const gchar* header_view_mode_action(const gchar* mode); -static void set_header_view_mode(MyApplication* self, const gchar* mode); -static void set_widget_tooltip_with_shortcut(GtkWidget* widget, - const gchar* tooltip, - const gchar* shortcut); - -static void replace_header_label(gchar** target, const gchar* value) { - if (value == nullptr) { - return; - } - g_free(*target); - *target = g_strdup(value); -} - -static const gchar* header_view_mode_label(MyApplication* self, - const gchar* mode) { - if (g_strcmp0(mode, "day") == 0) { - return self->header_day_label; - } - if (g_strcmp0(mode, "week") == 0) { - return self->header_week_label; - } - if (g_strcmp0(mode, "month") == 0) { - return self->header_month_label; - } - if (g_strcmp0(mode, "year") == 0) { - return self->header_year_label; - } - if (g_strcmp0(mode, "agenda") == 0) { - return self->header_agenda_label; - } - return ""; -} - -static const gchar* header_view_mode_icon_name(const gchar* mode) { - if (g_strcmp0(mode, "day") == 0) { - return "view-continuous-symbolic"; - } - if (g_strcmp0(mode, "week") == 0) { - return "calendar-week-symbolic"; - } - if (g_strcmp0(mode, "month") == 0) { - return "calendar-month-symbolic"; - } - if (g_strcmp0(mode, "year") == 0) { - return "view-app-grid-symbolic"; - } - if (g_strcmp0(mode, "agenda") == 0) { - return "view-list-symbolic"; - } - return "calendar-week-symbolic"; -} - -static const gchar* header_view_mode_shortcut(MyApplication* self, - const gchar* mode) { - if (g_strcmp0(mode, "day") == 0) { - return self->header_day_shortcut; - } - if (g_strcmp0(mode, "week") == 0) { - return self->header_week_shortcut; - } - if (g_strcmp0(mode, "month") == 0) { - return self->header_month_shortcut; - } - if (g_strcmp0(mode, "year") == 0) { - return self->header_year_shortcut; - } - if (g_strcmp0(mode, "agenda") == 0) { - return self->header_agenda_shortcut; - } - return ""; -} - -static void update_header_view_mode_presentation(MyApplication* self) { - if (self->view_mode_icon == nullptr || - !GTK_IS_IMAGE(self->view_mode_icon)) { - return; - } - const gchar* mode = - self->header_view_mode != nullptr ? self->header_view_mode : "week"; - const gchar* label = header_view_mode_label(self, mode); - gtk_image_set_from_icon_name(GTK_IMAGE(self->view_mode_icon), - header_view_mode_icon_name(mode), - GTK_ICON_SIZE_MENU); - set_widget_tooltip_with_shortcut(self->view_mode_button, label, - header_view_mode_shortcut(self, mode)); -} - -static void set_header_menu_button_model(GtkWidget* button, - GMenuModel* model, - GtkWidget** tracked_menu) { - if (button == nullptr || !GTK_IS_MENU_BUTTON(button) || model == nullptr) { - return; - } - close_header_menu_button(button); - // Pointer-opened header menus should not paint a keyboard focus ring around - // their first row. Keyboard traversal can still focus the trigger normally. - gtk_widget_set_focus_on_click(button, FALSE); - if (*tracked_menu != nullptr) { - clear_widget_pointer(tracked_menu); - } - // GtkMenu is a native xdg_popup on Wayland. A GtkPopover subsurface can have - // its GDK redraw clock stalled by Mutter while the Flutter parent is idle. - gtk_menu_button_set_use_popover(GTK_MENU_BUTTON(button), FALSE); - gtk_menu_button_set_menu_model(GTK_MENU_BUTTON(button), model); - GtkMenu* menu = gtk_menu_button_get_popup(GTK_MENU_BUTTON(button)); - if (menu == nullptr || !GTK_IS_MENU(menu)) { - return; - } - track_widget_pointer(tracked_menu, GTK_WIDGET(menu)); -} - -static void append_header_action_item(GMenu* menu, - const gchar* label, - const gchar* action, - const gchar* icon_name, - const gchar* shortcut) { - g_autoptr(GMenuItem) item = g_menu_item_new(label, action); - if (icon_name != nullptr && icon_name[0] != '\0') { - g_autoptr(GIcon) icon = g_themed_icon_new(icon_name); - g_menu_item_set_icon(item, icon); - } - if (shortcut != nullptr && shortcut[0] != '\0') { - set_menu_item_accelerator(item, shortcut); - } - g_menu_append_item(menu, item); -} - -static void append_header_view_mode_item(GMenu* menu, - const gchar* label, - const gchar* mode, - const gchar* icon_name, - const gchar* shortcut) { - g_autoptr(GMenuItem) item = g_menu_item_new(label, nullptr); - g_menu_item_set_action_and_target(item, "header.view-mode", "s", mode); - if (icon_name != nullptr && icon_name[0] != '\0') { - g_autoptr(GIcon) icon = g_themed_icon_new(icon_name); - g_menu_item_set_icon(item, icon); - } - if (shortcut != nullptr && shortcut[0] != '\0') { - set_menu_item_accelerator(item, shortcut); - } - g_menu_append_item(menu, item); -} - -static void rebuild_header_settings_menu_model(MyApplication* self) { - if (self->settings_menu_button == nullptr) { - return; - } - g_autoptr(GMenu) menu = g_menu_new(); - append_header_action_item(menu, self->header_settings_label, - "header.settings", "preferences-system-symbolic", - self->header_settings_shortcut); - append_header_action_item( - menu, self->header_keyboard_shortcuts_label, - "header.keyboard-shortcuts", "input-keyboard-symbolic", - self->header_keyboard_shortcuts_shortcut); - append_header_action_item(menu, self->header_report_issue_label, - "header.report-issue", "dialog-warning-symbolic", - nullptr); - append_header_action_item(menu, self->header_about_label, "header.about", - "help-about-symbolic", nullptr); - set_header_menu_button_model(self->settings_menu_button, G_MENU_MODEL(menu), - &self->settings_menu); -} - -static void rebuild_header_view_mode_menu_model(MyApplication* self) { - if (self->view_mode_button == nullptr) { - return; - } - g_autoptr(GMenu) menu = g_menu_new(); - append_header_view_mode_item(menu, self->header_day_label, "day", - header_view_mode_icon_name("day"), - self->header_day_shortcut); - append_header_view_mode_item(menu, self->header_week_label, "week", - header_view_mode_icon_name("week"), - self->header_week_shortcut); - append_header_view_mode_item(menu, self->header_month_label, "month", - header_view_mode_icon_name("month"), - self->header_month_shortcut); - append_header_view_mode_item(menu, self->header_year_label, "year", - header_view_mode_icon_name("year"), - self->header_year_shortcut); - append_header_view_mode_item(menu, self->header_agenda_label, "agenda", - header_view_mode_icon_name("agenda"), - self->header_agenda_shortcut); - set_header_menu_button_model(self->view_mode_button, G_MENU_MODEL(menu), - &self->view_mode_menu); - update_header_view_mode_presentation(self); -} - -static void rebuild_header_create_menu_model(MyApplication* self) { - if (self->create_button == nullptr) { - return; - } - g_autoptr(GMenu) menu = g_menu_new(); - append_header_action_item(menu, self->header_create_event_label, - "header.create-event", "x-office-calendar-symbolic", - self->header_create_event_shortcut); - append_header_action_item(menu, self->header_create_task_label, - "header.create-task", "checkbox-checked-symbolic", - self->header_create_task_shortcut); - set_header_menu_button_model(self->create_button, G_MENU_MODEL(menu), - &self->create_menu); -} - -static void rebuild_header_menu_models(MyApplication* self) { - rebuild_header_settings_menu_model(self); - rebuild_header_view_mode_menu_model(self); - rebuild_header_create_menu_model(self); -} - -static void header_menu_action_activated_cb(GSimpleAction* action, - GVariant*, - gpointer user_data) { - MyApplication* self = MY_APPLICATION(user_data); - if (self->suppress_header_bar_actions || - self->header_bar_modal_barrier_visible) { - return; - } - const gchar* bridge_action = static_cast( - g_object_get_data(G_OBJECT(action), "busymax-header-action")); - focus_flutter_view(self); - invoke_header_bar_action(self, bridge_action); -} - -static void header_view_mode_action_activated_cb(GSimpleAction* action, - GVariant* parameter, - gpointer user_data) { - MyApplication* self = MY_APPLICATION(user_data); - if (self->suppress_header_bar_actions || - self->header_bar_modal_barrier_visible || parameter == nullptr || - !g_variant_is_of_type(parameter, G_VARIANT_TYPE_STRING)) { - return; - } - const gchar* mode = g_variant_get_string(parameter, nullptr); - const gchar* bridge_action = header_view_mode_action(mode); - if (bridge_action == nullptr) { - return; - } - set_header_view_mode(self, mode); - focus_flutter_view(self); - invoke_header_bar_action(self, bridge_action); -} - -static GSimpleAction* create_header_bridge_action( - MyApplication* self, - const gchar* action_name, - const gchar* bridge_action) { - GSimpleAction* action = g_simple_action_new(action_name, nullptr); - g_object_set_data(G_OBJECT(action), "busymax-header-action", - const_cast(bridge_action)); - g_signal_connect(action, "activate", - G_CALLBACK(header_menu_action_activated_cb), self); - g_action_map_add_action(G_ACTION_MAP(self->header_menu_action_group), - G_ACTION(action)); - return action; -} - -static void initialize_header_menu_actions(MyApplication* self) { - self->header_menu_action_group = g_simple_action_group_new(); - - g_autoptr(GSimpleAction) settings = - create_header_bridge_action(self, "settings", "settings"); - g_autoptr(GSimpleAction) keyboard_shortcuts = create_header_bridge_action( - self, "keyboard-shortcuts", "keyboardShortcuts"); - g_autoptr(GSimpleAction) report_issue = - create_header_bridge_action(self, "report-issue", "reportIssue"); - g_autoptr(GSimpleAction) about = - create_header_bridge_action(self, "about", "aboutBusyMax"); - self->header_create_event_action = - create_header_bridge_action(self, "create-event", "createEvent"); - self->header_create_task_action = - create_header_bridge_action(self, "create-task", "createTask"); - - self->header_view_mode_menu_action = g_simple_action_new_stateful( - "view-mode", G_VARIANT_TYPE_STRING, g_variant_new_string("week")); - g_signal_connect(self->header_view_mode_menu_action, "activate", - G_CALLBACK(header_view_mode_action_activated_cb), self); - g_action_map_add_action(G_ACTION_MAP(self->header_menu_action_group), - G_ACTION(self->header_view_mode_menu_action)); - - if (self->main_window != nullptr && GTK_IS_WIDGET(self->main_window)) { - gtk_widget_insert_action_group( - GTK_WIDGET(self->main_window), "header", - G_ACTION_GROUP(self->header_menu_action_group)); - } -} - -static void make_header_icon_button_square(GtkWidget* button) { - gtk_widget_set_size_request(button, kHeaderButtonHeight, - kHeaderButtonHeight); - gtk_widget_set_valign(button, GTK_ALIGN_CENTER); -} - -static void style_header_control(GtkWidget* button) { - gtk_button_set_relief(GTK_BUTTON(button), GTK_RELIEF_NONE); - GtkStyleContext* context = gtk_widget_get_style_context(button); - gtk_style_context_add_class(context, GTK_STYLE_CLASS_FLAT); - gtk_style_context_add_class(context, kHeaderControlStyleClass); - gtk_widget_set_valign(button, GTK_ALIGN_CENTER); -} - -static const gchar* resolve_today_icon_name() { - GtkIconTheme* icon_theme = gtk_icon_theme_get_default(); - if (icon_theme != nullptr && - gtk_icon_theme_has_icon(icon_theme, "today-symbolic")) { - return "today-symbolic"; - } - return "x-office-calendar-symbolic"; -} - -static GtkWidget* create_header_icon_button(const gchar* icon_name, - const gchar* tooltip) { - GtkWidget* button = gtk_button_new(); - GtkWidget* image = gtk_image_new_from_icon_name(icon_name, GTK_ICON_SIZE_MENU); - gtk_button_set_image(GTK_BUTTON(button), image); - gtk_widget_set_tooltip_text(button, tooltip); - style_header_control(button); - make_header_icon_button_square(button); - return button; -} - -static GtkWidget* create_header_toggle_icon_button(const gchar* icon_name, - const gchar* tooltip) { - GtkWidget* button = gtk_toggle_button_new(); - GtkWidget* image = gtk_image_new_from_icon_name(icon_name, GTK_ICON_SIZE_MENU); - gtk_button_set_image(GTK_BUTTON(button), image); - gtk_widget_set_tooltip_text(button, tooltip); - style_header_control(button); - make_header_icon_button_square(button); - return button; -} - -static GtkWidget* create_header_text_button(const gchar* label, - const gchar* tooltip) { - GtkWidget* button = gtk_button_new_with_label(label); - gtk_widget_set_tooltip_text(button, tooltip); - style_header_control(button); - return button; -} - -static void set_button_label_and_tooltip(GtkWidget* button, - const gchar* label, - const gchar* tooltip) { - if (button == nullptr || !GTK_IS_BUTTON(button)) { - return; - } - if (label != nullptr) { - gtk_button_set_label(GTK_BUTTON(button), label); - } - if (tooltip != nullptr) { - gtk_widget_set_tooltip_text(button, tooltip); - } -} - -static void set_widget_tooltip(GtkWidget* widget, const gchar* tooltip) { - if (widget != nullptr && GTK_IS_WIDGET(widget) && tooltip != nullptr) { - gtk_widget_set_tooltip_text(widget, tooltip); - } -} - -static void set_widget_accessible_name(GtkWidget* widget, const gchar* name) { - if (widget == nullptr || !GTK_IS_WIDGET(widget) || name == nullptr) { - return; - } - AtkObject* accessible = gtk_widget_get_accessible(widget); - if (accessible != nullptr) { - atk_object_set_name(accessible, name); - } -} - -static void set_widget_tooltip_with_shortcut(GtkWidget* widget, - const gchar* tooltip, - const gchar* shortcut) { - if (widget == nullptr || !GTK_IS_WIDGET(widget) || tooltip == nullptr) { - return; - } - if (shortcut == nullptr || shortcut[0] == '\0') { - gtk_widget_set_tooltip_text(widget, tooltip); - return; - } - g_autofree gchar* combined = - g_strdup_printf("%s (%s%s%s)", tooltip, kLtrIsolateStart, shortcut, - kBidiIsolateEnd); - gtk_widget_set_tooltip_text(widget, combined); -} - -static void set_toggle_button_active(MyApplication* self, - GtkWidget* button, - gboolean active) { - if (button != nullptr && GTK_IS_TOGGLE_BUTTON(button)) { - const gboolean previous_suppression = self->suppress_header_bar_actions; - self->suppress_header_bar_actions = TRUE; - gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(button), active); - self->suppress_header_bar_actions = previous_suppression; - } -} - -static void set_widget_sensitive(GtkWidget* widget, gboolean sensitive) { - if (widget != nullptr && GTK_IS_WIDGET(widget)) { - gtk_widget_set_sensitive(widget, sensitive); - } -} - -static void set_header_create_capabilities(MyApplication* self, - gboolean can_create_event, - gboolean can_create_task) { - if (self->header_create_event_action != nullptr) { - g_simple_action_set_enabled(self->header_create_event_action, - can_create_event); - } - if (self->header_create_task_action != nullptr) { - g_simple_action_set_enabled(self->header_create_task_action, - can_create_task); - } - const gboolean can_create = can_create_event || can_create_task; - set_widget_sensitive(self->create_button, can_create); - if (!can_create) { - close_header_menu_button(self->create_button); - } -} - -static void set_widget_visible(GtkWidget* widget, gboolean visible) { - if (widget != nullptr && GTK_IS_WIDGET(widget)) { - gtk_widget_set_visible(widget, visible); - } -} - -static void update_header_sidebar_brand_geometry(MyApplication* self) { - if (self->header_sidebar_brand_box == nullptr || - !GTK_IS_WIDGET(self->header_sidebar_brand_box)) { - return; - } - const gint width = header_sidebar_effective_width(self); - gtk_widget_set_size_request(self->header_sidebar_brand_box, width, -1); - set_widget_visible(self->header_sidebar_brand_box, width > 0); -} - -static const gchar* header_view_mode_action(const gchar* mode) { - if (g_strcmp0(mode, "day") == 0) { - return "viewModeDay"; - } - if (g_strcmp0(mode, "week") == 0) { - return "viewModeWeek"; - } - if (g_strcmp0(mode, "month") == 0) { - return "viewModeMonth"; - } - if (g_strcmp0(mode, "year") == 0) { - return "viewModeYear"; - } - if (g_strcmp0(mode, "agenda") == 0) { - return "viewModeAgenda"; - } - return nullptr; -} - -static void set_header_view_mode_labels(MyApplication* self, - const gchar* day, - const gchar* week, - const gchar* month, - const gchar* year, - const gchar* agenda) { - replace_header_label(&self->header_day_label, day); - replace_header_label(&self->header_week_label, week); - replace_header_label(&self->header_month_label, month); - replace_header_label(&self->header_year_label, year); - replace_header_label(&self->header_agenda_label, agenda); - update_header_view_mode_presentation(self); -} - -static void update_header_title_fit(MyApplication* self) { - if (self->header_title_label == nullptr || - !GTK_IS_LABEL(self->header_title_label) || - self->header_title_text == nullptr || self->header_search_active) { - return; - } - const gint available_width = - self->header_title_stack != nullptr && - GTK_IS_WIDGET(self->header_title_stack) - ? gtk_widget_get_allocated_width(self->header_title_stack) - : gtk_widget_get_allocated_width(self->header_title_label); - PangoLayout* layout = gtk_widget_create_pango_layout( - self->header_title_label, self->header_title_text); - gint title_width = 0; - pango_layout_get_pixel_size(layout, &title_width, nullptr); - g_object_unref(layout); - const gchar* visible_title = - available_width <= 0 || title_width <= available_width - ? self->header_title_text - : ""; - if (g_strcmp0(gtk_label_get_text(GTK_LABEL(self->header_title_label)), - visible_title) != 0) { - gtk_label_set_text(GTK_LABEL(self->header_title_label), visible_title); - } -} - -static gboolean update_header_title_fit_cb(gpointer user_data) { - update_header_title_fit(MY_APPLICATION(user_data)); - return G_SOURCE_REMOVE; -} - -static void set_header_title(MyApplication* self, const gchar* title) { - if (title == nullptr) { - return; - } - replace_header_label(&self->header_title_text, title); - update_header_title_fit(self); -} - -static gboolean focus_header_search_entry(MyApplication* self) { - if (self->header_bar_modal_barrier_visible || - !self->header_search_active || self->search_entry == nullptr || - !GTK_IS_ENTRY(self->search_entry) || - !gtk_widget_get_visible(self->search_entry) || - !gtk_widget_get_child_visible(self->search_entry) || - !gtk_widget_get_sensitive(self->search_entry)) { - return FALSE; - } - - gtk_widget_grab_focus(self->search_entry); - gtk_editable_select_region(GTK_EDITABLE(self->search_entry), 0, -1); - return gtk_widget_has_focus(self->search_entry); -} - -static void set_header_search_state(MyApplication* self, - gboolean active, - const gchar* query) { - const gboolean effective_active = - active && self->header_schedule_controls_visible; - const gboolean active_changed = - self->header_search_active != effective_active; - const gboolean previous_suppression = self->suppress_header_bar_actions; - self->suppress_header_bar_actions = TRUE; - // Install Dart-owned text before activation transfers editing authority to - // the native GtkSearchEntry. Deactivation explicitly revokes native editing - // authority so Escape/clear snapshots cannot leave stale cached text. - set_header_search_query(self, query, effective_active); - self->header_search_active = effective_active; - set_toggle_button_active(self, self->search_button, effective_active); - if (self->header_title_stack != nullptr && - GTK_IS_STACK(self->header_title_stack)) { - GtkWidget* visible_child = - effective_active ? self->search_entry : self->header_title_label; - if (visible_child != nullptr && GTK_IS_WIDGET(visible_child)) { - gtk_stack_set_visible_child(GTK_STACK(self->header_title_stack), - visible_child); - } - } - self->suppress_header_bar_actions = previous_suppression; - update_header_control_visibility(self); - update_header_title_fit(self); - - if (!active_changed) { - return; - } - if (effective_active) { - focus_header_search_entry(self); - } else { - focus_flutter_view(self); - } -} - -static void set_header_view_mode(MyApplication* self, const gchar* mode) { - if (header_view_mode_action(mode) == nullptr) { - return; - } - if (g_strcmp0(self->header_view_mode, mode) != 0) { - g_free(self->header_view_mode); - self->header_view_mode = g_strdup(mode); - } - if (self->header_view_mode_menu_action != nullptr) { - g_autoptr(GVariant) state = g_variant_ref_sink(g_variant_new_string(mode)); - GVariant* current_state = - g_action_get_state(G_ACTION(self->header_view_mode_menu_action)); - const gboolean state_changed = - current_state == nullptr || !g_variant_equal(current_state, state); - if (current_state != nullptr) { - g_variant_unref(current_state); - } - if (state_changed) { - g_simple_action_set_state(self->header_view_mode_menu_action, state); - } - } - update_header_view_mode_presentation(self); -} - -static void update_header_title_box_geometry(MyApplication* self) { - if (self->header_title_box == nullptr || - !GTK_IS_WIDGET(self->header_title_box)) { - return; - } - const gboolean onboarding = self->header_onboarding_controls_visible; - gtk_widget_set_halign(self->header_title_box, - onboarding ? GTK_ALIGN_CENTER : GTK_ALIGN_FILL); - const gint width = - onboarding ? self->header_onboarding_content_width : -1; - gtk_widget_set_size_request(self->header_title_box, width, -1); - if (!onboarding) { - gtk_widget_set_margin_start(self->header_title_box, 0); - gtk_widget_set_margin_end(self->header_title_box, 0); - } -} - -static gboolean recenter_onboarding_header_controls_cb(gpointer user_data) { - MyApplication* self = MY_APPLICATION(user_data); - if (!self->header_onboarding_controls_visible || - self->header_bar == nullptr || - !GTK_IS_WIDGET(self->header_bar) || - self->header_title_box == nullptr || - !GTK_IS_WIDGET(self->header_title_box)) { - return G_SOURCE_REMOVE; - } - - GtkAllocation header_allocation; - GtkAllocation title_allocation; - gtk_widget_get_allocation(GTK_WIDGET(self->header_bar), - &header_allocation); - gtk_widget_get_allocation(self->header_title_box, &title_allocation); - if (header_allocation.width <= 0 || title_allocation.width <= 0) { - return G_SOURCE_REMOVE; - } - - const gint header_center = - header_allocation.x + header_allocation.width / 2; - const gint title_center = - title_allocation.x + title_allocation.width / 2; - const gint physical_delta = header_center - title_center; - if (std::abs(physical_delta) <= 1) { - return G_SOURCE_REMOVE; - } - - const GtkTextDirection direction = - gtk_widget_get_direction(self->header_title_box); - const gint logical_delta = - direction == GTK_TEXT_DIR_RTL ? -physical_delta : physical_delta; - const gint current_bias = - gtk_widget_get_margin_start(self->header_title_box) - - gtk_widget_get_margin_end(self->header_title_box); - const gint target_bias = - std::clamp(current_bias + logical_delta * 2, - -header_allocation.width, header_allocation.width); - const gint start_margin = std::max(target_bias, 0); - const gint end_margin = std::max(-target_bias, 0); - if (start_margin == gtk_widget_get_margin_start(self->header_title_box) && - end_margin == gtk_widget_get_margin_end(self->header_title_box)) { - return G_SOURCE_REMOVE; - } - - gtk_widget_set_margin_start(self->header_title_box, start_margin); - gtk_widget_set_margin_end(self->header_title_box, end_margin); - return G_SOURCE_REMOVE; -} - -static void header_bar_size_allocate_cb(GtkWidget*, - GtkAllocation*, - gpointer user_data) { - g_idle_add_full( - G_PRIORITY_DEFAULT_IDLE, recenter_onboarding_header_controls_cb, - g_object_ref(user_data), g_object_unref); - g_idle_add_full(G_PRIORITY_DEFAULT_IDLE, update_header_title_fit_cb, - g_object_ref(user_data), g_object_unref); -} - -static void update_header_control_visibility(MyApplication* self) { - const gboolean schedule_controls_visible = - self->header_schedule_controls_visible; - const gboolean search_inactive = !self->header_search_active; - if (!schedule_controls_visible || !search_inactive) { - close_header_menu_button(self->create_button); - } - set_widget_visible(self->header_start_box, - schedule_controls_visible || self->header_back_visible); - set_widget_visible(self->back_button, self->header_back_visible); - set_widget_visible(self->sidebar_collapsed_toggle_button, - schedule_controls_visible && - self->header_bar_can_show_sidebar); - set_widget_visible(self->today_button, - schedule_controls_visible && search_inactive); - set_widget_visible(self->previous_button, - schedule_controls_visible && - self->header_navigation_visible && search_inactive); - set_widget_visible(self->next_button, - schedule_controls_visible && - self->header_navigation_visible && search_inactive); - set_widget_visible(self->header_view_box, - schedule_controls_visible && search_inactive); - set_widget_visible(self->search_button, schedule_controls_visible); - set_widget_visible(self->create_button, - schedule_controls_visible && search_inactive); - set_widget_visible(self->refresh_button, - schedule_controls_visible && search_inactive); - set_widget_visible(self->settings_menu_button, - schedule_controls_visible || self->header_back_visible); -} - -static void set_header_schedule_controls_visible(MyApplication* self, - gboolean visible) { - self->header_schedule_controls_visible = visible; - if (!visible && self->header_search_active) { - set_header_search_state(self, FALSE, self->header_search_query); - } - update_header_control_visibility(self); -} - -static void set_header_navigation_visible(MyApplication* self, - gboolean visible) { - self->header_navigation_visible = visible; - update_header_control_visibility(self); -} - -static void set_header_back_visible(MyApplication* self, gboolean visible) { - self->header_back_visible = visible; - update_header_control_visibility(self); -} - -static void set_header_onboarding_controls(MyApplication* self, FlValue* args) { - const gboolean visible = fl_lookup_bool_arg(args, "visible", FALSE); - const gboolean can_go_back = fl_lookup_bool_arg(args, "canGoBack", FALSE); - const gboolean can_continue = - fl_lookup_bool_arg(args, "canContinue", FALSE); - const gchar* back_label = fl_lookup_string_arg(args, "backLabel"); - const gchar* continue_label = fl_lookup_string_arg(args, "continueLabel"); - gint64 content_width = 0; - if (fl_lookup_int_arg(args, "contentWidth", &content_width) && - content_width > 0 && content_width <= G_MAXINT) { - self->header_onboarding_content_width = - static_cast(content_width); - } - - self->header_onboarding_controls_visible = visible; - set_widget_visible(self->onboarding_back_slot, visible); - set_widget_visible(self->onboarding_back_button, visible); - set_widget_visible(self->onboarding_continue_slot, visible); - set_widget_visible(self->onboarding_continue_button, visible); - set_widget_sensitive(self->onboarding_back_button, can_go_back); - set_widget_sensitive(self->onboarding_continue_button, can_continue); - set_button_label_and_tooltip(self->onboarding_back_button, back_label, - back_label); - set_button_label_and_tooltip(self->onboarding_continue_button, - continue_label, continue_label); - update_header_title_box_geometry(self); -} - -static void update_header_sidebar_presentation(MyApplication* self) { - const gchar* label = self->header_bar_sidebar_visible - ? self->header_hide_sidebar_panel_label - : self->header_show_sidebar_panel_label; - set_widget_tooltip_with_shortcut(self->sidebar_collapsed_toggle_button, - label, self->header_sidebar_shortcut); -} - -static void set_header_sidebar_visible(MyApplication* self, gboolean visible) { - self->header_bar_sidebar_visible = visible; - set_toggle_button_active(self, self->sidebar_collapsed_toggle_button, visible); - update_header_sidebar_presentation(self); - update_header_sidebar_brand_geometry(self); - refresh_header_bar_css(self); -} - -static void set_header_can_show_sidebar(MyApplication* self, - gboolean can_show_sidebar) { - self->header_bar_can_show_sidebar = can_show_sidebar; - update_header_control_visibility(self); - update_header_sidebar_brand_geometry(self); - refresh_header_bar_css(self); -} - -static void set_header_sidebar_width(MyApplication* self, gdouble width) { - if (width <= 0) { - return; - } - self->header_bar_sidebar_width = static_cast(width); - update_header_sidebar_brand_geometry(self); - refresh_header_bar_css(self); -} - -static void set_header_text_direction(MyApplication* self, - const gchar* value) { - const GtkTextDirection direction = - g_strcmp0(value, "rtl") == 0 ? GTK_TEXT_DIR_RTL : GTK_TEXT_DIR_LTR; - GtkWidget* widgets[] = { - self->titlebar_handle, - self->titlebar_overlay, - self->titlebar_box, - GTK_WIDGET(self->header_bar), - self->header_start_box, - self->header_title_box, - self->header_title_stack, - self->header_sidebar_brand_box, - self->settings_menu_button, - self->settings_menu, - self->header_view_box, - self->search_entry, - self->back_button, - self->sidebar_collapsed_toggle_button, - self->today_button, - self->previous_button, - self->next_button, - self->view_mode_button, - self->view_mode_menu, - self->search_button, - self->create_button, - self->create_menu, - self->refresh_button, - }; - for (GtkWidget* widget : widgets) { - if (widget != nullptr && GTK_IS_WIDGET(widget)) { - gtk_widget_set_direction(widget, direction); - } - } -} - -static void set_header_bar_state(MyApplication* self, FlValue* args) { - if (args == nullptr || fl_value_get_type(args) != FL_VALUE_TYPE_MAP) { - return; - } - - gint64 schema_version = 0; - if (!fl_lookup_int_arg(args, "schemaVersion", &schema_version) || - schema_version != kHeaderBarStateSchemaVersion) { - g_warning("Ignoring unsupported BusyMax header bar state schema"); - return; - } - - set_header_title(self, fl_lookup_string_arg(args, "title")); - set_header_view_mode(self, fl_lookup_string_arg(args, "viewMode")); - - gboolean value = FALSE; - gboolean search_active = self->header_search_active; - fl_lookup_optional_bool_arg(args, "searchActive", &search_active); - const gchar* search_query = fl_lookup_string_arg(args, "searchQuery"); - if (search_query == nullptr) { - search_query = ""; - } - if (fl_lookup_optional_bool_arg(args, "canRefresh", &value)) { - set_widget_sensitive(self->refresh_button, value); - } - gboolean can_create_event = FALSE; - gboolean can_create_task = FALSE; - const gboolean has_can_create_event = fl_lookup_optional_bool_arg( - args, "canCreateEvent", &can_create_event); - const gboolean has_can_create_task = fl_lookup_optional_bool_arg( - args, "canCreateTask", &can_create_task); - if (has_can_create_event && has_can_create_task) { - set_header_create_capabilities(self, can_create_event, can_create_task); - } - - const gint previous_sidebar_width = header_sidebar_effective_width(self); - if (fl_lookup_optional_bool_arg(args, "canShowSidebar", &value)) { - self->header_bar_can_show_sidebar = value; - } - if (fl_lookup_optional_bool_arg(args, "sidebarVisible", &value)) { - self->header_bar_sidebar_visible = value; - set_toggle_button_active(self, self->sidebar_collapsed_toggle_button, value); - update_header_sidebar_presentation(self); - } - if (fl_lookup_optional_bool_arg(args, "navigationVisible", &value)) { - self->header_navigation_visible = value; - } - if (fl_lookup_optional_bool_arg(args, "scheduleControlsVisible", &value)) { - self->header_schedule_controls_visible = value; - } - if (fl_lookup_optional_bool_arg(args, "backVisible", &value)) { - self->header_back_visible = value; - } - - set_header_search_state(self, search_active, search_query); - update_header_control_visibility(self); - const gint sidebar_width = header_sidebar_effective_width(self); - if (sidebar_width != previous_sidebar_width) { - update_header_sidebar_brand_geometry(self); - refresh_header_bar_css(self); - } -} - -static void set_header_localized_labels(MyApplication* self, FlValue* args) { - const gchar* today = fl_lookup_string_arg(args, "today"); - const gchar* day = fl_lookup_string_arg(args, "day"); - const gchar* week = fl_lookup_string_arg(args, "week"); - const gchar* month = fl_lookup_string_arg(args, "month"); - const gchar* year = fl_lookup_string_arg(args, "year"); - const gchar* agenda = fl_lookup_string_arg(args, "agenda"); - const gchar* search = fl_lookup_string_arg(args, "search"); - const gchar* create = fl_lookup_string_arg(args, "create"); - const gchar* create_event = fl_lookup_string_arg(args, "createEvent"); - const gchar* create_task = fl_lookup_string_arg(args, "createTask"); - const gchar* refresh = fl_lookup_string_arg(args, "refresh"); - const gchar* menu = fl_lookup_string_arg(args, "menu"); - const gchar* previous = fl_lookup_string_arg(args, "previous"); - const gchar* next = fl_lookup_string_arg(args, "next"); - const gchar* show_sidebar_panel = - fl_lookup_string_arg(args, "showSidebarPanel"); - const gchar* hide_sidebar_panel = - fl_lookup_string_arg(args, "hideSidebarPanel"); - const gchar* back = fl_lookup_string_arg(args, "back"); - const gchar* settings = fl_lookup_string_arg(args, "settings"); - const gchar* keyboard_shortcuts = - fl_lookup_string_arg(args, "keyboardShortcuts"); - const gchar* report_issue = fl_lookup_string_arg(args, "reportIssue"); - const gchar* about_busymax = fl_lookup_string_arg(args, "aboutBusyMax"); - const gchar* today_shortcut = - fl_lookup_string_arg(args, "todayShortcut"); - const gchar* day_shortcut = fl_lookup_string_arg(args, "dayShortcut"); - const gchar* week_shortcut = fl_lookup_string_arg(args, "weekShortcut"); - const gchar* month_shortcut = fl_lookup_string_arg(args, "monthShortcut"); - const gchar* year_shortcut = fl_lookup_string_arg(args, "yearShortcut"); - const gchar* agenda_shortcut = - fl_lookup_string_arg(args, "agendaShortcut"); - const gchar* search_shortcut = - fl_lookup_string_arg(args, "searchShortcut"); - const gchar* sidebar_shortcut = - fl_lookup_string_arg(args, "sidebarShortcut"); - const gchar* create_event_shortcut = - fl_lookup_string_arg(args, "createEventShortcut"); - const gchar* create_task_shortcut = - fl_lookup_string_arg(args, "createTaskShortcut"); - const gchar* previous_shortcut = - fl_lookup_string_arg(args, "previousShortcut"); - const gchar* next_shortcut = fl_lookup_string_arg(args, "nextShortcut"); - const gchar* settings_shortcut = - fl_lookup_string_arg(args, "settingsShortcut"); - const gchar* keyboard_shortcuts_shortcut = - fl_lookup_string_arg(args, "keyboardShortcutsShortcut"); - - replace_header_label(&self->header_day_shortcut, day_shortcut); - replace_header_label(&self->header_week_shortcut, week_shortcut); - replace_header_label(&self->header_month_shortcut, month_shortcut); - replace_header_label(&self->header_year_shortcut, year_shortcut); - replace_header_label(&self->header_agenda_shortcut, agenda_shortcut); - replace_header_label(&self->header_show_sidebar_panel_label, - show_sidebar_panel); - replace_header_label(&self->header_hide_sidebar_panel_label, - hide_sidebar_panel); - replace_header_label(&self->header_sidebar_shortcut, sidebar_shortcut); - replace_header_label(&self->header_create_event_shortcut, - create_event_shortcut); - replace_header_label(&self->header_create_task_shortcut, - create_task_shortcut); - replace_header_label(&self->header_settings_shortcut, settings_shortcut); - replace_header_label(&self->header_keyboard_shortcuts_shortcut, - keyboard_shortcuts_shortcut); - - set_widget_accessible_name(self->today_button, today); - set_widget_tooltip_with_shortcut(self->today_button, today, today_shortcut); - set_header_view_mode_labels(self, day, week, month, year, agenda); - set_widget_tooltip(self->back_button, back); - set_widget_tooltip_with_shortcut(self->search_button, search, - search_shortcut); - if (self->search_entry != nullptr && GTK_IS_ENTRY(self->search_entry) && - search != nullptr) { - gtk_entry_set_placeholder_text(GTK_ENTRY(self->search_entry), search); - } - set_widget_tooltip(self->create_button, create); - replace_header_label(&self->header_create_event_label, create_event); - replace_header_label(&self->header_create_task_label, create_task); - set_widget_tooltip(self->settings_menu_button, menu); - set_widget_tooltip(self->refresh_button, refresh); - set_widget_tooltip_with_shortcut(self->previous_button, previous, - previous_shortcut); - set_widget_tooltip_with_shortcut(self->next_button, next, next_shortcut); - update_header_sidebar_presentation(self); - replace_header_label(&self->header_settings_label, settings); - replace_header_label(&self->header_keyboard_shortcuts_label, - keyboard_shortcuts); - replace_header_label(&self->header_report_issue_label, report_issue); - replace_header_label(&self->header_about_label, about_busymax); - rebuild_header_menu_models(self); -} - -static GtkWidget* create_busymax_titlebar(MyApplication* self) { - track_widget_pointer(&self->titlebar_box, - gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 0)); - gtk_widget_set_halign(self->titlebar_box, GTK_ALIGN_FILL); - gtk_widget_set_hexpand(self->titlebar_box, TRUE); - initialize_header_menu_actions(self); - - GtkHeaderBar* header_bar = GTK_HEADER_BAR(gtk_header_bar_new()); - track_header_bar_pointer(self, header_bar); - gtk_header_bar_set_show_close_button(header_bar, TRUE); - gtk_widget_set_hexpand(GTK_WIDGET(header_bar), TRUE); - g_signal_connect(header_bar, "size-allocate", - G_CALLBACK(header_bar_size_allocate_cb), self); - - track_widget_pointer(&self->header_sidebar_brand_box, - gtk_box_new(GTK_ORIENTATION_HORIZONTAL, - kHeaderButtonSpacing)); - gtk_widget_set_halign(self->header_sidebar_brand_box, GTK_ALIGN_FILL); - gtk_widget_set_hexpand(self->header_sidebar_brand_box, FALSE); - gtk_style_context_add_class( - gtk_widget_get_style_context(self->header_sidebar_brand_box), - "busymax-header-brand"); - track_widget_pointer( - &self->search_button, - create_header_toggle_icon_button("system-search-symbolic", "")); - connect_header_bar_action(self, self->search_button, "search"); - - GtkWidget* brand_center_box = - gtk_box_new(GTK_ORIENTATION_HORIZONTAL, kHeaderButtonSpacing); - gtk_widget_set_halign(brand_center_box, GTK_ALIGN_CENTER); - gtk_widget_set_valign(brand_center_box, GTK_ALIGN_CENTER); - gtk_widget_set_hexpand(brand_center_box, TRUE); - track_widget_pointer(&self->header_brand_label, - gtk_label_new(kApplicationDisplayName)); - gtk_widget_set_valign(self->header_brand_label, GTK_ALIGN_CENTER); - gtk_label_set_ellipsize(GTK_LABEL(self->header_brand_label), - PANGO_ELLIPSIZE_END); - gtk_style_context_add_class( - gtk_widget_get_style_context(self->header_brand_label), - GTK_STYLE_CLASS_TITLE); - gtk_box_pack_start(GTK_BOX(brand_center_box), self->header_brand_label, - FALSE, FALSE, 0); - - track_widget_pointer(&self->settings_menu_button, gtk_menu_button_new()); - gtk_button_set_image(GTK_BUTTON(self->settings_menu_button), - gtk_image_new_from_icon_name("open-menu-symbolic", - GTK_ICON_SIZE_MENU)); - style_header_control(self->settings_menu_button); - make_header_icon_button_square(self->settings_menu_button); - gtk_widget_set_margin_end(self->settings_menu_button, - kHeaderSidebarContentInset); - rebuild_header_settings_menu_model(self); - - gtk_box_pack_start(GTK_BOX(self->header_sidebar_brand_box), - brand_center_box, TRUE, TRUE, 0); - gtk_box_pack_start(GTK_BOX(self->titlebar_box), - self->header_sidebar_brand_box, FALSE, FALSE, 0); - - track_widget_pointer(&self->header_start_box, - gtk_box_new(GTK_ORIENTATION_HORIZONTAL, - kHeaderButtonSpacing)); - gtk_widget_set_margin_start(self->header_start_box, - kHeaderMainContentStartInset); - track_widget_pointer(&self->back_button, - create_header_icon_button("go-previous-symbolic", "")); - track_widget_pointer( - &self->sidebar_collapsed_toggle_button, - create_header_toggle_icon_button("sidebar-show-symbolic", "")); - track_widget_pointer(&self->today_button, - create_header_icon_button(resolve_today_icon_name(), - "")); - track_widget_pointer(&self->previous_button, - create_header_icon_button("go-previous-symbolic", - "")); - track_widget_pointer(&self->next_button, - create_header_icon_button("go-next-symbolic", "")); - connect_header_bar_action(self, self->back_button, "back"); - connect_header_bar_action(self, self->sidebar_collapsed_toggle_button, - "sidebarToggle"); - connect_header_bar_action(self, self->today_button, "today"); - connect_header_bar_action(self, self->previous_button, "previous"); - connect_header_bar_action(self, self->next_button, "next"); - gtk_box_pack_start(GTK_BOX(self->header_start_box), self->back_button, - FALSE, FALSE, 0); - gtk_box_pack_start(GTK_BOX(self->header_start_box), - self->sidebar_collapsed_toggle_button, FALSE, FALSE, 0); - gtk_box_pack_start(GTK_BOX(self->header_start_box), self->today_button, - FALSE, FALSE, 0); - gtk_box_pack_start(GTK_BOX(self->header_start_box), self->previous_button, - FALSE, FALSE, 0); - gtk_box_pack_start(GTK_BOX(self->header_start_box), self->next_button, - FALSE, FALSE, 0); - gtk_header_bar_pack_start(header_bar, self->header_start_box); - - track_widget_pointer(&self->header_title_box, - gtk_box_new(GTK_ORIENTATION_HORIZONTAL, - kHeaderButtonSpacing)); - gtk_widget_set_halign(self->header_title_box, GTK_ALIGN_FILL); - gtk_widget_set_hexpand(self->header_title_box, TRUE); - track_widget_pointer(&self->header_title_stack, gtk_stack_new()); - gtk_widget_set_halign(self->header_title_stack, GTK_ALIGN_FILL); - gtk_widget_set_hexpand(self->header_title_stack, TRUE); - gtk_stack_set_hhomogeneous(GTK_STACK(self->header_title_stack), FALSE); - gtk_stack_set_transition_type(GTK_STACK(self->header_title_stack), - GTK_STACK_TRANSITION_TYPE_NONE); - - track_widget_pointer(&self->onboarding_back_slot, - gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 0)); - gtk_widget_set_size_request(self->onboarding_back_slot, - kHeaderOnboardingSideWidth, -1); - gtk_widget_set_visible(self->onboarding_back_slot, FALSE); - - track_widget_pointer(&self->onboarding_back_button, - create_header_text_button("Back", "Back")); - gtk_style_context_add_class( - gtk_widget_get_style_context(self->onboarding_back_button), - kHeaderOnboardingTextButtonStyleClass); - connect_header_bar_action(self, self->onboarding_back_button, "back"); - gtk_widget_set_visible(self->onboarding_back_button, FALSE); - gtk_box_pack_start(GTK_BOX(self->onboarding_back_slot), - self->onboarding_back_button, FALSE, FALSE, 0); - - track_widget_pointer(&self->header_title_label, - gtk_label_new("")); - gtk_style_context_add_class(gtk_widget_get_style_context( - self->header_title_label), - "busymax-header-title"); - gtk_style_context_add_class( - gtk_widget_get_style_context(self->header_title_label), - GTK_STYLE_CLASS_TITLE); - gtk_label_set_ellipsize(GTK_LABEL(self->header_title_label), - PANGO_ELLIPSIZE_NONE); - gtk_label_set_xalign(GTK_LABEL(self->header_title_label), 0.5); - gtk_widget_set_halign(self->header_title_label, GTK_ALIGN_CENTER); - gtk_widget_set_hexpand(self->header_title_label, TRUE); - - track_widget_pointer(&self->search_entry, gtk_search_entry_new()); - gtk_style_context_add_class(gtk_widget_get_style_context(self->search_entry), - kHeaderSearchEntryStyleClass); - gtk_entry_set_placeholder_text(GTK_ENTRY(self->search_entry), ""); - gtk_entry_set_max_width_chars(GTK_ENTRY(self->search_entry), - kHeaderCenterMaximumWidthChars); - gtk_widget_set_halign(self->search_entry, GTK_ALIGN_FILL); - gtk_widget_set_valign(self->search_entry, GTK_ALIGN_CENTER); - gtk_widget_set_hexpand(self->search_entry, TRUE); - g_signal_connect(self->search_entry, "search-changed", - G_CALLBACK(header_search_entry_search_changed_cb), self); - g_signal_connect(self->search_entry, "focus-in-event", - G_CALLBACK(header_search_entry_focus_in_cb), self); - g_signal_connect(self->search_entry, "focus-out-event", - G_CALLBACK(header_search_entry_focus_out_cb), self); - g_signal_connect(self->search_entry, "icon-release", - G_CALLBACK(header_search_entry_icon_release_cb), self); - g_signal_connect(self->search_entry, "stop-search", - G_CALLBACK(header_search_entry_stop_search_cb), self); - - gtk_stack_add_named(GTK_STACK(self->header_title_stack), - self->header_title_label, "title"); - gtk_stack_add_named(GTK_STACK(self->header_title_stack), self->search_entry, - "search"); - gtk_stack_set_visible_child(GTK_STACK(self->header_title_stack), - self->header_title_label); - - track_widget_pointer(&self->onboarding_continue_slot, - gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 0)); - gtk_widget_set_size_request(self->onboarding_continue_slot, - kHeaderOnboardingSideWidth, -1); - gtk_widget_set_visible(self->onboarding_continue_slot, FALSE); - - track_widget_pointer(&self->onboarding_continue_button, - create_header_text_button("Continue", "Continue")); - gtk_style_context_add_class( - gtk_widget_get_style_context(self->onboarding_continue_button), - kHeaderOnboardingTextButtonStyleClass); - connect_header_bar_action(self, self->onboarding_continue_button, - "continueSetup"); - gtk_widget_set_visible(self->onboarding_continue_button, FALSE); - gtk_box_pack_end(GTK_BOX(self->onboarding_continue_slot), - self->onboarding_continue_button, FALSE, FALSE, 0); - - gtk_box_pack_start(GTK_BOX(self->header_title_box), - self->onboarding_back_slot, FALSE, FALSE, 0); - gtk_box_pack_start(GTK_BOX(self->header_title_box), - self->header_title_stack, TRUE, TRUE, 0); - gtk_box_pack_start(GTK_BOX(self->header_title_box), - self->onboarding_continue_slot, FALSE, FALSE, 0); - gtk_header_bar_set_custom_title(header_bar, self->header_title_box); - - GtkWidget* end_box = - gtk_box_new(GTK_ORIENTATION_HORIZONTAL, kHeaderButtonSpacing); - - track_widget_pointer(&self->header_view_box, - gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 0)); - - track_widget_pointer(&self->view_mode_button, gtk_menu_button_new()); - style_header_control(self->view_mode_button); - - GtkWidget* view_mode_button_box = - gtk_box_new(GTK_ORIENTATION_HORIZONTAL, kHeaderButtonSpacing); - track_widget_pointer( - &self->view_mode_icon, - gtk_image_new_from_icon_name(header_view_mode_icon_name("week"), - GTK_ICON_SIZE_MENU)); - GtkWidget* view_mode_arrow = - gtk_image_new_from_icon_name("pan-down-symbolic", GTK_ICON_SIZE_MENU); - gtk_box_pack_start(GTK_BOX(view_mode_button_box), self->view_mode_icon, - FALSE, FALSE, 0); - gtk_box_pack_start(GTK_BOX(view_mode_button_box), view_mode_arrow, - FALSE, FALSE, 0); - gtk_container_add(GTK_CONTAINER(self->view_mode_button), - view_mode_button_box); - rebuild_header_view_mode_menu_model(self); - - gtk_box_pack_start(GTK_BOX(self->header_view_box), self->view_mode_button, - FALSE, FALSE, 0); - gtk_box_pack_start(GTK_BOX(end_box), self->header_view_box, FALSE, FALSE, 0); - - track_widget_pointer(&self->create_button, gtk_menu_button_new()); - gtk_button_set_image( - GTK_BUTTON(self->create_button), - gtk_image_new_from_icon_name("list-add-symbolic", GTK_ICON_SIZE_MENU)); - style_header_control(self->create_button); - make_header_icon_button_square(self->create_button); - rebuild_header_create_menu_model(self); - gtk_box_pack_start(GTK_BOX(end_box), self->create_button, FALSE, FALSE, 0); - - track_widget_pointer(&self->refresh_button, - create_header_icon_button("view-refresh-symbolic", - "")); - connect_header_bar_action(self, self->refresh_button, "refresh"); - gtk_box_pack_start(GTK_BOX(end_box), self->refresh_button, FALSE, FALSE, 0); - gtk_box_pack_start(GTK_BOX(end_box), self->search_button, FALSE, FALSE, 0); - gtk_box_pack_start(GTK_BOX(end_box), self->settings_menu_button, FALSE, FALSE, - 0); - gtk_header_bar_pack_end(header_bar, end_box); - - set_header_view_mode(self, "week"); - set_header_back_visible(self, FALSE); - set_header_schedule_controls_visible(self, TRUE); - set_header_sidebar_visible(self, TRUE); - gtk_box_pack_start(GTK_BOX(self->titlebar_box), GTK_WIDGET(header_bar), TRUE, - TRUE, 0); - return self->titlebar_box; -} - -static gboolean consume_header_bar_modal_input_cb(GtkWidget*, - GdkEvent* event, - gpointer) { - switch (event->type) { - case GDK_BUTTON_PRESS: - case GDK_2BUTTON_PRESS: - case GDK_3BUTTON_PRESS: - case GDK_BUTTON_RELEASE: - case GDK_MOTION_NOTIFY: - case GDK_SCROLL: - case GDK_TOUCH_BEGIN: - case GDK_TOUCH_UPDATE: - case GDK_TOUCH_END: - case GDK_TOUCH_CANCEL: - case GDK_ENTER_NOTIFY: - case GDK_LEAVE_NOTIFY: - return TRUE; - default: - return FALSE; - } -} - -static GtkWidget* create_busymax_titlebar_handle(MyApplication* self) { - track_widget_pointer(&self->titlebar_handle, hdy_window_handle_new()); - gtk_widget_set_hexpand(self->titlebar_handle, TRUE); - gtk_style_context_add_class( - gtk_widget_get_style_context(self->titlebar_handle), - "busymax-titlebar"); - - track_widget_pointer(&self->titlebar_overlay, gtk_overlay_new()); - gtk_widget_set_hexpand(self->titlebar_overlay, TRUE); - gtk_container_add(GTK_CONTAINER(self->titlebar_overlay), - create_busymax_titlebar(self)); - - track_widget_pointer(&self->titlebar_modal_barrier, gtk_event_box_new()); - gtk_event_box_set_visible_window( - GTK_EVENT_BOX(self->titlebar_modal_barrier), TRUE); - // GtkOverlay allocates an aligned overlay child against the titlebar's - // existing allocation. Do not give this transient shield an expand request: - // a visible vexpand child propagates through HdyWindowHandle and makes the - // titlebar consume the window's spare vertical space while a modal is open. - gtk_widget_set_halign(self->titlebar_modal_barrier, GTK_ALIGN_FILL); - gtk_widget_set_valign(self->titlebar_modal_barrier, GTK_ALIGN_FILL); - gtk_widget_set_no_show_all(self->titlebar_modal_barrier, TRUE); - gtk_widget_add_events(self->titlebar_modal_barrier, GDK_ALL_EVENTS_MASK); - g_signal_connect(self->titlebar_modal_barrier, "event", - G_CALLBACK(consume_header_bar_modal_input_cb), nullptr); - gtk_style_context_add_class( - gtk_widget_get_style_context(self->titlebar_modal_barrier), - kHeaderModalBarrierStyleClass); - gtk_overlay_add_overlay(GTK_OVERLAY(self->titlebar_overlay), - self->titlebar_modal_barrier); - gtk_overlay_set_overlay_pass_through( - GTK_OVERLAY(self->titlebar_overlay), self->titlebar_modal_barrier, FALSE); - - gtk_container_add(GTK_CONTAINER(self->titlebar_handle), - self->titlebar_overlay); - return self->titlebar_handle; -} - -static gboolean show_header_create_menu(MyApplication* self) { - if (self->header_bar_modal_barrier_visible || - !self->header_schedule_controls_visible || - self->create_button == nullptr || - !GTK_IS_MENU_BUTTON(self->create_button) || - !gtk_widget_get_visible(self->create_button) || - !gtk_widget_get_sensitive(self->create_button)) { - return FALSE; - } - - // The Flutter command is keyboard-driven. Focus the native trigger before - // opening so GTK can move directly into its model rows. - gtk_widget_grab_focus(self->create_button); - gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(self->create_button), TRUE); - return gtk_toggle_button_get_active(GTK_TOGGLE_BUTTON(self->create_button)); -} - -static void header_bar_method_call_cb(FlMethodChannel* channel, - FlMethodCall* method_call, - gpointer user_data) { - MyApplication* self = MY_APPLICATION(user_data); - const gchar* method = fl_method_call_get_name(method_call); - FlValue* args = fl_method_call_get_args(method_call); - if (strcmp(method, "initialize") == 0) { - respond_bool(method_call, has_header_bar(self)); - } else if (strcmp(method, "setState") == 0) { - set_header_bar_state(self, args); - respond_success(method_call); - } else if (strcmp(method, "setTitleRange") == 0) { - set_header_title(self, fl_method_string_arg(args)); - respond_success(method_call); - } else if (strcmp(method, "setViewMode") == 0) { - set_header_view_mode(self, fl_method_string_arg(args)); - respond_success(method_call); - } else if (strcmp(method, "setCanRefresh") == 0) { - set_widget_sensitive(self->refresh_button, fl_method_bool_arg(args)); - respond_success(method_call); - } else if (strcmp(method, "setCanCreate") == 0) { - const gboolean can_create = fl_method_bool_arg(args); - set_header_create_capabilities(self, can_create, can_create); - respond_success(method_call); - } else if (strcmp(method, "showCreateMenu") == 0) { - respond_bool(method_call, show_header_create_menu(self)); - } else if (strcmp(method, "setLocalizedLabels") == 0) { - set_header_localized_labels(self, args); - respond_success(method_call); - } else if (strcmp(method, "setSidebarWidth") == 0) { - set_header_sidebar_width(self, fl_method_double_arg(args, 300)); - respond_success(method_call); - } else if (strcmp(method, "setTextDirection") == 0) { - set_header_text_direction(self, fl_method_string_arg(args)); - respond_success(method_call); - } else if (strcmp(method, "setSearchActive") == 0) { - set_header_search_state(self, fl_method_bool_arg(args), - self->header_search_query); - respond_success(method_call); - } else if (strcmp(method, "focusContent") == 0) { - focus_flutter_view(self); - respond_bool(method_call, TRUE); - } else if (strcmp(method, "focusSearch") == 0) { - respond_bool(method_call, focus_header_search_entry(self)); - } else if (strcmp(method, "setCanShowSidebar") == 0) { - set_header_can_show_sidebar(self, fl_method_bool_arg(args)); - respond_success(method_call); - } else if (strcmp(method, "setSidebarVisible") == 0) { - const gboolean visible = fl_method_bool_arg(args); - if (visible) { - // Preserve the behavior of the legacy method, where making the sidebar - // visible also made its native toggle available. - set_header_can_show_sidebar(self, TRUE); - } - set_header_sidebar_visible(self, visible); - respond_success(method_call); - } else if (strcmp(method, "setNavigationVisible") == 0) { - set_header_navigation_visible(self, fl_method_bool_arg(args)); - respond_success(method_call); - } else if (strcmp(method, "setScheduleControlsVisible") == 0) { - set_header_schedule_controls_visible(self, fl_method_bool_arg(args)); - respond_success(method_call); - } else if (strcmp(method, "setBackVisible") == 0) { - set_header_back_visible(self, fl_method_bool_arg(args)); - respond_success(method_call); - } else if (strcmp(method, "setOnboardingControls") == 0) { - set_header_onboarding_controls(self, args); - respond_success(method_call); - } else if (strcmp(method, "setModalBarrierVisible") == 0) { - set_header_bar_modal_barrier_visible(self, fl_method_bool_arg(args)); - respond_success(method_call); - } else if (strcmp(method, "setModalBarrierDepth") == 0) { - set_header_bar_modal_barrier_depth(self, fl_method_int_arg(args, 0)); - respond_success(method_call); - } else if (strcmp(method, "setModalBarrierState") == 0) { - gint64 shade_depth = 0; - if (!fl_lookup_int_arg(args, "shadeDepth", &shade_depth) || - shade_depth < 0 || shade_depth > G_MAXINT) { - shade_depth = 0; - } - set_header_bar_modal_barrier_state( - self, fl_lookup_bool_arg(args, "visible", FALSE), - static_cast(shade_depth)); - respond_success(method_call); - } else if (strcmp(method, "setTheme") == 0) { - set_header_bar_theme(self, args); - respond_success(method_call); - } else { - fl_method_call_respond_not_implemented(method_call, nullptr); - } -} - -static void register_header_bar_channel(MyApplication* self, FlView* view) { - g_autoptr(FlStandardMethodCodec) codec = fl_standard_method_codec_new(); - self->header_bar_channel = fl_method_channel_new( - fl_engine_get_binary_messenger(fl_view_get_engine(view)), - kHeaderBarChannel, FL_METHOD_CODEC(codec)); - fl_method_channel_set_method_call_handler( - self->header_bar_channel, header_bar_method_call_cb, self, nullptr); -} - -static FlValue* parse_gtk_font_name(const gchar* font_name) { - if (font_name == nullptr) { - return fl_value_new_null(); - } - - PangoFontDescription* desc = pango_font_description_from_string(font_name); - if (desc == nullptr) { - return fl_value_new_null(); - } - - const gchar* family = pango_font_description_get_family(desc); - const gint size = pango_font_description_get_size(desc); - const double point_size = - size > 0 ? static_cast(size) / PANGO_SCALE : 0.0; - - FlValue* result = fl_value_new_map(); - fl_value_set_string_take( - result, "family", - fl_value_new_string(family != nullptr ? family : "")); - fl_value_set_string_take(result, "size", fl_value_new_float(point_size)); - - pango_font_description_free(desc); - return result; -} - -static FlValue* get_gtk_font_settings() { - GtkSettings* settings = gtk_settings_get_default(); - if (settings == nullptr) { - return fl_value_new_null(); - } - - gchar* font_name = nullptr; - g_object_get(settings, "gtk-font-name", &font_name, nullptr); - FlValue* result = parse_gtk_font_name(font_name); - g_free(font_name); - return result; -} - -static guint color_channel(double value) { - if (value <= 0) { - return 0; +static guint color_channel(double value) { + if (value <= 0) { + return 0; } if (value >= 1) { return 255; @@ -4355,6 +2069,61 @@ static void set_theme_color(FlValue* result, fl_value_set_string_take(result, key, fl_value_new_string(hex)); } +static void set_search_entry_color(FlValue* result, + const gchar* key, + const GdkRGBA* color) { + g_autofree gchar* hex = rgba_to_hex(color); + fl_value_set_string_take(result, key, fl_value_new_string(hex)); +} + +static FlValue* gtk_search_entry_state_to_fl_value( + const BusyMaxGtkSearchEntryState& state) { + FlValue* result = fl_value_new_map(); + set_search_entry_color(result, "background", &state.background); + set_search_entry_color(result, "foreground", &state.foreground); + set_search_entry_color(result, "borderColor", &state.border_color); + set_search_entry_color(result, "primaryIconForeground", + &state.primary_icon_foreground); + set_search_entry_color(result, "primaryIconForegroundRtl", + &state.primary_icon_foreground_rtl); + set_search_entry_color(result, "secondaryIconForeground", + &state.secondary_icon_foreground); + set_search_entry_color(result, "secondaryIconForegroundRtl", + &state.secondary_icon_foreground_rtl); + fl_value_set_string_take(result, "borderTop", + fl_value_new_int(state.border_width.top)); + fl_value_set_string_take(result, "borderRight", + fl_value_new_int(state.border_width.right)); + fl_value_set_string_take(result, "borderBottom", + fl_value_new_int(state.border_width.bottom)); + fl_value_set_string_take(result, "borderLeft", + fl_value_new_int(state.border_width.left)); + fl_value_set_string_take(result, "radius", + fl_value_new_int(state.border_radius)); + fl_value_set_string_take(result, "hasInnerFocus", + fl_value_new_bool(state.has_inner_focus)); + set_search_entry_color(result, "innerFocusColor", + &state.inner_focus_color); + fl_value_set_string_take(result, "innerFocusWidth", + fl_value_new_int(state.inner_focus_width)); + return result; +} + +static FlValue* gtk_search_entry_theme_to_fl_value( + const BusyMaxGtkSearchEntryTheme& theme) { + FlValue* result = fl_value_new_map(); + fl_value_set_string_take( + result, "normal", gtk_search_entry_state_to_fl_value(theme.normal)); + fl_value_set_string_take( + result, "focused", gtk_search_entry_state_to_fl_value(theme.focused)); + fl_value_set_string_take( + result, "backdrop", gtk_search_entry_state_to_fl_value(theme.backdrop)); + fl_value_set_string_take( + result, "backdropFocused", + gtk_search_entry_state_to_fl_value(theme.backdrop_focused)); + return result; +} + static gboolean lookup_context_color(GtkStyleContext* context, const gchar* name, GdkRGBA* color) { @@ -4564,6 +2333,12 @@ static FlValue* get_gtk_theme_colors() { set_theme_color(result, "floatingBorder", &floating_border_color); set_theme_color(result, "sidebarBorder", &sidebar_border_color); set_theme_color(result, "shade", &shade_color); + BusyMaxGtkSearchEntryTheme search_entry_theme = {}; + if (busymax_sample_gtk_search_entry_theme(&search_entry_theme)) { + fl_value_set_string_take( + result, "searchEntry", + gtk_search_entry_theme_to_fl_value(search_entry_theme)); + } gtk_widget_destroy(dim_label); gtk_widget_destroy(separator); @@ -4572,28 +2347,42 @@ static FlValue* get_gtk_theme_colors() { return result; } -static void apply_gtk_theme_to_bootstrap_chrome(MyApplication* self) { +static void apply_gtk_theme_to_native_surfaces(MyApplication* self) { g_autoptr(FlValue) colors = get_gtk_theme_colors(); const gchar* window_color = fl_lookup_string_arg(colors, "window"); - const gchar* sidebar_color = fl_lookup_string_arg(colors, "sidebar"); // Dart sends the complete semantic palette after its first build. Until - // then, use GTK's already-resolved active variant so the native titlebar and - // Flutter backing surface never expose the runner's dark safety fallback. - set_css_color_field(&self->header_bar_window_background_color, window_color); - set_css_color_field(&self->header_bar_background_color, window_color); - set_css_color_field( - &self->header_bar_sidebar_background_color, - is_css_color_token(sidebar_color) ? sidebar_color : window_color); - set_css_color_field( - &self->header_bar_sidebar_border_color, - fl_lookup_string_arg(colors, "sidebarBorder")); - set_css_color_field(&self->header_bar_foreground_color, - fl_lookup_string_arg(colors, "foreground")); - set_css_color_field(&self->header_bar_dialog_background_color, + // then, use GTK's resolved active variant for retained GTK surfaces and the + // Flutter backing surface. + set_css_color_field(&self->native_surface_window_background_color, + window_color); + set_css_color_field(&self->native_surface_dialog_background_color, fl_lookup_string_arg(colors, "dialog")); } +static FlValue* gtk_window_preferences_to_fl_value( + const BusyMaxGtkWindowPreferences& preferences) { + FlValue* result = fl_value_new_map(); + fl_value_set_string_take( + result, "decorationLayout", + fl_value_new_string(preferences.decoration_layout.c_str())); + fl_value_set_string_take( + result, "doubleClick", + fl_value_new_string(preferences.double_click.c_str())); + fl_value_set_string_take( + result, "middleClick", + fl_value_new_string(preferences.middle_click.c_str())); + fl_value_set_string_take( + result, "rightClick", + fl_value_new_string(preferences.right_click.c_str())); + return result; +} + +static FlValue* get_gtk_window_preferences(MyApplication* self) { + return gtk_window_preferences_to_fl_value( + self->gtk_window_preferences->Read()); +} + static void gtk_settings_method_call_cb(FlMethodChannel* channel, FlMethodCall* method_call, gpointer user_data) { @@ -4619,9 +2408,25 @@ static void gtk_settings_method_call_cb(FlMethodChannel* channel, } else if (strcmp(method, "getGtkThemeColors") == 0) { g_autoptr(FlValue) result = get_gtk_theme_colors(); fl_method_call_respond_success(method_call, result, nullptr); + } else if (strcmp(method, "getGtkAnimationsEnabled") == 0) { + g_autoptr(FlValue) result = + fl_value_new_bool(get_gtk_animations_enabled()); + fl_method_call_respond_success(method_call, result, nullptr); } else if (strcmp(method, "setGtkThemePreference") == 0) { set_gtk_theme_preference(fl_method_bool_arg(args)); fl_method_call_respond_success(method_call, nullptr, nullptr); + } else if (strcmp(method, "setNativeSurfaceTheme") == 0) { + set_native_surface_theme(self, args); + fl_method_call_respond_success(method_call, nullptr, nullptr); + } else if (strcmp(method, "getGtkWindowPreferences") == 0) { + g_autoptr(FlValue) result = get_gtk_window_preferences(self); + fl_method_call_respond_success(method_call, result, nullptr); + } else if (strcmp(method, "lowerWindow") == 0) { + if (self->main_window != nullptr) { + GdkWindow* window = gtk_widget_get_window(GTK_WIDGET(self->main_window)); + if (window != nullptr) gdk_window_lower(window); + } + fl_method_call_respond_success(method_call, nullptr, nullptr); } else { fl_method_call_respond_not_implemented(method_call, nullptr); } @@ -4748,11 +2553,11 @@ static void gtk_theme_colors_notify_cb(GObject*, GParamSpec*, gpointer user_data) { MyApplication* self = MY_APPLICATION(user_data); - if (!self->header_bar_theme_received) { - apply_gtk_theme_to_bootstrap_chrome(self); + if (!self->native_surface_theme_received) { + apply_gtk_theme_to_native_surfaces(self); set_main_flutter_view_background(self); } - refresh_header_bar_css(self); + refresh_native_surface_css(self); send_gtk_theme_colors_event(self); } @@ -4791,6 +2596,266 @@ static FlMethodErrorResponse* gtk_theme_colors_cancel_cb( return nullptr; } +static void disconnect_gtk_animation_settings_signal(MyApplication* self) { + if (self->gtk_animation_settings_signal_id == 0) { + return; + } + GtkSettings* settings = gtk_settings_get_default(); + if (settings != nullptr) { + g_signal_handler_disconnect(settings, + self->gtk_animation_settings_signal_id); + } + self->gtk_animation_settings_signal_id = 0; +} + +static void send_gtk_animation_settings_event(MyApplication* self) { + if (!self->gtk_animation_settings_listening || + self->gtk_animation_settings_event_channel == nullptr) { + return; + } + g_autoptr(FlValue) value = + fl_value_new_bool(get_gtk_animations_enabled()); + g_autoptr(GError) error = nullptr; + if (!fl_event_channel_send(self->gtk_animation_settings_event_channel, + value, nullptr, &error)) { + const gchar* message = error != nullptr ? error->message : "unknown error"; + g_warning("Failed to send GTK animation settings event: %s", message); + } +} + +static void gtk_animation_settings_notify_cb(GObject*, GParamSpec*, + gpointer user_data) { + MyApplication* self = MY_APPLICATION(user_data); + send_gtk_animation_settings_event(self); +} + +static FlMethodErrorResponse* gtk_animation_settings_listen_cb( + FlEventChannel*, FlValue*, gpointer user_data) { + MyApplication* self = MY_APPLICATION(user_data); + self->gtk_animation_settings_listening = TRUE; + GtkSettings* settings = gtk_settings_get_default(); + if (settings != nullptr && self->gtk_animation_settings_signal_id == 0) { + self->gtk_animation_settings_signal_id = g_signal_connect( + settings, "notify::gtk-enable-animations", + G_CALLBACK(gtk_animation_settings_notify_cb), self); + } + send_gtk_animation_settings_event(self); + return nullptr; +} + +static FlMethodErrorResponse* gtk_animation_settings_cancel_cb( + FlEventChannel*, FlValue*, gpointer user_data) { + MyApplication* self = MY_APPLICATION(user_data); + self->gtk_animation_settings_listening = FALSE; + disconnect_gtk_animation_settings_signal(self); + return nullptr; +} + +static void send_gtk_window_preferences_event( + MyApplication* self, + const BusyMaxGtkWindowPreferences& preferences) { + if (!self->gtk_window_preferences_listening || + self->gtk_window_preferences_event_channel == nullptr) { + return; + } + g_autoptr(FlValue) value = + gtk_window_preferences_to_fl_value(preferences); + g_autoptr(GError) error = nullptr; + if (!fl_event_channel_send(self->gtk_window_preferences_event_channel, + value, nullptr, &error)) { + const gchar* message = error != nullptr ? error->message : "unknown error"; + g_warning("Failed to send GTK window preferences event: %s", message); + } +} + +static FlMethodErrorResponse* gtk_window_preferences_listen_cb( + FlEventChannel*, FlValue*, gpointer user_data) { + MyApplication* self = MY_APPLICATION(user_data); + self->gtk_window_preferences_listening = TRUE; + self->gtk_window_preferences->Start( + [self](const BusyMaxGtkWindowPreferences& preferences) { + send_gtk_window_preferences_event(self, preferences); + }); + send_gtk_window_preferences_event( + self, self->gtk_window_preferences->Read()); + return nullptr; +} + +static FlMethodErrorResponse* gtk_window_preferences_cancel_cb( + FlEventChannel*, FlValue*, gpointer user_data) { + MyApplication* self = MY_APPLICATION(user_data); + self->gtk_window_preferences_listening = FALSE; + if (self->gtk_window_preferences != nullptr) { + self->gtk_window_preferences->Stop(); + } + return nullptr; +} + +static void send_gtk_header_icons_changed_event(MyApplication* self) { + if (!self->gtk_header_icons_listening || + self->gtk_header_icons_changed_event_channel == nullptr || + self->gtk_header_icons == nullptr) { + return; + } + g_autoptr(FlValue) event = fl_value_new_map(); + fl_value_set_string_take( + event, "revision", + fl_value_new_int(self->gtk_header_icons_revision)); + fl_value_set_string_take(event, "scale", + fl_value_new_int(self->gtk_header_icons->scale())); + g_autoptr(GError) error = nullptr; + if (!fl_event_channel_send(self->gtk_header_icons_changed_event_channel, + event, nullptr, &error)) { + const gchar* message = error != nullptr ? error->message : "unknown error"; + g_warning("Failed to send GTK header-icon invalidation: %s", message); + } +} + +static FlMethodErrorResponse* gtk_header_icons_listen_cb( + FlEventChannel*, FlValue*, gpointer user_data) { + MyApplication* self = MY_APPLICATION(user_data); + self->gtk_header_icons_listening = TRUE; + if (self->gtk_header_icons_revision > 0) { + send_gtk_header_icons_changed_event(self); + } + return nullptr; +} + +static FlMethodErrorResponse* gtk_header_icons_cancel_cb( + FlEventChannel*, FlValue*, gpointer user_data) { + MyApplication* self = MY_APPLICATION(user_data); + self->gtk_header_icons_listening = FALSE; + return nullptr; +} + +static gboolean parse_gtk_header_icon_request( + FlValue* request, + const gchar** key_out, + std::vector* names_out, + BusyMaxGtkIconDirection* direction_out, + gboolean* allow_missing_out) { + if (request == nullptr || fl_value_get_type(request) != FL_VALUE_TYPE_MAP) { + return FALSE; + } + FlValue* key = fl_value_lookup_string(request, "key"); + FlValue* names = fl_value_lookup_string(request, "names"); + FlValue* direction = fl_value_lookup_string(request, "direction"); + FlValue* allow_missing = fl_value_lookup_string(request, "allowMissing"); + if (key == nullptr || fl_value_get_type(key) != FL_VALUE_TYPE_STRING || + names == nullptr || fl_value_get_type(names) != FL_VALUE_TYPE_LIST || + fl_value_get_length(names) == 0 || direction == nullptr || + fl_value_get_type(direction) != FL_VALUE_TYPE_STRING || + (allow_missing != nullptr && + fl_value_get_type(allow_missing) != FL_VALUE_TYPE_BOOL)) { + return FALSE; + } + names_out->clear(); + for (size_t index = 0; index < fl_value_get_length(names); index++) { + FlValue* name = fl_value_get_list_value(names, index); + if (name == nullptr || fl_value_get_type(name) != FL_VALUE_TYPE_STRING) { + return FALSE; + } + names_out->emplace_back(fl_value_get_string(name)); + } + const gchar* direction_value = fl_value_get_string(direction); + if (g_strcmp0(direction_value, "ltr") == 0) { + *direction_out = BusyMaxGtkIconDirection::kLtr; + } else if (g_strcmp0(direction_value, "rtl") == 0) { + *direction_out = BusyMaxGtkIconDirection::kRtl; + } else { + return FALSE; + } + *allow_missing_out = + allow_missing == nullptr ? FALSE : fl_value_get_bool(allow_missing); + *key_out = fl_value_get_string(key); + return TRUE; +} + +static FlValue* gtk_header_icon_asset_to_fl_value( + const BusyMaxGtkHeaderIconAsset& asset) { + FlValue* value = fl_value_new_map(); + fl_value_set_string_take( + value, "bytes", + fl_value_new_uint8_list(asset.png_bytes.data(), asset.png_bytes.size())); + fl_value_set_string_take( + value, "resolvedName", + fl_value_new_string(asset.resolved_name.c_str())); + fl_value_set_string_take(value, "scale", fl_value_new_int(asset.scale)); + fl_value_set_string_take(value, "pixelWidth", + fl_value_new_int(asset.pixel_width)); + fl_value_set_string_take(value, "pixelHeight", + fl_value_new_int(asset.pixel_height)); + return value; +} + +static void gtk_header_icons_method_call_cb(FlMethodChannel*, + FlMethodCall* method_call, + gpointer user_data) { + MyApplication* self = MY_APPLICATION(user_data); + if (g_strcmp0(fl_method_call_get_name(method_call), "loadIcons") != 0) { + fl_method_call_respond_not_implemented(method_call, nullptr); + return; + } + FlValue* requests = fl_method_call_get_args(method_call); + if (requests == nullptr || + fl_value_get_type(requests) != FL_VALUE_TYPE_LIST) { + fl_method_call_respond_error( + method_call, "invalid-arguments", + "loadIcons requires a list of keyed icon requests.", nullptr, + nullptr); + return; + } + + g_autoptr(FlValue) result = fl_value_new_map(); + for (size_t index = 0; index < fl_value_get_length(requests); index++) { + const gchar* key = nullptr; + std::vector names; + BusyMaxGtkIconDirection direction = BusyMaxGtkIconDirection::kLtr; + gboolean allow_missing = FALSE; + if (!parse_gtk_header_icon_request( + fl_value_get_list_value(requests, index), &key, &names, + &direction, &allow_missing)) { + fl_method_call_respond_error( + method_call, "invalid-arguments", + "Each icon request requires key, non-empty names, ltr/rtl " + "direction, and an optional boolean allowMissing.", + nullptr, nullptr); + return; + } + const auto asset = self->gtk_header_icons->Load( + names, direction, 0, allow_missing); + if (asset) { + fl_value_set_string_take( + result, key, gtk_header_icon_asset_to_fl_value(*asset)); + } else { + fl_value_set_string_take(result, key, fl_value_new_null()); + } + } + fl_method_call_respond_success(method_call, result, nullptr); +} + +static void register_gtk_header_icons(MyApplication* self, FlView* view) { + g_autoptr(FlStandardMethodCodec) codec = fl_standard_method_codec_new(); + FlBinaryMessenger* messenger = + fl_engine_get_binary_messenger(fl_view_get_engine(view)); + self->gtk_header_icons_channel = fl_method_channel_new( + messenger, kGtkHeaderIconsChannel, FL_METHOD_CODEC(codec)); + fl_method_channel_set_method_call_handler( + self->gtk_header_icons_channel, gtk_header_icons_method_call_cb, self, + nullptr); + self->gtk_header_icons_changed_event_channel = fl_event_channel_new( + messenger, kGtkHeaderIconsChangedEventChannel, FL_METHOD_CODEC(codec)); + fl_event_channel_set_stream_handlers( + self->gtk_header_icons_changed_event_channel, + gtk_header_icons_listen_cb, gtk_header_icons_cancel_cb, self, nullptr); + + self->gtk_header_icons = new BusyMaxGtkHeaderIcons(GTK_WIDGET(view)); + self->gtk_header_icons->Start([self]() { + self->gtk_header_icons_revision += 1; + send_gtk_header_icons_changed_event(self); + }); +} + static void register_gtk_settings_channel(MyApplication* self, FlView* view) { g_autoptr(FlStandardMethodCodec) codec = fl_standard_method_codec_new(); FlBinaryMessenger* messenger = @@ -4819,6 +2884,20 @@ static void register_gtk_settings_channel(MyApplication* self, FlView* view) { fl_event_channel_set_stream_handlers( self->gtk_theme_colors_event_channel, gtk_theme_colors_listen_cb, gtk_theme_colors_cancel_cb, self, nullptr); + + self->gtk_animation_settings_event_channel = fl_event_channel_new( + messenger, kGtkAnimationSettingsEventChannel, FL_METHOD_CODEC(codec)); + fl_event_channel_set_stream_handlers( + self->gtk_animation_settings_event_channel, + gtk_animation_settings_listen_cb, + gtk_animation_settings_cancel_cb, self, nullptr); + + self->gtk_window_preferences_event_channel = fl_event_channel_new( + messenger, kGtkWindowPreferencesEventChannel, FL_METHOD_CODEC(codec)); + fl_event_channel_set_stream_handlers( + self->gtk_window_preferences_event_channel, + gtk_window_preferences_listen_cb, + gtk_window_preferences_cancel_cb, self, nullptr); } static gboolean window_delete_event_cb(GtkWidget* widget, @@ -5038,15 +3117,13 @@ static void my_application_activate(GApplication* application) { return; } - apply_gtk_theme_to_bootstrap_chrome(self); + apply_gtk_theme_to_native_surfaces(self); + refresh_native_surface_css(self); GtkWindow* window = GTK_WINDOW(hdy_application_window_new()); gtk_application_add_window(GTK_APPLICATION(application), window); self->main_window = window; gtk_widget_set_name(GTK_WIDGET(window), "busymax-window"); - GtkWidget* titlebar_handle = create_busymax_titlebar_handle(self); - gtk_widget_show_all(titlebar_handle); - g_autoptr(GdkPixbuf) application_icon = load_application_icon(); if (application_icon != nullptr) { gtk_window_set_default_icon(application_icon); @@ -5060,27 +3137,22 @@ static void my_application_activate(GApplication* application) { } gtk_window_set_default_size(window, kMainWindowDefaultWidth, kMainWindowDefaultHeight); + gtk_window_set_title(window, kApplicationDisplayName); g_signal_connect(window, "delete-event", G_CALLBACK(window_delete_event_cb), self); - g_signal_connect( - window, "notify::is-active", - G_CALLBACK(header_focus_window_is_active_notify_cb), self); g_autoptr(FlDartProject) project = fl_dart_project_new(); fl_dart_project_set_dart_entrypoint_arguments( project, self->dart_entrypoint_arguments); FlView* view = fl_view_new(project); - track_widget_pointer(&self->flutter_view, GTK_WIDGET(view)); + self->flutter_view = GTK_WIDGET(view); + g_object_add_weak_pointer( + G_OBJECT(view), reinterpret_cast(&self->flutter_view)); set_main_flutter_view_background(self); gtk_widget_show(GTK_WIDGET(view)); - GtkWidget* window_content = - gtk_box_new(GTK_ORIENTATION_VERTICAL, 0); - gtk_box_pack_start(GTK_BOX(window_content), titlebar_handle, FALSE, FALSE, 0); - gtk_box_pack_start(GTK_BOX(window_content), GTK_WIDGET(view), TRUE, TRUE, 0); - gtk_widget_show(window_content); - gtk_container_add(GTK_CONTAINER(window), window_content); + gtk_container_add(GTK_CONTAINER(window), GTK_WIDGET(view)); // Show the window when Flutter renders. // Requires the view to be realized so we can start rendering. @@ -5095,11 +3167,10 @@ static void my_application_activate(GApplication* application) { register_external_calendar_open_channel(self, view); register_external_uri_launcher_channel(self, view); register_window_channel(self, view); - register_header_bar_channel(self, view); register_gtk_settings_channel(self, view); + register_gtk_header_icons(self, view); gtk_widget_grab_focus(GTK_WIDGET(view)); - schedule_header_bar_focus_state_refresh(self); } // Implements GApplication::open. @@ -5185,115 +3256,58 @@ static void my_application_shutdown(GApplication* application) { static void my_application_dispose(GObject* object) { MyApplication* self = MY_APPLICATION(object); disconnect_gtk_theme_colors_signals(self); - if (self->header_bar_css_provider != nullptr) { + disconnect_gtk_font_settings_signal(self); + disconnect_gtk_animation_settings_signal(self); + if (self->gtk_window_preferences != nullptr) { + self->gtk_window_preferences->Stop(); + } + if (self->gtk_header_icons != nullptr) { + self->gtk_header_icons->Stop(); + } + GdkScreen* screen = gdk_screen_get_default(); + if (screen != nullptr && self->native_surface_css_provider != nullptr) { gtk_style_context_remove_provider_for_screen( - gdk_screen_get_default(), GTK_STYLE_PROVIDER(self->header_bar_css_provider)); - g_clear_object(&self->header_bar_css_provider); + screen, GTK_STYLE_PROVIDER(self->native_surface_css_provider)); } + g_clear_object(&self->native_surface_css_provider); g_clear_object(&self->native_date_time_picker_channel); g_clear_object(&self->native_dialog_channel); g_clear_object(&self->native_menu_channel); g_clear_object(&self->window_channel); - g_clear_object(&self->header_bar_channel); - self->first_weekday_listening = FALSE; - delete self->first_weekday_preference; - self->first_weekday_preference = nullptr; g_clear_object(&self->gtk_settings_channel); + g_clear_object(&self->gtk_header_icons_channel); g_clear_object(&self->first_weekday_event_channel); g_clear_object(&self->external_calendar_open_channel); g_clear_object(&self->external_uri_launcher_channel); - disconnect_gtk_font_settings_signal(self); g_clear_object(&self->gtk_font_settings_event_channel); g_clear_object(&self->gtk_theme_colors_event_channel); - if (self->main_window != nullptr && GTK_IS_WIDGET(self->main_window)) { - gtk_widget_insert_action_group(GTK_WIDGET(self->main_window), "header", - nullptr); - } - g_clear_object(&self->header_view_mode_menu_action); - g_clear_object(&self->header_create_event_action); - g_clear_object(&self->header_create_task_action); - g_clear_object(&self->header_menu_action_group); - if (self->header_focus_transient_window != nullptr) { + g_clear_object(&self->gtk_animation_settings_event_channel); + g_clear_object(&self->gtk_window_preferences_event_channel); + g_clear_object(&self->gtk_header_icons_changed_event_channel); + self->first_weekday_listening = FALSE; + delete self->first_weekday_preference; + self->first_weekday_preference = nullptr; + delete self->gtk_window_preferences; + self->gtk_window_preferences = nullptr; + delete self->gtk_header_icons; + self->gtk_header_icons = nullptr; + if (self->flutter_view != nullptr && G_IS_OBJECT(self->flutter_view)) { g_object_remove_weak_pointer( - G_OBJECT(self->header_focus_transient_window), - reinterpret_cast( - &self->header_focus_transient_window)); - self->header_focus_transient_window = nullptr; + G_OBJECT(self->flutter_view), + reinterpret_cast(&self->flutter_view)); } + self->flutter_view = nullptr; self->main_window = nullptr; - clear_widget_pointer(&self->flutter_view); - clear_widget_pointer(&self->titlebar_handle); - clear_widget_pointer(&self->titlebar_overlay); - clear_widget_pointer(&self->titlebar_modal_barrier); - clear_widget_pointer(&self->titlebar_box); - clear_header_bar_pointer(self); - clear_widget_pointer(&self->header_start_box); - clear_widget_pointer(&self->header_title_box); - clear_widget_pointer(&self->header_title_stack); - clear_widget_pointer(&self->onboarding_back_slot); - clear_widget_pointer(&self->onboarding_back_button); - clear_widget_pointer(&self->onboarding_continue_slot); - clear_widget_pointer(&self->onboarding_continue_button); - clear_widget_pointer(&self->header_sidebar_brand_box); - clear_widget_pointer(&self->header_brand_label); - clear_widget_pointer(&self->settings_menu_button); - clear_widget_pointer(&self->settings_menu); - clear_widget_pointer(&self->header_view_box); - clear_widget_pointer(&self->header_title_label); - clear_widget_pointer(&self->search_entry); - clear_widget_pointer(&self->back_button); - clear_widget_pointer(&self->sidebar_collapsed_toggle_button); - clear_widget_pointer(&self->today_button); - clear_widget_pointer(&self->previous_button); - clear_widget_pointer(&self->next_button); - clear_widget_pointer(&self->view_mode_button); - clear_widget_pointer(&self->view_mode_icon); - clear_widget_pointer(&self->view_mode_menu); - clear_widget_pointer(&self->search_button); - clear_widget_pointer(&self->create_button); - clear_widget_pointer(&self->create_menu); - clear_widget_pointer(&self->refresh_button); - g_clear_pointer(&self->header_bar_window_background_color, g_free); - g_clear_pointer(&self->header_bar_background_color, g_free); - g_clear_pointer(&self->header_bar_sidebar_background_color, g_free); - g_clear_pointer(&self->header_bar_sidebar_border_color, g_free); - g_clear_pointer(&self->header_bar_foreground_color, g_free); - g_clear_pointer(&self->header_bar_dialog_background_color, g_free); - g_clear_pointer(&self->header_bar_dialog_outline_color, g_free); - g_clear_pointer(&self->header_bar_modal_barrier_color, g_free); - g_clear_pointer(&self->header_bar_tooltip_background_color, g_free); - g_clear_pointer(&self->header_bar_tooltip_foreground_color, g_free); - g_clear_pointer(&self->header_bar_tooltip_border_color, g_free); - g_clear_pointer(&self->header_view_mode, g_free); - g_clear_pointer(&self->header_title_text, g_free); - g_clear_pointer(&self->header_day_label, g_free); - g_clear_pointer(&self->header_week_label, g_free); - g_clear_pointer(&self->header_month_label, g_free); - g_clear_pointer(&self->header_year_label, g_free); - g_clear_pointer(&self->header_agenda_label, g_free); - g_clear_pointer(&self->header_show_sidebar_panel_label, g_free); - g_clear_pointer(&self->header_hide_sidebar_panel_label, g_free); - g_clear_pointer(&self->header_create_event_label, g_free); - g_clear_pointer(&self->header_create_task_label, g_free); - g_clear_pointer(&self->header_settings_label, g_free); - g_clear_pointer(&self->header_keyboard_shortcuts_label, g_free); - g_clear_pointer(&self->header_report_issue_label, g_free); - g_clear_pointer(&self->header_about_label, g_free); - g_clear_pointer(&self->header_day_shortcut, g_free); - g_clear_pointer(&self->header_week_shortcut, g_free); - g_clear_pointer(&self->header_month_shortcut, g_free); - g_clear_pointer(&self->header_year_shortcut, g_free); - g_clear_pointer(&self->header_agenda_shortcut, g_free); - g_clear_pointer(&self->header_sidebar_shortcut, g_free); - g_clear_pointer(&self->header_create_event_shortcut, g_free); - g_clear_pointer(&self->header_create_task_shortcut, g_free); - g_clear_pointer(&self->header_settings_shortcut, g_free); - g_clear_pointer(&self->header_keyboard_shortcuts_shortcut, g_free); - g_clear_pointer(&self->header_search_query, g_free); + g_clear_pointer(&self->native_surface_window_background_color, g_free); + g_clear_pointer(&self->native_surface_dialog_background_color, g_free); + g_clear_pointer(&self->native_surface_dialog_outline_color, g_free); + g_clear_pointer(&self->native_surface_tooltip_background_color, g_free); + g_clear_pointer(&self->native_surface_tooltip_foreground_color, g_free); + g_clear_pointer(&self->native_surface_tooltip_border_color, 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); + 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); @@ -5315,124 +3329,54 @@ static void my_application_init(MyApplication* self) { self->native_dialog_channel = nullptr; self->native_menu_channel = nullptr; self->window_channel = nullptr; - self->header_bar_channel = nullptr; self->gtk_settings_channel = nullptr; + self->gtk_header_icons_channel = nullptr; self->external_calendar_open_channel = nullptr; self->external_uri_launcher_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_animation_settings_event_channel = nullptr; + self->gtk_window_preferences_event_channel = nullptr; + self->gtk_header_icons_changed_event_channel = nullptr; self->first_weekday_event_channel = nullptr; self->first_weekday_preference = nullptr; + self->gtk_window_preferences = + new BusyMaxGtkWindowPreferencesWatcher(); + self->gtk_header_icons = nullptr; self->gtk_font_settings_signal_id = 0; self->gtk_theme_name_signal_id = 0; self->gtk_theme_dark_signal_id = 0; + self->gtk_animation_settings_signal_id = 0; self->gtk_font_settings_listening = FALSE; self->gtk_theme_colors_listening = FALSE; + self->gtk_animation_settings_listening = FALSE; + self->gtk_window_preferences_listening = FALSE; + self->gtk_header_icons_listening = FALSE; + self->gtk_header_icons_revision = 0; self->first_weekday_listening = FALSE; - self->hide_on_close = FALSE; - self->suppress_header_bar_actions = FALSE; - self->header_schedule_controls_visible = TRUE; - self->header_back_visible = FALSE; - self->header_onboarding_controls_visible = FALSE; - self->header_onboarding_content_width = kHeaderOnboardingContentWidth; - self->header_bar_css_provider = nullptr; - self->header_bar_window_background_color = + self->native_surface_css_provider = nullptr; + self->native_surface_window_background_color = g_strdup(kDefaultWindowBackgroundColor); - self->header_bar_background_color = - g_strdup(kDefaultHeaderBarBackgroundColor); - self->header_bar_sidebar_background_color = - g_strdup(kDefaultHeaderBarSidebarBackgroundColor); - self->header_bar_sidebar_border_color = nullptr; - self->header_bar_foreground_color = nullptr; - self->header_bar_dialog_background_color = nullptr; - self->header_bar_dialog_outline_color = + self->native_surface_dialog_background_color = nullptr; + self->native_surface_dialog_outline_color = g_strdup(kDefaultDialogOutlineColor); - self->header_bar_modal_barrier_color = nullptr; - self->header_bar_tooltip_background_color = nullptr; - self->header_bar_tooltip_foreground_color = nullptr; - self->header_bar_tooltip_border_color = nullptr; - self->header_bar_tooltip_radius = kDefaultTooltipRadius; - self->header_bar_tooltip_font_size = kDefaultTooltipFontSize; - self->header_bar_tooltip_horizontal_padding = + self->native_surface_tooltip_background_color = nullptr; + self->native_surface_tooltip_foreground_color = nullptr; + self->native_surface_tooltip_border_color = nullptr; + self->native_surface_tooltip_radius = kDefaultTooltipRadius; + self->native_surface_tooltip_font_size = kDefaultTooltipFontSize; + self->native_surface_tooltip_horizontal_padding = kDefaultTooltipHorizontalPadding; - self->header_bar_tooltip_vertical_padding = + self->native_surface_tooltip_vertical_padding = kDefaultTooltipVerticalPadding; - self->header_bar_tooltip_minimum_height = kDefaultTooltipMinimumHeight; - self->header_bar_high_contrast = FALSE; - self->header_bar_sidebar_width = 300; - self->header_bar_can_show_sidebar = TRUE; - self->header_bar_sidebar_visible = TRUE; - self->header_bar_modal_barrier_visible = FALSE; - self->header_bar_modal_barrier_shade_depth = 0; - self->header_bar_theme_received = FALSE; + self->native_surface_tooltip_minimum_height = kDefaultTooltipMinimumHeight; + self->native_surface_high_contrast = FALSE; + self->native_surface_theme_received = FALSE; self->main_window = nullptr; - self->header_focus_transient_window = nullptr; self->flutter_view = nullptr; - self->titlebar_handle = nullptr; - self->titlebar_overlay = nullptr; - self->titlebar_modal_barrier = nullptr; - self->titlebar_box = nullptr; - self->header_bar = nullptr; - self->header_start_box = nullptr; - self->header_title_box = nullptr; - self->header_title_stack = nullptr; - self->onboarding_back_slot = nullptr; - self->onboarding_back_button = nullptr; - self->onboarding_continue_slot = nullptr; - self->onboarding_continue_button = nullptr; - self->header_sidebar_brand_box = nullptr; - self->header_brand_label = nullptr; - self->settings_menu_button = nullptr; - self->settings_menu = nullptr; - self->header_view_box = nullptr; - self->header_title_label = nullptr; - self->search_entry = nullptr; - self->back_button = nullptr; - self->sidebar_collapsed_toggle_button = nullptr; - self->today_button = nullptr; - self->previous_button = nullptr; - self->next_button = nullptr; - self->view_mode_button = nullptr; - self->view_mode_icon = nullptr; - self->view_mode_menu = nullptr; - self->search_button = nullptr; - self->create_button = nullptr; - self->create_menu = nullptr; - self->refresh_button = nullptr; - self->header_menu_action_group = nullptr; - self->header_view_mode_menu_action = nullptr; - self->header_create_event_action = nullptr; - self->header_create_task_action = nullptr; - self->header_view_mode = nullptr; - self->header_title_text = g_strdup(""); - self->header_day_label = g_strdup("Day"); - self->header_week_label = g_strdup("Week"); - self->header_month_label = g_strdup("Month"); - self->header_year_label = g_strdup("Year"); - self->header_agenda_label = g_strdup("Agenda"); - self->header_show_sidebar_panel_label = g_strdup("Show sidebar panel"); - self->header_hide_sidebar_panel_label = g_strdup("Hide sidebar panel"); - self->header_create_event_label = g_strdup("Event"); - self->header_create_task_label = g_strdup("Task"); - self->header_settings_label = g_strdup("Settings"); - self->header_keyboard_shortcuts_label = g_strdup("Keyboard Shortcuts"); - self->header_report_issue_label = g_strdup("Report an issue"); - self->header_about_label = g_strdup("About BusyMax"); - self->header_day_shortcut = g_strdup("1"); - self->header_week_shortcut = g_strdup("2"); - self->header_month_shortcut = g_strdup("3"); - self->header_year_shortcut = g_strdup("4"); - self->header_agenda_shortcut = g_strdup("5"); - self->header_sidebar_shortcut = g_strdup("F9"); - self->header_create_event_shortcut = g_strdup("E"); - self->header_create_task_shortcut = g_strdup("T"); - self->header_settings_shortcut = g_strdup("Ctrl+Alt+S"); - self->header_keyboard_shortcuts_shortcut = g_strdup("Ctrl+Alt+K"); - self->header_search_query = g_strdup(""); - self->header_search_active = FALSE; - self->header_navigation_visible = TRUE; + self->hide_on_close = FALSE; } MyApplication* my_application_new() { diff --git a/linux/runner/tests/gtk_header_icons_test.cc b/linux/runner/tests/gtk_header_icons_test.cc new file mode 100644 index 00000000..ed970cb8 --- /dev/null +++ b/linux/runner/tests/gtk_header_icons_test.cc @@ -0,0 +1,164 @@ +#include "../gtk_header_icons.h" + +#include + +#include +#include +#include + +namespace { + +bool Check(bool condition, const char* message) { + if (condition) return true; + std::cerr << message << '\n'; + return false; +} + +void CountWarning(const gchar*, GLogLevelFlags, const gchar*, gpointer data) { + *static_cast(data) += 1; +} + +} // namespace + +int main(int argc, char** argv) { + if (!gtk_init_check(&argc, &argv)) { + std::cerr << "GTK could not initialize\n"; + return 1; + } + + GtkIconTheme* theme = gtk_icon_theme_get_default(); + if (!Check(theme != nullptr, "GTK icon theme is unavailable")) return 1; + g_autoptr(GtkWidget) view = gtk_window_new(GTK_WINDOW_TOPLEVEL); + g_object_ref_sink(view); + BusyMaxGtkHeaderIcons icons(view, theme); + bool passed = Check(kBusyMaxGtkHeaderIconLogicalSize == 16, + "Production nominal GTK header icon size is not 16"); + + const std::vector search_role_names = { + "system-search-symbolic", "edit-find-symbolic", "edit-clear-symbolic"}; + for (const std::string& name : search_role_names) { + const auto asset = icons.Load({name}, BusyMaxGtkIconDirection::kLtr, 1); + passed = Check(asset.has_value(), "GTK Search-role icon lookup failed") && + Check(asset && asset->resolved_name == name, + "GTK Search-role icon resolved under the wrong name") && + passed; + } + + const std::vector known = {"go-previous-symbolic"}; + const auto normal = icons.Load(known, BusyMaxGtkIconDirection::kLtr, 1); + passed = Check(normal.has_value(), + "Known standard symbolic icon lookup failed") && + passed; + if (normal) { + passed = Check(normal->resolved_name == known.front(), + "Known icon resolved under an unexpected name") && + Check(normal->scale == 1, + "Normal-DPI lookup did not retain the supplied scale") && + Check(!normal->png_bytes.empty(), + "Known icon produced no PNG bytes") && + Check(normal->pixel_width > 0 && normal->pixel_height > 0, + "Known icon produced an empty raster") && + passed; + + g_autoptr(GInputStream) stream = g_memory_input_stream_new_from_data( + normal->png_bytes.data(), normal->png_bytes.size(), nullptr); + g_autoptr(GError) decode_error = nullptr; + g_autoptr(GdkPixbuf) decoded = + gdk_pixbuf_new_from_stream(stream, nullptr, &decode_error); + passed = Check(decoded != nullptr, + "Produced PNG bytes could not be decoded") && + Check(decoded == nullptr || + (gdk_pixbuf_get_width(decoded) > 0 && + gdk_pixbuf_get_height(decoded) > 0), + "Decoded PNG was empty") && + passed; + } + + const auto high_dpi = + icons.Load(known, BusyMaxGtkIconDirection::kLtr, 2); + passed = Check(high_dpi.has_value(), "High-DPI icon lookup failed") && + Check(high_dpi && high_dpi->scale == 2, + "High-DPI lookup did not use the supplied scale") && + passed; + if (normal && high_dpi) { + passed = Check(high_dpi->pixel_width >= normal->pixel_width && + high_dpi->pixel_height >= normal->pixel_height, + "High-DPI raster was smaller than the 1x raster") && + passed; + } + + const std::vector ordered = { + "go-next-symbolic", "go-previous-symbolic"}; + const auto preferred = + icons.Load(ordered, BusyMaxGtkIconDirection::kLtr, 1); + passed = Check(preferred && + preferred->resolved_name == "go-next-symbolic", + "Ordered lookup did not select the first available icon") && + passed; + + const std::vector fallback = { + "busymax-deliberately-missing-symbolic", "go-next-symbolic"}; + const auto secondary = + icons.Load(fallback, BusyMaxGtkIconDirection::kLtr, 1); + passed = Check(secondary && + secondary->resolved_name == "go-next-symbolic", + "Missing primary did not fall through to valid secondary") && + passed; + + const std::vector missing = { + "busymax-deliberately-missing-symbolic"}; + int warning_count = 0; + const guint warning_handler = g_log_set_handler( + nullptr, G_LOG_LEVEL_WARNING, CountWarning, &warning_count); + const auto required_missing = + icons.Load(missing, BusyMaxGtkIconDirection::kLtr, 1, false); + passed = Check(!required_missing.has_value(), + "Required missing icon unexpectedly resolved") && + Check(warning_count == 1, + "Required missing icon did not retain its warning path") && + passed; + const auto optional_missing = + icons.Load(missing, BusyMaxGtkIconDirection::kLtr, 1, true); + passed = Check(!optional_missing.has_value(), + "Optional missing icon unexpectedly resolved") && + Check(warning_count == 1, + "Optional missing icon emitted a warning") && + passed; + g_log_remove_handler(nullptr, warning_handler); + + int callback_count = 0; + passed = Check(icons.Start([&callback_count]() { callback_count += 1; }), + "Icon invalidation watcher failed to start") && + passed; + const gulong theme_signal = icons.theme_changed_signal_id(); + const gulong scale_signal = icons.scale_changed_signal_id(); + const gulong screen_signal = icons.screen_changed_signal_id(); + passed = Check(theme_signal != 0, + "Theme changed signal was not connected") && + Check(scale_signal != 0, + "Scale-factor signal was not connected") && + Check(screen_signal != 0, + "Screen-changed signal was not connected") && + passed; + + g_signal_emit_by_name(theme, "changed"); + passed = Check(callback_count == 1, + "Theme change did not invoke invalidation callback") && + passed; + + icons.Stop(); + icons.Stop(); + passed = Check(!g_signal_handler_is_connected(theme, theme_signal), + "Theme signal remained connected after stop") && + Check(!g_signal_handler_is_connected(view, scale_signal), + "Scale signal remained connected after stop") && + Check(!g_signal_handler_is_connected(view, screen_signal), + "Screen signal remained connected after stop") && + passed; + g_signal_emit_by_name(theme, "changed"); + passed = Check(callback_count == 1, + "Callback occurred after watcher stop") && + passed; + + return passed ? 0 : 1; +} diff --git a/linux/runner/tests/gtk_search_entry_theme_test.cc b/linux/runner/tests/gtk_search_entry_theme_test.cc new file mode 100644 index 00000000..743f2307 --- /dev/null +++ b/linux/runner/tests/gtk_search_entry_theme_test.cc @@ -0,0 +1,369 @@ +#include "../gtk_search_entry_theme.h" + +#include + +#include +#include +#include +#include + +namespace { + +bool Check(bool condition, const char* message) { + if (condition) return true; + std::cerr << message << '\n'; + return false; +} + +bool IsValidColor(const GdkRGBA& color) { + return std::isfinite(color.red) && std::isfinite(color.green) && + std::isfinite(color.blue) && std::isfinite(color.alpha) && + color.red >= 0.0 && color.red <= 1.0 && color.green >= 0.0 && + color.green <= 1.0 && color.blue >= 0.0 && color.blue <= 1.0 && + color.alpha >= 0.0 && color.alpha <= 1.0; +} + +bool IsValidState(const BusyMaxGtkSearchEntryState& state) { + return IsValidColor(state.background) && IsValidColor(state.foreground) && + IsValidColor(state.border_color) && + IsValidColor(state.primary_icon_foreground) && + IsValidColor(state.primary_icon_foreground_rtl) && + IsValidColor(state.secondary_icon_foreground) && + IsValidColor(state.secondary_icon_foreground_rtl) && + state.border_width.top >= 0 && state.border_width.right >= 0 && + state.border_width.bottom >= 0 && state.border_width.left >= 0 && + state.border_radius >= 0 && IsValidColor(state.inner_focus_color) && + state.inner_focus_width >= 0; +} + +bool ThemeInstalled(const char* theme_name) { + const gchar* const* data_dirs = g_get_system_data_dirs(); + for (const gchar* const* directory = data_dirs; *directory != nullptr; + ++directory) { + g_autofree gchar* path = g_build_filename( + *directory, "themes", theme_name, "gtk-3.0", "gtk.css", nullptr); + if (g_file_test(path, G_FILE_TEST_IS_REGULAR)) { + return true; + } + } + return false; +} + +std::string ColorString(const GdkRGBA& color) { + g_autofree gchar* value = gdk_rgba_to_string(&color); + return value == nullptr ? "invalid" : value; +} + +bool ColorsEqual(const GdkRGBA& first, const GdkRGBA& second) { + constexpr double kTolerance = 1e-6; + return std::abs(first.red - second.red) < kTolerance && + std::abs(first.green - second.green) < kTolerance && + std::abs(first.blue - second.blue) < kTolerance && + std::abs(first.alpha - second.alpha) < kTolerance; +} + +void ReportTheme(const char* theme_name, + const BusyMaxGtkSearchEntryTheme& theme) { + std::cout << "GTK theme=" << theme_name + << " normal.radius=" << theme.normal.border_radius + << " normal.background=" << ColorString(theme.normal.background) + << " normal.border=" << ColorString(theme.normal.border_color) + << " normal.borderWidth=" << theme.normal.border_width.top << ',' + << theme.normal.border_width.right << ',' + << theme.normal.border_width.bottom << ',' + << theme.normal.border_width.left + << " focused.border=" << ColorString(theme.focused.border_color) + << " focused.borderWidth=" << theme.focused.border_width.top << ',' + << theme.focused.border_width.right << ',' + << theme.focused.border_width.bottom << ',' + << theme.focused.border_width.left + << " focused.innerFocus=" + << (theme.focused.has_inner_focus ? "true" : "false") << ',' + << theme.focused.inner_focus_width << ',' + << ColorString(theme.focused.inner_focus_color) + << " backdrop.background=" + << ColorString(theme.backdrop.background) + << " backdrop.foreground=" + << ColorString(theme.backdrop.foreground) + << " backdrop.border=" << ColorString(theme.backdrop.border_color) + << " primary.ltr=" + << ColorString(theme.normal.primary_icon_foreground) + << " primary.rtl=" + << ColorString(theme.normal.primary_icon_foreground_rtl) + << " secondary.ltr=" + << ColorString(theme.normal.secondary_icon_foreground) + << " secondary.rtl=" + << ColorString(theme.normal.secondary_icon_foreground_rtl) + << '\n'; +} + +struct OriginalTheme { + explicit OriginalTheme(GtkSettings* settings) : settings(settings) { + g_object_get(settings, "gtk-theme-name", &theme_name, + "gtk-application-prefer-dark-theme", &prefer_dark, nullptr); + } + + ~OriginalTheme() { + g_object_set(settings, "gtk-theme-name", theme_name, + "gtk-application-prefer-dark-theme", prefer_dark, nullptr); + g_free(theme_name); + } + + GtkSettings* settings; + gchar* theme_name = nullptr; + gboolean prefer_dark = FALSE; +}; + +} // namespace + +int main(int argc, char** argv) { + if (!gtk_init_check(&argc, &argv)) { + std::cerr << "GTK could not initialize\n"; + return 1; + } + + GtkSettings* settings = gtk_settings_get_default(); + if (!Check(settings != nullptr, "GTK settings are unavailable")) return 1; + OriginalTheme original(settings); + bool passed = true; + + GdkScreen* screen = gdk_screen_get_default(); + passed = Check(screen != nullptr, "GTK screen is unavailable") && passed; + if (screen != nullptr) { + GtkCssProvider* provider = gtk_css_provider_new(); + constexpr const char* kDirectionalIconCss = + "entry image.left { color: #112233; }\n" + "entry image.right { color: #445566; }\n" + "entry image.left:disabled { color: #a1b2c3; }\n" + "entry image.right:disabled { color: #d4e5f6; }\n" + "entry image.left:disabled:backdrop { color: #123456; }\n"; + g_autoptr(GError) error = nullptr; + const bool css_loaded = gtk_css_provider_load_from_data( + provider, kDirectionalIconCss, -1, &error); + passed = Check(css_loaded, "Directional image-node CSS did not load") && + passed; + if (css_loaded) { + gtk_style_context_add_provider_for_screen( + screen, GTK_STYLE_PROVIDER(provider), + GTK_STYLE_PROVIDER_PRIORITY_APPLICATION); + GtkWidget* entry = gtk_search_entry_new(); + g_object_ref_sink(entry); + GtkStyleContext* context = gtk_widget_get_style_context(entry); + GdkRGBA primary_ltr = {}; + GdkRGBA primary_rtl = {}; + GdkRGBA secondary_ltr = {}; + GdkRGBA secondary_rtl = {}; + GdkRGBA backdrop_primary_ltr = {}; + GdkRGBA sensitive_left = {}; + GdkRGBA sensitive_right = {}; + GdkRGBA disabled_left = {}; + GdkRGBA disabled_right = {}; + GdkRGBA disabled_backdrop_left = {}; + gdk_rgba_parse(&sensitive_left, "#112233"); + gdk_rgba_parse(&sensitive_right, "#445566"); + gdk_rgba_parse(&disabled_left, "#a1b2c3"); + gdk_rgba_parse(&disabled_right, "#d4e5f6"); + gdk_rgba_parse(&disabled_backdrop_left, "#123456"); + const bool sampled_primary_ltr = + busymax_sample_gtk_search_entry_icon_foreground( + context, GTK_STATE_FLAG_NORMAL, GTK_ENTRY_ICON_PRIMARY, + GTK_TEXT_DIR_LTR, &primary_ltr); + const bool sampled_secondary_ltr = + busymax_sample_gtk_search_entry_icon_foreground( + context, GTK_STATE_FLAG_NORMAL, GTK_ENTRY_ICON_SECONDARY, + GTK_TEXT_DIR_LTR, &secondary_ltr); + const bool sampled_primary_rtl = + busymax_sample_gtk_search_entry_icon_foreground( + context, GTK_STATE_FLAG_NORMAL, GTK_ENTRY_ICON_PRIMARY, + GTK_TEXT_DIR_RTL, &primary_rtl); + const bool sampled_secondary_rtl = + busymax_sample_gtk_search_entry_icon_foreground( + context, GTK_STATE_FLAG_NORMAL, GTK_ENTRY_ICON_SECONDARY, + GTK_TEXT_DIR_RTL, &secondary_rtl); + const bool sampled_backdrop_primary_ltr = + busymax_sample_gtk_search_entry_icon_foreground( + context, GTK_STATE_FLAG_BACKDROP, GTK_ENTRY_ICON_PRIMARY, + GTK_TEXT_DIR_LTR, &backdrop_primary_ltr); + std::cout << "Directional CSS primary.ltr=" << ColorString(primary_ltr) + << " secondary.ltr=" << ColorString(secondary_ltr) + << " primary.rtl=" << ColorString(primary_rtl) + << " secondary.rtl=" << ColorString(secondary_rtl) + << " backdrop.primary.ltr=" + << ColorString(backdrop_primary_ltr) << '\n'; + passed = + Check(sampled_primary_ltr, + "LTR primary image-node color was not sampled") && + Check(sampled_secondary_ltr, + "LTR secondary image-node color was not sampled") && + Check(sampled_primary_rtl, + "RTL primary image-node color was not sampled") && + Check(sampled_secondary_rtl, + "RTL secondary image-node color was not sampled") && + Check(sampled_backdrop_primary_ltr, + "Backdrop LTR primary image-node color was not sampled") && + Check(ColorsEqual(primary_ltr, disabled_left), + "LTR primary did not use disabled image.left") && + Check(ColorsEqual(secondary_ltr, sensitive_right), + "LTR secondary did not use sensitive image.right") && + Check(ColorsEqual(primary_rtl, disabled_right), + "RTL primary did not use disabled image.right") && + Check(ColorsEqual(secondary_rtl, sensitive_left), + "RTL secondary did not use sensitive image.left") && + Check(ColorsEqual(backdrop_primary_ltr, + disabled_backdrop_left), + "LTR primary did not preserve backdrop while disabled") && + passed; + gtk_widget_destroy(entry); + g_object_unref(entry); + gtk_style_context_remove_provider_for_screen( + screen, GTK_STYLE_PROVIDER(provider)); + } + g_object_unref(provider); + } + + BusyMaxGtkSearchEntryTheme initial = {}; + passed = Check(busymax_sample_gtk_search_entry_theme(&initial), + "A real GtkSearchEntry could not be sampled") && + passed; + passed = Check(IsValidColor(initial.normal.background), + "Normal background was not returned") && + Check(IsValidColor(initial.normal.foreground), + "Normal foreground was not returned") && + Check(IsValidColor(initial.normal.border_color), + "Normal border color was not returned") && + Check(initial.normal.border_width.top >= 0 && + initial.normal.border_width.right >= 0 && + initial.normal.border_width.bottom >= 0 && + initial.normal.border_width.left >= 0, + "Normal border widths were negative") && + Check(initial.normal.border_radius >= 0, + "GTK_STYLE_PROPERTY_BORDER_RADIUS was not a non-negative " + "pixel value") && + Check(IsValidState(initial.focused), + "Focused state could not be sampled") && + Check(IsValidState(initial.backdrop), + "Backdrop state could not be sampled") && + Check(IsValidState(initial.backdrop_focused), + "Backdrop-focused state could not be sampled") && + Check(IsValidColor(initial.normal.primary_icon_foreground) && + IsValidColor( + initial.normal.primary_icon_foreground_rtl) && + IsValidColor(initial.normal.secondary_icon_foreground) && + IsValidColor( + initial.normal.secondary_icon_foreground_rtl), + "Search-entry image-node foregrounds could not be sampled") && + passed; + + passed = Check(!busymax_gtk_theme_is_standard_yaru("Adwaita"), + "Adwaita was classified as ordinary Yaru") && + Check(busymax_gtk_theme_is_standard_yaru("Yaru"), + "Yaru was not classified for compatibility") && + Check(busymax_gtk_theme_is_standard_yaru("Yaru-magenta-dark"), + "A normal Yaru variant was not classified") && + Check(!busymax_gtk_theme_is_standard_yaru("Yaru-HighContrast"), + "HighContrast Yaru received the ordinary-Yaru override") && + Check(!busymax_gtk_theme_is_standard_yaru( + "Yaru-high-contrast-dark"), + "Hyphenated HighContrast Yaru received the override") && + passed; + + BusyMaxGtkSearchEntryTheme yaru_model = {}; + gdk_rgba_parse(&yaru_model.focused.border_color, "#123456"); + yaru_model.focused.border_width.top = 7; + yaru_model.focused.border_width.right = 8; + yaru_model.focused.border_width.bottom = 9; + yaru_model.focused.border_width.left = 10; + yaru_model.normal.has_inner_focus = TRUE; + yaru_model.normal.inner_focus_width = 4; + yaru_model.backdrop.has_inner_focus = TRUE; + yaru_model.backdrop.inner_focus_width = 4; + yaru_model.backdrop_focused.has_inner_focus = TRUE; + yaru_model.backdrop_focused.inner_focus_width = 4; + busymax_apply_yaru_search_entry_compatibility(&yaru_model); + passed = + Check(yaru_model.normal.border_radius == 9 && + yaru_model.focused.border_radius == 9 && + yaru_model.backdrop.border_radius == 9 && + yaru_model.backdrop_focused.border_radius == 9, + "Yaru compatibility did not apply the 9px radius") && + Check(yaru_model.focused.border_width.top == 7 && + yaru_model.focused.border_width.right == 8 && + yaru_model.focused.border_width.bottom == 9 && + yaru_model.focused.border_width.left == 10, + "Yaru compatibility modified sampled focused border widths") && + Check(!yaru_model.normal.has_inner_focus && + yaru_model.normal.inner_focus_width == 0, + "Yaru normal state retained an inner focus stroke") && + Check(yaru_model.focused.has_inner_focus && + yaru_model.focused.inner_focus_width == 1 && + ColorsEqual(yaru_model.focused.inner_focus_color, + yaru_model.focused.border_color), + "Yaru focused state did not expose its one-pixel inset") && + Check(!yaru_model.backdrop.has_inner_focus && + yaru_model.backdrop.inner_focus_width == 0 && + !yaru_model.backdrop_focused.has_inner_focus && + yaru_model.backdrop_focused.inner_focus_width == 0, + "Yaru backdrop states retained an active inner focus stroke") && + passed; + + struct ThemeCase { + const char* name; + bool dark; + bool expect_yaru_radius; + bool expect_adwaita_radius; + }; + constexpr std::array themes = {{ + {"Yaru", false, true, false}, + {"Yaru-dark", true, true, false}, + {"HighContrast", false, false, false}, + {"Adwaita", false, false, true}, + }}; + + for (const ThemeCase& test : themes) { + if (!ThemeInstalled(test.name)) { + std::cout << "GTK theme=" << test.name << " skipped (not installed)\n"; + continue; + } + g_object_set(settings, "gtk-theme-name", test.name, + "gtk-application-prefer-dark-theme", test.dark, nullptr); + BusyMaxGtkSearchEntryTheme sampled = {}; + passed = Check(busymax_sample_gtk_search_entry_theme(&sampled), + "Installed GTK theme could not be sampled") && + Check(IsValidState(sampled.normal) && + IsValidState(sampled.focused) && + IsValidState(sampled.backdrop) && + IsValidState(sampled.backdrop_focused), + "Installed GTK theme returned invalid state data") && + passed; + if (test.expect_yaru_radius) { + passed = Check(sampled.normal.border_radius == 9 && + sampled.focused.border_radius == 9 && + sampled.backdrop.border_radius == 9 && + sampled.backdrop_focused.border_radius == 9, + "Yaru did not receive BusyMax's 9px compatibility radius") && + Check(sampled.focused.border_width.top == 1 && + sampled.focused.border_width.right == 1 && + sampled.focused.border_width.bottom == 1 && + sampled.focused.border_width.left == 1, + "Yaru focused border was not the native 1px border") && + Check(sampled.focused.has_inner_focus && + sampled.focused.inner_focus_width == 1 && + ColorsEqual(sampled.focused.inner_focus_color, + sampled.focused.border_color), + "Yaru focused state did not expose a separate 1px inset") && + Check(!sampled.normal.has_inner_focus && + !sampled.backdrop.has_inner_focus && + !sampled.backdrop_focused.has_inner_focus, + "Yaru non-active states exposed an active focus inset") && + passed; + } + if (test.expect_adwaita_radius) { + passed = Check(sampled.normal.border_radius != 9, + "Adwaita incorrectly received the Yaru-only radius") && + passed; + } + ReportTheme(test.name, sampled); + } + + return passed ? 0 : 1; +} diff --git a/linux/runner/tests/gtk_window_preferences_test.cc b/linux/runner/tests/gtk_window_preferences_test.cc new file mode 100644 index 00000000..5c7f4510 --- /dev/null +++ b/linux/runner/tests/gtk_window_preferences_test.cc @@ -0,0 +1,165 @@ +#include "../gtk_window_preferences.h" + +#include + +#include +#include +#include +#include + +namespace { + +bool check(bool condition, const char* message) { + if (condition) return true; + std::cerr << message << '\n'; + return false; +} + +const char* alternate(const char* current, const char* first, + const char* second) { + return current != nullptr && std::strcmp(current, first) == 0 ? second + : first; +} + +struct OriginalSettings { + explicit OriginalSettings(GtkSettings* settings) : settings(settings) { + g_object_get(settings, + "gtk-decoration-layout", &decoration_layout, + "gtk-titlebar-double-click", &double_click, + "gtk-titlebar-middle-click", &middle_click, + "gtk-titlebar-right-click", &right_click, + nullptr); + } + + ~OriginalSettings() { + g_object_set(settings, + "gtk-decoration-layout", decoration_layout, + "gtk-titlebar-double-click", double_click, + "gtk-titlebar-middle-click", middle_click, + "gtk-titlebar-right-click", right_click, + nullptr); + g_free(decoration_layout); + g_free(double_click); + g_free(middle_click); + g_free(right_click); + } + + GtkSettings* settings; + gchar* decoration_layout = nullptr; + gchar* double_click = nullptr; + gchar* middle_click = nullptr; + gchar* right_click = nullptr; +}; + +} // namespace + +int main(int argc, char** argv) { + if (!gtk_init_check(&argc, &argv)) { + std::cerr << "GTK could not initialize\n"; + return 1; + } + + GtkSettings* settings = gtk_settings_get_default(); + if (!check(settings != nullptr, "GTK settings are unavailable")) return 1; + OriginalSettings original(settings); + + const BusyMaxGtkWindowPreferences defaults = + BusyMaxGtkWindowPreferencesWatcher::Defaults(); + bool passed = + check(defaults.decoration_layout == "menu:minimize,maximize,close", + "Production default decoration layout does not match GTK 3") && + check(defaults.double_click == "toggle-maximize", + "Production default double-click action is incorrect") && + check(defaults.middle_click == "none", + "Production default middle-click action is incorrect") && + check(defaults.right_click == "menu", + "Production default right-click action is incorrect"); + + BusyMaxGtkWindowPreferencesWatcher watcher(settings); + const BusyMaxGtkWindowPreferences initial = watcher.Read(); + passed = check(!initial.decoration_layout.empty(), + "Production reader returned no decoration layout") && + check(!initial.double_click.empty(), + "Production reader returned no double-click action") && + check(!initial.middle_click.empty(), + "Production reader returned no middle-click action") && + check(!initial.right_click.empty(), + "Production reader returned no right-click action") && + passed; + + int callback_count = 0; + BusyMaxGtkWindowPreferences observed = initial; + passed = check( + watcher.Start( + [&](const BusyMaxGtkWindowPreferences& preferences) { + callback_count += 1; + observed = preferences; + }), + "Production watcher failed to start") && + passed; + const std::array signals = watcher.signal_ids(); + for (gulong signal : signals) { + passed = check(signal != 0, "Failed to connect a production signal") && + passed; + } + + const char* changed_layout = + alternate(original.decoration_layout, "icon:close", "menu:minimize"); + int before = callback_count; + g_object_set(settings, "gtk-decoration-layout", changed_layout, nullptr); + passed = check(callback_count > before, + "Decoration-layout callback was not delivered") && + check(observed.decoration_layout == changed_layout, + "Production reader did not return the changed layout") && + passed; + + const char* changed_double_click = + alternate(original.double_click, "minimize", "toggle-maximize"); + before = callback_count; + g_object_set(settings, "gtk-titlebar-double-click", changed_double_click, + nullptr); + passed = check(callback_count > before, + "Double-click callback was not delivered") && + check(observed.double_click == changed_double_click, + "Production reader did not return the changed double-click action") && + passed; + + const char* changed_middle_click = + alternate(original.middle_click, "lower", "none"); + before = callback_count; + g_object_set(settings, "gtk-titlebar-middle-click", changed_middle_click, + nullptr); + passed = check(callback_count > before, + "Middle-click callback was not delivered") && + check(observed.middle_click == changed_middle_click, + "Production reader did not return the changed middle-click action") && + passed; + + const char* changed_right_click = + alternate(original.right_click, "none", "menu"); + before = callback_count; + g_object_set(settings, "gtk-titlebar-right-click", changed_right_click, + nullptr); + passed = check(callback_count > before, + "Right-click callback was not delivered") && + check(observed.right_click == changed_right_click, + "Production reader did not return the changed right-click action") && + passed; + + watcher.Stop(); + watcher.Stop(); + for (gulong signal : signals) { + passed = check(!g_signal_handler_is_connected(settings, signal), + "Production watcher signal remained connected") && + passed; + } + + before = callback_count; + g_object_set(settings, "gtk-decoration-layout", + alternate(changed_layout, "close:", "icon:"), nullptr); + passed = check(callback_count == before, + "Callback was delivered after production watcher stop") && + passed; + + return passed ? 0 : 1; +} diff --git a/pubspec.yaml b/pubspec.yaml index 64d660e0..8067b5de 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: busymax description: BusyMax calendar and task manager. publish_to: 'none' -version: 0.2.2+2 +version: 0.2.3 environment: sdk: ^3.12.0 diff --git a/snap/snapcraft.yaml b/snap/snapcraft.yaml index b9faf36c..b6b81efd 100644 --- a/snap/snapcraft.yaml +++ b/snap/snapcraft.yaml @@ -1,6 +1,6 @@ name: busymax title: BusyMax -version: "0.2.2" +version: "0.2.3" 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 3d4738c4..fd4e79d6 100644 --- a/test/app/about_dialog_test.dart +++ b/test/app/about_dialog_test.dart @@ -5,9 +5,7 @@ import 'package:busymax/src/app/busymax_design.dart'; import 'package:busymax/src/app/busymax_dialog_identity.dart'; import 'package:busymax/src/app/busymax_dialogs.dart'; import 'package:busymax/src/app/busymax_yaru_theme.dart'; -import 'package:busymax/src/platform/linux_header_bar_service.dart'; import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:package_info_plus/package_info_plus.dart'; import 'package:yaru/yaru.dart'; @@ -35,7 +33,8 @@ void main() { expect(find.text('https://github.com/busystack/busymax/'), findsOneWidget); expect(find.text('Send feedback'), findsNothing); expect(find.text('Report an issue'), findsNothing); - expect(find.text('v1.2.3+45'), findsOneWidget); + expect(find.text('v1.2.3'), findsOneWidget); + expect(find.textContaining('+45'), findsNothing); expect(find.byType(YaruDialogTitleBar), findsOneWidget); expect(find.byType(YaruWindowControl), findsOneWidget); expect(find.text('Close'), findsNothing); @@ -136,23 +135,8 @@ void main() { } testWidgets( - 'about modal route stays responsive and restores the native barrier', + 'about modal route stays responsive and removes its local barrier', (tester) async { - const channel = MethodChannel('busymax_test/about_modal_route'); - final calls = []; - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(channel, (call) async { - calls.add(call); - return call.method == 'initialize' ? true : null; - }); - addTearDown(() { - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(channel, null); - }); - final service = LinuxHeaderBarService(channel: channel, isLinux: true); - addTearDown(service.dispose); - await service.initialize(); - tester.view ..devicePixelRatio = 1 ..physicalSize = const Size(480, 320); @@ -174,7 +158,6 @@ void main() { late BuildContext dialogContext; final result = showBusyMaxModalDialog( hostContext, - headerBarService: service, builder: (context) { dialogContext = context; return const BusyMaxAboutDialog(); @@ -201,24 +184,14 @@ void main() { .maxScrollExtent, greaterThan(0), ); - expect( - calls - .where((call) => call.method == 'setModalBarrierState') - .single - .arguments, - {'visible': true, 'shadeDepth': 1}, - ); + expect(find.byType(AnimatedModalBarrier), findsOneWidget); await tester.tap(find.byType(YaruWindowControl)); await tester.pumpAndSettle(); await result; expect(find.byType(BusyMaxAboutDialog), findsNothing); - final barrierCalls = calls - .where((call) => call.method == 'setModalBarrierState') - .toList(); - expect(barrierCalls, hasLength(2)); - expect(barrierCalls.last.arguments, {'visible': false, 'shadeDepth': 0}); + expect(find.byType(AnimatedModalBarrier), findsNothing); expect(tester.takeException(), isNull); }, ); @@ -371,29 +344,21 @@ void main() { expect(source, contains('https://www.apache.org/licenses/LICENSE-2.0')); }); - test( - 'about dialog uses native headerbar dimming and shared close adapter', - () { - final source = File( - 'lib/src/app/busymax_about_dialog.dart', - ).readAsStringSync(); - final dialogs = File( - 'lib/src/app/busymax_dialogs.dart', - ).readAsStringSync(); - - expect(source, contains('showBusyMaxModalDialog')); - expect(source, contains('headerBarService: headerBarService')); - expect(dialogs, contains('acquireBusyMaxModalBarrier')); - expect(dialogs, contains('releaseBusyMaxModalBarrier')); - expect(dialogs, contains('await acquireBusyMaxModalBarrier(')); - expect(dialogs, contains('await releaseBusyMaxModalBarrier(')); - expect(dialogs, contains('shadesHeader: shadesHeader')); - expect(source, isNot(contains('barrierColor: Colors.transparent'))); - expect(source, contains('BusyMaxInformationalDialog(')); - expect(source, isNot(contains('BusyMaxPopoverIconButton('))); - expect(source, isNot(contains('BusyMaxDialogCloseButton'))); - }, - ); + test('about dialog uses the shared Flutter modal and close adapter', () { + final source = File( + 'lib/src/app/busymax_about_dialog.dart', + ).readAsStringSync(); + final dialogs = File('lib/src/app/busymax_dialogs.dart').readAsStringSync(); + + expect(source, contains('showBusyMaxModalDialog')); + expect(dialogs, contains('await route.completed')); + expect(dialogs, isNot(contains('LinuxHeaderBarService'))); + expect(dialogs, isNot(contains('setModalBarrierState'))); + expect(source, isNot(contains('barrierColor: Colors.transparent'))); + expect(source, contains('BusyMaxInformationalDialog(')); + expect(source, isNot(contains('BusyMaxPopoverIconButton('))); + expect(source, isNot(contains('BusyMaxDialogCloseButton'))); + }); test('about logo renders the PNG asset, not the launcher SVG', () { final source = File( diff --git a/test/app/app_bootstrap_provider_test.dart b/test/app/app_bootstrap_provider_test.dart index 2438b264..3aa8022b 100644 --- a/test/app/app_bootstrap_provider_test.dart +++ b/test/app/app_bootstrap_provider_test.dart @@ -1,6 +1,8 @@ import 'dart:async'; +import 'dart:convert'; import 'package:busymax/src/core/secrets/secret_store.dart'; +import 'package:busymax/src/core/http/request_dispatch_exception.dart'; import 'package:busymax/src/dav/dav_provider_profile.dart'; import 'package:drift/native.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; @@ -12,6 +14,8 @@ 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/notifications/desktop_notification_service.dart'; +import 'package:busymax/src/features/connectivity/network_connectivity_service.dart'; +import 'package:busymax/src/features/sync/account_sync_operations.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'; @@ -212,6 +216,238 @@ void main() { }, ); + test( + 'signed-in sync runner unwraps Tasks auth failure and preserves credential', + () async { + final database = AppDatabase(NativeDatabase.memory()); + await _seedSignedInGoogleAccount(database); + final oAuth = _FakeOAuthGateway()..activeId = 'account-1'; + const failure = KnownUnsentRequestException( + kind: RequestPreDispatchFailureKind.authentication, + cause: OAuthRefreshException( + 'OAuthRefreshFailed', + 'Provider refresh failed.', + statusCode: 400, + oauthError: 'invalid_grant', + ), + ); + final operations = _ThrowingAccountSyncOperations(failure); + final container = ProviderContainer( + overrides: [ + databaseProvider.overrideWithValue(database), + authRepositoryProvider.overrideWithValue( + AuthRepository(oAuth: oAuth, database: database), + ), + accountSyncOperationsProvider.overrideWithValue(operations), + ], + ); + addTearDown(() async { + container.dispose(); + await database.close(); + }); + + await expectLater( + container.read(signedInSyncRunnerProvider)('account-1', false), + throwsA(same(failure)), + ); + + final account = await AccountsRepository( + database: database, + ).accountById('account-1'); + expect(operations.accountCalls, 1); + expect(account?.authState, accountAuthStateReauthRequired); + expect( + await AccountsRepository(database: database).listSyncEligibleAccounts(), + isEmpty, + ); + expect(oAuth.activeId, 'account-1'); + }, + ); + + test('signed-in sync runner dispatches an eligible account once', () async { + final database = AppDatabase(NativeDatabase.memory()); + await _seedSignedInGoogleAccount(database); + final operations = _RecordingAccountSyncOperations(); + final container = ProviderContainer( + overrides: [ + databaseProvider.overrideWithValue(database), + accountSyncOperationsProvider.overrideWithValue(operations), + ], + ); + addTearDown(() async { + container.dispose(); + await database.close(); + }); + + await container.read(signedInSyncRunnerProvider)('account-1', true); + + expect(operations.accountCalls, [('account-1', true)]); + }); + + test('signed-in sync runner skips a reconnect-required account', () async { + final database = AppDatabase(NativeDatabase.memory()); + await _seedSignedInGoogleAccount(database); + await AccountsRepository( + database: database, + ).markReconnectRequired('account-1'); + final operations = _RecordingAccountSyncOperations(); + final container = ProviderContainer( + overrides: [ + databaseProvider.overrideWithValue(database), + accountSyncOperationsProvider.overrideWithValue(operations), + ], + ); + addTearDown(() async { + container.dispose(); + await database.close(); + }); + + await container.read(signedInSyncRunnerProvider)('account-1', true); + + expect(operations.accountCalls, isEmpty); + }); + + test('signed-in sync runner reads eligibility when invoked', () async { + final database = AppDatabase(NativeDatabase.memory()); + await _seedSignedInGoogleAccount(database); + final operations = _RecordingAccountSyncOperations(); + final container = ProviderContainer( + overrides: [ + databaseProvider.overrideWithValue(database), + accountSyncOperationsProvider.overrideWithValue(operations), + ], + ); + addTearDown(() async { + container.dispose(); + await database.close(); + }); + final runSync = container.read(signedInSyncRunnerProvider); + await AccountsRepository( + database: database, + ).markReconnectRequired('account-1'); + + await runSync('account-1', false); + + expect(operations.accountCalls, isEmpty); + }); + + test( + 'signed-in sync runner skips an account that no longer exists', + () async { + final database = AppDatabase(NativeDatabase.memory()); + final operations = _RecordingAccountSyncOperations(); + final container = ProviderContainer( + overrides: [ + databaseProvider.overrideWithValue(database), + accountSyncOperationsProvider.overrideWithValue(operations), + ], + ); + addTearDown(() async { + container.dispose(); + await database.close(); + }); + + await container.read(signedInSyncRunnerProvider)('missing', false); + + expect(operations.accountCalls, isEmpty); + }, + ); + + test( + 'pending mutation stays queued until account eligibility is restored', + () async { + final database = AppDatabase(NativeDatabase.memory()); + await _seedSignedInGoogleAccount(database); + final accounts = AccountsRepository(database: database); + await accounts.markReconnectRequired('account-1'); + await database.pendingOpsDao.enqueue( + PendingOpsCompanion.insert( + id: 'pending-task', + accountId: 'account-1', + entityType: 'task', + operation: 'create_task', + taskListId: const Value('list-1'), + taskId: const Value('local-task'), + localTempId: const Value('local-task'), + requestJson: jsonEncode({ + 'body': {'title': 'Queued task'}, + }), + createdAtUtc: '2026-06-04T00:00:00.000Z', + updatedAtUtc: '2026-06-04T00:00:00.000Z', + ), + ); + final operations = _RecordingAccountSyncOperations(); + final connectivity = + NetworkConnectivityMonitor.withoutPlatformObservation(); + final container = ProviderContainer( + overrides: [ + databaseProvider.overrideWithValue(database), + accountSyncOperationsProvider.overrideWithValue(operations), + networkConnectivityMonitorProvider.overrideWithValue(connectivity), + ], + ); + addTearDown(() async { + container.dispose(); + await connectivity.dispose(); + await database.close(); + }); + final requester = container.read( + pendingMutationSyncRequesterForAccountProvider('account-1'), + ); + + requester.request(); + await Future.delayed(const Duration(milliseconds: 350)); + + expect(operations.taskCalls, isEmpty); + expect(await database.pendingOpsDao.getOp('pending-task'), isNotNull); + + await _seedSignedInGoogleAccount(database); + requester.request(); + await Future.delayed(const Duration(milliseconds: 350)); + + expect(operations.taskCalls, [('account-1', false)]); + expect(await database.pendingOpsDao.getOp('pending-task'), isNotNull); + }, + ); + + test('signed-in sync runner handles direct Calendar OAuth failure', () async { + final database = AppDatabase(NativeDatabase.memory()); + await _seedSignedInGoogleAccount(database); + final oAuth = _FakeOAuthGateway()..activeId = 'account-1'; + const failure = OAuthRefreshException( + 'OAuthRefreshFailed', + 'Provider refresh failed.', + statusCode: 400, + oauthError: 'invalid_grant', + ); + final container = ProviderContainer( + overrides: [ + databaseProvider.overrideWithValue(database), + authRepositoryProvider.overrideWithValue( + AuthRepository(oAuth: oAuth, database: database), + ), + accountSyncOperationsProvider.overrideWithValue( + _ThrowingAccountSyncOperations(failure), + ), + ], + ); + addTearDown(() async { + container.dispose(); + await database.close(); + }); + + await expectLater( + container.read(signedInSyncRunnerProvider)('account-1', false), + throwsA(same(failure)), + ); + + final account = await AccountsRepository( + database: database, + ).accountById('account-1'); + expect(account?.authState, accountAuthStateReauthRequired); + expect(oAuth.activeId, 'account-1'); + }); + test( 'production DAV sync rebuilds schedules before checking notifications', () async { @@ -518,6 +754,50 @@ class _SyncCall { final bool initial; } +final class _ThrowingAccountSyncOperations implements AccountSyncOperations { + _ThrowingAccountSyncOperations(this.error); + + final Object error; + int accountCalls = 0; + + @override + Future syncAccount(String accountId, {required bool full}) async { + accountCalls += 1; + throw error; + } + + @override + Future syncCalendar(String accountId, {required bool full}) async { + throw error; + } + + @override + Future syncTasks(String accountId, {required bool full}) async { + throw error; + } +} + +final class _RecordingAccountSyncOperations implements AccountSyncOperations { + final List<(String, bool)> accountCalls = []; + final List<(String, bool)> calendarCalls = []; + final List<(String, bool)> taskCalls = []; + + @override + Future syncAccount(String accountId, {required bool full}) async { + accountCalls.add((accountId, full)); + } + + @override + Future syncCalendar(String accountId, {required bool full}) async { + calendarCalls.add((accountId, full)); + } + + @override + Future syncTasks(String accountId, {required bool full}) async { + taskCalls.add((accountId, full)); + } +} + class _FakeOAuthGateway implements OAuthGateway { String? activeId; diff --git a/test/app/app_settings_controller_test.dart b/test/app/app_settings_controller_test.dart index ff0bcf6d..46ac1a3e 100644 --- a/test/app/app_settings_controller_test.dart +++ b/test/app/app_settings_controller_test.dart @@ -7,6 +7,30 @@ import 'package:flutter_test/flutter_test.dart'; import '../support/memory_settings_store.dart'; void main() { + test( + 'creation defaults and last used destinations survive restart', + () async { + final store = MemorySettingsStore(); + final controller = AppSettingsController(store); + addTearDown(controller.dispose); + await controller.ready; + const calendar = CreationDestination( + accountId: 'account-a', + id: 'calendar', + ); + const taskList = CreationDestination(accountId: 'account-b', id: 'tasks'); + await controller.setDefaultCalendar(calendar); + await controller.rememberCalendar(calendar); + await controller.rememberTaskList(taskList); + expect(AppSettings.fromJson(store.value).defaultCalendar, calendar); + expect(AppSettings.fromJson(store.value).lastUsedCalendar, calendar); + expect(AppSettings.fromJson(store.value).lastUsedTaskList, taskList); + await controller.setDefaultCalendar(null); + expect(AppSettings.fromJson(store.value).defaultCalendar, isNull); + expect(AppSettings.fromJson(store.value).lastUsedCalendar, calendar); + }, + ); + test('clock changes survive loading and preserve loaded language', () async { final store = _DelayedLoadSettingsStore(); final controller = AppSettingsController(store); diff --git a/test/app/app_settings_test.dart b/test/app/app_settings_test.dart index c140de5c..ffa306e1 100644 --- a/test/app/app_settings_test.dart +++ b/test/app/app_settings_test.dart @@ -4,6 +4,46 @@ import 'package:flutter/widgets.dart'; import 'package:flutter_test/flutter_test.dart'; void main() { + test('creation destinations persist and prefer selected over Last used', () { + const first = CreationDestination(accountId: 'a', id: 'shared'); + const second = CreationDestination(accountId: 'b', id: 'shared'); + final settings = AppSettings.defaults().copyWith( + defaultCalendar: first, + lastUsedCalendar: second, + lastUsedTaskList: first, + ); + final restored = AppSettings.fromJson(settings.toJson()); + expect(restored.defaultCalendar, first); + expect(restored.defaultTaskList, isNull); + expect(restored.lastUsedCalendar, second); + expect(restored.lastUsedTaskList, first); + expect( + preferredCreationDestination( + [second, first], + selected: restored.defaultCalendar, + lastUsed: restored.lastUsedCalendar, + destinationOf: (value) => value, + ), + first, + ); + expect( + preferredCreationDestination( + [second], + selected: restored.defaultCalendar, + lastUsed: restored.lastUsedCalendar, + destinationOf: (value) => value, + ), + second, + ); + expect(restored.copyWith(defaultCalendar: null).defaultCalendar, isNull); + expect( + AppSettings.fromJson({ + 'defaultCalendar': {'id': 'shared'}, + }).defaultCalendar, + isNull, + ); + }); + test( 'clock preference persists independently of language and canonical data', () { diff --git a/test/app/busymax_button_style_test.dart b/test/app/busymax_button_style_test.dart new file mode 100644 index 00000000..72492b2c --- /dev/null +++ b/test/app/busymax_button_style_test.dart @@ -0,0 +1,588 @@ +import 'dart:io'; +import 'dart:ui'; + +import 'package:busymax/src/app/app_theme.dart'; +import 'package:busymax/src/app/busymax_design.dart'; +import 'package:busymax/src/app/busymax_surface_colors.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; + +const _accent = Color(0xFF8B5CF6); + +void main() { + test('standard and suggested roles resolve the complete Ubuntu contract', () { + for (final brightness in Brightness.values) { + for (final highContrast in [false, true]) { + final theme = _theme( + brightness: brightness, + highContrast: highContrast, + ); + final colors = theme.extension()!; + final standard = theme.filledButtonTheme.style!; + final suggested = theme.elevatedButtonTheme.style!; + final disabledHover = {WidgetState.disabled, WidgetState.hovered}; + + for (final style in [standard, suggested]) { + expect( + style.padding?.resolve({}), + const EdgeInsets.symmetric(horizontal: 17, vertical: 5), + ); + expect(style.minimumSize?.resolve({}), const Size(34, 34)); + expect(style.textStyle?.resolve({})?.fontFamily, 'Ubuntu Sans'); + expect(style.textStyle?.resolve({})?.fontWeight, FontWeight.bold); + expect(style.visualDensity, VisualDensity.standard); + expect(style.tapTargetSize, MaterialTapTargetSize.shrinkWrap); + expect(style.elevation?.resolve({WidgetState.pressed}), 0); + expect(style.shadowColor?.resolve({}), Colors.transparent); + expect(style.surfaceTintColor?.resolve({}), Colors.transparent); + expect( + style.overlayColor?.resolve({WidgetState.hovered}), + Colors.transparent, + ); + expect(style.splashFactory, NoSplash.splashFactory); + expect(style.backgroundBuilder, isNotNull); + final shape = style.shape?.resolve({})! as RoundedRectangleBorder; + expect(shape.borderRadius, BorderRadius.circular(BusyMaxRadius.sm)); + } + + expect(standard.foregroundColor?.resolve({}), colors.foreground); + expect(standard.backgroundColor?.resolve({}), colors.control); + expect( + standard.backgroundColor?.resolve({WidgetState.hovered}), + colors.controlHover, + ); + expect( + standard.backgroundColor?.resolve({WidgetState.pressed}), + colors.controlActive, + ); + expect( + standard.backgroundColor?.resolve(disabledHover), + colors.disabledControl, + ); + + final accent = theme.colorScheme.primary; + final onAccent = theme.colorScheme.onPrimary; + expect(suggested.foregroundColor?.resolve({}), onAccent); + expect(suggested.backgroundColor?.resolve({}), accent); + expect( + suggested.backgroundColor?.resolve({WidgetState.hovered}), + Color.alphaBlend(onAccent.withValues(alpha: 0.10), accent), + ); + expect( + suggested.backgroundColor?.resolve({WidgetState.pressed}), + Color.alphaBlend(Colors.black.withValues(alpha: 0.20), accent), + ); + expect( + suggested.backgroundColor?.resolve({ + WidgetState.focused, + WidgetState.pressed, + }), + suggested.backgroundColor?.resolve({WidgetState.pressed}), + ); + expect( + suggested.backgroundColor?.resolve(disabledHover), + colors.disabledControl, + ); + } + } + }); + + testWidgets('destructive role shares geometry and owns every color state', ( + tester, + ) async { + for (final brightness in Brightness.values) { + for (final highContrast in [false, true]) { + late BuildContext buttonContext; + final theme = _theme( + brightness: brightness, + highContrast: highContrast, + ); + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: Builder( + builder: (context) { + buttonContext = context; + return BusyMaxPushButton.destructive( + context: context, + onPressed: () {}, + child: const Text('Delete'), + ); + }, + ), + ), + ); + await tester.pumpAndSettle(); + + final widget = tester.widget( + find.byType(ElevatedButton), + ); + final style = widget.style!.merge(theme.elevatedButtonTheme.style); + final colors = theme.extension()!; + final error = theme.colorScheme.error; + final onError = theme.colorScheme.onError; + expect(buttonContext, isNotNull); + expect( + style.padding?.resolve({}), + const EdgeInsets.symmetric(horizontal: 17, vertical: 5), + ); + expect(style.minimumSize?.resolve({}), const Size(34, 34)); + expect(style.textStyle?.resolve({})?.fontWeight, FontWeight.bold); + expect(style.foregroundColor?.resolve({}), onError); + expect(style.backgroundColor?.resolve({}), error); + expect( + style.backgroundColor?.resolve({WidgetState.hovered}), + Color.alphaBlend(onError.withValues(alpha: 0.10), error), + ); + expect( + style.backgroundColor?.resolve({WidgetState.pressed}), + Color.alphaBlend(Colors.black.withValues(alpha: 0.20), error), + ); + expect( + style.backgroundColor?.resolve({ + WidgetState.disabled, + WidgetState.pressed, + }), + colors.disabledControl, + ); + expect( + style.overlayColor?.resolve({WidgetState.pressed}), + Colors.transparent, + ); + expect(style.backgroundBuilder, isNotNull); + } + } + }); + + testWidgets('focus geometry is role-aware and layout-stable', (tester) async { + final standardStates = WidgetStatesController(); + final suggestedStates = WidgetStatesController(); + addTearDown(standardStates.dispose); + addTearDown(suggestedStates.dispose); + + await tester.pumpWidget( + MaterialApp( + theme: _theme(brightness: Brightness.dark), + home: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + BusyMaxPushButton.standard( + key: const ValueKey('standard'), + statesController: standardStates, + onPressed: () {}, + child: const Text('Standard'), + ), + BusyMaxPushButton.suggested( + key: const ValueKey('suggested'), + statesController: suggestedStates, + onPressed: () {}, + child: const Text('Suggested'), + ), + ], + ), + ), + ); + + final standard = find.byKey(const ValueKey('standard')); + final suggested = find.byKey(const ValueKey('suggested')); + final restingStandardSize = tester.getSize(standard); + final restingSuggestedSize = tester.getSize(suggested); + await tester.sendKeyEvent(LogicalKeyboardKey.tab); + standardStates.value = {WidgetState.focused}; + suggestedStates.value = {WidgetState.focused}; + await tester.pump(); + + expect(tester.getSize(standard), restingStandardSize); + expect(tester.getSize(suggested), restingSuggestedSize); + final standardPainter = _focusPainter(tester, standard); + final suggestedPainter = _focusPainter(tester, suggested); + expect(standardPainter.placement, BusyMaxPushButtonFocusPlacement.inset); + expect(standardPainter.outlineOffset, -2); + expect(suggestedPainter.placement, BusyMaxPushButtonFocusPlacement.outside); + expect(suggestedPainter.outlineOffset, 1); + expect(BusyMaxPushButtonFocusPainter.outlineWidth, 2); + expect(standardPainter.color.a, closeTo(0.50, 0.001)); + expect(suggestedPainter.color.a, closeTo(0.50, 0.001)); + expect(suggestedPainter.color, _accent.withValues(alpha: 0.50)); + expect(tester.widget(suggested).clipBehavior, Clip.none); + }); + + testWidgets('mouse focus stays quiet while Tab and Shift+Tab show the ring', ( + tester, + ) async { + final previousStrategy = FocusManager.instance.highlightStrategy; + FocusManager.instance.highlightStrategy = FocusHighlightStrategy.automatic; + addTearDown(() { + FocusManager.instance.highlightStrategy = previousStrategy; + }); + final standardFocus = FocusNode(); + final suggestedFocus = FocusNode(); + addTearDown(standardFocus.dispose); + addTearDown(suggestedFocus.dispose); + var activations = 0; + await tester.pumpWidget( + MaterialApp( + theme: _theme(brightness: Brightness.light), + home: Scaffold( + body: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + BusyMaxPushButton.standard( + key: const ValueKey('mouse-standard'), + focusNode: standardFocus, + onPressed: () { + activations += 1; + standardFocus.requestFocus(); + }, + child: const Text('Standard'), + ), + BusyMaxPushButton.suggested( + key: const ValueKey('mouse-suggested'), + focusNode: suggestedFocus, + onPressed: () { + activations += 1; + suggestedFocus.requestFocus(); + }, + child: const Text('Suggested'), + ), + ], + ), + ), + ), + ); + await tester.pump(); + + final standard = find.byKey(const ValueKey('mouse-standard')); + final suggested = find.byKey(const ValueKey('mouse-suggested')); + final mouse = await tester.createGesture(kind: PointerDeviceKind.mouse); + addTearDown(mouse.removePointer); + await mouse.addPointer(location: Offset.zero); + await mouse.moveTo(tester.getCenter(standard)); + await tester.pumpAndSettle(); + final colors = _theme( + brightness: Brightness.light, + ).extension()!; + expect(_buttonMaterial(tester, standard).color, colors.controlHover); + + await mouse.down(tester.getCenter(standard)); + await tester.pump(); + expect(_buttonMaterial(tester, standard).color, colors.controlActive); + await mouse.up(); + await tester.pump(); + expect(activations, 1); + expect(standardFocus.hasFocus, isTrue); + expect(_focusPainter(tester, standard).color, Colors.transparent); + + await tester.sendKeyEvent(LogicalKeyboardKey.tab); + await tester.pump(); + expect(suggestedFocus.hasFocus, isTrue); + expect(_focusPainter(tester, suggested).color.a, closeTo(0.50, 0.001)); + expect(_focusPainter(tester, suggested).outlineOffset, 1); + + await tester.sendKeyDownEvent(LogicalKeyboardKey.shiftLeft); + await tester.sendKeyEvent(LogicalKeyboardKey.tab); + await tester.sendKeyUpEvent(LogicalKeyboardKey.shiftLeft); + await tester.pump(); + expect(standardFocus.hasFocus, isTrue); + expect(_focusPainter(tester, standard).color.a, closeTo(0.50, 0.001)); + expect(_focusPainter(tester, standard).outlineOffset, -2); + + await mouse.moveTo(tester.getCenter(suggested)); + await mouse.down(tester.getCenter(suggested)); + await mouse.up(); + await tester.pump(); + expect(activations, 2); + expect(suggestedFocus.hasFocus, isTrue); + expect(_focusPainter(tester, suggested).color, Colors.transparent); + + await tester.sendKeyEvent(LogicalKeyboardKey.enter); + await tester.pump(); + expect(activations, 3); + expect(_focusPainter(tester, suggested).color.a, closeTo(0.50, 0.001)); + await tester.sendKeyEvent(LogicalKeyboardKey.space); + await tester.pump(); + expect(activations, 4); + }); + + testWidgets('disabled action exposes no activation semantics', ( + tester, + ) async { + final semantics = tester.ensureSemantics(); + await tester.pumpWidget( + MaterialApp( + theme: _theme(brightness: Brightness.light), + home: const Center( + child: FilledButton(onPressed: null, child: Text('Disabled')), + ), + ), + ); + + final data = tester.getSemantics(find.byType(FilledButton)); + expect(data.flagsCollection.isButton, isTrue); + expect(data.flagsCollection.isEnabled, Tristate.isFalse); + expect(data.getSemanticsData().hasAction(SemanticsAction.tap), isFalse); + semantics.dispose(); + }); + + testWidgets('editor header loading stays stable, labelled, and disabled', ( + tester, + ) async { + final semantics = tester.ensureSemantics(); + Future< + ({ + Size cancel, + Size save, + TextStyle cancelText, + TextStyle saveText, + String semanticsLabel, + Tristate semanticsEnabled, + bool hasTapAction, + }) + > + measure({required bool saving}) async { + await tester.pumpWidget( + MaterialApp( + theme: _theme(brightness: Brightness.dark), + home: Column( + children: [ + BusyMaxEditorHeader( + title: 'Editor', + cancelLabel: 'Cancel', + saveLabel: 'Save changes', + onCancel: () {}, + onSave: saving ? null : () {}, + saving: saving, + ), + BusyMaxPushButton.standard( + key: const ValueKey('ordinary-cancel'), + onPressed: () {}, + child: const Text('Cancel'), + ), + ], + ), + ), + ); + await tester.pump(); + final cancel = find.widgetWithText(FilledButton, 'Cancel').first; + final save = find.byType(ElevatedButton).first; + final cancelText = tester.widget( + find.descendant(of: cancel, matching: find.byType(RichText)).first, + ); + final saveText = tester.widget( + find.descendant(of: save, matching: find.byType(RichText)).first, + ); + final saveSemantics = tester.getSemantics(save).getSemanticsData(); + return ( + cancel: tester.getSize(cancel), + save: tester.getSize(save), + cancelText: cancelText.text.style!, + saveText: saveText.text.style!, + semanticsLabel: saveSemantics.label, + semanticsEnabled: saveSemantics.flagsCollection.isEnabled, + hasTapAction: saveSemantics.hasAction(SemanticsAction.tap), + ); + } + + final resting = await measure(saving: false); + final loading = await measure(saving: true); + expect(resting.cancel.height, 34); + expect(resting.save.height, 34); + expect(resting.cancelText.fontWeight, FontWeight.bold); + expect(resting.saveText.fontWeight, FontWeight.bold); + expect(resting.cancelText.fontSize, resting.saveText.fontSize); + expect(loading.save, resting.save); + expect(find.text('Save changes'), findsOneWidget); + expect(resting.semanticsLabel, 'Save changes'); + expect(resting.semanticsEnabled, Tristate.isTrue); + expect(resting.hasTapAction, isTrue); + expect(loading.semanticsLabel, 'Save changes'); + expect(loading.semanticsEnabled, Tristate.isFalse); + expect(loading.hasTapAction, isFalse); + final hiddenLabel = tester.widget( + find.ancestor( + of: find.text('Save changes'), + matching: find.byType(Opacity), + ), + ); + expect(hiddenLabel.opacity, 0); + expect(hiddenLabel.alwaysIncludeSemantics, isTrue); + semantics.dispose(); + }); + + testWidgets('mounted button follows live accent and GTK font changes', ( + tester, + ) async { + ThemeData liveTheme(Color accent, String font) => buildBusyMaxTheme( + brightness: Brightness.dark, + accentColor: accent, + gtkFontFamily: font, + gtkFontSize: 11, + ); + + final theme = ValueNotifier( + liveTheme(const Color(0xFF7C4DFF), 'First GTK Sans'), + ); + addTearDown(theme.dispose); + await tester.pumpWidget( + ValueListenableBuilder( + valueListenable: theme, + builder: (context, value, child) => MaterialApp( + theme: value, + home: Center( + child: BusyMaxPushButton.suggested( + onPressed: () {}, + child: const Text('Live action'), + ), + ), + ), + ), + ); + + final button = find.byType(ElevatedButton); + expect(_buttonMaterial(tester, button).color, const Color(0xFF7C4DFF)); + expect( + _buttonText(tester, button).text.style?.fontFamily, + 'First GTK Sans', + ); + + theme.value = liveTheme(const Color(0xFF2E7D32), 'Second GTK Sans'); + await tester.pumpAndSettle(); + expect(_buttonMaterial(tester, button).color, const Color(0xFF2E7D32)); + expect( + _buttonText(tester, button).text.style?.fontFamily, + 'Second GTK Sans', + ); + }); + + testWidgets('long RTL labels grow under text scaling without overflow', ( + tester, + ) async { + tester.view.physicalSize = const Size(360, 480); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + await tester.pumpWidget( + MaterialApp( + theme: _theme(brightness: Brightness.light), + builder: (context, child) => MediaQuery( + data: MediaQuery.of( + context, + ).copyWith(textScaler: const TextScaler.linear(2)), + child: Directionality( + textDirection: TextDirection.rtl, + child: child!, + ), + ), + home: BusyMaxEditorHeader( + title: 'Editor', + cancelLabel: 'Cancel all pending changes', + saveLabel: 'Save every pending change', + onCancel: () {}, + onSave: () {}, + ), + ), + ); + await tester.pump(); + + expect(tester.takeException(), isNull); + expect(tester.getSize(find.byType(FilledButton)).height, greaterThan(34)); + expect(tester.getSize(find.byType(ElevatedButton)).height, greaterThan(34)); + }); + + test('Linux action call sites do not bypass the shared push-button role', () { + final rawFeatureButtons = >{}; + final constructor = RegExp( + r'\b(TextButton|FilledButton|ElevatedButton|OutlinedButton)\s*\(', + ); + for (final entity in Directory( + 'lib/src/features', + ).listSync(recursive: true)) { + if (entity is! File || !entity.path.endsWith('.dart')) { + continue; + } + final path = entity.path.replaceAll('\\', '/'); + final matches = constructor + .allMatches(entity.readAsStringSync()) + .map((match) => match.group(1)!) + .toList(); + if (matches.isNotEmpty) { + rawFeatureButtons[path] = matches; + } + } + + // These are intentionally specialized calendar cells/navigation labels, + // a month-overflow command, and a time-period selector. Any new raw + // feature-level constructor must be reviewed against the action roles. + expect(rawFeatureButtons, { + 'lib/src/features/schedule/presentation/mini_calendar.dart': [ + 'TextButton', + 'TextButton', + ], + 'lib/src/features/schedule/presentation/schedule_month_view.dart': [ + 'TextButton', + ], + 'lib/src/features/tasks/presentation/desktop_date_time_fields.dart': [ + 'TextButton', + ], + }); + + // These two formerly raw commands are ordinary actions. + final eventEditor = File( + 'lib/src/features/calendar/presentation/event_editor.dart', + ).readAsStringSync(); + final settings = File( + 'lib/src/features/settings/presentation/settings_screen.dart', + ).readAsStringSync(); + expect( + eventEditor, + contains( + 'BusyMaxPushButton.standard(\n onPressed: () => showLinuxNextcloudSchedulingDialog', + ), + ); + expect( + settings, + contains( + 'BusyMaxPushButton.standard(\n onPressed: () =>', + ), + ); + }); +} + +ThemeData _theme({required Brightness brightness, bool highContrast = false}) { + return buildBusyMaxTheme( + brightness: brightness, + accentColor: _accent, + gtkFontFamily: 'Ubuntu Sans', + gtkFontSize: 11, + highContrast: highContrast, + ); +} + +BusyMaxPushButtonFocusPainter _focusPainter( + WidgetTester tester, + Finder button, +) { + return tester + .widgetList( + find.descendant(of: button, matching: find.byType(CustomPaint)), + ) + .map((widget) => widget.foregroundPainter) + .whereType() + .single; +} + +Material _buttonMaterial(WidgetTester tester, Finder button) { + return tester + .widgetList( + find.descendant(of: button, matching: find.byType(Material)), + ) + .firstWhere((material) => material.type == MaterialType.button); +} + +RichText _buttonText(WidgetTester tester, Finder button) { + return tester.widget( + find.descendant(of: button, matching: find.byType(RichText)).first, + ); +} diff --git a/test/app/busymax_button_visual_test.dart b/test/app/busymax_button_visual_test.dart new file mode 100644 index 00000000..aa39110f --- /dev/null +++ b/test/app/busymax_button_visual_test.dart @@ -0,0 +1,25 @@ +import 'dart:io'; +import 'dart:ui'; + +import 'package:flutter_test/flutter_test.dart'; + +import '../../tool/linux_button_comparison.dart'; + +void main() { + testWidgets('production Linux buttons match the reviewed fixture', ( + tester, + ) async { + tester.view.physicalSize = const Size(1100, 1000); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + await tester.pumpWidget(const LinuxButtonComparisonFixture()); + await tester.pumpAndSettle(); + + await expectLater( + find.byType(LinuxButtonComparisonFixture), + matchesGoldenFile('goldens/linux_buttons_reviewed.png'), + ); + }, skip: !Platform.isLinux); +} diff --git a/test/app/busymax_dialogs_test.dart b/test/app/busymax_dialogs_test.dart index a1884f4f..bee43c1e 100644 --- a/test/app/busymax_dialogs_test.dart +++ b/test/app/busymax_dialogs_test.dart @@ -4,10 +4,7 @@ import 'package:busymax/src/app/busymax_design.dart'; import 'package:busymax/src/app/busymax_dialogs.dart'; import 'package:busymax/src/app/busymax_shortcuts.dart'; import 'package:busymax/src/app/busymax_yaru_theme.dart'; -import 'package:busymax/src/platform/linux_header_bar_provider.dart'; -import 'package:busymax/src/platform/linux_header_bar_service.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 'package:yaru/yaru.dart'; @@ -105,64 +102,50 @@ void main() { }, ); - testWidgets('modal coordinator synchronizes the native barrier', ( + testWidgets('modal route blocks the complete application surface', ( tester, ) async { - const channel = MethodChannel('busymax_test/modal_barrier'); - final calls = []; - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(channel, (call) async { - calls.add(call); - return call.method == 'initialize' ? true : null; - }); - addTearDown(() { - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(channel, null); - }); - - final service = LinuxHeaderBarService(channel: channel, isLinux: true); - addTearDown(service.dispose); - await service.initialize(); - + var backgroundActivations = 0; late BuildContext hostContext; await tester.pumpWidget( localizedTestApp( - child: Builder( - builder: (context) { - hostContext = context; - return const SizedBox(); - }, + child: Scaffold( + body: Builder( + builder: (context) { + hostContext = context; + return TextButton( + onPressed: () => backgroundActivations += 1, + child: const Text('Background action'), + ); + }, + ), ), ), ); - final result = showBusyMaxConfirm( + final result = showBusyMaxModalDialog( hostContext, - title: 'Remove item?', - message: 'This action cannot be undone.', - confirmLabel: 'Remove', - destructive: true, - headerBarService: service, + barrierDismissible: false, + builder: (_) => const BusyMaxConfirmDialog( + title: 'Remove item?', + message: 'This action cannot be undone.', + confirmLabel: 'Remove', + destructive: true, + ), ); await tester.pumpAndSettle(); expect(find.byType(BusyMaxConfirmDialog), findsOneWidget); - expect( - calls.where((call) => call.method == 'setModalBarrierState'), - hasLength(1), - ); - expect(calls.last.arguments, {'visible': true, 'shadeDepth': 1}); + expect(find.byType(AnimatedModalBarrier), findsOneWidget); + await tester.tap(find.text('Background action'), warnIfMissed: false); + await tester.pump(); + expect(backgroundActivations, 0); await tester.tap(find.text('Remove')); await tester.pumpAndSettle(); expect(await result, isTrue); - final barrierCalls = calls - .where((call) => call.method == 'setModalBarrierState') - .toList(); - expect(barrierCalls, hasLength(2)); - expect(barrierCalls.first.arguments, {'visible': true, 'shadeDepth': 1}); - expect(barrierCalls.last.arguments, {'visible': false, 'shadeDepth': 0}); + expect(find.byType(AnimatedModalBarrier), findsNothing); }); testWidgets('open modal barrier follows live theme changes', (tester) async { @@ -290,25 +273,9 @@ void main() { expect(find.text('Discard changes'), findsOneWidget); }); - testWidgets('nested modals do not re-dim the native headerbar', ( + testWidgets('nested modals retain independent local barriers', ( tester, ) async { - const channel = MethodChannel('busymax_test/nested_modal_barrier'); - final calls = []; - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(channel, (call) async { - calls.add(call); - return call.method == 'initialize' ? true : null; - }); - addTearDown(() { - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(channel, null); - }); - - final service = LinuxHeaderBarService(channel: channel, isLinux: true); - addTearDown(service.dispose); - await service.initialize(); - late BuildContext hostContext; await tester.pumpWidget( localizedTestApp( @@ -323,209 +290,69 @@ void main() { final first = showBusyMaxModalDialog( hostContext, - headerBarService: service, builder: (context) => const Dialog(child: Text('First dialog')), ); await tester.pumpAndSettle(); final second = showBusyMaxModalDialog( hostContext, - headerBarService: service, barrierColor: Colors.transparent, builder: (context) => const Dialog(child: Text('Second dialog')), ); await tester.pumpAndSettle(); - expect( - calls - .where((call) => call.method == 'setModalBarrierState') - .map((call) => call.arguments), - [ - {'visible': true, 'shadeDepth': 1}, - ], - ); + expect(find.byType(AnimatedModalBarrier), findsOneWidget); + expect(find.text('First dialog'), findsOneWidget); + expect(find.text('Second dialog'), findsOneWidget); Navigator.of(hostContext, rootNavigator: true).pop(); await tester.pumpAndSettle(); await second; - expect( - calls - .where((call) => call.method == 'setModalBarrierState') - .map((call) => call.arguments), - [ - {'visible': true, 'shadeDepth': 1}, - ], - ); + expect(find.byType(AnimatedModalBarrier), findsOneWidget); + expect(find.text('First dialog'), findsOneWidget); Navigator.of(hostContext, rootNavigator: true).pop(); await tester.pumpAndSettle(); await first; - - final barrierCalls = calls - .where((call) => call.method == 'setModalBarrierState') - .toList(); - expect(barrierCalls.map((call) => call.arguments), [ - {'visible': true, 'shadeDepth': 1}, - {'visible': false, 'shadeDepth': 0}, - ]); - - await acquireBusyMaxModalBarrier(service); - await acquireBusyMaxModalBarrier(service); - await releaseBusyMaxModalBarrier(service); - await releaseBusyMaxModalBarrier(service); - - expect( - calls - .where((call) => call.method == 'setModalBarrierState') - .map((call) => call.arguments) - .skip(2), - [ - {'visible': true, 'shadeDepth': 1}, - {'visible': true, 'shadeDepth': 2}, - {'visible': true, 'shadeDepth': 1}, - {'visible': false, 'shadeDepth': 0}, - ], - reason: 'two visibly shaded surfaces must still compound normally', - ); - }); - - testWidgets('serializes rapid manual native barrier transitions', ( - tester, - ) async { - const channel = MethodChannel('busymax_test/serialized_modal_barrier'); - final firstUpdate = Completer(); - final transitions = <({bool visible, int shadeDepth})>[]; - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(channel, (call) async { - if (call.method == 'initialize') { - return true; - } - if (call.method == 'setModalBarrierState') { - final arguments = call.arguments! as Map; - transitions.add(( - visible: arguments['visible']! as bool, - shadeDepth: arguments['shadeDepth']! as int, - )); - if (transitions.length == 1) { - await firstUpdate.future; - } - } - return null; - }); - addTearDown(() { - if (!firstUpdate.isCompleted) { - firstUpdate.complete(); - } - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(channel, null); - }); - - final service = LinuxHeaderBarService(channel: channel, isLinux: true); - addTearDown(service.dispose); - await service.initialize(); - - final acquire = acquireBusyMaxModalBarrier(service); - await tester.pump(); - expect(transitions, [(visible: true, shadeDepth: 1)]); - - final release = releaseBusyMaxModalBarrier(service); - await tester.pump(); - expect(transitions, [ - (visible: true, shadeDepth: 1), - ], reason: 'the native hide must wait for the in-flight native show'); - - firstUpdate.complete(); - await Future.wait([acquire, release]); - - expect(transitions, [ - (visible: true, shadeDepth: 1), - (visible: false, shadeDepth: 0), - ]); + expect(find.byType(AnimatedModalBarrier), findsNothing); }); - testWidgets('failed native barrier acquisition rolls back and can retry', ( + testWidgets('modal future completes only after route removal', ( tester, ) async { - final service = _FailingModalBarrierService(); - addTearDown(service.dispose); - - await expectLater( - acquireBusyMaxModalBarrier(service), - throwsA(isA()), - ); - expect(service.transitions, [ - (visible: true, shadeDepth: 1), - (visible: false, shadeDepth: 0), - ], reason: 'a failed native show requires a best-effort native rollback'); - - await acquireBusyMaxModalBarrier(service); - await releaseBusyMaxModalBarrier(service); - - expect(service.transitions, [ - (visible: true, shadeDepth: 1), - (visible: false, shadeDepth: 0), - (visible: true, shadeDepth: 1), - (visible: false, shadeDepth: 0), - ]); - }); - - testWidgets('modal coordinator resolves the service from ProviderScope', ( - tester, - ) async { - const channel = MethodChannel('busymax_test/automatic_modal_barrier'); - final calls = []; - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(channel, (call) async { - calls.add(call); - return call.method == 'initialize' ? true : null; - }); - addTearDown(() { - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(channel, null); - }); - - final service = LinuxHeaderBarService(channel: channel, isLinux: true); - addTearDown(service.dispose); - await service.initialize(); late BuildContext hostContext; await tester.pumpWidget( - ProviderScope( - overrides: [linuxHeaderBarServiceProvider.overrideWithValue(service)], - child: localizedTestApp( - child: Builder( - builder: (context) { - hostContext = context; - return const SizedBox(); - }, - ), + localizedTestApp( + child: Builder( + builder: (context) { + hostContext = context; + return const SizedBox(); + }, ), ), ); - final result = showBusyMaxConfirm( + var completed = false; + final result = showBusyMaxModalDialog( hostContext, - title: 'Remove item?', - message: 'This action cannot be undone.', - confirmLabel: 'Remove', + builder: (dialogContext) => Dialog( + child: TextButton( + onPressed: () => Navigator.of(dialogContext).pop(), + child: const Text('Close route'), + ), + ), ); + unawaited(result.then((_) => completed = true)); await tester.pumpAndSettle(); - expect(calls.first.method, 'initialize'); - expect( - calls - .where((call) => call.method == 'setModalBarrierState') - .single - .arguments, - {'visible': true, 'shadeDepth': 1}, - ); - - await tester.tap(find.text('Cancel')); + await tester.tap(find.text('Close route')); + await tester.pump(); + expect(completed, isFalse); + expect(find.byType(AnimatedModalBarrier), findsOneWidget); await tester.pumpAndSettle(); - expect(await result, isFalse); - final barrierCalls = calls - .where((call) => call.method == 'setModalBarrierState') - .toList(); - expect(barrierCalls.last.arguments, {'visible': false, 'shadeDepth': 0}); + await result; + expect(completed, isTrue); + expect(find.byType(AnimatedModalBarrier), findsNothing); }); testWidgets('editor dialog requires an explicit cancel action', ( @@ -686,6 +513,7 @@ void main() { localizedTestApp( child: Shortcuts( shortcuts: const { + BusyMaxShortcutActivators.back: _ApplicationNavigationIntent(), BusyMaxShortcutActivators.settings: _ApplicationNavigationIntent(), BusyMaxShortcutActivators.keyboardShortcuts: _ApplicationNavigationIntent(), @@ -716,30 +544,61 @@ void main() { await tester.sendKeyUpEvent(LogicalKeyboardKey.altLeft); await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft); } + await tester.sendKeyDownEvent(LogicalKeyboardKey.altLeft); + await tester.sendKeyEvent(LogicalKeyboardKey.arrowLeft); + await tester.sendKeyUpEvent(LogicalKeyboardKey.altLeft); expect(applicationNavigationCount, 0); }); + + testWidgets('closing the final modal restores the previous focus', ( + tester, + ) async { + final hostFocus = FocusNode(); + final dialogFocus = FocusNode(); + addTearDown(hostFocus.dispose); + addTearDown(dialogFocus.dispose); + late BuildContext hostContext; + await tester.pumpWidget( + localizedTestApp( + child: Builder( + builder: (context) { + hostContext = context; + return Scaffold( + body: TextField(focusNode: hostFocus, autofocus: true), + ); + }, + ), + ), + ); + await tester.pump(); + expect(hostFocus.hasFocus, isTrue); + + final result = showBusyMaxModalDialog( + hostContext, + builder: (dialogContext) => BusyMaxDialogShell( + title: 'Keep editing?', + actions: [ + BusyMaxPushButton.standard( + onPressed: () => Navigator.of(dialogContext).pop(), + child: const Text('Close'), + ), + ], + children: [TextField(focusNode: dialogFocus, autofocus: true)], + ), + ); + await tester.pumpAndSettle(); + expect(hostFocus.hasFocus, isFalse); + expect(dialogFocus.hasFocus, isTrue); + + await tester.tap(find.text('Close')); + await tester.pumpAndSettle(); + + await result; + expect(hostFocus.hasFocus, isTrue); + }); } class _ApplicationNavigationIntent extends Intent { const _ApplicationNavigationIntent(); } - -class _FailingModalBarrierService extends LinuxHeaderBarService { - _FailingModalBarrierService() : super(isLinux: false); - - final transitions = <({bool visible, int shadeDepth})>[]; - var _failNextShow = true; - - @override - Future setModalBarrierState({ - required bool visible, - required int shadeDepth, - }) async { - transitions.add((visible: visible, shadeDepth: shadeDepth)); - if (visible && _failNextShow) { - _failNextShow = false; - throw StateError('simulated native response failure'); - } - } -} diff --git a/test/app/busymax_grouped_surface_test.dart b/test/app/busymax_grouped_surface_test.dart index 327bb7f3..483d7976 100644 --- a/test/app/busymax_grouped_surface_test.dart +++ b/test/app/busymax_grouped_surface_test.dart @@ -886,20 +886,34 @@ void main() { final sidebar = find.byKey(const ValueKey('startup-sidebar')); final content = find.byKey(const ValueKey('startup-content')); + final sidebarViewport = find.byKey( + const ValueKey('linux-sidebar-viewport'), + ); final sidebarRect = tester.getRect(sidebar); final contentRect = tester.getRect(content); expect(sidebarRect.width, BusyMaxSizes.sidebarWidth); expect(sidebarRect.left, 0); expect(sidebarRect.right, contentRect.left); - expect(sidebarRect.height, 720); + expect(sidebarRect.top, BusyMaxSizes.toolbarHeight); + expect(sidebarRect.height, 720 - BusyMaxSizes.toolbarHeight); + expect(tester.getRect(sidebarViewport).height, 720); expect(find.byType(BusyMaxSidebarSurface), findsOneWidget); expect(find.byType(YaruCircularProgressIndicator), findsOneWidget); tester.view.physicalSize = const Size(600, 720); await tester.pump(); - expect(sidebar, findsNothing); - expect(tester.getRect(content), const Rect.fromLTWH(0, 0, 600, 720)); + expect(sidebar, findsOneWidget); + expect(tester.getRect(sidebarViewport).width, 0); + expect( + tester.getRect(content), + const Rect.fromLTWH( + 0, + BusyMaxSizes.toolbarHeight, + 600, + 720 - BusyMaxSizes.toolbarHeight, + ), + ); expect(tester.takeException(), isNull); }); @@ -2038,6 +2052,7 @@ void main() { expect(tester.getSize(save).width, lessThan(slotWidth)); expect(tester.getSize(cancel).height, kYaruButtonHeight); expect(tester.getSize(save).height, kYaruButtonHeight); + final restingSaveWidth = tester.getSize(save).width; final headerBottom = tester .getBottomRight(find.byType(BusyMaxEditorHeader)) .dy; @@ -2045,8 +2060,17 @@ void main() { expect(headerBottom - actionBottom, BusyMaxSpacing.headerInset); final cancelButton = tester.widget(cancel); final saveButton = tester.widget(save); - final actionTextStyle = Theme.of(tester.element(save)).textTheme.titleSmall; - expect(saveButton.style?.textStyle?.resolve(const {}), actionTextStyle); + final theme = Theme.of(tester.element(save)); + expect(cancelButton.style?.textStyle, isNull); + expect(saveButton.style?.textStyle, isNull); + expect( + theme.filledButtonTheme.style?.textStyle?.resolve(const {})?.fontWeight, + FontWeight.bold, + ); + expect( + theme.elevatedButtonTheme.style?.textStyle?.resolve(const {})?.fontWeight, + FontWeight.bold, + ); for (final style in [cancelButton.style, saveButton.style]) { expect(style?.minimumSize, isNull); expect(style?.fixedSize, isNull); @@ -2059,10 +2083,10 @@ void main() { await tester.pumpWidget(_linuxTestApp(header(saving: true))); await tester.pump(); - expect(tester.getSize(save).width, lessThan(slotWidth)); + expect(tester.getSize(save).width, restingSaveWidth); expect(tester.getSize(save).height, kYaruButtonHeight); expect(find.byType(CircularProgressIndicator), findsOneWidget); - expect(find.text('Save'), findsNothing); + expect(find.text('Save'), findsOneWidget); }); testWidgets('custom dialogs announce their title as route semantics', ( @@ -2152,6 +2176,40 @@ void main() { expect(tester.getTopLeft(find.text('Edit event')).dy, titleTop); }); + testWidgets('modal editor body keeps page padding below its content', ( + tester, + ) async { + await tester.pumpWidget( + _testApp( + BusyMaxModalEditorScaffold( + title: 'Recurring event scope', + cancelLabel: 'Cancel', + saveLabel: 'Save', + onCancel: () {}, + onSave: null, + children: const [Text('This event')], + ), + ), + ); + + final clamp = tester.widget( + find.descendant( + of: find.byType(BusyMaxModalEditorScaffold), + matching: find.byType(BusyMaxClamp), + ), + ); + + expect( + clamp.padding, + const EdgeInsets.fromLTRB( + BusyMaxSpacing.lg, + BusyMaxSpacing.headerInset, + BusyMaxSpacing.lg, + BusyMaxSpacing.lg, + ), + ); + }); + testWidgets('dialog actions wrap at narrow localized text widths', ( tester, ) async { diff --git a/test/app/busymax_menu_button_test.dart b/test/app/busymax_menu_button_test.dart index d6deb14e..95d77be8 100644 --- a/test/app/busymax_menu_button_test.dart +++ b/test/app/busymax_menu_button_test.dart @@ -258,6 +258,7 @@ void main() { BusyMaxMenuEntry( value: 'open', label: 'Open in provider', + nativeIconName: 'explicit-provider-symbolic', role: BusyMaxMenuEntryRole.radio, ), ], @@ -297,6 +298,7 @@ void main() { }, { 'label': 'Open in provider', + 'icon': 'explicit-provider-symbolic', 'enabled': true, 'role': 'radio', 'selected': false, diff --git a/test/app/busymax_native_search_entry_theme_test.dart b/test/app/busymax_native_search_entry_theme_test.dart new file mode 100644 index 00000000..3e9fd7c3 --- /dev/null +++ b/test/app/busymax_native_search_entry_theme_test.dart @@ -0,0 +1,267 @@ +import 'package:busymax/src/app/busymax_yaru_theme.dart'; +import 'package:busymax/src/platform/gtk_font_service.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test('native GTK Search style overrides the BusyMax fallback', () { + final theme = BusyMaxYaruTheme.build( + brightness: Brightness.light, + accentColor: const Color(0xFFAA4400), + gtkThemeColors: const GtkThemeColors( + brightness: Brightness.light, + searchEntry: _nativeSearchTheme, + ), + ); + + final search = theme.extension(); + expect(search, isNotNull); + expect(search?.normal.radius, 13); + expect(search?.normal.borderTop, 1); + expect(search?.normal.borderRight, 2); + expect(search?.normal.borderBottom, 3); + expect(search?.normal.borderLeft, 4); + expect(search?.focused.background, const Color(0xFF222222)); + expect(search?.focused.borderTop, 1); + expect(search?.focused.hasInnerFocus, isTrue); + expect(search?.focused.innerFocusWidth, 1); + expect(search?.focused.innerFocusColor, const Color(0xFF336699)); + expect(search?.backdrop.foreground, const Color(0xFF777777)); + expect(search?.normal.hasInnerFocus, isFalse); + expect(search?.backdrop.hasInnerFocus, isFalse); + expect(search?.backdropFocused.hasInnerFocus, isFalse); + expect(search?.backdropFocused.borderColor, const Color(0xFF888888)); + }); + + test('sampled GTK geometry remains authoritative in high contrast', () { + final theme = BusyMaxYaruTheme.build( + brightness: Brightness.light, + accentColor: const Color(0xFFAA4400), + highContrast: true, + gtkThemeColors: const GtkThemeColors( + brightness: Brightness.light, + searchEntry: _nativeSearchTheme, + ), + ); + + expect(theme.extension()?.normal.radius, 13); + }); + + test('missing or wrong-brightness GTK Search style uses fallback', () { + final normal = BusyMaxYaruTheme.build( + brightness: Brightness.light, + accentColor: const Color(0xFFAA4400), + ); + final highContrast = BusyMaxYaruTheme.build( + brightness: Brightness.light, + accentColor: const Color(0xFFAA4400), + highContrast: true, + ); + final wrongBrightness = BusyMaxYaruTheme.build( + brightness: Brightness.light, + accentColor: const Color(0xFFAA4400), + gtkThemeColors: const GtkThemeColors( + brightness: Brightness.dark, + searchEntry: _nativeSearchTheme, + ), + ); + + expect(normal.extension()?.normal.radius, 9); + expect( + highContrast.extension()?.normal.radius, + 6, + ); + expect( + wrongBrightness.extension()?.normal.radius, + 9, + ); + final fallback = normal.extension()!.normal; + expect(fallback.primaryIconForeground, fallback.secondaryIconForeground); + expect( + fallback.primaryIconForegroundRtl, + fallback.secondaryIconForegroundRtl, + ); + final focusedFallback = normal + .extension()! + .focused; + expect(focusedFallback.borderTop, 1); + expect(focusedFallback.hasInnerFocus, isTrue); + expect(focusedFallback.innerFocusColor, const Color(0xFFAA4400)); + expect(focusedFallback.innerFocusWidth, 1); + final backdropFallback = normal + .extension()! + .backdrop; + expect(backdropFallback.hasInnerFocus, isFalse); + expect( + highContrast + .extension()! + .focused + .hasInnerFocus, + isFalse, + ); + expect( + highContrast + .extension()! + .focused + .borderTop, + 2, + ); + }); + + test('lerp interpolates all primary and secondary icon colors', () { + const first = BusyMaxNativeSearchEntryTheme( + normal: _firstLerpState, + focused: _firstLerpState, + backdrop: _firstLerpState, + backdropFocused: _firstLerpState, + ); + const second = BusyMaxNativeSearchEntryTheme( + normal: _secondLerpState, + focused: _secondLerpState, + backdrop: _secondLerpState, + backdropFocused: _secondLerpState, + ); + + final state = first.lerp(second, .5).normal; + expect( + state.primaryIconForeground, + Color.lerp( + _firstLerpState.primaryIconForeground, + _secondLerpState.primaryIconForeground, + .5, + ), + ); + expect( + state.primaryIconForegroundRtl, + Color.lerp( + _firstLerpState.primaryIconForegroundRtl, + _secondLerpState.primaryIconForegroundRtl, + .5, + ), + ); + expect( + state.secondaryIconForeground, + Color.lerp( + _firstLerpState.secondaryIconForeground, + _secondLerpState.secondaryIconForeground, + .5, + ), + ); + expect( + state.secondaryIconForegroundRtl, + Color.lerp( + _firstLerpState.secondaryIconForegroundRtl, + _secondLerpState.secondaryIconForegroundRtl, + .5, + ), + ); + expect(state.hasInnerFocus, isTrue); + expect( + state.innerFocusColor, + Color.lerp( + _firstLerpState.innerFocusColor, + _secondLerpState.innerFocusColor, + .5, + ), + ); + expect(state.innerFocusWidth, .5); + }); +} + +const _firstLerpState = GtkSearchEntryStateStyle( + background: Color(0xFF000000), + foreground: Color(0xFF000000), + borderTop: 1, + borderRight: 1, + borderBottom: 1, + borderLeft: 1, + borderColor: Color(0xFF000000), + primaryIconForeground: Color(0xFF002000), + primaryIconForegroundRtl: Color(0xFF200000), + secondaryIconForeground: Color(0xFF000020), + secondaryIconForegroundRtl: Color(0xFF200020), + radius: 1, + hasInnerFocus: false, + innerFocusColor: Color(0x00000000), + innerFocusWidth: 0, +); + +const _secondLerpState = GtkSearchEntryStateStyle( + background: Color(0xFFFFFFFF), + foreground: Color(0xFFFFFFFF), + borderTop: 2, + borderRight: 2, + borderBottom: 2, + borderLeft: 2, + borderColor: Color(0xFFFFFFFF), + primaryIconForeground: Color(0xFFFF0000), + primaryIconForegroundRtl: Color(0xFF00FF00), + secondaryIconForeground: Color(0xFF0000E0), + secondaryIconForegroundRtl: Color(0xFFE000E0), + radius: 2, + hasInnerFocus: true, + innerFocusColor: Color(0xFFFFFFFF), + innerFocusWidth: 1, +); + +const _nativeSearchTheme = GtkSearchEntryTheme( + normal: GtkSearchEntryStateStyle( + background: Color(0xFF111111), + foreground: Color(0xFFEEEEEE), + borderTop: 1, + borderRight: 2, + borderBottom: 3, + borderLeft: 4, + borderColor: Color(0xFF555555), + primaryIconForeground: Color(0xFF666666), + primaryIconForegroundRtl: Color(0xFF676767), + secondaryIconForeground: Color(0xFF686868), + secondaryIconForegroundRtl: Color(0xFF696969), + radius: 13, + ), + focused: GtkSearchEntryStateStyle( + background: Color(0xFF222222), + foreground: Color(0xFFDDDDDD), + borderTop: 1, + borderRight: 1, + borderBottom: 1, + borderLeft: 1, + borderColor: Color(0xFF336699), + primaryIconForeground: Color(0xFFBBBBBB), + primaryIconForegroundRtl: Color(0xFFBCBCBC), + secondaryIconForeground: Color(0xFFBDBDBD), + secondaryIconForegroundRtl: Color(0xFFBEBEBE), + radius: 12, + hasInnerFocus: true, + innerFocusColor: Color(0xFF336699), + innerFocusWidth: 1, + ), + backdrop: GtkSearchEntryStateStyle( + background: Color(0xFF333333), + foreground: Color(0xFF777777), + borderTop: 1, + borderRight: 1, + borderBottom: 1, + borderLeft: 1, + borderColor: Color(0xFF444444), + primaryIconForeground: Color(0xFF555555), + primaryIconForegroundRtl: Color(0xFF565656), + secondaryIconForeground: Color(0xFF575757), + secondaryIconForegroundRtl: Color(0xFF585858), + radius: 11, + ), + backdropFocused: GtkSearchEntryStateStyle( + background: Color(0xFF444444), + foreground: Color(0xFF666666), + borderTop: 1, + borderRight: 1, + borderBottom: 1, + borderLeft: 1, + borderColor: Color(0xFF888888), + primaryIconForeground: Color(0xFF999999), + primaryIconForegroundRtl: Color(0xFF9A9A9A), + secondaryIconForeground: Color(0xFF9B9B9B), + secondaryIconForegroundRtl: Color(0xFF9C9C9C), + radius: 10, + ), +); diff --git a/test/app/busymax_search_field_test.dart b/test/app/busymax_search_field_test.dart index c57f3000..0ca80b60 100644 --- a/test/app/busymax_search_field_test.dart +++ b/test/app/busymax_search_field_test.dart @@ -79,16 +79,29 @@ void main() { expect(clearCount, 1); }); - test('search consumers use only the shared Yaru adapter', () { + test('generic search keeps Yaru while the Linux header owns its shell', () { final design = File('lib/src/app/busymax_design.dart').readAsStringSync(); final schedule = File( 'lib/src/features/schedule/presentation/schedule_workspace.dart', ).readAsStringSync(); + final toolbar = File( + 'lib/src/features/schedule/presentation/schedule_toolbar.dart', + ).readAsStringSync(); + final linuxHeader = File( + 'lib/src/app/linux/linux_header_style.dart', + ).readAsStringSync(); expect(design, contains('class BusyMaxSearchField')); expect(RegExp(r'YaruSearchField\(').allMatches(design), hasLength(1)); - expect(schedule, contains('BusyMaxSearchField(')); + expect( + linuxHeader, + contains('class BusyMaxLinuxHeaderSearchField extends StatefulWidget'), + ); + expect(linuxHeader, isNot(contains('BusyMaxSearchField('))); + expect(toolbar, contains('BusyMaxLinuxHeaderSearchField(')); expect(schedule, isNot(contains('class _ScheduleSearchField'))); expect(schedule, isNot(contains('YaruSearchField('))); + expect(toolbar, isNot(contains('YaruSearchField('))); + expect(linuxHeader, isNot(contains('YaruSearchField('))); }); } diff --git a/test/app/busymax_selection_controls_test.dart b/test/app/busymax_selection_controls_test.dart new file mode 100644 index 00000000..f901efe7 --- /dev/null +++ b/test/app/busymax_selection_controls_test.dart @@ -0,0 +1,1151 @@ +import 'dart:ui' as ui; + +import 'package:busymax/src/app/busymax_design.dart'; +import 'package:busymax/src/app/busymax_yaru_theme.dart'; +import 'package:busymax/src/features/schedule/presentation/schedule_task_chip.dart'; +import 'package:busymax/src/providers/busy_provider.dart'; +import 'package:busymax/src/schedule/schedule_item.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:yaru/yaru.dart'; + +import '../test_localized_app.dart'; + +const _accent = Color(0xFF6D4AFF); +const _alternateAccent = Color(0xFFE95420); +const _captureBackground = Color(0xFF26313C); +const _controlKey = ValueKey('selection-control'); + +void main() { + test('Linux themes suppress only the Yaru state indicators', () { + final cases = <({ThemeData base, ThemeData theme, String name})>[ + ( + name: 'light', + base: createYaruLightTheme(primaryColor: _accent), + theme: _theme(Brightness.light), + ), + ( + name: 'dark', + base: createYaruDarkTheme(primaryColor: _accent), + theme: _theme(Brightness.dark), + ), + ( + name: 'high contrast', + base: createYaruDarkTheme(primaryColor: _accent, highContrast: true), + theme: _theme(Brightness.dark, highContrast: true), + ), + ]; + const stateSets = >[ + {}, + {WidgetState.hovered}, + {WidgetState.focused}, + {WidgetState.hovered, WidgetState.focused}, + {WidgetState.pressed}, + {WidgetState.selected}, + {WidgetState.disabled}, + {WidgetState.selected, WidgetState.disabled}, + ]; + + for (final testCase in cases) { + final theme = testCase.theme; + final radio = theme.extension(); + final checkbox = theme.extension(); + final yaruSwitch = theme.extension(); + + expect(radio, isNotNull, reason: testCase.name); + expect(checkbox, isNotNull, reason: testCase.name); + expect(yaruSwitch, isNotNull, reason: testCase.name); + expect( + theme.extensions.values.whereType(), + hasLength(1), + ); + expect( + theme.extensions.values.whereType(), + hasLength(1), + ); + expect( + theme.extensions.values.whereType(), + hasLength(1), + ); + + for (final states in stateSets) { + expect( + radio!.indicatorColor!.resolve(states), + Colors.transparent, + reason: '${testCase.name} radio $states', + ); + expect( + checkbox!.indicatorColor!.resolve(states), + Colors.transparent, + reason: '${testCase.name} checkbox $states', + ); + expect( + yaruSwitch!.indicatorColor!.resolve(states), + Colors.transparent, + reason: '${testCase.name} switch $states', + ); + } + + _expectRadioConfigurationPreserved( + testCase.base.extension(), + radio!, + ); + _expectCheckboxConfigurationPreserved( + testCase.base.extension(), + checkbox!, + ); + _expectSwitchConfigurationPreserved( + testCase.base.extension(), + yaruSwitch!, + ); + + expect(theme.extension(), isNotNull); + for (final extension in testCase.base.extensions.values) { + if (extension is YaruRadioThemeData || + extension is YaruCheckboxThemeData || + extension is YaruSwitchThemeData) { + continue; + } + expect( + theme.extensions.values.any( + (candidate) => candidate.runtimeType == extension.runtimeType, + ), + isTrue, + reason: '${testCase.name} should retain ${extension.runtimeType}', + ); + } + + expect( + theme.checkboxTheme.fillColor!.resolve({WidgetState.selected}), + isNot(Colors.transparent), + ); + expect( + theme.radioTheme.fillColor!.resolve({WidgetState.selected}), + isNot(Colors.transparent), + ); + expect( + theme.switchTheme.trackColor!.resolve({WidgetState.selected}), + isNot(Colors.transparent), + ); + expect( + theme.checkboxTheme.fillColor!.resolve({WidgetState.disabled}), + isNot(Colors.transparent), + ); + expect( + theme.radioTheme.fillColor!.resolve({WidgetState.disabled}), + isNot(Colors.transparent), + ); + expect( + theme.switchTheme.trackColor!.resolve({WidgetState.disabled}), + isNot(Colors.transparent), + ); + } + }); + + test('Yaru indicator colors remain transparent while themes interpolate', () { + final light = _theme(Brightness.light); + final dark = BusyMaxYaruTheme.build( + brightness: Brightness.dark, + accentColor: _alternateAccent, + ); + final highContrast = BusyMaxYaruTheme.build( + brightness: Brightness.dark, + accentColor: _alternateAccent, + highContrast: true, + ); + + for (final transition in [ + ThemeData.lerp(light, dark, 0.25), + ThemeData.lerp(light, dark, 0.5), + ThemeData.lerp(dark, highContrast, 0.5), + ThemeData.lerp(dark, highContrast, 0.75), + ]) { + for (final indicator in [ + transition.extension()!.indicatorColor!, + transition.extension()!.indicatorColor!, + transition.extension()!.indicatorColor!, + ]) { + expect(indicator.resolve({WidgetState.hovered}), Colors.transparent); + expect(indicator.resolve({WidgetState.focused}), Colors.transparent); + expect( + indicator.resolve({ + WidgetState.hovered, + WidgetState.focused, + WidgetState.selected, + }), + Colors.transparent, + ); + } + } + }); + + for (final controlCase in _controlCases) { + testWidgets('${controlCase.name} paints no hover or focused-hover disk', ( + tester, + ) async { + final focusNode = FocusNode(debugLabel: controlCase.name); + addTearDown(focusNode.dispose); + final boundaryKey = GlobalKey(); + var activationCount = 0; + + await tester.pumpWidget( + _selectionControlHost( + boundaryKey: boundaryKey, + focusNode: focusNode, + controlCase: controlCase, + onActivation: () => activationCount += 1, + ), + ); + await tester.pumpAndSettle(); + + final resting = await _capturePixels(tester, boundaryKey); + expect( + _pixelsDifferentFrom(resting, _captureBackground), + greaterThan(20), + reason: '${controlCase.name} should retain its visible control paint', + ); + + final mouse = await tester.createGesture( + kind: ui.PointerDeviceKind.mouse, + ); + addTearDown(mouse.removePointer); + await mouse.addPointer(location: Offset.zero); + await mouse.moveTo(tester.getCenter(find.byKey(_controlKey))); + await _pumpStateAnimations(tester); + + final hovered = await _capturePixels(tester, boundaryKey); + expect(hovered.bytes, orderedEquals(resting.bytes)); + + await mouse.down(tester.getCenter(find.byKey(_controlKey))); + await mouse.up(); + await _pumpStateAnimations(tester); + final hoveredAfterClick = await _capturePixels(tester, boundaryKey); + expect(hoveredAfterClick.bytes, orderedEquals(resting.bytes)); + expect( + activationCount, + controlCase.enabled ? 1 : 0, + reason: controlCase.name, + ); + + await mouse.moveTo(const Offset(790, 590)); + await _pumpStateAnimations(tester); + FocusManager.instance.primaryFocus?.unfocus(); + await tester.pump(); + + await tester.sendKeyEvent(LogicalKeyboardKey.tab); + await tester.pump(); + expect( + focusNode.hasPrimaryFocus, + isFalse, + reason: 'the traversal sentinel receives focus first', + ); + await tester.sendKeyEvent(LogicalKeyboardKey.tab); + await _pumpStateAnimations(tester); + + if (controlCase.enabled) { + expect(focusNode.hasPrimaryFocus, isTrue); + final focused = await _capturePixels(tester, boundaryKey); + await mouse.moveTo(tester.getCenter(find.byKey(_controlKey))); + await _pumpStateAnimations(tester); + final focusedAndHovered = await _capturePixels(tester, boundaryKey); + expect(focusedAndHovered.bytes, orderedEquals(focused.bytes)); + } else { + expect(focusNode.hasFocus, isFalse); + } + }); + } + + testWidgets( + 'standalone controls expose outline focus, activation, disabled semantics, ' + 'and switch dragging', + (tester) async { + final checkboxFocus = FocusNode(debugLabel: 'checkbox'); + final disabledFocus = FocusNode(debugLabel: 'disabled checkbox'); + final switchFocus = FocusNode(debugLabel: 'switch'); + addTearDown(checkboxFocus.dispose); + addTearDown(disabledFocus.dispose); + addTearDown(switchFocus.dispose); + final checkboxBoundary = GlobalKey(); + final switchBoundary = GlobalKey(); + var checkboxValue = false; + var switchValue = false; + var checkboxChanges = 0; + var switchChanges = 0; + + await tester.pumpWidget( + MaterialApp( + theme: _theme(Brightness.light), + home: Scaffold( + body: StatefulBuilder( + builder: (context, setState) => Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + RepaintBoundary( + key: checkboxBoundary, + child: ColoredBox( + color: _captureBackground, + child: SizedBox( + width: 96, + height: 64, + child: Center( + child: BusyMaxYaruFocusBorder( + focusNode: checkboxFocus, + builder: (context, focusNode) => YaruCheckbox( + key: const ValueKey('standalone-checkbox'), + value: checkboxValue, + focusNode: focusNode, + hasFocusBorder: false, + onChanged: (value) { + checkboxChanges += 1; + setState( + () => checkboxValue = value ?? false, + ); + }, + ), + ), + ), + ), + ), + ), + BusyMaxYaruFocusBorder( + focusNode: disabledFocus, + builder: (context, focusNode) => YaruCheckbox( + key: const ValueKey('disabled-checkbox'), + value: false, + focusNode: focusNode, + hasFocusBorder: false, + onChanged: null, + ), + ), + RepaintBoundary( + key: switchBoundary, + child: ColoredBox( + color: _captureBackground, + child: SizedBox( + width: 112, + height: 68, + child: Center( + child: BusyMaxYaruFocusBorder( + focusNode: switchFocus, + builder: (context, focusNode) => YaruSwitch( + key: const ValueKey('standalone-switch'), + value: switchValue, + focusNode: focusNode, + hasFocusBorder: false, + onChanged: (value) { + switchChanges += 1; + setState(() => switchValue = value); + }, + ), + ), + ), + ), + ), + ), + ], + ), + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + final checkboxResting = await _capturePixels(tester, checkboxBoundary); + final switchResting = await _capturePixels(tester, switchBoundary); + final mouse = await tester.createGesture( + kind: ui.PointerDeviceKind.mouse, + ); + addTearDown(mouse.removePointer); + await mouse.addPointer(location: Offset.zero); + await mouse.moveTo( + tester.getCenter(find.byKey(const ValueKey('standalone-checkbox'))), + ); + await _pumpStateAnimations(tester); + final checkboxHovered = await _capturePixels(tester, checkboxBoundary); + expect(checkboxHovered.bytes, orderedEquals(checkboxResting.bytes)); + await mouse.moveTo(const Offset(790, 590)); + await _pumpStateAnimations(tester); + + await tester.sendKeyEvent(LogicalKeyboardKey.tab); + await _pumpStateAnimations(tester); + + expect(checkboxFocus.hasPrimaryFocus, isTrue); + expect( + FocusManager.instance.highlightMode, + FocusHighlightMode.traditional, + ); + expect(checkboxValue, isFalse); + expect(checkboxChanges, 0); + final checkboxFocused = await _capturePixels(tester, checkboxBoundary); + expect( + _changedPixelCount(checkboxResting, checkboxFocused), + greaterThan(0), + ); + + await tester.sendKeyEvent(LogicalKeyboardKey.space); + await tester.pumpAndSettle(); + expect(checkboxValue, isTrue); + expect(checkboxChanges, 1); + + await tester.sendKeyEvent(LogicalKeyboardKey.tab); + await _pumpStateAnimations(tester); + expect(disabledFocus.hasFocus, isFalse); + expect(switchFocus.hasPrimaryFocus, isTrue); + expect(switchValue, isFalse); + expect(switchChanges, 0); + final switchFocused = await _capturePixels(tester, switchBoundary); + expect(_changedPixelCount(switchResting, switchFocused), greaterThan(0)); + + final disabledSemantics = tester + .getSemantics(find.byKey(const ValueKey('disabled-checkbox'))) + .getSemanticsData(); + expect(disabledSemantics.flagsCollection.isEnabled, ui.Tristate.isFalse); + expect( + disabledSemantics.flagsCollection.isChecked, + ui.CheckedState.isFalse, + ); + + await tester.sendKeyEvent(LogicalKeyboardKey.space); + await tester.pumpAndSettle(); + expect(switchValue, isTrue); + expect(switchChanges, 1); + + await tester.sendKeyDownEvent(LogicalKeyboardKey.shiftLeft); + await tester.sendKeyEvent(LogicalKeyboardKey.tab); + await tester.sendKeyUpEvent(LogicalKeyboardKey.shiftLeft); + await _pumpStateAnimations(tester); + expect(checkboxFocus.hasPrimaryFocus, isTrue); + expect(checkboxValue, isTrue); + expect(switchValue, isTrue); + expect(checkboxChanges, 1); + expect(switchChanges, 1); + + await tester.drag( + find.byKey(const ValueKey('standalone-switch')), + const Offset(-36, 0), + ); + await tester.pumpAndSettle(); + expect(switchValue, isFalse); + expect(switchChanges, 2); + }, + ); + + testWidgets( + 'checkbox list tile gives row and control distinct outlines and actions', + (tester) async { + final rowFocus = FocusNode(debugLabel: 'checkbox row'); + final controlFocus = FocusNode(debugLabel: 'checkbox control'); + addTearDown(rowFocus.dispose); + addTearDown(controlFocus.dispose); + final boundaryKey = GlobalKey(); + var value = false; + var changes = 0; + + await tester.pumpWidget( + MaterialApp( + theme: _theme(Brightness.light), + home: Scaffold( + body: Center( + child: StatefulBuilder( + builder: (context, setState) { + void onChanged(bool? next) { + changes += 1; + setState(() => value = next ?? false); + } + + return RepaintBoundary( + key: boundaryKey, + child: ColoredBox( + color: _captureBackground, + child: SizedBox( + width: 360, + child: BusyMaxYaruFocusBorder( + focusNode: rowFocus, + borderStrokeAlign: BorderSide.strokeAlignInside, + builder: (context, rowFocusNode) => + YaruCheckboxListTile( + value: value, + onChanged: onChanged, + focusNode: rowFocusNode, + hasFocusBorder: false, + control: BusyMaxYaruFocusBorder( + focusNode: controlFocus, + builder: (context, controlFocusNode) => + YaruCheckbox( + key: const ValueKey( + 'list-checkbox-control', + ), + value: value, + onChanged: onChanged, + focusNode: controlFocusNode, + hasFocusBorder: false, + ), + ), + title: const Text('Include details'), + shape: const RoundedRectangleBorder(), + ), + ), + ), + ), + ); + }, + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + final resting = await _capturePixels(tester, boundaryKey); + await tester.sendKeyEvent(LogicalKeyboardKey.tab); + await _pumpStateAnimations(tester); + expect(rowFocus.hasPrimaryFocus, isTrue); + expect(controlFocus.hasFocus, isFalse); + expect(value, isFalse); + expect(changes, 0); + final rowFocused = await _capturePixels(tester, boundaryKey); + expect(_changedPixelCount(resting, rowFocused), greaterThan(0)); + expect( + _pixelAt(rowFocused, x: 1, y: rowFocused.height ~/ 2), + isNot(_pixelAt(resting, x: 1, y: resting.height ~/ 2)), + ); + + await tester.sendKeyEvent(LogicalKeyboardKey.tab); + await _pumpStateAnimations(tester); + expect(rowFocus.hasPrimaryFocus, isFalse); + expect(controlFocus.hasPrimaryFocus, isTrue); + expect(value, isFalse); + expect(changes, 0); + final controlFocused = await _capturePixels(tester, boundaryKey); + expect(_changedPixelCount(resting, controlFocused), greaterThan(0)); + expect( + _pixelAt(controlFocused, x: 1, y: controlFocused.height ~/ 2), + isNot(_accent), + reason: 'child focus must not also draw the row outline', + ); + + await tester.tap(find.text('Include details')); + await tester.pumpAndSettle(); + expect(value, isTrue); + expect(changes, 1); + var semantics = tester + .getSemantics(find.text('Include details')) + .getSemanticsData(); + expect(semantics.flagsCollection.isChecked, ui.CheckedState.isTrue); + + await tester.tap(find.byKey(const ValueKey('list-checkbox-control'))); + await tester.pumpAndSettle(); + expect(value, isFalse); + expect(changes, 2); + semantics = tester + .getSemantics(find.text('Include details')) + .getSemanticsData(); + expect(semantics.flagsCollection.isChecked, ui.CheckedState.isFalse); + expect(semantics.flagsCollection.isEnabled, ui.Tristate.isTrue); + }, + ); + + testWidgets( + 'BusyMaxSwitchRow preserves row hover, single actions, and dragging', + (tester) async { + final boundaryKey = GlobalKey(); + var value = false; + var changes = 0; + + await tester.pumpWidget( + MaterialApp( + theme: _theme(Brightness.light), + home: Scaffold( + body: Center( + child: StatefulBuilder( + builder: (context, setState) { + void onChanged(bool next) { + changes += 1; + setState(() => value = next); + } + + return RepaintBoundary( + key: boundaryKey, + child: SizedBox( + width: 360, + child: BusyMaxSwitchRow( + title: 'Show reminders', + subtitle: 'Display task reminders', + value: value, + onChanged: onChanged, + ), + ), + ); + }, + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + final tile = tester.widget( + find.byType(YaruSwitchListTile), + ); + final control = tester.widget(find.byType(YaruSwitch)); + expect(tile.hasFocusBorder, isFalse); + expect(tile.control, isA()); + expect(control.hasFocusBorder, isFalse); + expect(find.byType(BusyMaxYaruFocusBorder), findsNWidgets(2)); + expect(identical(tile.onChanged, control.onChanged), isTrue); + + final mouse = await tester.createGesture( + kind: ui.PointerDeviceKind.mouse, + ); + addTearDown(mouse.removePointer); + await mouse.addPointer(location: Offset.zero); + await mouse.moveTo(const Offset(790, 590)); + await _pumpStateAnimations(tester); + final resting = await _capturePixels(tester, boundaryKey); + await mouse.moveTo(tester.getCenter(find.text('Show reminders'))); + await _pumpStateAnimations(tester); + final hovered = await _capturePixels(tester, boundaryKey); + expect(_changedPixelCount(resting, hovered), greaterThan(0)); + + await tester.tap(find.text('Show reminders')); + await tester.pumpAndSettle(); + expect(value, isTrue); + expect(changes, 1); + + await tester.tap(find.byType(YaruSwitch)); + await tester.pumpAndSettle(); + expect(value, isFalse); + expect(changes, 2); + + await tester.drag(find.byType(YaruSwitch), const Offset(36, 0)); + await tester.pumpAndSettle(); + expect(value, isTrue); + expect(changes, 3); + }, + ); + + for (final compact in [false, true]) { + testWidgets( + '${compact ? 'compact' : 'normal'} task chip keeps completion separate ' + 'inside its real constraints', + (tester) async { + const item = TaskScheduleItem( + id: 'task:selection-control', + accountId: 'account:1', + provider: BusyProvider.microsoft, + sourceId: 'tasks:inbox', + title: 'Finish report', + completed: false, + allDay: true, + sourceName: 'Inbox', + ); + var rowActivations = 0; + var completionChanges = 0; + bool? lastCompletion; + final height = compact ? 26.0 : 54.0; + + await tester.pumpWidget( + localizedTestApp( + theme: _theme(Brightness.light), + child: Scaffold( + body: Center( + child: ScheduleTaskChip( + item: item, + width: 180, + height: height, + compact: compact, + onTap: (_, [_]) => rowActivations += 1, + onCompletionChanged: (value) { + completionChanges += 1; + lastCompletion = value; + }, + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + expect(tester.takeException(), isNull); + expect(tester.getSize(find.byType(ScheduleTaskChip)).height, height); + expect(find.byType(YaruCheckbox), findsOneWidget); + expect(find.byType(BusyMaxYaruFocusBorder), findsOneWidget); + + await tester.tap(find.byType(YaruCheckbox)); + await tester.pumpAndSettle(); + expect(completionChanges, 1); + expect(lastCompletion, isTrue); + expect(rowActivations, 0); + + FocusManager.instance.primaryFocus?.unfocus(); + await tester.pump(); + await tester.sendKeyEvent(LogicalKeyboardKey.tab); + await tester.sendKeyEvent(LogicalKeyboardKey.tab); + await _pumpStateAnimations(tester); + await tester.sendKeyEvent(LogicalKeyboardKey.space); + await tester.pumpAndSettle(); + expect(completionChanges, 2); + expect(rowActivations, 0); + + await tester.tap(find.text('Finish report')); + await tester.pumpAndSettle(); + expect(rowActivations, 1); + expect(completionChanges, 2); + expect(tester.takeException(), isNull); + }, + ); + } + + testWidgets( + 'mounted focused and hovered controls do not flash a disk across themes', + (tester) async { + final boundaryKey = GlobalKey(); + final focusNode = FocusNode(debugLabel: 'transition checkbox'); + addTearDown(focusNode.dispose); + var theme = _theme(Brightness.light); + late StateSetter setThemeState; + + await tester.pumpWidget( + StatefulBuilder( + builder: (context, setState) { + setThemeState = setState; + return MaterialApp( + theme: theme, + themeAnimationDuration: const Duration(milliseconds: 300), + themeAnimationCurve: Curves.linear, + home: Scaffold( + body: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + TextButton(onPressed: () {}, child: const Text('Before')), + RepaintBoundary( + key: boundaryKey, + child: ColoredBox( + color: _captureBackground, + child: SizedBox( + width: 104, + height: 80, + child: Center( + child: YaruCheckbox( + key: _controlKey, + value: true, + onChanged: (_) {}, + focusNode: focusNode, + hasFocusBorder: false, + ), + ), + ), + ), + ), + ], + ), + ), + ), + ); + }, + ), + ); + await tester.pumpAndSettle(); + await tester.sendKeyEvent(LogicalKeyboardKey.tab); + await tester.sendKeyEvent(LogicalKeyboardKey.tab); + await _pumpStateAnimations(tester); + expect(focusNode.hasPrimaryFocus, isTrue); + + final mouse = await tester.createGesture( + kind: ui.PointerDeviceKind.mouse, + ); + addTearDown(mouse.removePointer); + await mouse.addPointer(location: Offset.zero); + await mouse.moveTo(tester.getCenter(find.byKey(_controlKey))); + await _pumpStateAnimations(tester); + + final lightPixels = await _capturePixels(tester, boundaryKey); + _expectOutsideControlIsBackground(tester, lightPixels, boundaryKey); + final lightCenter = _pixelAtGlobal( + tester, + lightPixels, + boundaryKey, + tester.getCenter(find.byKey(_controlKey)), + ); + + setThemeState(() { + theme = BusyMaxYaruTheme.build( + brightness: Brightness.dark, + accentColor: _alternateAccent, + ); + }); + for (final duration in const [ + Duration.zero, + Duration(milliseconds: 75), + Duration(milliseconds: 75), + Duration(milliseconds: 150), + ]) { + await tester.pump(duration); + expect(focusNode.hasPrimaryFocus, isTrue); + _expectOutsideControlIsBackground( + tester, + await _capturePixels(tester, boundaryKey), + boundaryKey, + ); + } + final darkPixels = await _capturePixels(tester, boundaryKey); + final darkCenter = _pixelAtGlobal( + tester, + darkPixels, + boundaryKey, + tester.getCenter(find.byKey(_controlKey)), + ); + expect(darkCenter, isNot(lightCenter)); + + setThemeState(() { + theme = BusyMaxYaruTheme.build( + brightness: Brightness.dark, + accentColor: _alternateAccent, + highContrast: true, + ); + }); + for (final duration in const [ + Duration.zero, + Duration(milliseconds: 100), + Duration(milliseconds: 200), + ]) { + await tester.pump(duration); + expect(focusNode.hasPrimaryFocus, isTrue); + _expectOutsideControlIsBackground( + tester, + await _capturePixels(tester, boundaryKey), + boundaryKey, + ); + } + }, + ); +} + +ThemeData _theme(Brightness brightness, {bool highContrast = false}) { + return BusyMaxYaruTheme.build( + brightness: brightness, + accentColor: _accent, + highContrast: highContrast, + ); +} + +void _expectRadioConfigurationPreserved( + YaruRadioThemeData? base, + YaruRadioThemeData actual, +) { + expect(actual.color, base?.color); + expect(actual.borderColor, base?.borderColor); + expect(actual.checkmarkColor, base?.checkmarkColor); + expect(actual.mouseCursor, base?.mouseCursor); +} + +void _expectCheckboxConfigurationPreserved( + YaruCheckboxThemeData? base, + YaruCheckboxThemeData actual, +) { + expect(actual.color, base?.color); + expect(actual.borderColor, base?.borderColor); + expect(actual.checkmarkColor, base?.checkmarkColor); + expect(actual.mouseCursor, base?.mouseCursor); +} + +void _expectSwitchConfigurationPreserved( + YaruSwitchThemeData? base, + YaruSwitchThemeData actual, +) { + expect(actual.color, base?.color); + expect(actual.borderColor, base?.borderColor); + expect(actual.thumbColor, base?.thumbColor); + expect(actual.mouseCursor, base?.mouseCursor); +} + +class _ControlCase { + const _ControlCase({ + required this.name, + required this.builder, + this.enabled = true, + }); + + final String name; + final bool enabled; + final Widget Function(FocusNode focusNode, VoidCallback onActivation) builder; +} + +final _controlCases = <_ControlCase>[ + _ControlCase( + name: 'unselected YaruRadio', + builder: (focusNode, onActivation) => YaruRadio( + value: 1, + groupValue: 2, + onChanged: (_) => onActivation(), + focusNode: focusNode, + hasFocusBorder: false, + ), + ), + _ControlCase( + name: 'selected YaruRadio', + builder: (focusNode, onActivation) => YaruRadio( + value: 1, + groupValue: 1, + onChanged: (_) => onActivation(), + focusNode: focusNode, + hasFocusBorder: false, + ), + ), + _ControlCase( + name: 'unchecked YaruCheckbox', + builder: (focusNode, onActivation) => YaruCheckbox( + value: false, + onChanged: (_) => onActivation(), + focusNode: focusNode, + hasFocusBorder: false, + ), + ), + _ControlCase( + name: 'checked YaruCheckbox', + builder: (focusNode, onActivation) => YaruCheckbox( + value: true, + onChanged: (_) => onActivation(), + focusNode: focusNode, + hasFocusBorder: false, + ), + ), + _ControlCase( + name: 'mixed YaruCheckbox', + builder: (focusNode, onActivation) => YaruCheckbox( + value: null, + tristate: true, + onChanged: (_) => onActivation(), + focusNode: focusNode, + hasFocusBorder: false, + ), + ), + _ControlCase( + name: 'off YaruSwitch', + builder: (focusNode, onActivation) => YaruSwitch( + value: false, + onChanged: (_) => onActivation(), + focusNode: focusNode, + hasFocusBorder: false, + ), + ), + _ControlCase( + name: 'on YaruSwitch', + builder: (focusNode, onActivation) => YaruSwitch( + value: true, + onChanged: (_) => onActivation(), + focusNode: focusNode, + hasFocusBorder: false, + ), + ), + _ControlCase( + name: 'disabled YaruRadio', + enabled: false, + builder: (focusNode, onActivation) => YaruRadio( + value: 1, + groupValue: 1, + onChanged: null, + focusNode: focusNode, + hasFocusBorder: false, + ), + ), + _ControlCase( + name: 'disabled YaruCheckbox', + enabled: false, + builder: (focusNode, onActivation) => YaruCheckbox( + value: true, + onChanged: null, + focusNode: focusNode, + hasFocusBorder: false, + ), + ), + _ControlCase( + name: 'disabled YaruSwitch', + enabled: false, + builder: (focusNode, onActivation) => YaruSwitch( + value: true, + onChanged: null, + focusNode: focusNode, + hasFocusBorder: false, + ), + ), +]; + +Widget _selectionControlHost({ + required GlobalKey boundaryKey, + required FocusNode focusNode, + required _ControlCase controlCase, + required VoidCallback onActivation, +}) { + return MaterialApp( + theme: _theme(Brightness.light), + home: Scaffold( + body: Center( + child: FocusTraversalGroup( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + TextButton( + key: const ValueKey('traversal-sentinel'), + onPressed: () {}, + child: const Text('Before'), + ), + RepaintBoundary( + key: boundaryKey, + child: ColoredBox( + color: _captureBackground, + child: SizedBox( + width: 104, + height: 80, + child: Center( + child: KeyedSubtree( + key: _controlKey, + child: controlCase.builder(focusNode, onActivation), + ), + ), + ), + ), + ), + ], + ), + ), + ), + ), + ); +} + +Future _pumpStateAnimations(WidgetTester tester) async { + await tester.pump(); + await tester.pump(const Duration(milliseconds: 300)); + await tester.pump(); +} + +Future<_CapturedPixels> _capturePixels( + WidgetTester tester, + GlobalKey key, +) async { + final boundary = + key.currentContext!.findRenderObject()! as RenderRepaintBoundary; + final image = (await tester.binding.runAsync( + () => boundary.toImage(pixelRatio: 1), + ))!; + try { + final data = (await tester.binding.runAsync( + () => image.toByteData(format: ui.ImageByteFormat.rawStraightRgba), + ))!; + return _CapturedPixels( + bytes: Uint8List.fromList( + data.buffer.asUint8List(data.offsetInBytes, data.lengthInBytes), + ), + width: image.width, + height: image.height, + ); + } finally { + image.dispose(); + } +} + +int _pixelsDifferentFrom(_CapturedPixels pixels, Color color) { + final red = (color.r * 255).round(); + final green = (color.g * 255).round(); + final blue = (color.b * 255).round(); + final alpha = (color.a * 255).round(); + var count = 0; + for (var offset = 0; offset < pixels.bytes.length; offset += 4) { + if (pixels.bytes[offset] != red || + pixels.bytes[offset + 1] != green || + pixels.bytes[offset + 2] != blue || + pixels.bytes[offset + 3] != alpha) { + count += 1; + } + } + return count; +} + +int _changedPixelCount(_CapturedPixels before, _CapturedPixels after) { + expect(after.width, before.width); + expect(after.height, before.height); + var count = 0; + for (var offset = 0; offset < before.bytes.length; offset += 4) { + if (before.bytes[offset] != after.bytes[offset] || + before.bytes[offset + 1] != after.bytes[offset + 1] || + before.bytes[offset + 2] != after.bytes[offset + 2] || + before.bytes[offset + 3] != after.bytes[offset + 3]) { + count += 1; + } + } + return count; +} + +Color _pixelAt(_CapturedPixels pixels, {required int x, required int y}) { + final offset = (y * pixels.width + x) * 4; + return Color.fromARGB( + pixels.bytes[offset + 3], + pixels.bytes[offset], + pixels.bytes[offset + 1], + pixels.bytes[offset + 2], + ); +} + +Color _pixelAtGlobal( + WidgetTester tester, + _CapturedPixels pixels, + GlobalKey boundaryKey, + Offset globalPosition, +) { + final boundary = + boundaryKey.currentContext!.findRenderObject()! as RenderRepaintBoundary; + final local = boundary.globalToLocal(globalPosition); + return _pixelAt( + pixels, + x: local.dx.floor().clamp(0, pixels.width - 1), + y: local.dy.floor().clamp(0, pixels.height - 1), + ); +} + +void _expectOutsideControlIsBackground( + WidgetTester tester, + _CapturedPixels pixels, + GlobalKey boundaryKey, +) { + final boundary = + boundaryKey.currentContext!.findRenderObject()! as RenderRepaintBoundary; + final control = tester.renderObject(find.byKey(_controlKey)); + final topLeft = boundary.globalToLocal(control.localToGlobal(Offset.zero)); + final bottomRight = boundary.globalToLocal( + control.localToGlobal(control.size.bottomRight(Offset.zero)), + ); + for (var y = 0; y < pixels.height; y += 1) { + for (var x = 0; x < pixels.width; x += 1) { + final inside = + x >= topLeft.dx.floor() && + x < bottomRight.dx.ceil() && + y >= topLeft.dy.floor() && + y < bottomRight.dy.ceil(); + if (!inside) { + expect( + _pixelAt(pixels, x: x, y: y), + _captureBackground, + reason: 'unexpected paint outside the control at ($x, $y)', + ); + } + } + } +} + +class _CapturedPixels { + const _CapturedPixels({ + required this.bytes, + required this.width, + required this.height, + }); + + final Uint8List bytes; + final int width; + final int height; +} diff --git a/test/app/busymax_window_close_test.dart b/test/app/busymax_window_close_test.dart new file mode 100644 index 00000000..3b65da41 --- /dev/null +++ b/test/app/busymax_window_close_test.dart @@ -0,0 +1,139 @@ +import 'dart:async'; + +import 'package:busymax/src/app/busymax_dialogs.dart'; +import 'package:busymax/src/app/busymax_window_close.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test('close is allowed when no transient surface owns a handler', () async { + final coordinator = BusyMaxWindowCloseCoordinator(); + + expect(await coordinator.requestClose(), isTrue); + expect(coordinator.hasActiveHandler, isFalse); + }); + + testWidgets('the topmost mounted surface owns the close decision', ( + tester, + ) async { + final coordinator = BusyMaxWindowCloseCoordinator(); + var outerCalls = 0; + var innerCalls = 0; + + await tester.pumpWidget( + BusyMaxWindowCloseScope( + coordinator: coordinator, + child: BusyMaxWindowCloseGuard( + onCloseRequested: () { + outerCalls += 1; + return true; + }, + child: BusyMaxWindowCloseGuard( + onCloseRequested: () { + innerCalls += 1; + return false; + }, + child: const SizedBox(), + ), + ), + ), + ); + + expect(coordinator.hasActiveHandler, isTrue); + expect(await coordinator.requestClose(), isFalse); + expect(innerCalls, 1); + expect(outerCalls, 0); + }); + + testWidgets('concurrent close requests share one active resolution', ( + tester, + ) async { + final coordinator = BusyMaxWindowCloseCoordinator(); + final resolution = Completer(); + var calls = 0; + + await tester.pumpWidget( + BusyMaxWindowCloseScope( + coordinator: coordinator, + child: BusyMaxWindowCloseGuard( + onCloseRequested: () { + calls += 1; + return resolution.future; + }, + child: const SizedBox(), + ), + ), + ); + + final first = coordinator.requestClose(); + final second = coordinator.requestClose(); + expect(calls, 1); + + resolution.complete(false); + expect(await first, isFalse); + expect(await second, isFalse); + expect(calls, 1); + }); + + testWidgets('unmounted surfaces no longer participate in close requests', ( + tester, + ) async { + final coordinator = BusyMaxWindowCloseCoordinator(); + await tester.pumpWidget( + BusyMaxWindowCloseScope( + coordinator: coordinator, + child: BusyMaxWindowCloseGuard( + onCloseRequested: () => false, + child: const SizedBox(), + ), + ), + ); + expect(coordinator.hasActiveHandler, isTrue); + + await tester.pumpWidget( + BusyMaxWindowCloseScope( + coordinator: coordinator, + child: const SizedBox(), + ), + ); + + expect(coordinator.hasActiveHandler, isFalse); + expect(await coordinator.requestClose(), isTrue); + }); + + testWidgets('an active application dialog blocks top-level termination', ( + tester, + ) async { + final coordinator = BusyMaxWindowCloseCoordinator(); + await tester.pumpWidget( + BusyMaxWindowCloseScope( + coordinator: coordinator, + child: MaterialApp( + home: Builder( + builder: (context) => TextButton( + onPressed: () => unawaited( + showBusyMaxModalDialog( + context, + builder: (_) => + const AlertDialog(content: Text('Protected modal')), + ), + ), + child: const Text('Open'), + ), + ), + ), + ), + ); + + await tester.tap(find.text('Open')); + await tester.pumpAndSettle(); + + expect(find.text('Protected modal'), findsOneWidget); + expect(coordinator.hasActiveHandler, isTrue); + expect(await coordinator.requestClose(), isFalse); + + Navigator.of(tester.element(find.text('Protected modal'))).pop(); + await tester.pumpAndSettle(); + expect(coordinator.hasActiveHandler, isFalse); + }); +} diff --git a/test/app/caldav_release_metadata_test.dart b/test/app/caldav_release_metadata_test.dart index 40c9c4ce..067d7555 100644 --- a/test/app/caldav_release_metadata_test.dart +++ b/test/app/caldav_release_metadata_test.dart @@ -11,10 +11,14 @@ void main() { final metainfo = File( 'linux/io.busystack.busymax.metainfo.xml', ).readAsStringSync(); + final windowsRunner = File('windows/runner/Runner.rc').readAsStringSync(); + final androidBuild = File( + 'android/app/build.gradle.kts', + ).readAsStringSync(); final pubspecVersion = _capture( pubspec, - RegExp(r'^version:\s*([^\s+]+)', multiLine: true), + RegExp(r'^version:\s*(\S+)', multiLine: true), ); final snapVersion = _capture( snap, @@ -25,8 +29,13 @@ void main() { RegExp(r' taps += 1, + child: const Text('Sidebar action'), + ), + ), + ), + ); + }, + ), + ), + ); + expect(tester.getSize(find.byKey(const ValueKey('reveal'))).width, 200); + + update(() { + visible = false; + generation = 1; + }); + await tester.pump(); + await tester.tap(find.text('Sidebar action'), warnIfMissed: false); + expect(taps, 0); + await tester.pump(const Duration(milliseconds: 80)); + final collapsingProgress = _revealProgress(tester); + expect(collapsingProgress, inExclusiveRange(0, 1)); + + update(() { + visible = true; + generation = 2; + }); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 40)); + final reversedProgress = _revealProgress(tester); + expect(reversedProgress, greaterThan(collapsingProgress)); + await tester.pumpAndSettle(); + expect(tester.getSize(find.byKey(const ValueKey('reveal'))).width, 200); + }); + + testWidgets('enabling reduced motion settles an active reveal immediately', ( + tester, + ) async { + var visible = true; + var generation = 0; + var disabled = false; + late StateSetter update; + await tester.pumpWidget( + MaterialApp( + home: StatefulBuilder( + builder: (context, setState) { + update = setState; + return MediaQuery( + data: MediaQuery.of( + context, + ).copyWith(disableAnimations: disabled), + child: Align( + alignment: Alignment.topLeft, + child: BusyMaxHorizontalReveal( + key: const ValueKey('reveal'), + visible: visible, + width: 200, + transitionGeneration: generation, + child: const SizedBox(width: 200, height: 40), + ), + ), + ); + }, + ), + ), + ); + update(() { + visible = false; + generation = 1; + }); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 40)); + expect(_revealProgress(tester), greaterThan(0)); + + update(() => disabled = true); + await tester.pump(); + expect(_revealProgress(tester), 0); + }); + + testWidgets('same-frame sidebar reversal cancels the obsolete direction', ( + tester, + ) async { + var visible = true; + var generation = 0; + late StateSetter update; + await tester.pumpWidget( + MaterialApp( + home: StatefulBuilder( + builder: (context, setState) { + update = setState; + return BusyMaxHorizontalReveal( + key: const ValueKey('reveal'), + visible: visible, + width: 200, + transitionGeneration: generation, + child: const SizedBox(width: 200, height: 40), + ); + }, + ), + ), + ); + + update(() { + visible = false; + generation = 1; + }); + await tester.pump(); + update(() { + visible = true; + generation = 2; + }); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 80)); + + expect(_revealProgress(tester), 1); + expect(tester.binding.hasScheduledFrame, isFalse); + }); + + testWidgets('unchanged-generation rebuild preserves reveal progress', ( + tester, + ) async { + var visible = true; + var generation = 0; + var unrelated = 0; + late StateSetter update; + await tester.pumpWidget( + MaterialApp( + home: StatefulBuilder( + builder: (context, setState) { + update = setState; + return Column( + children: [ + Text('data-$unrelated'), + BusyMaxHorizontalReveal( + key: const ValueKey('reveal'), + visible: visible, + width: 200, + transitionGeneration: generation, + child: const SizedBox(width: 200, height: 40), + ), + ], + ); + }, + ), + ), + ); + + update(() { + visible = false; + generation = 1; + }); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 80)); + final beforeRebuild = _revealProgress(tester); + expect(beforeRebuild, inExclusiveRange(0, 1)); + + update(() => unrelated += 1); + await tester.pump(); + expect(_revealProgress(tester), closeTo(beforeRebuild, .001)); + await tester.pump(const Duration(milliseconds: 20)); + expect(_revealProgress(tester), lessThan(beforeRebuild)); + await tester.pumpAndSettle(); + expect(_revealProgress(tester), 0); + }); + + testWidgets('first directional generation animates from a mounted host', ( + tester, + ) async { + var generation = 0; + var direction = 0; + var label = 'January'; + late StateSetter update; + await tester.pumpWidget( + MaterialApp( + home: StatefulBuilder( + builder: (context, setState) { + update = setState; + return SizedBox( + width: 300, + height: 200, + child: BusyMaxDirectionalSwitcher( + generation: generation, + direction: direction, + child: ColoredBox( + key: ValueKey(label), + color: Colors.blue, + child: Text(label), + ), + ), + ); + }, + ), + ), + ); + + update(() { + generation = 1; + direction = 1; + label = 'February'; + }); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 80)); + + final translations = tester + .widgetList(find.byType(FractionalTranslation)) + .map((widget) => widget.translation.dx) + .toList(); + expect(translations.any((value) => value.abs() > 0), isTrue); + final opacity = tester + .widgetList(find.byType(Opacity)) + .map((widget) => widget.opacity); + expect(opacity.any((value) => value > 0 && value < 1), isTrue); + }); + + testWidgets('retained crossfade keeps two live pages and hides outgoing UI', ( + tester, + ) async { + final counts = {}; + Widget build(int index) => MaterialApp( + home: Scaffold( + body: SizedBox( + width: 300, + height: 200, + child: BusyMaxRetainedCrossfade( + index: index, + children: [ + _TrackedPage( + key: const ValueKey('page-a'), + name: 'A', + counts: counts, + ), + _TrackedPage( + key: const ValueKey('page-b'), + name: 'B', + counts: counts, + ), + ], + ), + ), + ), + ); + + await tester.pumpWidget(build(0)); + expect(counts, {'A': 1, 'B': 1}); + await tester.enterText(find.byKey(const ValueKey('field-A')), 'draft'); + + await tester.pumpWidget(build(1)); + await tester.pump(const Duration(milliseconds: 50)); + expect(find.text('draft'), findsOneWidget); + final opacityBeforeReversal = _pageOpacity(tester, 'page-a'); + expect( + tester + .widget( + find + .ancestor( + of: find.byKey(const ValueKey('field-A')), + matching: find.byType(IgnorePointer), + ) + .first, + ) + .ignoring, + isTrue, + ); + + await tester.pumpWidget(build(0)); + await tester.pump(); + expect( + _pageOpacity(tester, 'page-a'), + closeTo(opacityBeforeReversal, .001), + ); + await tester.pumpAndSettle(); + expect(find.text('draft'), findsOneWidget); + expect(counts, {'A': 1, 'B': 1}); + }); + + testWidgets('keyed crossfade retains three semantic destinations', ( + tester, + ) async { + final counts = {}; + var destination = 'Week'; + late StateSetter update; + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: StatefulBuilder( + builder: (context, setState) { + update = setState; + return SizedBox( + width: 300, + height: 200, + child: BusyMaxKeyedCrossfade( + transitionKey: destination, + child: _TrackedPage( + key: ValueKey('page-$destination'), + name: destination, + counts: counts, + ), + ), + ); + }, + ), + ), + ), + ); + await tester.enterText( + find.byKey(const ValueKey('field-Week')), + 'retained planner state', + ); + final weekState = tester.state( + find.byKey(const ValueKey('page-Week'), skipOffstage: false), + ); + + update(() => destination = 'Month'); + await tester.pump(); + expect(counts, {'Week': 1, 'Month': 1}); + expect( + tester.state( + find.byKey(const ValueKey('page-Week'), skipOffstage: false), + ), + same(weekState), + ); + await tester.pump(const Duration(milliseconds: 30)); + update(() => destination = 'Year'); + await tester.pump(); + expect(counts, {'Week': 1, 'Month': 1, 'Year': 1}); + expect( + tester.state( + find.byKey(const ValueKey('page-Week'), skipOffstage: false), + ), + same(weekState), + ); + await tester.pump(const Duration(milliseconds: 30)); + update(() => destination = 'Week'); + await tester.pump(); + await tester.pumpAndSettle(); + + expect( + find.byKey(const ValueKey('field-Week'), skipOffstage: false), + findsOneWidget, + ); + expect( + tester.state(find.byKey(const ValueKey('page-Week'))), + same(weekState), + ); + expect(counts, {'Week': 1, 'Month': 1, 'Year': 1}); + expect( + tester + .widget( + find.descendant( + of: find.byKey(const ValueKey('field-Week')), + matching: find.byType(EditableText), + ), + ) + .controller + .text, + 'retained planner state', + ); + }); +} + +double _revealProgress(WidgetTester tester) => tester + .widgetList( + find.descendant( + of: find.byKey(const ValueKey('reveal')), + matching: find.byType(Align), + ), + ) + .singleWhere( + (align) => + align.widthFactor != null && + align.alignment == AlignmentDirectional.centerStart, + ) + .widthFactor!; + +double _pageOpacity(WidgetTester tester, String key) => tester + .widget( + find + .ancestor( + of: find.byKey(ValueKey(key)), + matching: find.byType(Opacity), + ) + .first, + ) + .opacity; + +class _TrackedPage extends StatefulWidget { + const _TrackedPage({super.key, required this.name, required this.counts}); + + final String name; + final Map counts; + + @override + State<_TrackedPage> createState() => _TrackedPageState(); +} + +class _TrackedPageState extends State<_TrackedPage> { + @override + void initState() { + super.initState(); + widget.counts.update(widget.name, (value) => value + 1, ifAbsent: () => 1); + } + + @override + Widget build(BuildContext context) => TextField( + key: ValueKey('field-${widget.name}'), + decoration: InputDecoration(labelText: widget.name), + ); +} diff --git a/test/app/common/busymax_mutation_list_test.dart b/test/app/common/busymax_mutation_list_test.dart new file mode 100644 index 00000000..5407ce8a --- /dev/null +++ b/test/app/common/busymax_mutation_list_test.dart @@ -0,0 +1,529 @@ +import 'package:busymax/src/app/common/busymax_mutation_list.dart'; +import 'package:busymax/src/schedule/task_list_mutation_intent.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +const _account = 'account'; +const _list = 'list'; + +void main() { + TaskListMutationIntent intent({ + required TaskListMutationPresentation presentation, + required String id, + required int generation, + bool? completed, + }) => TaskListMutationIntent( + presentation: presentation, + accountId: _account, + taskListId: _list, + taskId: id, + generation: generation, + completed: completed, + ); + + testWidgets('identified removal retains an inert row through collapse', ( + tester, + ) async { + var taps = 0; + var consumed = 0; + final pending = intent( + presentation: TaskListMutationPresentation.completion, + id: 'b', + generation: 1, + completed: true, + ); + + await tester.pumpWidget( + _harness( + items: const [_Item('a'), _Item('b')], + mutation: pending, + onTap: () => taps += 1, + onConsumed: (_) => consumed += 1, + ), + ); + await tester.pumpWidget( + _harness( + items: const [_Item('a')], + mutation: pending, + onTap: () => taps += 1, + onConsumed: (_) => consumed += 1, + ), + ); + + expect(find.text('b'), findsOneWidget); + await tester.tap(find.text('b'), warnIfMissed: false); + expect(taps, 0); + expect(consumed, 0); + await tester.pump(const Duration(milliseconds: 90)); + expect(find.text('b'), findsOneWidget); + expect(tester.getSize(find.text('b')).height, greaterThan(0)); + await tester.pumpAndSettle(); + expect(find.text('b'), findsNothing); + expect(consumed, 1); + }); + + testWidgets('insertion is consumed once after its entrance completes', ( + tester, + ) async { + final pending = intent( + presentation: TaskListMutationPresentation.insertion, + id: 'b', + generation: 8, + ); + var consumed = 0; + await tester.pumpWidget(_harness(items: const [_Item('a')])); + await tester.pumpWidget( + _harness( + items: const [_Item('a'), _Item('b')], + mutation: pending, + onConsumed: (_) => consumed++, + ), + ); + expect(consumed, 0); + await tester.pump(const Duration(milliseconds: 90)); + expect(find.text('b'), findsOneWidget); + expect(consumed, 0); + await tester.pumpAndSettle(); + expect(consumed, 1); + await tester.pumpWidget( + _harness( + items: const [_Item('a'), _Item('b')], + mutation: pending, + onConsumed: (_) => consumed++, + ), + ); + expect(consumed, 1); + }); + + testWidgets('parent can clear insertion intent on consumption', ( + tester, + ) async { + final pending = intent( + presentation: TaskListMutationPresentation.insertion, + id: 'b', + generation: 9, + ); + var inserted = false; + TaskListMutationIntent? current; + var consumed = 0; + late StateSetter rebuild; + await tester.pumpWidget( + StatefulBuilder( + builder: (context, setState) { + rebuild = setState; + return _harness( + items: inserted + ? const [_Item('a'), _Item('b')] + : const [_Item('a')], + mutation: current, + onConsumed: (_) => setState(() { + current = null; + consumed++; + }), + ); + }, + ), + ); + rebuild(() { + inserted = true; + current = pending; + }); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 90)); + expect(current, same(pending)); + expect(consumed, 0); + await tester.pumpAndSettle(); + expect(current, isNull); + expect(consumed, 1); + expect(find.text('b'), findsOneWidget); + expect(tester.binding.hasScheduledFrame, isFalse); + }); + + testWidgets('offscreen insertion is consumed by the parent animation', ( + tester, + ) async { + final before = List.generate(100, (index) => _Item('item-$index')); + final pending = intent( + presentation: TaskListMutationPresentation.insertion, + id: 'item-100', + generation: 10, + ); + var consumed = 0; + await tester.pumpWidget(_harness(items: before, viewportHeight: 160)); + await tester.pumpWidget( + _harness( + items: [...before, const _Item('item-100')], + mutation: pending, + viewportHeight: 160, + onConsumed: (_) => consumed++, + ), + ); + expect(find.text('item-100'), findsNothing); + await tester.pump(const Duration(milliseconds: 90)); + expect(consumed, 0); + await tester.pumpAndSettle(); + expect(consumed, 1); + }); + + testWidgets('reduced motion consumes insertion immediately', (tester) async { + final pending = intent( + presentation: TaskListMutationPresentation.insertion, + id: 'b', + generation: 11, + ); + var consumed = 0; + await tester.pumpWidget( + _harness(items: const [_Item('a')], disabled: true), + ); + await tester.pumpWidget( + _harness( + items: const [_Item('a'), _Item('b')], + mutation: pending, + disabled: true, + onConsumed: (_) => consumed++, + ), + ); + await tester.pump(); + expect(consumed, 1); + expect(find.text('b'), findsOneWidget); + expect(tester.binding.hasScheduledFrame, isFalse); + }); + + testWidgets('disappearing insertion target settles its intent', ( + tester, + ) async { + final pending = intent( + presentation: TaskListMutationPresentation.insertion, + id: 'b', + generation: 12, + ); + var consumed = 0; + await tester.pumpWidget(_harness(items: const [_Item('a')])); + await tester.pumpWidget( + _harness( + items: const [_Item('a'), _Item('b')], + mutation: pending, + onConsumed: (_) => consumed++, + ), + ); + await tester.pump(const Duration(milliseconds: 60)); + await tester.pumpWidget( + _harness( + items: const [_Item('a')], + mutation: pending, + onConsumed: (_) => consumed++, + ), + ); + await tester.pump(); + expect(consumed, 1); + expect(find.text('b'), findsNothing); + }); + + testWidgets('disposing the list consumes its pending insertion', ( + tester, + ) async { + final pending = intent( + presentation: TaskListMutationPresentation.insertion, + id: 'b', + generation: 13, + ); + var consumed = 0; + await tester.pumpWidget(_harness(items: const [_Item('a')])); + await tester.pumpWidget( + _harness( + items: const [_Item('a'), _Item('b')], + mutation: pending, + onConsumed: (_) => consumed++, + ), + ); + await tester.pump(const Duration(milliseconds: 60)); + expect(consumed, 0); + await tester.pumpWidget(const MaterialApp(home: SizedBox.shrink())); + await tester.pump(); + expect(consumed, 1); + }); + + testWidgets('unrelated refresh reconciles without structural animation', ( + tester, + ) async { + await tester.pumpWidget( + _harness(items: const [_Item('a', completed: true), _Item('b')]), + ); + await tester.pumpWidget( + _harness(items: const [_Item('a', completed: true)]), + ); + + expect(find.text('b'), findsNothing); + expect(tester.binding.hasScheduledFrame, isFalse); + }); + + testWidgets('reduced motion completes a pending row lifecycle', ( + tester, + ) async { + final pending = intent( + presentation: TaskListMutationPresentation.removal, + id: 'b', + generation: 2, + ); + await tester.pumpWidget( + _harness(items: const [_Item('a'), _Item('b')], disabled: true), + ); + await tester.pumpWidget( + _harness(items: const [_Item('a')], mutation: pending, disabled: true), + ); + await tester.pump(); + + expect(find.text('b'), findsNothing); + }); + + testWidgets('final row exits before the empty projection is shown', ( + tester, + ) async { + final pending = intent( + presentation: TaskListMutationPresentation.removal, + id: 'only', + generation: 4, + ); + await tester.pumpWidget(_harness(items: const [_Item('only')])); + await tester.pumpWidget(_harness(items: const [], mutation: pending)); + + expect(find.text('only'), findsOneWidget); + expect(find.text('Empty projection'), findsNothing); + await tester.pump(const Duration(milliseconds: 90)); + expect(find.text('only'), findsOneWidget); + expect(find.text('Empty projection'), findsNothing); + await tester.pumpAndSettle(); + expect(find.text('only'), findsNothing); + expect(find.text('Empty projection'), findsOneWidget); + }); + + testWidgets('offscreen removal cannot suspend later reconciliation', ( + tester, + ) async { + final controller = ScrollController(); + addTearDown(controller.dispose); + var consumed = 0; + final before = List.generate(80, (index) => _Item('item-$index')); + final pending = intent( + presentation: TaskListMutationPresentation.removal, + id: 'item-1', + generation: 3, + ); + + await tester.pumpWidget( + _harness(items: before, controller: controller, viewportHeight: 160), + ); + await tester.pumpWidget( + _harness( + items: before.where((item) => item.id != 'item-1').toList(), + mutation: pending, + controller: controller, + viewportHeight: 160, + onConsumed: (_) => consumed += 1, + ), + ); + expect(find.text('item-1'), findsOneWidget); + + controller.jumpTo(controller.position.maxScrollExtent); + await tester.pump(); + expect(find.text('item-1'), findsNothing); + + final filtered = List.generate(30, (index) => _Item('filtered-$index')); + await tester.pumpWidget( + _harness( + items: filtered, + mutation: pending, + controller: controller, + viewportHeight: 160, + onConsumed: (_) => consumed += 1, + ), + ); + await tester.pump(const Duration(milliseconds: 200)); + await tester.pump(); + + controller.jumpTo(0); + await tester.pump(); + expect(find.text('filtered-0'), findsOneWidget); + expect(find.text('item-1'), findsNothing); + expect(consumed, 1); + }); + + testWidgets('a never-built removal settles and accepts later projections', ( + tester, + ) async { + final controller = ScrollController(); + addTearDown(controller.dispose); + var consumed = 0; + final before = List.generate(100, (index) => _Item('item-$index')); + final pending = intent( + presentation: TaskListMutationPresentation.removal, + id: 'item-99', + generation: 6, + ); + + await tester.pumpWidget( + _harness(items: before, controller: controller, viewportHeight: 160), + ); + expect(find.text('item-99'), findsNothing); + + await tester.pumpWidget( + _harness( + items: before.take(99).toList(), + mutation: pending, + controller: controller, + viewportHeight: 160, + onConsumed: (_) => consumed += 1, + ), + ); + expect(find.text('item-99'), findsNothing); + await tester.pump(const Duration(milliseconds: 200)); + await tester.pump(); + expect(consumed, 1); + + final updated = <_Item>[ + const _Item('updated-first'), + ...before.skip(1).take(98), + ]; + await tester.pumpWidget( + _harness( + items: updated, + mutation: pending, + controller: controller, + viewportHeight: 160, + onConsumed: (_) => consumed += 1, + ), + ); + await tester.pump(); + expect(find.text('updated-first'), findsOneWidget); + expect(consumed, 1); + }); + + testWidgets('reduced motion settles a never-built removal in the parent', ( + tester, + ) async { + var consumed = 0; + final before = List.generate(100, (index) => _Item('item-$index')); + final pending = intent( + presentation: TaskListMutationPresentation.removal, + id: 'item-99', + generation: 7, + ); + + await tester.pumpWidget( + _harness(items: before, disabled: true, viewportHeight: 160), + ); + expect(find.text('item-99'), findsNothing); + await tester.pumpWidget( + _harness( + items: before.take(99).toList(), + mutation: pending, + disabled: true, + viewportHeight: 160, + onConsumed: (_) => consumed += 1, + ), + ); + await tester.pump(); + expect(consumed, 1); + + await tester.pumpWidget( + _harness( + items: const [_Item('reconciled')], + mutation: pending, + disabled: true, + viewportHeight: 160, + onConsumed: (_) => consumed += 1, + ), + ); + expect(find.text('reconciled'), findsOneWidget); + expect(consumed, 1); + }); + + testWidgets('empty projection keeps its footer available', (tester) async { + var loadMoreCalls = 0; + await tester.pumpWidget( + _harness( + items: const [], + footer: TextButton( + onPressed: () => loadMoreCalls += 1, + child: const Text('Load more'), + ), + ), + ); + + expect(find.text('Empty projection'), findsOneWidget); + expect(find.text('Load more'), findsOneWidget); + await tester.tap(find.text('Load more')); + expect(loadMoreCalls, 1); + }); + + testWidgets('disposing the list completes its pending removal intent', ( + tester, + ) async { + var consumed = 0; + final pending = intent( + presentation: TaskListMutationPresentation.removal, + id: 'b', + generation: 5, + ); + await tester.pumpWidget(_harness(items: const [_Item('a'), _Item('b')])); + await tester.pumpWidget( + _harness( + items: const [_Item('a')], + mutation: pending, + onConsumed: (_) => consumed += 1, + ), + ); + await tester.pump(const Duration(milliseconds: 60)); + await tester.pumpWidget(const MaterialApp(home: SizedBox.shrink())); + await tester.pump(); + + expect(consumed, 1); + }); +} + +Widget _harness({ + required List<_Item> items, + TaskListMutationIntent? mutation, + VoidCallback? onTap, + ValueChanged? onConsumed, + bool disabled = false, + ScrollController? controller, + double? viewportHeight, + Widget? footer, +}) => MaterialApp( + builder: (context, child) => MediaQuery( + data: MediaQuery.of(context).copyWith(disableAnimations: disabled), + child: child!, + ), + home: Scaffold( + body: Align( + alignment: Alignment.topCenter, + child: SizedBox( + width: double.infinity, + height: viewportHeight ?? double.infinity, + child: BusyMaxMutationList<_Item>( + items: items, + mutation: mutation, + identityOf: (item) => '$_account\u0000$_list\u0000${item.id}', + mutationApplied: (item, value) => + value.completed == null || item.completed == value.completed, + onMutationConsumed: onConsumed, + controller: controller, + emptyBuilder: (context) => const Text('Empty projection'), + footer: footer, + itemBuilder: (context, item, index) => SizedBox( + height: 48, + child: TextButton(onPressed: onTap, child: Text(item.id)), + ), + ), + ), + ), + ), +); + +class _Item { + const _Item(this.id, {this.completed = false}); + + final String id; + final bool completed; +} diff --git a/test/app/goldens/linux_buttons_reviewed.png b/test/app/goldens/linux_buttons_reviewed.png new file mode 100644 index 00000000..547a9d71 Binary files /dev/null and b/test/app/goldens/linux_buttons_reviewed.png differ diff --git a/test/app/keyboard_shortcuts_dialog_test.dart b/test/app/keyboard_shortcuts_dialog_test.dart index 92e17793..9f8f3b7d 100644 --- a/test/app/keyboard_shortcuts_dialog_test.dart +++ b/test/app/keyboard_shortcuts_dialog_test.dart @@ -29,6 +29,8 @@ void main() { expect(find.text('Ctrl+Alt+S'), findsOneWidget); expect(find.text('Ctrl+F'), findsOneWidget); expect(find.text('F9'), findsOneWidget); + expect(find.text('Back'), findsOneWidget); + expect(find.text('Alt+Left'), findsOneWidget); expect(find.text('Ctrl+N'), findsNothing); expect(find.text('Shift+Right'), findsOneWidget); expect(find.text('Shift+Left'), findsOneWidget); @@ -207,23 +209,23 @@ void main() { ); } - test('keyboard shortcuts are available from native headerbar menu', () { + test('keyboard shortcuts are available from the Flutter header menu', () { final app = File('lib/src/app/busymax_app.dart').readAsStringSync(); - final service = File( - 'lib/src/platform/linux_header_bar_service.dart', + final toolbar = File( + 'lib/src/features/schedule/presentation/schedule_toolbar.dart', ).readAsStringSync(); final shortcuts = File( 'lib/src/app/busymax_shortcuts.dart', ).readAsStringSync(); final native = File('linux/runner/my_application.cc').readAsStringSync(); - expect(app, contains('keyboardShortcuts: l10n.keyboardShortcuts')); + expect(app, contains('showBusyMaxKeyboardShortcutsDialog(')); expect(app, contains('BusyMaxShortcutActivators.keyboardShortcuts')); expect(shortcuts, contains('LogicalKeyboardKey.keyK')); expect(shortcuts, contains('LogicalKeyboardKey.keyS')); expect(shortcuts, contains('LogicalKeyboardKey.f9')); - expect(service, contains('keyboardShortcuts')); - expect(native, contains('"Keyboard Shortcuts"')); - expect(native, contains('"keyboardShortcuts"')); + expect(toolbar, contains('ScheduleToolbarMenuAction.keyboardShortcuts')); + expect(toolbar, contains('context.l10n.keyboardShortcuts')); + expect(native, isNot(contains('"keyboardShortcuts"'))); }); } diff --git a/test/app/linux_header_style_test.dart b/test/app/linux_header_style_test.dart new file mode 100644 index 00000000..ea826493 --- /dev/null +++ b/test/app/linux_header_style_test.dart @@ -0,0 +1,1655 @@ +import 'dart:convert'; +import 'dart:math' as math; +import 'dart:ui' show SemanticsAction; + +import 'package:busymax/src/app/app_theme.dart'; +import 'package:busymax/src/app/busymax_design.dart'; +import 'package:busymax/src/app/busymax_native_search_entry_theme.dart'; +import 'package:busymax/src/app/busymax_surface_colors.dart'; +import 'package:busymax/src/app/linux/linux_header_style.dart'; +import 'package:busymax/src/app/linux/linux_window_host.dart'; +import 'package:busymax/src/platform/gtk_header_icon_service.dart'; +import 'package:busymax/src/platform/gtk_font_service.dart'; +import 'package:busymax/src/platform/gtk_window_preferences_service.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:yaru/yaru.dart'; + +void main() { + group('absolute Linux header title centering', () { + for (final testCase in const [ + _LayoutCase('small leading and large trailing', 34, 154, 0, 0, false), + _LayoutCase('large leading and small trailing', 154, 34, 0, 0, false), + _LayoutCase('system controls on the right', 68, 102, 0, 110, false), + _LayoutCase('system controls on the left', 102, 68, 110, 0, false), + _LayoutCase('partially presented sidebar', 102, 102, 42, 110, false), + _LayoutCase('RTL', 68, 154, 93, 0, true), + ]) { + testWidgets(testCase.label, (tester) async { + await _pumpLayout(tester, testCase); + + final header = tester.getRect(find.byKey(_headerKey)); + final title = tester.getRect(find.byKey(_titleKey)); + final leading = tester.getRect(find.byKey(_leadingKey)); + final trailing = tester.getRect(find.byKey(_trailingKey)); + expect(title.center.dx, closeTo(header.center.dx, .001)); + expect(title.overlaps(leading), isFalse); + expect(title.overlaps(trailing), isFalse); + + final physicalLeft = testCase.rtl ? trailing : leading; + final physicalRight = testCase.rtl ? leading : trailing; + expect( + physicalLeft.left, + greaterThanOrEqualTo( + testCase.leftObstruction + BusyMaxSpacing.headerInset, + ), + ); + expect( + physicalRight.right, + lessThanOrEqualTo( + header.width - + testCase.rightObstruction - + BusyMaxSpacing.headerInset, + ), + ); + final leftGap = title.left - physicalLeft.right; + final rightGap = physicalRight.left - title.right; + expect(leftGap, greaterThanOrEqualTo(BusyMaxSpacing.headerInset)); + expect(rightGap, greaterThanOrEqualTo(BusyMaxSpacing.headerInset)); + expect( + math.min(leftGap, rightGap), + closeTo(BusyMaxSpacing.headerInset, .001), + ); + }); + } + }); + + group('Linux header fill-between-controls allocation', () { + for (final testCase in const [ + _LayoutCase('small leading and large trailing', 34, 154, 0, 0, false), + _LayoutCase('large leading and small trailing', 154, 34, 0, 0, false), + _LayoutCase('empty leading group', 0, 154, 0, 0, false), + _LayoutCase('system controls on the right', 68, 102, 0, 110, false), + _LayoutCase('system controls on the left', 102, 68, 110, 0, false), + _LayoutCase('RTL', 68, 154, 93, 0, true), + ]) { + testWidgets(testCase.label, (tester) async { + await _pumpLayout( + tester, + testCase, + centerAllocation: + BusyMaxLinuxHeaderCenterAllocation.fillBetweenControls, + ); + + final title = tester.getRect(find.byKey(_titleKey)); + final leading = tester.getRect(find.byKey(_leadingKey)); + final trailing = tester.getRect(find.byKey(_trailingKey)); + final physicalLeft = testCase.rtl ? trailing : leading; + final physicalRight = testCase.rtl ? leading : trailing; + + expect( + title.left, + closeTo(physicalLeft.right + BusyMaxSpacing.headerInset, .001), + ); + expect( + title.right, + closeTo(physicalRight.left - BusyMaxSpacing.headerInset, .001), + ); + expect(title.overlaps(leading), isFalse); + expect(title.overlaps(trailing), isFalse); + }); + } + + testWidgets('uses actual groups placed by maxContentWidth', (tester) async { + const testCase = _LayoutCase('max content width', 34, 154, 0, 0, false); + await _pumpLayout( + tester, + testCase, + centerAllocation: + BusyMaxLinuxHeaderCenterAllocation.fillBetweenControls, + maxContentWidth: 600, + ); + + final title = tester.getRect(find.byKey(_titleKey)); + final leading = tester.getRect(find.byKey(_leadingKey)); + final trailing = tester.getRect(find.byKey(_trailingKey)); + expect( + title.left, + closeTo(leading.right + BusyMaxSpacing.headerInset, .001), + ); + expect( + title.right, + closeTo(trailing.left - BusyMaxSpacing.headerInset, .001), + ); + }); + + testWidgets('uses zero width when the safe edges cross', (tester) async { + const testCase = _LayoutCase( + 'pathological narrow width', + 68, + 68, + 0, + 0, + false, + ); + await _pumpLayout( + tester, + testCase, + centerAllocation: + BusyMaxLinuxHeaderCenterAllocation.fillBetweenControls, + headerWidth: 100, + ); + + expect(tester.getSize(find.byKey(_titleKey)).width, 0); + expect(tester.takeException(), isNull); + }); + + testWidgets('relayouts when the allocation policy changes', (tester) async { + const testCase = _LayoutCase('policy change', 34, 154, 0, 0, false); + await _pumpLayout(tester, testCase); + final centeredWidth = tester.getSize(find.byKey(_titleKey)).width; + + await _pumpLayout( + tester, + testCase, + centerAllocation: + BusyMaxLinuxHeaderCenterAllocation.fillBetweenControls, + ); + final fillWidth = tester.getSize(find.byKey(_titleKey)).width; + + expect(fillWidth, greaterThan(centeredWidth)); + }); + }); + + testWidgets('title and brand use GTK body role with distinct emphasis', ( + tester, + ) async { + const bodyStyle = TextStyle( + color: Color(0xFF284664), + fontFamily: 'GTK body', + fontSize: 14.25, + ); + await tester.pumpWidget( + _testApp( + windowActive: true, + theme: ThemeData( + textTheme: const TextTheme( + bodyMedium: bodyStyle, + titleMedium: TextStyle(fontSize: 30, fontFamily: 'Wrong role'), + ), + ), + child: const Row( + children: [ + BusyMaxLinuxHeaderTitle('June 2026'), + BusyMaxLinuxHeaderTitle('BusyMax', brand: true), + ], + ), + ), + ); + + final title = tester.widget(find.text('June 2026')); + final brand = tester.widget(find.text('BusyMax')); + expect(title.style?.fontSize, bodyStyle.fontSize); + expect(title.style?.fontFamily, bodyStyle.fontFamily); + expect(title.style?.color, bodyStyle.color); + expect(title.style?.fontWeight, FontWeight.bold); + expect(brand.style?.fontSize, bodyStyle.fontSize); + expect(brand.style?.fontFamily, bodyStyle.fontFamily); + expect(brand.style?.color, bodyStyle.color); + expect(brand.style?.fontWeight, FontWeight.w800); + }); + + testWidgets('active and inactive foreground metrics match native chrome', ( + tester, + ) async { + const bodyColor = Color(0xFF204060); + Future<(Color?, Color?, Color?, Color?)> pump(bool active) async { + await tester.pumpWidget( + _testApp( + windowActive: active, + theme: ThemeData( + textTheme: const TextTheme(bodyMedium: TextStyle(color: bodyColor)), + ), + child: const Column( + children: [ + BusyMaxLinuxHeaderTitle('Title', key: ValueKey('title')), + BusyMaxLinuxHeaderTitle( + 'Brand', + key: ValueKey('brand'), + brand: true, + ), + BusyMaxLinuxHeaderIconButton( + key: ValueKey('enabled'), + icon: BusyMaxLinuxHeaderIcon.search, + tooltip: 'Enabled', + onPressed: _noop, + ), + BusyMaxLinuxHeaderIconButton( + key: ValueKey('disabled'), + icon: BusyMaxLinuxHeaderIcon.create, + tooltip: 'Disabled', + onPressed: null, + ), + ], + ), + ), + ); + final enabled = _iconButton(tester, const ValueKey('enabled')); + final disabled = _iconButton(tester, const ValueKey('disabled')); + return ( + tester.widget(find.text('Title')).style?.color, + tester.widget(find.text('Brand')).style?.color, + enabled.style?.foregroundColor?.resolve(const {}), + disabled.style?.foregroundColor?.resolve(const {WidgetState.disabled}), + ); + } + + final active = await pump(true); + expect(active.$1, bodyColor); + expect(active.$2, bodyColor); + expect(active.$3?.a, 1); + expect(active.$4?.a, .38); + + final inactive = await pump(false); + expect(inactive.$1?.a, .50); + expect(inactive.$2?.a, .50); + expect(inactive.$3?.a, .50); + expect(inactive.$4?.a, .19); + }); + + testWidgets('application control states use neutral current-color layers', ( + tester, + ) async { + late BuildContext testContext; + await tester.pumpWidget( + _testApp( + windowActive: true, + theme: ThemeData(colorSchemeSeed: const Color(0xFFFF00FF)), + child: YaruTheme( + data: const YaruThemeData(focusBorders: true), + child: Builder( + builder: (context) { + testContext = context; + return const BusyMaxLinuxHeaderIconButton( + icon: BusyMaxLinuxHeaderIcon.mainMenu, + tooltip: 'Menu', + selected: true, + onPressed: _noop, + ); + }, + ), + ), + ), + ); + + final foreground = busyMaxLinuxHeaderForeground(testContext); + final background = busyMaxLinuxHeaderControlBackground(testContext); + Color? resolve(Set states) => background.resolve(states); + expect(resolve(const {}), Colors.transparent); + expect(resolve(const {WidgetState.focused}), Colors.transparent); + expect(resolve(const {WidgetState.disabled}), Colors.transparent); + expect( + resolve(const {WidgetState.hovered}), + foreground.withValues(alpha: foreground.a * .07), + ); + expect( + resolve(const {WidgetState.pressed}), + foreground.withValues(alpha: foreground.a * .16), + ); + expect( + resolve(const {WidgetState.selected}), + foreground.withValues(alpha: foreground.a * .10), + ); + expect( + resolve(const {WidgetState.selected, WidgetState.hovered}), + foreground.withValues(alpha: foreground.a * .13), + ); + expect( + resolve(const {WidgetState.selected, WidgetState.pressed}), + foreground.withValues(alpha: foreground.a * .19), + ); + + final button = tester.widget(find.byType(IconButton)); + expect(button.style?.splashFactory, NoSplash.splashFactory); + expect( + button.style?.overlayColor?.resolve(const {WidgetState.pressed}), + Colors.transparent, + ); + expect( + button.style?.foregroundColor?.resolve(const {WidgetState.selected}), + isNot(Theme.of(testContext).colorScheme.primary), + ); + final focusBorder = tester.widget( + find.byType(YaruFocusBorder), + ); + expect( + focusBorder.borderRadius, + BorderRadius.circular(BusyMaxLinuxHeaderStyle.controlRadius), + ); + }); + + testWidgets('header Search owns GTK geometry, colors, and artwork', ( + tester, + ) async { + final semantics = tester.ensureSemantics(); + final controller = TextEditingController(text: 'planning'); + addTearDown(controller.dispose); + final png = base64Decode( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVQIHWP4z8DwHwAFgAI/ScL1WQAAAABJRU5ErkJggg==', + ); + final service = GtkHeaderIconService.testing( + initialCatalog: GtkHeaderIconCatalog({ + 'searchEntryFind.ltr': GtkHeaderIconAsset( + bytes: png, + resolvedName: 'edit-find-symbolic', + scale: 1, + pixelWidth: 16, + pixelHeight: 16, + ), + 'searchClear.ltr': GtkHeaderIconAsset( + bytes: png, + resolvedName: 'edit-clear-symbolic', + scale: 1, + pixelWidth: 16, + pixelHeight: 16, + ), + }, revision: 1), + ); + addTearDown(service.dispose); + + await tester.pumpWidget( + GtkHeaderIconScope( + service: service, + child: _testApp( + theme: ThemeData( + colorScheme: ColorScheme.fromSeed( + seedColor: _searchFocus, + primary: _searchFocus, + ), + textTheme: const TextTheme( + bodyMedium: TextStyle(fontFamily: 'GTK body', fontSize: 14.25), + ), + extensions: const [_searchColors, _searchTheme], + ), + child: Align( + child: SizedBox( + width: 1000, + child: Align( + child: BusyMaxLinuxHeaderSearchField( + controller: controller, + focusRequest: 0, + semanticLabel: 'Search', + onChanged: (_) {}, + onClear: controller.clear, + autofocus: false, + ), + ), + ), + ), + ), + ), + ); + + final field = find.byType(BusyMaxLinuxHeaderSearchField); + expect( + tester.getSize(field).height, + BusyMaxLinuxHeaderStyle.searchEntryHeight, + ); + expect(tester.getSize(field).width, 800); + final textField = tester.widget( + find.descendant(of: field, matching: find.byType(TextField)), + ); + expect(textField.decoration?.hintText, isNull); + expect(textField.decoration?.filled, isFalse); + expect(textField.decoration?.border, InputBorder.none); + expect(textField.style?.fontFamily, 'GTK body'); + expect(textField.style?.fontSize, 14.25); + expect(textField.style?.fontWeight, FontWeight.normal); + expect(textField.style?.color, _searchNormal.foreground); + expect(textField.cursorColor, _searchFocus); + expect(textField.cursorWidth, 1); + + final normalDecoration = _searchShellDecoration(tester); + expect(normalDecoration.color, _searchNormal.background); + expect( + normalDecoration.borderRadius, + BorderRadius.circular(_searchNormal.radius), + ); + final normalBorderDecoration = _searchShellBorderDecoration(tester); + final normalBorder = _border(normalBorderDecoration); + expect(normalBorder.top.color, _searchNormal.borderColor); + expect(normalBorder.top.width, _searchNormal.borderTop); + expect(normalBorder.right.width, _searchNormal.borderRight); + expect(normalBorder.bottom.width, _searchNormal.borderBottom); + expect(normalBorder.left.width, _searchNormal.borderLeft); + final icons = tester + .widgetList( + find.descendant( + of: field, + matching: find.byType(BusyMaxGtkHeaderIcon), + ), + ) + .toList(); + expect(icons, hasLength(2)); + expect(icons.map((icon) => icon.icon), [ + BusyMaxLinuxHeaderIcon.searchEntryFind, + BusyMaxLinuxHeaderIcon.searchClear, + ]); + for (final icon in icons) { + expect( + tester.getSize(find.byWidget(icon)), + const Size.square(BusyMaxLinuxHeaderStyle.symbolicIconSize), + ); + } + expect( + find.descendant(of: field, matching: find.byType(Image)), + findsNWidgets(2), + ); + expect( + find.descendant(of: field, matching: find.byType(Icon)), + findsNothing, + ); + expect( + find.descendant( + of: field, + matching: find.byType(BusyMaxHeaderIconButton), + ), + findsNothing, + ); + expect( + find.descendant(of: field, matching: find.byType(IconButton)), + findsNothing, + ); + + final clearIcon = find.byWidgetPredicate( + (widget) => + widget is BusyMaxGtkHeaderIcon && + widget.icon == BusyMaxLinuxHeaderIcon.searchClear, + ); + final findIcon = _primarySearchIcon(); + final fieldRect = tester.getRect(field); + final textRect = tester.getRect(find.byType(TextField)); + final findIconRect = tester.getRect(findIcon); + final clearIconRect = tester.getRect(clearIcon); + expect( + findIconRect.left - fieldRect.left, + BusyMaxLinuxHeaderStyle.searchEntryHorizontalPadding, + ); + expect( + textRect.left - findIconRect.right, + BusyMaxLinuxHeaderStyle.searchEntryIconGap, + ); + expect( + clearIconRect.left - textRect.right, + BusyMaxLinuxHeaderStyle.searchEntryIconGap, + ); + expect( + fieldRect.right - clearIconRect.right, + BusyMaxLinuxHeaderStyle.searchEntryHorizontalPadding, + ); + expect(_iconColor(tester, findIcon), _searchNormal.primaryIconForeground); + expect( + _iconColor(tester, clearIcon), + _searchNormal.secondaryIconForeground, + ); + final clearTarget = find.byKey(BusyMaxLinuxHeaderSearchField.clearKey); + final clearSemantics = tester.getSemantics(clearTarget).getSemanticsData(); + expect( + clearSemantics.label, + MaterialLocalizations.of(tester.element(clearTarget)).clearButtonTooltip, + ); + expect(clearSemantics.flagsCollection.isButton, isTrue); + expect(clearSemantics.hasAction(SemanticsAction.tap), isTrue); + + final mouse = await tester.createGesture(kind: PointerDeviceKind.mouse); + addTearDown(mouse.removePointer); + await mouse.addPointer(location: Offset.zero); + await mouse.moveTo(tester.getCenter(clearIcon)); + await tester.pump(); + expect(_iconColor(tester, clearIcon), _searchNormal.foreground); + await mouse.down(tester.getCenter(clearIcon)); + await tester.pump(); + expect(_iconColor(tester, clearIcon), _searchFocus); + await mouse.up(); + await tester.pump(); + + expect(controller.text, isEmpty); + expect(find.byType(BusyMaxLinuxHeaderSearchField), findsOneWidget); + expect(clearIcon, findsNothing); + expect(findIcon, findsOneWidget); + expect( + tester.getRect(findIcon).left, + tester.getRect(field).left + + BusyMaxLinuxHeaderStyle.searchEntryHorizontalPadding, + ); + expect(_iconColor(tester, findIcon), _searchFocused.primaryIconForeground); + expect(find.text('Search'), findsNothing); + expect(find.bySemanticsLabel('Search'), findsOneWidget); + semantics.dispose(); + }); + + testWidgets('bounded header Search fills the complete safe center slot', ( + tester, + ) async { + final controller = TextEditingController(); + addTearDown(controller.dispose); + + for (final width in const [600.0, 320.0]) { + await tester.pumpWidget( + _testApp( + child: Center( + child: SizedBox( + width: width, + child: BusyMaxLinuxHeaderSearchField( + controller: controller, + focusRequest: 0, + semanticLabel: 'Search', + onChanged: (_) {}, + onClear: controller.clear, + autofocus: false, + ), + ), + ), + ), + ); + + expect( + tester.getSize(find.byType(BusyMaxLinuxHeaderSearchField)).width, + width, + ); + } + }); + + testWidgets('unbounded header Search uses the GTK natural-width hint', ( + tester, + ) async { + final controller = TextEditingController(); + addTearDown(controller.dispose); + await tester.pumpWidget( + _testApp( + theme: ThemeData(extensions: const [_searchColors, _searchTheme]), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + BusyMaxLinuxHeaderSearchField( + controller: controller, + focusRequest: 0, + semanticLabel: 'Search', + onChanged: (_) {}, + onClear: controller.clear, + autofocus: false, + ), + ], + ), + ), + ); + + final textField = tester.widget(find.byType(TextField)); + final probe = TextPainter( + text: TextSpan( + text: List.filled( + BusyMaxLinuxHeaderSearchField.naturalWidthChars, + '0', + ).join(), + style: textField.style, + ), + maxLines: 1, + textDirection: TextDirection.ltr, + )..layout(); + final expected = + probe.width + + BusyMaxLinuxHeaderStyle.searchEntryHorizontalPadding * 2 + + _searchNormal.borderLeft + + _searchNormal.borderRight; + probe.dispose(); + + expect( + tester.getSize(find.byType(BusyMaxLinuxHeaderSearchField)).width, + closeTo(expected, .001), + ); + }); + + testWidgets('non-empty Search publication waits exactly 150ms', ( + tester, + ) async { + final controller = TextEditingController(); + final published = []; + addTearDown(controller.dispose); + await _pumpIsolatedSearch( + tester, + controller: controller, + onChanged: published.add, + ); + + await tester.enterText(find.byType(TextField), 'planning'); + expect(controller.text, 'planning'); + expect(published, isEmpty); + await tester.pump(const Duration(milliseconds: 149)); + expect(published, isEmpty); + await tester.pump(const Duration(milliseconds: 1)); + expect(published, ['planning']); + }); + + testWidgets('new Search edits restart the delayed publication', ( + tester, + ) async { + final controller = TextEditingController(); + final published = []; + addTearDown(controller.dispose); + await _pumpIsolatedSearch( + tester, + controller: controller, + onChanged: published.add, + ); + + await tester.enterText(find.byType(TextField), 'p'); + await tester.pump(const Duration(milliseconds: 100)); + await tester.enterText(find.byType(TextField), 'pl'); + await tester.pump(const Duration(milliseconds: 100)); + expect(published, isEmpty); + await tester.pump(const Duration(milliseconds: 50)); + expect(published, ['pl']); + }); + + testWidgets('empty Search edits publish immediately', (tester) async { + final controller = TextEditingController(text: 'planning'); + final published = []; + addTearDown(controller.dispose); + await _pumpIsolatedSearch( + tester, + controller: controller, + onChanged: published.add, + ); + + await tester.enterText(find.byType(TextField), ''); + expect(controller.text, isEmpty); + expect(published, ['']); + }); + + testWidgets('Clear cancels a pending non-empty Search publication', ( + tester, + ) async { + final controller = TextEditingController(); + final published = []; + addTearDown(controller.dispose); + await _pumpIsolatedSearch( + tester, + controller: controller, + onChanged: published.add, + onClear: () { + published.add(''); + controller.clear(); + }, + ); + + await tester.enterText(find.byType(TextField), 'planning'); + await tester.pump(const Duration(milliseconds: 50)); + await tester.tap(find.byKey(BusyMaxLinuxHeaderSearchField.clearKey)); + await tester.pump(); + expect(controller.text, isEmpty); + expect(published, ['']); + await tester.pump(const Duration(milliseconds: 151)); + expect(published, ['']); + }); + + testWidgets('disposing Search cancels pending publication', (tester) async { + final controller = TextEditingController(); + final published = []; + addTearDown(controller.dispose); + await _pumpIsolatedSearch( + tester, + controller: controller, + onChanged: published.add, + ); + + await tester.enterText(find.byType(TextField), 'planning'); + await tester.pump(const Duration(milliseconds: 50)); + await tester.pumpWidget(const SizedBox.shrink()); + await tester.pump(const Duration(milliseconds: 151)); + expect(published, isEmpty); + }); + + testWidgets( + 'controller replacement and programmatic edits cancel stale text', + (tester) async { + final first = TextEditingController(); + final second = TextEditingController(text: 'replacement'); + final published = []; + addTearDown(first.dispose); + addTearDown(second.dispose); + await _pumpIsolatedSearch( + tester, + controller: first, + onChanged: published.add, + ); + + await tester.enterText(find.byType(TextField), 'old'); + await tester.pump(const Duration(milliseconds: 50)); + await _pumpIsolatedSearch( + tester, + controller: second, + onChanged: published.add, + ); + await tester.pump(const Duration(milliseconds: 151)); + expect(published, isEmpty); + + await tester.enterText(find.byType(TextField), 'pending'); + second.text = 'programmatic'; + await tester.pump(const Duration(milliseconds: 151)); + expect(published, isEmpty); + }, + ); + + testWidgets('header Search radius comes from the injected GTK theme', ( + tester, + ) async { + final controller = TextEditingController(); + addTearDown(controller.dispose); + + for (final radius in const [9.0, 5.0, 11.0]) { + await tester.pumpWidget( + _testApp( + theme: ThemeData( + extensions: [_searchColors, _searchThemeWithRadius(radius)], + ), + child: BusyMaxLinuxHeaderSearchField( + controller: controller, + focusRequest: 0, + semanticLabel: 'Search', + onChanged: (_) {}, + onClear: controller.clear, + autofocus: false, + ), + ), + ); + await tester.pumpAndSettle(); + + expect( + _searchShellDecoration(tester).borderRadius, + BorderRadius.circular(radius), + ); + } + }); + + testWidgets('header Search uses focused and backdrop-focused GTK states', ( + tester, + ) async { + final controller = TextEditingController(text: 'planning review'); + addTearDown(controller.dispose); + + Widget app({required bool active}) => _testApp( + windowActive: active, + theme: ThemeData( + colorScheme: ColorScheme.fromSeed(seedColor: _searchFocus), + extensions: const [_searchColors, _searchTheme], + ), + child: BusyMaxLinuxHeaderSearchField( + controller: controller, + focusRequest: 0, + semanticLabel: 'Search', + onChanged: (_) {}, + onClear: controller.clear, + autofocus: false, + ), + ); + + await tester.pumpWidget(app(active: true)); + await tester.tap(find.byType(TextField)); + await tester.pump(); + controller.selection = const TextSelection(baseOffset: 2, extentOffset: 7); + final editableBefore = tester.widget( + find.byType(EditableText), + ); + final focusNode = editableBefore.focusNode; + _expectSearchState(tester, _searchFocused); + expect( + _iconColor(tester, _primarySearchIcon()), + _searchFocused.primaryIconForeground, + ); + expect( + _iconColor(tester, _secondarySearchIcon()), + _searchFocused.secondaryIconForeground, + ); + + await tester.pumpWidget(app(active: false)); + await tester.pump(); + _expectSearchState(tester, _searchBackdropFocused); + expect( + _iconColor(tester, _primarySearchIcon()), + _searchBackdropFocused.primaryIconForeground, + ); + expect( + _iconColor(tester, _secondarySearchIcon()), + _searchBackdropFocused.secondaryIconForeground, + ); + final editableBackdrop = tester.widget( + find.byType(EditableText), + ); + expect(editableBackdrop.focusNode, same(focusNode)); + expect(editableBackdrop.focusNode.hasFocus, isTrue); + expect(controller.text, 'planning review'); + expect( + controller.selection, + const TextSelection(baseOffset: 2, extentOffset: 7), + ); + + final mouse = await tester.createGesture(kind: PointerDeviceKind.mouse); + addTearDown(mouse.removePointer); + await mouse.addPointer(location: Offset.zero); + await mouse.moveTo(tester.getCenter(_secondarySearchIcon())); + await tester.pump(); + expect( + _iconColor(tester, _secondarySearchIcon()), + _searchBackdropFocused.secondaryIconForeground, + ); + + await tester.pumpWidget(app(active: true)); + await tester.pump(); + _expectSearchState(tester, _searchFocused); + expect( + _iconColor(tester, _secondarySearchIcon()), + _searchFocused.foreground, + ); + expect(controller.text, 'planning review'); + expect( + controller.selection, + const TextSelection(baseOffset: 2, extentOffset: 7), + ); + }); + + testWidgets('inactive unfocused header Search uses GTK backdrop state', ( + tester, + ) async { + final controller = TextEditingController(text: 'planning'); + addTearDown(controller.dispose); + await tester.pumpWidget( + _testApp( + windowActive: false, + theme: ThemeData(extensions: const [_searchColors, _searchTheme]), + child: BusyMaxLinuxHeaderSearchField( + controller: controller, + focusRequest: 0, + semanticLabel: 'Search', + onChanged: (_) {}, + onClear: controller.clear, + autofocus: false, + ), + ), + ); + + _expectSearchState(tester, _searchBackdrop); + expect( + _iconColor(tester, _primarySearchIcon()), + _searchBackdrop.primaryIconForeground, + ); + expect( + _iconColor(tester, _secondarySearchIcon()), + _searchBackdrop.secondaryIconForeground, + ); + }); + + testWidgets('Find and Clear use logical primary and secondary sides', ( + tester, + ) async { + final controller = TextEditingController(); + addTearDown(controller.dispose); + + for (final direction in TextDirection.values) { + for (final query in const ['', 'query']) { + controller.text = query; + await tester.pumpWidget( + _testApp( + direction: direction, + theme: ThemeData(extensions: const [_searchColors, _searchTheme]), + child: BusyMaxLinuxHeaderSearchField( + controller: controller, + focusRequest: 0, + semanticLabel: 'Search', + onChanged: (_) {}, + onClear: controller.clear, + autofocus: false, + ), + ), + ); + + final icons = tester + .widgetList(find.byType(BusyMaxGtkHeaderIcon)) + .toList(); + expect(icons, hasLength(query.isEmpty ? 1 : 2)); + expect(icons.first.icon, BusyMaxLinuxHeaderIcon.searchEntryFind); + if (query.isNotEmpty) { + expect(icons.last.icon, BusyMaxLinuxHeaderIcon.searchClear); + } + final fieldRect = tester.getRect( + find.byType(BusyMaxLinuxHeaderSearchField), + ); + final primaryRect = tester.getRect(_primarySearchIcon()); + final textRect = tester.getRect(find.byType(TextField)); + if (direction == TextDirection.ltr) { + expect( + primaryRect.left - fieldRect.left, + BusyMaxLinuxHeaderStyle.searchEntryHorizontalPadding, + ); + expect( + textRect.left - primaryRect.right, + BusyMaxLinuxHeaderStyle.searchEntryIconGap, + ); + expect( + _iconColor(tester, _primarySearchIcon()), + _searchNormal.primaryIconForeground, + ); + if (query.isEmpty) { + expect( + fieldRect.right - textRect.right, + BusyMaxLinuxHeaderStyle.searchEntryHorizontalPadding, + ); + } else { + final secondaryRect = tester.getRect(_secondarySearchIcon()); + expect( + secondaryRect.left - textRect.right, + BusyMaxLinuxHeaderStyle.searchEntryIconGap, + ); + expect( + fieldRect.right - secondaryRect.right, + BusyMaxLinuxHeaderStyle.searchEntryHorizontalPadding, + ); + expect( + _iconColor(tester, _secondarySearchIcon()), + _searchNormal.secondaryIconForeground, + ); + } + } else { + expect( + fieldRect.right - primaryRect.right, + BusyMaxLinuxHeaderStyle.searchEntryHorizontalPadding, + ); + expect( + primaryRect.left - textRect.right, + BusyMaxLinuxHeaderStyle.searchEntryIconGap, + ); + expect( + _iconColor(tester, _primarySearchIcon()), + _searchNormal.primaryIconForegroundRtl, + ); + if (query.isEmpty) { + expect( + textRect.left - fieldRect.left, + BusyMaxLinuxHeaderStyle.searchEntryHorizontalPadding, + ); + } else { + final secondaryRect = tester.getRect(_secondarySearchIcon()); + expect( + textRect.left - secondaryRect.right, + BusyMaxLinuxHeaderStyle.searchEntryIconGap, + ); + expect( + secondaryRect.left - fieldRect.left, + BusyMaxLinuxHeaderStyle.searchEntryHorizontalPadding, + ); + expect( + _iconColor(tester, _secondarySearchIcon()), + _searchNormal.secondaryIconForegroundRtl, + ); + } + } + } + } + }); + + testWidgets( + 'header Search focus chrome and high contrast radius are native', + (tester) async { + final controller = TextEditingController(); + addTearDown(controller.dispose); + final theme = ThemeData( + colorScheme: ColorScheme.fromSeed( + seedColor: _searchFocus, + primary: _searchFocus, + ), + extensions: const [_searchColors], + ); + await tester.pumpWidget( + _testApp( + theme: theme, + child: BusyMaxLinuxHeaderSearchField( + controller: controller, + focusRequest: 0, + semanticLabel: 'Search', + onChanged: (_) {}, + onClear: controller.clear, + autofocus: false, + ), + ), + ); + + await tester.tap(find.byType(TextField)); + await tester.pump(); + final focusedDecoration = _searchShellBorderDecoration(tester); + expect(_border(focusedDecoration).top.color, _searchFocus); + expect( + _border(focusedDecoration).top.width, + BusyMaxLinuxHeaderStyle.searchEntryBorderWidth, + ); + final focusedInset = _searchInnerFocusDecoration(tester); + expect(_border(focusedInset).top.color, _searchFocus); + expect( + _border(focusedInset).top.width, + BusyMaxLinuxHeaderStyle.searchEntryInnerFocusWidth, + ); + expect( + tester + .widget( + find.byKey(BusyMaxLinuxHeaderSearchField.shellKey), + ) + .duration, + BusyMaxLinuxHeaderStyle.searchEntryFocusDuration, + ); + + await tester.pumpWidget( + _testApp( + theme: theme, + child: MediaQuery( + data: const MediaQueryData(disableAnimations: true), + child: BusyMaxLinuxHeaderSearchField( + controller: controller, + focusRequest: 0, + semanticLabel: 'Search', + onChanged: (_) {}, + onClear: controller.clear, + autofocus: false, + ), + ), + ), + ); + await tester.tap(find.byType(TextField)); + await tester.pump(); + expect( + tester + .widget( + find.byKey(BusyMaxLinuxHeaderSearchField.shellKey), + ) + .duration, + Duration.zero, + ); + expect( + tester + .widget( + find.byKey(BusyMaxLinuxHeaderSearchField.innerFocusKey), + ) + .duration, + Duration.zero, + ); + + final highContrastTheme = buildBusyMaxTheme( + brightness: Brightness.light, + accentColor: _searchFocus, + highContrast: true, + ); + await tester.pumpWidget( + _testApp( + child: Theme( + data: highContrastTheme, + child: BusyMaxLinuxHeaderSearchField( + controller: controller, + focusRequest: 0, + semanticLabel: 'Search', + onChanged: (_) {}, + onClear: controller.clear, + autofocus: false, + ), + ), + ), + ); + expect( + _searchShellDecoration(tester).borderRadius, + BorderRadius.circular(BusyMaxLinuxHeaderStyle.controlRadius), + ); + final highContrastColors = highContrastTheme + .extension()!; + expect(_searchShellDecoration(tester).color, highContrastColors.view); + expect( + _border(_searchShellBorderDecoration(tester)).top.color, + highContrastColors.border, + ); + final highContrastSearchIcon = find.byWidgetPredicate( + (widget) => + widget is BusyMaxGtkHeaderIcon && + widget.icon == BusyMaxLinuxHeaderIcon.searchEntryFind, + ); + expect( + _iconColor(tester, highContrastSearchIcon), + highContrastColors.mutedForeground, + ); + }, + ); + + testWidgets('Search focus paint does not shift shell, icons, or text', ( + tester, + ) async { + final controller = TextEditingController(text: 'planning'); + addTearDown(controller.dispose); + await _pumpIsolatedSearch( + tester, + controller: controller, + onChanged: (_) {}, + ); + + final field = find.byType(BusyMaxLinuxHeaderSearchField); + final primary = _primarySearchIcon(); + final text = find.byType(TextField); + final secondary = _secondarySearchIcon(); + final before = ( + tester.getRect(field), + tester.getRect(primary), + tester.getRect(text), + tester.getRect(secondary), + ); + + await tester.tap(text); + await tester.pumpAndSettle(); + + final after = ( + tester.getRect(field), + tester.getRect(primary), + tester.getRect(text), + tester.getRect(secondary), + ); + expect(after, before); + expect(_border(_searchShellBorderDecoration(tester)).top.width, 1); + expect(_border(_searchInnerFocusDecoration(tester)).top.width, 1); + }); + + testWidgets('Search focus inset snaps at supported device pixel ratios', ( + tester, + ) async { + final controller = TextEditingController(); + addTearDown(controller.dispose); + + for (final ratio in const [1.0, 1.25, 1.5, 2.0]) { + tester.view.devicePixelRatio = ratio; + addTearDown(tester.view.resetDevicePixelRatio); + await _pumpIsolatedSearch( + tester, + controller: controller, + onChanged: (_) {}, + ); + await tester.tap(find.byType(TextField)); + await tester.pumpAndSettle(); + + final insetWidth = _border(_searchInnerFocusDecoration(tester)).top.width; + expect(insetWidth * ratio, closeTo((1 * ratio).roundToDouble(), .001)); + final insetContainer = tester.widget( + find.byKey(BusyMaxLinuxHeaderSearchField.innerFocusKey), + ); + final insetMargin = insetContainer.margin! as EdgeInsets; + final expectedPhysicalInset = (1 * ratio).roundToDouble(); + expect(insetMargin.left * ratio, closeTo(expectedPhysicalInset, .001)); + expect(insetMargin.top * ratio, closeTo(expectedPhysicalInset, .001)); + } + }); + + testWidgets('header Search uses semantic view chrome in light and dark', ( + tester, + ) async { + final controller = TextEditingController(); + addTearDown(controller.dispose); + + for (final brightness in Brightness.values) { + final theme = buildBusyMaxTheme( + brightness: brightness, + accentColor: _searchFocus, + ); + final colors = theme.extension()!; + await tester.pumpWidget( + _testApp( + child: Theme( + data: theme, + child: BusyMaxLinuxHeaderSearchField( + controller: controller, + focusRequest: 0, + semanticLabel: 'Search', + onChanged: (_) {}, + onClear: controller.clear, + autofocus: false, + ), + ), + ), + ); + + expect(_searchShellDecoration(tester).color, colors.view); + expect( + _border(_searchShellBorderDecoration(tester)).top.color, + colors.border, + ); + expect( + tester.widget(find.byType(TextField)).style?.color, + colors.foreground, + ); + final searchIcon = find.byWidgetPredicate( + (widget) => + widget is BusyMaxGtkHeaderIcon && + widget.icon == BusyMaxLinuxHeaderIcon.searchEntryFind, + ); + expect(_iconColor(tester, searchIcon), colors.mutedForeground); + expect(_searchShellDecoration(tester).color, isNot(colors.headerbar)); + } + }); + + testWidgets('header Search focus requests select all without rebuild churn', ( + tester, + ) async { + final controller = TextEditingController(text: 'planning review'); + addTearDown(controller.dispose); + + Widget app(int focusRequest, Color surface) => _testApp( + theme: ThemeData(scaffoldBackgroundColor: surface), + child: BusyMaxLinuxHeaderSearchField( + controller: controller, + focusRequest: focusRequest, + semanticLabel: 'Search', + onChanged: (_) {}, + onClear: controller.clear, + ), + ); + + await tester.pumpWidget(app(0, Colors.white)); + await tester.pump(); + var editable = tester.widget(find.byType(EditableText)); + final focusNode = editable.focusNode; + expect(focusNode.hasFocus, isTrue); + expect( + controller.selection, + const TextSelection(baseOffset: 0, extentOffset: 15), + ); + + controller.selection = const TextSelection(baseOffset: 2, extentOffset: 8); + await tester.pumpWidget(app(0, Colors.grey)); + await tester.pump(); + editable = tester.widget(find.byType(EditableText)); + expect(editable.focusNode, same(focusNode)); + expect( + controller.selection, + const TextSelection(baseOffset: 2, extentOffset: 8), + ); + + await tester.pumpWidget(app(1, Colors.grey)); + await tester.pump(); + expect( + controller.selection, + const TextSelection(baseOffset: 0, extentOffset: 15), + ); + }); + + testWidgets('GTK appearance changes retain Search query focus and caret', ( + tester, + ) async { + final controller = TextEditingController(text: 'planning review'); + addTearDown(controller.dispose); + + Widget app(double radius) => _testApp( + theme: ThemeData( + extensions: [_searchColors, _searchThemeWithRadius(radius)], + ), + child: BusyMaxLinuxHeaderSearchField( + controller: controller, + focusRequest: 0, + semanticLabel: 'Search', + onChanged: (_) {}, + onClear: controller.clear, + autofocus: false, + ), + ); + + await tester.pumpWidget(app(5)); + await tester.tap(find.byType(TextField)); + await tester.pump(); + controller.selection = const TextSelection.collapsed(offset: 6); + final before = tester.widget(find.byType(EditableText)); + + await tester.pumpWidget(app(13)); + await tester.pumpAndSettle(); + + final after = tester.widget(find.byType(EditableText)); + expect(after.controller, same(before.controller)); + expect(after.focusNode, same(before.focusNode)); + expect(after.focusNode.hasFocus, isTrue); + expect(controller.text, 'planning review'); + expect(controller.selection, const TextSelection.collapsed(offset: 6)); + expect( + _searchShellDecoration(tester).borderRadius, + BorderRadius.circular(13), + ); + }); +} + +const _searchFocus = Color(0xFF2468AC); +const _searchNormal = GtkSearchEntryStateStyle( + background: Color(0xFFF8F9FA), + foreground: Color(0xFF182838), + borderTop: 1, + borderRight: 2, + borderBottom: 3, + borderLeft: 4, + borderColor: Color(0xFF8192A3), + primaryIconForeground: Color(0xFF506070), + primaryIconForegroundRtl: Color(0xFF516171), + secondaryIconForeground: Color(0xFF526272), + secondaryIconForegroundRtl: Color(0xFF536373), + radius: 11, +); +const _searchFocused = GtkSearchEntryStateStyle( + background: Color(0xFFF1F5FA), + foreground: Color(0xFF102030), + borderTop: 1, + borderRight: 1, + borderBottom: 1, + borderLeft: 1, + borderColor: Color(0xFF2468AC), + primaryIconForeground: Color(0xFF304860), + primaryIconForegroundRtl: Color(0xFF314961), + secondaryIconForeground: Color(0xFF324A62), + secondaryIconForegroundRtl: Color(0xFF334B63), + radius: 10, + hasInnerFocus: true, + innerFocusColor: Color(0xFF2468AC), + innerFocusWidth: 1, +); +const _searchBackdrop = GtkSearchEntryStateStyle( + background: Color(0xFFE1E2E3), + foreground: Color(0xFF747576), + borderTop: .5, + borderRight: 1, + borderBottom: 1.5, + borderLeft: 2, + borderColor: Color(0xFF919293), + primaryIconForeground: Color(0xFF858687), + primaryIconForegroundRtl: Color(0xFF868788), + secondaryIconForeground: Color(0xFF878889), + secondaryIconForegroundRtl: Color(0xFF88898A), + radius: 9, +); +const _searchBackdropFocused = GtkSearchEntryStateStyle( + background: Color(0xFFD1D2D3), + foreground: Color(0xFF646566), + borderTop: 1, + borderRight: 1, + borderBottom: 1, + borderLeft: 1, + borderColor: Color(0xFF717273), + primaryIconForeground: Color(0xFF757677), + primaryIconForegroundRtl: Color(0xFF767778), + secondaryIconForeground: Color(0xFF777879), + secondaryIconForegroundRtl: Color(0xFF78797A), + radius: 8, +); +const _searchTheme = BusyMaxNativeSearchEntryTheme( + normal: _searchNormal, + focused: _searchFocused, + backdrop: _searchBackdrop, + backdropFocused: _searchBackdropFocused, +); +const _searchColors = BusyMaxSurfaceColors( + window: Color(0xFFF0F0F0), + view: Color(0xFFFAFBFC), + sidebar: Color(0xFFE0E0E0), + secondarySidebar: Color(0xFFE8E8E8), + headerbar: Color(0xFFF0F0F0), + headerbarFlat: Color(0xFFF0F0F0), + card: Color(0xFFFFFFFF), + groupedSurface: Color(0xFFFFFFFF), + dialog: Color(0xFFFFFFFF), + popover: Color(0xFFFFFFFF), + control: Color(0xFFD0D0D0), + controlHover: Color(0xFFC0C0C0), + controlActive: Color(0xFFB0B0B0), + activeToggle: Color(0xFFFFFFFF), + foreground: Color(0xFF182838), + mutedForeground: Color(0xFF607080), + disabledForeground: Color(0xFF909090), + disabledControl: Color(0xFFE0E0E0), + border: Color(0xFF8192A3), + divider: Color(0xFFC0C0C0), + cardShade: Color(0xFFD0D0D0), + dialogOutline: Color(0xFFC0C0C0), + floatingBorder: Color(0xFFB0B0B0), + sidebarBorder: Color(0xFFA0A0A0), + shade: Color(0x22000000), +); + +BoxDecoration _searchShellDecoration(WidgetTester tester) => + tester + .widget( + find.byKey(BusyMaxLinuxHeaderSearchField.shellKey), + ) + .decoration! + as BoxDecoration; + +BoxDecoration _searchShellBorderDecoration(WidgetTester tester) => + tester + .widget( + find.byKey(BusyMaxLinuxHeaderSearchField.shellKey), + ) + .foregroundDecoration! + as BoxDecoration; + +BoxDecoration _searchInnerFocusDecoration(WidgetTester tester) => + tester + .widget( + find.byKey(BusyMaxLinuxHeaderSearchField.innerFocusKey), + ) + .foregroundDecoration! + as BoxDecoration; + +Border _border(BoxDecoration decoration) => decoration.border! as Border; + +Color? _iconColor(WidgetTester tester, Finder icon) => + IconTheme.of(tester.element(icon)).color; + +Finder _secondarySearchIcon() => find.byWidgetPredicate( + (widget) => + widget is BusyMaxGtkHeaderIcon && + widget.icon == BusyMaxLinuxHeaderIcon.searchClear, +); + +Finder _primarySearchIcon() => find.byWidgetPredicate( + (widget) => + widget is BusyMaxGtkHeaderIcon && + widget.icon == BusyMaxLinuxHeaderIcon.searchEntryFind, +); + +void _expectSearchState( + WidgetTester tester, + GtkSearchEntryStateStyle expected, +) { + expect(_searchShellDecoration(tester).color, expected.background); + expect( + _searchShellDecoration(tester).borderRadius, + BorderRadius.circular(expected.radius), + ); + final border = _border(_searchShellBorderDecoration(tester)); + expect( + border.top, + BorderSide(color: expected.borderColor, width: expected.borderTop), + ); + expect( + border.right, + BorderSide(color: expected.borderColor, width: expected.borderRight), + ); + expect( + border.bottom, + BorderSide(color: expected.borderColor, width: expected.borderBottom), + ); + expect( + border.left, + BorderSide(color: expected.borderColor, width: expected.borderLeft), + ); + expect( + tester.widget(find.byType(TextField)).style?.color, + expected.foreground, + ); + final innerFocus = _border(_searchInnerFocusDecoration(tester)); + expect( + innerFocus.top.width, + expected.hasInnerFocus ? expected.innerFocusWidth : 0, + ); + expect( + innerFocus.top.color, + expected.hasInnerFocus + ? expected.innerFocusColor + : expected.innerFocusColor.withValues(alpha: 0), + ); +} + +BusyMaxNativeSearchEntryTheme _searchThemeWithRadius(double radius) { + GtkSearchEntryStateStyle apply(GtkSearchEntryStateStyle state) { + return GtkSearchEntryStateStyle( + background: state.background, + foreground: state.foreground, + borderTop: state.borderTop, + borderRight: state.borderRight, + borderBottom: state.borderBottom, + borderLeft: state.borderLeft, + borderColor: state.borderColor, + primaryIconForeground: state.primaryIconForeground, + primaryIconForegroundRtl: state.primaryIconForegroundRtl, + secondaryIconForeground: state.secondaryIconForeground, + secondaryIconForegroundRtl: state.secondaryIconForegroundRtl, + radius: radius, + hasInnerFocus: state.hasInnerFocus, + innerFocusColor: state.innerFocusColor, + innerFocusWidth: state.innerFocusWidth, + ); + } + + return BusyMaxNativeSearchEntryTheme( + normal: apply(_searchNormal), + focused: apply(_searchFocused), + backdrop: apply(_searchBackdrop), + backdropFocused: apply(_searchBackdropFocused), + ); +} + +const _headerKey = ValueKey('header'); +const _titleKey = ValueKey('title'); +const _leadingKey = ValueKey('leading'); +const _trailingKey = ValueKey('trailing'); + +void _noop() {} + +Future _pumpIsolatedSearch( + WidgetTester tester, { + required TextEditingController controller, + required ValueChanged onChanged, + VoidCallback? onClear, +}) async { + await tester.pumpWidget( + _testApp( + theme: ThemeData(extensions: const [_searchColors, _searchTheme]), + child: Center( + child: SizedBox( + width: 600, + child: BusyMaxLinuxHeaderSearchField( + controller: controller, + focusRequest: 0, + semanticLabel: 'Search', + onChanged: onChanged, + onClear: onClear ?? controller.clear, + autofocus: false, + ), + ), + ), + ), + ); +} + +IconButton _iconButton(WidgetTester tester, Key key) { + return tester.widget( + find.descendant(of: find.byKey(key), matching: find.byType(IconButton)), + ); +} + +Future _pumpLayout( + WidgetTester tester, + _LayoutCase testCase, { + BusyMaxLinuxHeaderCenterAllocation centerAllocation = + BusyMaxLinuxHeaderCenterAllocation.centered, + double headerWidth = 1000, + double? maxContentWidth, +}) async { + tester.view + ..devicePixelRatio = 1 + ..physicalSize = Size(headerWidth, 200); + addTearDown(tester.view.reset); + await tester.pumpWidget( + _testApp( + direction: testCase.rtl ? TextDirection.rtl : TextDirection.ltr, + child: LinuxPageHeaderInsetsScope( + leftObstruction: testCase.leftObstruction, + rightObstruction: testCase.rightObstruction, + child: SizedBox( + key: _headerKey, + width: headerWidth, + child: BusyMaxLinuxHeaderLayout( + centerAllocation: centerAllocation, + maxContentWidth: maxContentWidth, + leading: SizedBox( + key: _leadingKey, + width: testCase.leadingWidth, + height: BusyMaxSizes.headerIconButton, + ), + title: const SizedBox( + key: _titleKey, + width: double.infinity, + child: BusyMaxLinuxHeaderTitle('Centered title'), + ), + trailing: SizedBox( + key: _trailingKey, + width: testCase.trailingWidth, + height: BusyMaxSizes.headerIconButton, + ), + ), + ), + ), + ), + ); +} + +Widget _testApp({ + required Widget child, + bool windowActive = true, + TextDirection direction = TextDirection.ltr, + ThemeData? theme, +}) { + return MaterialApp( + theme: theme, + home: LinuxWindowMetricsScope( + leftControlInset: 0, + rightControlInset: 0, + windowActive: windowActive, + preferences: GtkWindowPreferences.defaults(), + child: Directionality( + textDirection: direction, + child: Scaffold(body: child), + ), + ), + ); +} + +class _LayoutCase { + const _LayoutCase( + this.label, + this.leadingWidth, + this.trailingWidth, + this.leftObstruction, + this.rightObstruction, + this.rtl, + ); + + final String label; + final double leadingWidth; + final double trailingWidth; + final double leftObstruction; + final double rightObstruction; + final bool rtl; +} diff --git a/test/app/linux_page_frame_test.dart b/test/app/linux_page_frame_test.dart new file mode 100644 index 00000000..97642a0b --- /dev/null +++ b/test/app/linux_page_frame_test.dart @@ -0,0 +1,377 @@ +import 'package:busymax/src/app/busymax_design.dart'; +import 'package:busymax/src/app/linux/linux_header_style.dart'; +import 'package:busymax/src/app/linux/linux_page_frame.dart'; +import 'package:busymax/src/app/linux/linux_window_host.dart'; +import 'package:busymax/src/platform/gtk_window_preferences_service.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('one viewport owns the full-height sidebar edge at every frame', ( + tester, + ) async { + final harnessKey = GlobalKey<_FrameHarnessState>(); + await tester.pumpWidget(_testApp(_FrameHarness(key: harnessKey))); + + _expectSharedEdge(tester, expectedWidth: 0); + harnessKey.currentState!.toggle(); + await tester.pump(); + for (final elapsed in const [ + Duration(milliseconds: 35), + Duration(milliseconds: 45), + Duration(milliseconds: 55), + ]) { + await tester.pump(elapsed); + _expectSharedEdge(tester); + } + await tester.pumpAndSettle(); + _expectSharedEdge(tester, expectedWidth: BusyMaxSizes.sidebarWidth); + + harnessKey.currentState!.toggle(); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 45)); + final closingWidth = _sidebarWidth(tester); + harnessKey.currentState!.toggle(); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 20)); + expect(_sidebarWidth(tester), greaterThan(closingWidth)); + _expectSharedEdge(tester); + }); + + testWidgets('unrelated rebuild preserves partial progress and body state', ( + tester, + ) async { + final harnessKey = GlobalKey<_FrameHarnessState>(); + await tester.pumpWidget(_testApp(_FrameHarness(key: harnessKey))); + expect( + MediaQuery.disableAnimationsOf( + tester.element(find.byType(LinuxPageFrame)), + ), + isFalse, + ); + expect(_sidebarWidth(tester), 0); + harnessKey.currentState!.toggle(); + await tester.pump(const Duration(milliseconds: 80)); + final before = _sidebarWidth(tester); + final bodyState = tester.state(find.byType(_RetainedBody)); + + harnessKey.currentState!.rebuild(); + await tester.pump(); + + expect(_sidebarWidth(tester), closeTo(before, 0.01)); + expect(tester.state(find.byType(_RetainedBody)), same(bodyState)); + _expectSharedEdge(tester); + }); + + testWidgets( + 'breakpoint settlement is immediate and retained state survives', + (tester) async { + final harnessKey = GlobalKey<_FrameHarnessState>(); + await tester.pumpWidget(_testApp(_FrameHarness(key: harnessKey))); + harnessKey.currentState!.toggle(); + await tester.pumpAndSettle(); + final sidebarState = tester.state(find.byType(_RetainedSidebar)); + + harnessKey.currentState!.setAvailable(false); + await tester.pump(); + _expectSharedEdge(tester, expectedWidth: 0); + harnessKey.currentState!.setAvailable(true); + await tester.pump(); + _expectSharedEdge(tester, expectedWidth: BusyMaxSizes.sidebarWidth); + expect(tester.state(find.byType(_RetainedSidebar)), same(sidebarState)); + }, + ); + + testWidgets('RTL and fractional device pixels share one snapped edge', ( + tester, + ) async { + tester.view.devicePixelRatio = 1.25; + addTearDown(tester.view.reset); + final harnessKey = GlobalKey<_FrameHarnessState>(); + await tester.pumpWidget( + _testApp( + _FrameHarness(key: harnessKey), + direction: TextDirection.rtl, + devicePixelRatio: 1.25, + ), + ); + harnessKey.currentState!.toggle(); + await tester.pump(const Duration(milliseconds: 73)); + + final width = _sidebarWidth(tester); + expect(width * 1.25, closeTo((width * 1.25).roundToDouble(), 0.0001)); + _expectSharedEdge(tester, rtl: true); + }); + + testWidgets( + 'divider follows the snapped physical viewport while opening and closing', + (tester) async { + tester.view.devicePixelRatio = .75; + addTearDown(tester.view.reset); + final harnessKey = GlobalKey<_FrameHarnessState>(); + await tester.pumpWidget( + _testApp(_FrameHarness(key: harnessKey), devicePixelRatio: .75), + ); + + harnessKey.currentState!.toggle(); + await tester.pump(); + await tester.pump(const Duration(microseconds: 1)); + expect(_sidebarWidth(tester), 0); + expect(_sidebarTicker(tester).enabled, isTrue); + expect(_dividerAlpha(tester), 0); + + await tester.pump(const Duration(milliseconds: 10)); + expect(_sidebarWidth(tester), greaterThanOrEqualTo(1 / .75)); + expect(_dividerAlpha(tester), 1); + _expectSharedEdge(tester); + await tester.pumpAndSettle(); + + harnessKey.currentState!.toggle(); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 199)); + expect(_sidebarWidth(tester), 0); + expect(_sidebarTicker(tester).enabled, isTrue); + expect(_dividerAlpha(tester), 0); + _expectSharedEdge(tester, expectedWidth: 0); + + await tester.pumpAndSettle(); + expect(_sidebarTicker(tester).enabled, isFalse); + expect(_dividerAlpha(tester), 0); + }, + ); + + testWidgets( + 'animation setting settles a running reveal to its logical target', + (tester) async { + final harnessKey = GlobalKey<_FrameHarnessState>(); + await tester.pumpWidget(_testApp(_FrameHarness(key: harnessKey))); + expect( + MediaQuery.disableAnimationsOf( + tester.element(find.byType(LinuxPageFrame)), + ), + isFalse, + ); + expect(_sidebarWidth(tester), 0); + harnessKey.currentState!.toggle(); + await tester.pump(); + expect(_sidebarWidth(tester), 0); + await tester.pump(const Duration(milliseconds: 60)); + expect(_sidebarWidth(tester), inExclusiveRange(0, 300)); + + harnessKey.currentState!.setDisableAnimations(true); + await tester.pump(); + _expectSharedEdge(tester, expectedWidth: BusyMaxSizes.sidebarWidth); + }, + ); + + testWidgets('header receives only the presented system-control obstruction', ( + tester, + ) async { + final harnessKey = GlobalKey<_FrameHarnessState>(); + await tester.pumpWidget( + _testApp( + _FrameHarness(key: harnessKey), + leftControlInset: 240, + rightControlInset: 100, + ), + ); + + LinuxPageHeaderInsetsScope insets() => LinuxPageHeaderInsetsScope.of( + tester.element(find.byKey(const ValueKey('frame-header-probe'))), + ); + expect(insets().leftObstruction, 240); + expect(insets().rightObstruction, 100); + + harnessKey.currentState!.toggle(); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 80)); + final presentedWidth = _sidebarWidth(tester); + expect(presentedWidth, inExclusiveRange(0, 240)); + expect(insets().leftObstruction, closeTo(240 - presentedWidth, .001)); + expect(insets().rightObstruction, 100); + + await tester.pumpAndSettle(); + expect(insets().leftObstruction, 0); + expect(insets().rightObstruction, 100); + }); + + testWidgets('frame is the single owner of the full-height sidebar seam', ( + tester, + ) async { + final harnessKey = GlobalKey<_FrameHarnessState>(); + await tester.pumpWidget(_testApp(_FrameHarness(key: harnessKey))); + + final surface = tester.widget( + find.byKey(const ValueKey('frame-sidebar-surface')), + ); + expect(surface.showEndBorder, isFalse); + expect(_dividerAlpha(tester), 0); + expect(_seamDecorations(tester), hasLength(1)); + + harnessKey.currentState!.toggle(); + await tester.pumpAndSettle(); + expect(_dividerAlpha(tester), 1); + expect(_seamDecorations(tester), hasLength(1)); + _expectSharedEdge(tester, expectedWidth: BusyMaxSizes.sidebarWidth); + }); +} + +Widget _testApp( + Widget child, { + TextDirection direction = TextDirection.ltr, + bool disableAnimations = false, + double devicePixelRatio = 1, + double leftControlInset = 0, + double rightControlInset = 0, +}) => MaterialApp( + home: LinuxWindowMetricsScope( + leftControlInset: leftControlInset, + rightControlInset: rightControlInset, + windowActive: true, + preferences: GtkWindowPreferences.defaults(), + child: MediaQuery( + data: MediaQueryData( + size: const Size(1000, 700), + devicePixelRatio: devicePixelRatio, + disableAnimations: disableAnimations, + ), + child: Directionality(textDirection: direction, child: child), + ), + ), +); + +double _sidebarWidth(WidgetTester tester) => + tester.getSize(find.byKey(const ValueKey('linux-sidebar-viewport'))).width; + +TickerMode _sidebarTicker(WidgetTester tester) => tester.widget( + find.descendant( + of: find.byKey(const ValueKey('linux-sidebar-viewport')), + matching: find.byType(TickerMode), + ), +); + +double _dividerAlpha(WidgetTester tester) { + final box = tester.widget( + find.byKey(const ValueKey('linux-sidebar-viewport')), + ); + final decoration = box.decoration as BoxDecoration; + final border = decoration.border! as BorderDirectional; + return border.end.color.a; +} + +List _seamDecorations(WidgetTester tester) => tester + .widgetList( + find.descendant( + of: find.byType(LinuxPageFrame), + matching: find.byType(DecoratedBox), + ), + ) + .where( + (box) => + box.decoration is BoxDecoration && + (box.decoration as BoxDecoration).border is BorderDirectional, + ) + .toList(); + +void _expectSharedEdge( + WidgetTester tester, { + double? expectedWidth, + bool rtl = false, +}) { + final viewport = tester.getRect( + find.byKey(const ValueKey('linux-sidebar-viewport')), + ); + final main = tester.getRect(find.byKey(const ValueKey('frame-main'))); + if (rtl) { + expect(viewport.left, closeTo(main.right, 0.001)); + } else { + expect(viewport.right, closeTo(main.left, 0.001)); + } + if (expectedWidth != null) { + expect(viewport.width, closeTo(expectedWidth, 0.001)); + } + expect( + tester.getSize(find.byKey(const ValueKey('full-sidebar-child'))).width, + BusyMaxSizes.sidebarWidth, + ); + expect(find.byType(ClipRect), findsOneWidget); +} + +class _FrameHarness extends StatefulWidget { + const _FrameHarness({super.key}); + + @override + State<_FrameHarness> createState() => _FrameHarnessState(); +} + +class _FrameHarnessState extends State<_FrameHarness> { + var expanded = false; + var available = true; + var generation = 0; + var rebuildCount = 0; + var disableAnimations = false; + + void toggle() => setState(() { + expanded = !expanded; + generation += 1; + }); + + void setAvailable(bool value) => setState(() => available = value); + void setDisableAnimations(bool value) => + setState(() => disableAnimations = value); + void rebuild() => setState(() => rebuildCount += 1); + + @override + Widget build(BuildContext context) { + return MediaQuery( + data: MediaQuery.of( + context, + ).copyWith(disableAnimations: disableAnimations), + child: LinuxPageFrame( + header: Builder( + builder: (context) => SizedBox( + key: const ValueKey('frame-header-probe'), + child: Text('$rebuildCount'), + ), + ), + body: const _RetainedBody(key: ValueKey('frame-main')), + sidebarHeader: const SizedBox(height: 46), + sidebarBody: const BusyMaxSidebarSurface( + key: ValueKey('frame-sidebar-surface'), + showEndBorder: false, + child: _RetainedSidebar(), + ), + sidebarAvailable: available, + sidebarExpanded: expanded, + sidebarTransitionGeneration: generation, + ), + ); + } +} + +class _RetainedBody extends StatefulWidget { + const _RetainedBody({super.key}); + + @override + State<_RetainedBody> createState() => _RetainedBodyState(); +} + +class _RetainedBodyState extends State<_RetainedBody> { + @override + Widget build(BuildContext context) => const SizedBox.expand(); +} + +class _RetainedSidebar extends StatefulWidget { + const _RetainedSidebar(); + + @override + State<_RetainedSidebar> createState() => _RetainedSidebarState(); +} + +class _RetainedSidebarState extends State<_RetainedSidebar> { + @override + Widget build(BuildContext context) => const SizedBox( + key: ValueKey('full-sidebar-child'), + width: BusyMaxSizes.sidebarWidth, + ); +} diff --git a/test/app/linux_window_host_test.dart b/test/app/linux_window_host_test.dart new file mode 100644 index 00000000..70725bc3 --- /dev/null +++ b/test/app/linux_window_host_test.dart @@ -0,0 +1,693 @@ +import 'dart:async'; +import 'dart:ui' show AppExitResponse; + +import 'package:busymax/l10n/generated/app_localizations.dart'; +import 'package:busymax/src/app/busymax_app.dart'; +import 'package:busymax/src/app/busymax_design.dart'; +import 'package:busymax/src/app/busymax_dialogs.dart'; +import 'package:busymax/src/app/linux/linux_window_host.dart'; +import 'package:busymax/src/platform/gtk_header_icon_service.dart'; +import 'package:busymax/src/app/linux/linux_header_style.dart'; +import 'package:busymax/src/app/busymax_window_close.dart'; +import 'package:busymax/src/platform/gtk_window_preferences_service.dart'; +import 'package:flutter/gestures.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 'package:yaru/yaru.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + const yaruWindowChannel = MethodChannel('yaru_window'); + const yaruEventsChannel = EventChannel('yaru_window/events'); + late List windowCalls; + late Map nativeState; + late MockStreamHandlerEventSink eventSink; + late int eventListenCount; + late int eventCancelCount; + + setUp(() { + windowCalls = []; + nativeState = {}; + eventListenCount = 0; + eventCancelCount = 0; + final messenger = + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger; + messenger.setMockMethodCallHandler(yaruWindowChannel, (call) async { + windowCalls.add(call); + return call.method == 'state' ? nativeState : null; + }); + messenger.setMockStreamHandler( + yaruEventsChannel, + MockStreamHandler.inline( + onListen: (_, sink) { + eventListenCount += 1; + eventSink = sink; + }, + onCancel: (_) { + eventCancelCount += 1; + }, + ), + ); + }); + + tearDown(() { + final messenger = + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger; + messenger.setMockMethodCallHandler(yaruWindowChannel, null); + messenger.setMockStreamHandler(yaruEventsChannel, null); + }); + + testWidgets('icon uses its physical GTK side and contributes to the inset', ( + tester, + ) async { + const preferences = GtkWindowPreferences( + decorationLayout: GtkDecorationLayout( + left: [ + GtkWindowDecorationElement.windowIcon, + GtkWindowDecorationElement.minimize, + ], + right: [GtkWindowDecorationElement.close], + ), + doubleClick: GtkTitlebarAction.toggleMaximize, + middleClick: GtkTitlebarAction.none, + rightClick: GtkTitlebarAction.menu, + ); + + await _pumpHost( + tester, + preferences: preferences, + locale: const Locale('ar'), + ); + + final left = find.byKey(const ValueKey('linux-window-controls-left')); + final right = find.byKey(const ValueKey('linux-window-controls-right')); + expect(left, findsOneWidget); + expect(right, findsOneWidget); + expect( + find.descendant(of: left, matching: find.byType(Image)), + findsOneWidget, + ); + expect( + find.descendant(of: right, matching: find.byType(Image)), + findsNothing, + ); + expect(tester.getRect(left).left, 0); + expect(tester.getRect(right).right, 800); + + final metrics = LinuxWindowMetricsScope.of( + tester.element(find.byKey(const ValueKey('metrics-probe'))), + ); + expect( + metrics.leftControlInset, + BusyMaxLinuxWindowMetrics.clusterWidth(preferences.decorationLayout.left), + ); + expect( + metrics.rightControlInset, + BusyMaxLinuxWindowMetrics.clusterWidth( + preferences.decorationLayout.right, + ), + ); + + final leftControls = find.descendant( + of: left, + matching: find.byType(YaruWindowControl), + ); + expect(leftControls, findsOneWidget); + expect( + tester.getCenter(find.byType(Image)).dx, + lessThan(tester.getCenter(leftControls).dx), + ); + }); + + testWidgets( + 'fallback application menu is absent while titlebar menu still works', + (tester) async { + const preferences = GtkWindowPreferences( + decorationLayout: GtkDecorationLayout( + left: [GtkWindowDecorationElement.fallbackApplicationMenu], + right: [GtkWindowDecorationElement.close], + ), + doubleClick: GtkTitlebarAction.toggleMaximize, + middleClick: GtkTitlebarAction.none, + rightClick: GtkTitlebarAction.menu, + ); + await _pumpHost( + tester, + preferences: preferences, + child: const LinuxTitlebarGestureRegion( + child: SizedBox( + key: ValueKey('titlebar-drag-region'), + width: 180, + height: 46, + ), + ), + ); + + expect(find.byIcon(Icons.menu), findsNothing); + expect(windowCalls.where((call) => call.method == 'showMenu'), isEmpty); + final metrics = LinuxWindowMetricsScope.of( + tester.element(find.byKey(const ValueKey('metrics-probe'))), + ); + expect(metrics.leftControlInset, 0); + + await tester.tapAt( + tester.getCenter(find.byKey(const ValueKey('titlebar-drag-region'))), + buttons: kSecondaryMouseButton, + ); + await tester.pump(); + + expect( + windowCalls.where((call) => call.method == 'showMenu'), + hasLength(1), + ); + }, + ); + + testWidgets('window-state stream survives unrelated host rebuilds', ( + tester, + ) async { + final preferences = StreamController(); + addTearDown(preferences.close); + final harnessKey = GlobalKey<_HostHarnessState>(); + + await _pumpHost( + tester, + preferencesStream: preferences.stream, + harnessKey: harnessKey, + ); + final initialStateCalls = _callCount(windowCalls, 'state'); + expect(initialStateCalls, 1); + expect(eventListenCount, 1); + + preferences.add( + const GtkWindowPreferences( + decorationLayout: GtkDecorationLayout( + left: [GtkWindowDecorationElement.windowIcon], + right: [GtkWindowDecorationElement.maximize], + ), + doubleClick: GtkTitlebarAction.minimize, + middleClick: GtkTitlebarAction.none, + rightClick: GtkTitlebarAction.menu, + ), + ); + await tester.pump(); + await tester.pump(); + harnessKey.currentState! + ..rebuildParent() + ..toggleTheme() + ..toggleLocale(); + await tester.pump(); + + expect(_callCount(windowCalls, 'state'), initialStateCalls); + expect(eventListenCount, 1); + expect(eventCancelCount, 0); + + await _sendState(tester, eventSink, { + 'active': true, + 'maximized': true, + 'restorable': true, + }); + + final control = tester.widget( + find.byType(YaruWindowControl), + ); + expect(control.type, YaruWindowControlType.restore); + expect(_callCount(windowCalls, 'state'), initialStateCalls); + }); + + testWidgets('window Close and platform exit use the guarded boundary', ( + tester, + ) async { + var allowClose = false; + var closeRequests = 0; + await _pumpHost( + tester, + child: BusyMaxWindowCloseGuard( + onCloseRequested: () { + closeRequests += 1; + return allowClose; + }, + child: const SizedBox(), + ), + ); + + await tester.tap(find.byType(YaruWindowControl)); + await tester.pump(); + + expect(_callCount(windowCalls, 'close'), 1); + expect(closeRequests, 0); + expect(await tester.binding.handleRequestAppExit(), AppExitResponse.cancel); + expect(closeRequests, 1); + + allowClose = true; + expect(await tester.binding.handleRequestAppExit(), AppExitResponse.exit); + expect(closeRequests, 2); + }); + + testWidgets('root dialogs retain the host close coordinator', (tester) async { + final coordinator = BusyMaxWindowCloseCoordinator(); + await tester.pumpWidget( + ProviderScope( + overrides: [ + gtkWindowPreferencesProvider.overrideWith( + (ref) => Stream.value( + const GtkWindowPreferences( + decorationLayout: GtkDecorationLayout(left: [], right: []), + doubleClick: GtkTitlebarAction.toggleMaximize, + middleClick: GtkTitlebarAction.none, + rightClick: GtkTitlebarAction.menu, + ), + ), + ), + ], + child: MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + builder: (context, child) => LinuxWindowHost( + closeCoordinator: coordinator, + child: child ?? const SizedBox.shrink(), + ), + home: Builder( + builder: (context) => TextButton( + onPressed: () => unawaited( + showBusyMaxModalDialog( + context, + builder: (_) => + const AlertDialog(content: Text('Window modal')), + ), + ), + child: const Text('Open modal'), + ), + ), + ), + ), + ); + await tester.pump(); + await tester.pump(); + + await tester.tap(find.text('Open modal')); + await tester.pumpAndSettle(); + + expect(find.text('Window modal'), findsOneWidget); + expect(coordinator.hasActiveHandler, isTrue); + expect(await coordinator.requestClose(), isFalse); + }); + + for (final scenario + in <(String, Map, YaruWindowControlType, String)>[ + ( + 'normal', + {'maximizable': true}, + YaruWindowControlType.maximize, + 'maximize', + ), + ( + 'maximized', + {'maximized': true, 'restorable': true}, + YaruWindowControlType.restore, + 'restore', + ), + ( + 'fullscreen', + {'fullscreen': true, 'restorable': true}, + YaruWindowControlType.restore, + 'restore', + ), + ]) { + testWidgets('${scenario.$1} maximize decoration has matching behavior', ( + tester, + ) async { + nativeState = scenario.$2; + await _pumpHost(tester, preferences: _maximizePreferences); + + final controlFinder = find.byType(YaruWindowControl); + final control = tester.widget(controlFinder); + expect(control.type, scenario.$3); + expect( + find.bySemanticsLabel( + scenario.$3 == YaruWindowControlType.restore ? 'Restore' : 'Maximize', + ), + findsWidgets, + ); + + await tester.tap(controlFinder); + await tester.pump(); + + expect(_callCount(windowCalls, scenario.$4), 1); + if (scenario.$1 == 'fullscreen') { + expect(_callCount(windowCalls, 'maximize'), 0); + } + }); + } + + testWidgets('fullscreen titlebar double-click still restores', ( + tester, + ) async { + nativeState = {'fullscreen': true, 'restorable': true}; + await _pumpHost( + tester, + preferences: _maximizePreferences, + child: const LinuxTitlebarGestureRegion( + child: SizedBox( + key: ValueKey('titlebar-double-click-region'), + width: 180, + height: 46, + ), + ), + ); + + final region = find.byKey(const ValueKey('titlebar-double-click-region')); + await tester.tapAt(tester.getCenter(region)); + await tester.pump(const Duration(milliseconds: 50)); + await tester.tapAt(tester.getCenter(region)); + await tester.pump(const Duration(milliseconds: 50)); + + expect(_callCount(windowCalls, 'restore'), 1); + expect(_callCount(windowCalls, 'maximize'), 0); + }); + + testWidgets('enabled motion retains the normal active-window fade', ( + tester, + ) async { + nativeState = {'active': true}; + await _pumpHost(tester, preferences: _closePreferences); + expect(_controlAnimatedOpacity(tester).duration, BusyMaxMotion.fast); + + await _sendState(tester, eventSink, {'active': false}); + await tester.pump(_half(BusyMaxMotion.fast)); + + expect(_renderedControlOpacity(tester), inExclusiveRange(.5, 1)); + await tester.pumpAndSettle(); + expect(_renderedControlOpacity(tester), .5); + }); + + testWidgets('Flutter reduced motion settles the host fade immediately', ( + tester, + ) async { + nativeState = {'active': true}; + await _pumpHost( + tester, + preferences: _closePreferences, + disableAnimations: true, + ); + + await _sendState(tester, eventSink, {'active': false}); + + expect(_controlAnimatedOpacityFinder(), findsNothing); + expect(_renderedControlOpacity(tester), .5); + }); + + testWidgets('GTK animation policy reaches the actual host overlay', ( + tester, + ) async { + nativeState = {'active': true}; + await _pumpHost( + tester, + preferences: _closePreferences, + gtkAnimationsEnabled: false, + ); + + final hostContext = tester.element(find.byType(LinuxWindowHost)); + expect(MediaQuery.disableAnimationsOf(hostContext), isTrue); + expect(MediaQuery.alwaysUse24HourFormatOf(hostContext), isTrue); + expect(_controlAnimatedOpacityFinder(), findsNothing); + }); + + testWidgets('enabling reduced motion stops a fade and later fades can run', ( + tester, + ) async { + nativeState = {'active': true}; + final harnessKey = GlobalKey<_HostHarnessState>(); + await _pumpHost( + tester, + preferences: _closePreferences, + harnessKey: harnessKey, + ); + + await _sendState(tester, eventSink, {'active': false}); + await tester.pump(_half(BusyMaxMotion.fast)); + expect(_renderedControlOpacity(tester), inExclusiveRange(.5, 1)); + + harnessKey.currentState!.setDisableAnimations(true); + await tester.pump(); + expect(_controlAnimatedOpacityFinder(), findsNothing); + expect(_renderedControlOpacity(tester), .5); + + harnessKey.currentState!.setDisableAnimations(false); + await tester.pump(); + expect(_controlAnimatedOpacity(tester).duration, BusyMaxMotion.fast); + expect(_renderedControlOpacity(tester), .5); + + await _sendState(tester, eventSink, {'active': true}); + await tester.pump(_half(BusyMaxMotion.fast)); + expect(_renderedControlOpacity(tester), inExclusiveRange(.5, 1)); + await tester.pumpAndSettle(); + expect(_renderedControlOpacity(tester), 1); + }); + + testWidgets('Yaru active state drives application chrome exactly once', ( + tester, + ) async { + nativeState = {'active': true}; + await _pumpHost( + tester, + preferences: _closePreferences, + child: const Column( + children: [ + BusyMaxLinuxHeaderTitle('Header title'), + BusyMaxLinuxHeaderTitle('BusyMax brand', brand: true), + BusyMaxLinuxHeaderIconButton( + key: ValueKey('active-header-button'), + icon: BusyMaxLinuxHeaderIcon.search, + tooltip: 'Enabled', + onPressed: _noop, + ), + BusyMaxLinuxHeaderIconButton( + key: ValueKey('disabled-header-button'), + icon: BusyMaxLinuxHeaderIcon.create, + tooltip: 'Disabled', + onPressed: null, + ), + ], + ), + ); + + Color titleColor(String text) => + tester.widget(find.text(text)).style!.color!; + Color iconColor(String key, Set states) => tester + .widget( + find.descendant( + of: find.byKey(ValueKey(key)), + matching: find.byType(IconButton), + ), + ) + .style! + .foregroundColor! + .resolve(states)!; + + expect(titleColor('Header title').a, 1); + expect(titleColor('BusyMax brand').a, 1); + expect(iconColor('active-header-button', const {}).a, 1); + expect( + iconColor('disabled-header-button', const {WidgetState.disabled}).a, + .38, + ); + expect(_renderedControlOpacity(tester), 1); + + await _sendState(tester, eventSink, {'active': false}); + await tester.pumpAndSettle(); + + final metrics = LinuxWindowMetricsScope.of( + tester.element(find.byKey(const ValueKey('metrics-probe'))), + ); + expect(metrics.windowActive, isFalse); + expect(titleColor('Header title').a, .50); + expect(titleColor('BusyMax brand').a, .50); + expect(iconColor('active-header-button', const {}).a, .50); + expect( + iconColor('disabled-header-button', const {WidgetState.disabled}).a, + .19, + ); + expect(_renderedControlOpacity(tester), .50); + }); +} + +void _noop() {} + +const _maximizePreferences = GtkWindowPreferences( + decorationLayout: GtkDecorationLayout( + left: [], + right: [GtkWindowDecorationElement.maximize], + ), + doubleClick: GtkTitlebarAction.toggleMaximize, + middleClick: GtkTitlebarAction.none, + rightClick: GtkTitlebarAction.menu, +); + +const _closePreferences = GtkWindowPreferences( + decorationLayout: GtkDecorationLayout( + left: [], + right: [GtkWindowDecorationElement.close], + ), + doubleClick: GtkTitlebarAction.toggleMaximize, + middleClick: GtkTitlebarAction.none, + rightClick: GtkTitlebarAction.menu, +); + +Future _pumpHost( + WidgetTester tester, { + GtkWindowPreferences? preferences, + Stream? preferencesStream, + GlobalKey<_HostHarnessState>? harnessKey, + Widget? child, + Locale locale = const Locale('en'), + bool disableAnimations = false, + bool gtkAnimationsEnabled = true, +}) async { + final stream = + preferencesStream ?? Stream.value(preferences ?? _closePreferences); + await tester.pumpWidget( + ProviderScope( + overrides: [gtkWindowPreferencesProvider.overrideWith((ref) => stream)], + child: _HostHarness( + key: harnessKey, + initialLocale: locale, + initialDisableAnimations: disableAnimations, + initialGtkAnimationsEnabled: gtkAnimationsEnabled, + child: child, + ), + ), + ); + await tester.pump(); + await tester.pump(); +} + +void _emitState(MockStreamHandlerEventSink sink, Map state) { + sink.success({'id': 0, 'type': 'state', ...state}); +} + +Future _sendState( + WidgetTester tester, + MockStreamHandlerEventSink sink, + Map state, +) async { + _emitState(sink, state); + await tester.runAsync(pumpEventQueue); + await tester.pump(); +} + +int _callCount(List calls, String method) => + calls.where((call) => call.method == method).length; + +Duration _half(Duration duration) => + Duration(microseconds: duration.inMicroseconds ~/ 2); + +Finder _controlAnimatedOpacityFinder() => find.byWidgetPredicate( + (widget) => + widget is AnimatedOpacity && + widget.key == const ValueKey('linux-window-controls-opacity'), +); + +AnimatedOpacity _controlAnimatedOpacity(WidgetTester tester) => + tester.widget( + find.byWidgetPredicate( + (widget) => + widget is AnimatedOpacity && + widget.key == const ValueKey('linux-window-controls-opacity'), + ), + ); + +double _renderedControlOpacity(WidgetTester tester) { + final opacity = find.byWidgetPredicate( + (widget) => + widget is Opacity && + widget.key == const ValueKey('linux-window-controls-opacity'), + ); + if (opacity.evaluate().isNotEmpty) { + return tester.widget(opacity).opacity; + } + return tester + .widget( + find.descendant( + of: find.byKey(const ValueKey('linux-window-controls-opacity')), + matching: find.byType(FadeTransition), + ), + ) + .opacity + .value; +} + +class _HostHarness extends StatefulWidget { + const _HostHarness({ + super.key, + required this.initialLocale, + required this.initialDisableAnimations, + required this.initialGtkAnimationsEnabled, + this.child, + }); + + final Locale initialLocale; + final bool initialDisableAnimations; + final bool initialGtkAnimationsEnabled; + final Widget? child; + + @override + State<_HostHarness> createState() => _HostHarnessState(); +} + +class _HostHarnessState extends State<_HostHarness> { + late Locale locale = widget.initialLocale; + late bool disableAnimations = widget.initialDisableAnimations; + late bool gtkAnimationsEnabled = widget.initialGtkAnimationsEnabled; + var dark = false; + var rebuilds = 0; + + void rebuildParent() => setState(() => rebuilds += 1); + void toggleTheme() => setState(() => dark = !dark); + void toggleLocale() => setState(() { + locale = locale.languageCode == 'en' + ? const Locale('ar') + : const Locale('en'); + }); + void setDisableAnimations(bool value) => + setState(() => disableAnimations = value); + + @override + Widget build(BuildContext context) { + return MaterialApp( + locale: locale, + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + theme: ThemeData(brightness: dark ? Brightness.dark : Brightness.light), + home: Builder( + builder: (context) => MediaQuery( + data: MediaQuery.of( + context, + ).copyWith(disableAnimations: disableAnimations), + child: LinuxApplicationMediaQuery( + alwaysUse24HourFormat: true, + gtkAnimationsEnabled: gtkAnimationsEnabled, + child: LinuxWindowHost( + child: Scaffold( + body: Stack( + children: [ + Center(child: Text('$rebuilds')), + const SizedBox( + key: ValueKey('metrics-probe'), + width: 1, + height: 1, + ), + if (widget.child case final child?) child, + ], + ), + ), + ), + ), + ), + ), + ); + } +} diff --git a/test/app/modal_barrier_test.dart b/test/app/modal_barrier_test.dart index b2f9c84a..412366b0 100644 --- a/test/app/modal_barrier_test.dart +++ b/test/app/modal_barrier_test.dart @@ -7,74 +7,19 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; void main() { - test('native dark startup fallback matches the Dart semantic shade', () { - final source = File('linux/runner/my_application.cc').readAsStringSync(); - final match = RegExp( - r'kDefaultModalBarrierColor\[\] = ' - r'"rgba\(0,0,0,([0-9.]+)\)"', - ).firstMatch(source); + test('Flutter owns modal shading and native header barriers are absent', () { + final runner = File('linux/runner/my_application.cc').readAsStringSync(); + final dialogs = File('lib/src/app/busymax_dialogs.dart').readAsStringSync(); + final workspace = File( + 'lib/src/features/schedule/presentation/schedule_workspace.dart', + ).readAsStringSync(); - expect(match, isNotNull); - final nativeAlpha = double.parse(match!.group(1)!); - final dartAlpha = busyMaxFallbackSurfaceColors(Brightness.dark).shade.a; - expect(nativeAlpha, closeTo(dartAlpha, 0.0001)); - expect(source, contains('modal_barrier_color_for_depth(')); - expect(source, contains('std::pow(1.0 - barrier.alpha')); - expect( - source, - contains( - 'g_autofree gchar* modal_barrier_color = ' - 'modal_barrier_color_for_depth(', - ), - ); - expect(source, contains('self->header_bar_modal_barrier_color')); - expect(source, contains('self->header_bar_modal_barrier_shade_depth')); - expect(source, contains('kDefaultModalBarrierColor')); - }); - - test('native modal blocking and visual shade depth remain independent', () { - final source = File('linux/runner/my_application.cc').readAsStringSync(); - - expect(source, contains('set_header_bar_modal_barrier_state')); - expect(source, contains('set_header_bar_modal_barrier_visible')); - expect( - source, - contains( - 'set_header_bar_modal_barrier_state(self, visible, visible ? 1 : 0);', - ), - ); - expect( - source, - contains( - 'self->header_bar_modal_barrier_shade_depth = ' - 'effective_shade_depth;', - ), - ); - final setterStart = source.indexOf( - 'static void set_header_bar_modal_barrier_state(', - ); - final setterEnd = source.indexOf( - 'static void set_header_bar_modal_barrier_visible(', - setterStart, - ); - final setter = source.substring(setterStart, setterEnd); - expect( - setter, - contains('gtk_widget_set_visible(self->titlebar_modal_barrier, visible)'), - ); - expect(setter, contains('if (shade_changed) {')); - expect(setter, contains('refresh_header_bar_css(self);')); - expect( - source, - contains( - 'set_header_bar_modal_barrier_state(\n' - ' self, fl_lookup_bool_arg(args, "visible", FALSE),', - ), - ); - expect( - source, - contains('1.0 - std::pow(1.0 - barrier.alpha, effective_depth)'), - ); + expect(runner, isNot(contains('set_header_bar_modal_barrier'))); + expect(runner, isNot(contains('titlebar_modal_barrier'))); + expect(dialogs, contains('initialBarrierColor')); + expect(dialogs, contains('await route.completed')); + expect(workspace, contains('ModalBarrier(')); + expect(workspace, contains('child: frame')); }); for (final (brightness, expectedAlpha) in [ diff --git a/test/app/native_ui_audit_test.dart b/test/app/native_ui_audit_test.dart index 15ae0b1f..64d78e7f 100644 --- a/test/app/native_ui_audit_test.dart +++ b/test/app/native_ui_audit_test.dart @@ -59,7 +59,7 @@ void main() { expect(source, contains('hdy_init()')); expect(activateBody, contains('hdy_application_window_new(')); expect(activateBody, isNot(contains('gtk_application_window_new('))); - expect(source, contains('hdy_window_handle_new()')); + expect(activateBody, isNot(contains('hdy_window_handle_new()'))); expect(activateBody, isNot(contains('gtk_window_set_titlebar('))); expect( linuxCmake, @@ -91,14 +91,17 @@ void main() { test('Yaru GTK3 compatibility replaces legacy decoration rings', () { final source = File('linux/runner/my_application.cc').readAsStringSync(); + final searchTheme = File( + 'linux/runner/gtk_search_entry_theme.cc', + ).readAsStringSync(); final themeGateStart = source.indexOf( 'static gboolean current_gtk_theme_uses_legacy_yaru_shadow()', ); final refreshStart = source.indexOf( - 'static void refresh_header_bar_css(MyApplication* self)', + 'static void refresh_native_surface_css(MyApplication* self)', ); final refreshEnd = source.indexOf( - 'static void set_css_color_field(', + 'static void set_native_surface_theme(', refreshStart, ); @@ -109,22 +112,23 @@ void main() { final themeGate = source.substring(themeGateStart, refreshStart); final refresh = source.substring(refreshStart, refreshEnd); final compatibilityStart = refresh.indexOf( - 'g_autofree gchar* yaru_window_decoration_css =', + 'g_autofree gchar* decoration_css =', ); final compatibilityEnd = refresh.indexOf( - 'GtkWidget* header_bar', + 'g_autofree gchar* css =', compatibilityStart, ); expect(themeGate, contains('gtk_settings_get_default()')); expect(themeGate, contains('"gtk-theme-name"')); - expect(themeGate, contains('g_ascii_strdown(theme_name, -1)')); - expect(themeGate, contains('g_strcmp0(normalized_theme, "yaru")')); expect( themeGate, - contains('g_str_has_prefix(normalized_theme, "yaru-")'), + contains('busymax_gtk_theme_is_standard_yaru(theme_name)'), ); - expect(themeGate, contains('strstr(normalized_theme, "highcontrast")')); + expect(searchTheme, contains('g_ascii_strdown(theme_name, -1)')); + expect(searchTheme, contains('g_strcmp0(normalized, "yaru")')); + expect(searchTheme, contains('g_str_has_prefix(normalized, "yaru-")')); + expect(searchTheme, contains('std::strstr(normalized, "highcontrast")')); expect(compatibilityStart, isNonNegative); expect(compatibilityEnd, greaterThan(compatibilityStart)); @@ -133,7 +137,7 @@ void main() { compatibilityEnd, ); - expect(refresh, contains('!self->header_bar_high_contrast')); + expect(refresh, contains('!self->native_surface_high_contrast')); expect(refresh, contains('current_gtk_theme_uses_legacy_yaru_shadow()')); expect( compatibility, @@ -141,7 +145,6 @@ void main() { ); expect(compatibility, contains('box-shadow: 0 3px 9px 1px transparent,')); expect(compatibility, contains('0 2px 6px 2px rgba(0,0,0,0.2);')); - expect(compatibility, contains('box-shadow: 0 0 0 20px transparent;')); expect(compatibility, contains('not(.solid-csd)')); expect(compatibility, contains('not(.maximized)')); expect(compatibility, contains('not(.fullscreen)')); @@ -216,7 +219,10 @@ void main() { expect(settings, contains('class _SettingsSidebar')); expect(settings, contains('enum SettingsPage')); expect(settings, contains('filled: true')); - expect(settings, contains('DiagnosticsPanel(scrollable: false)')); + expect( + settings, + contains(RegExp(r'DiagnosticsPanel\(\s*scrollable:\s*false,')), + ); expect(settings, isNot(contains("context.go('/diagnostics')"))); expect(diagnostics, contains('class DiagnosticsPanel')); expect(diagnostics, isNot(contains('class DiagnosticsScreen'))); @@ -231,12 +237,12 @@ void main() { expect(settings, isNot(contains('SettingsPage.appearance'))); expect(settings, isNot(contains('SettingsPage.localization'))); expect(settings, isNot(contains('l10n.themeFamily'))); - expect(settings, contains('.claimSession()')); - expect(settings, contains('_headerBarSession.updateState(')); - expect(settings, contains('BusyMaxHeaderBarState(')); - expect(settings, contains('backVisible: true')); - expect(settings, contains('canShowSidebar: showSidebar')); - expect(settings, contains('sidebarVisible: showSidebar')); + expect(settings, contains('LinuxPageFrame(')); + expect(settings, contains('_SettingsHeader(')); + expect(settings, contains('onBack: _goBack')); + expect(settings, contains('sidebarAvailable: showSidebar')); + expect(settings, contains('sidebarExpanded: true')); + expect(settings, isNot(contains('LinuxHeaderBarSession'))); expect(newTaskDialog, contains('showBusyMaxModalEditorDialog')); expect(newTaskDialog, contains('TaskDetailsEditor')); expect(newTaskDialog, isNot(contains('BusyMaxDialogShell'))); @@ -372,945 +378,431 @@ void main() { ); }); - test('native headerbar keeps sidebar branded with GTK-owned centering', () { - final source = File('linux/runner/my_application.cc').readAsStringSync(); - final signIn = File( - 'lib/src/features/auth/presentation/sign_in_screen.dart', + test('one Flutter view owns the main header and full-height sidebar', () { + final runner = File('linux/runner/my_application.cc').readAsStringSync(); + final frame = File( + 'lib/src/app/linux/linux_page_frame.dart', ).readAsStringSync(); final schedule = File( 'lib/src/features/schedule/presentation/schedule_workspace.dart', ).readAsStringSync(); - final titlebarHandleStart = source.indexOf( - 'static GtkWidget* create_busymax_titlebar_handle', - ); - final titlebarHandleEnd = source.indexOf( - 'static gboolean show_header_create_menu', - titlebarHandleStart, + final settings = File( + 'lib/src/features/settings/presentation/settings_screen.dart', + ).readAsStringSync(); + final signIn = File( + 'lib/src/features/auth/presentation/sign_in_screen.dart', + ).readAsStringSync(); + final activateStart = runner.indexOf( + 'static void my_application_activate(GApplication* application)', ); - expect(titlebarHandleStart, isNonNegative); - expect(titlebarHandleEnd, greaterThan(titlebarHandleStart)); - final headerBarSource = source.substring( - titlebarHandleStart, - titlebarHandleEnd, + final activateEnd = runner.indexOf( + 'static void my_application_startup(GApplication* application)', + activateStart, ); + final activate = runner.substring(activateStart, activateEnd); + + expect('fl_view_new('.allMatches(activate), hasLength(1)); + expect(activate, contains('gtk_container_add(GTK_CONTAINER(window)')); + expect(activate, isNot(contains('hdy_header_bar_new'))); + expect(activate, isNot(contains('hdy_window_handle_new'))); + expect(activate, isNot(contains('gtk_window_set_titlebar'))); + expect(runner, isNot(contains('create_busymax_titlebar_handle'))); + expect(runner, isNot(contains('header_bar_sidebar_width'))); + expect(frame, contains('class LinuxPageFrame')); + expect(frame, contains("ValueKey('linux-sidebar-viewport')")); + expect(frame, contains('width: BusyMaxSizes.sidebarWidth')); + expect(frame, contains('final presentedWidth')); + expect(frame, contains('Row(')); + expect(schedule, contains('LinuxPageFrame(')); + expect(settings, contains('LinuxPageFrame(')); + expect(signIn, contains('LinuxPageFrame(')); + for (final source in [schedule, settings, signIn]) { + expect(source, isNot(contains('LinuxHeaderBarSession'))); + } + }); + + test('top-level Linux header triggers use semantic GTK icon assets', () { + final style = File( + 'lib/src/app/linux/linux_header_style.dart', + ).readAsStringSync(); + final toolbar = File( + 'lib/src/features/schedule/presentation/schedule_toolbar.dart', + ).readAsStringSync(); + final workspace = File( + 'lib/src/features/schedule/presentation/schedule_workspace.dart', + ).readAsStringSync(); + final settings = File( + 'lib/src/features/settings/presentation/settings_screen.dart', + ).readAsStringSync(); + final presentationStart = toolbar.indexOf( + 'final class _ScheduleViewPresentation', + ); + expect(presentationStart, isNonNegative); + final topLevelToolbar = toolbar.substring(0, presentationStart); + final settingsHeaderStart = settings.indexOf('class _SettingsHeader'); + final settingsHeaderEnd = settings.indexOf( + 'class _SettingsPageLayout', + settingsHeaderStart, + ); + expect(settingsHeaderStart, isNonNegative); + expect(settingsHeaderEnd, greaterThan(settingsHeaderStart)); + final settingsHeader = settings.substring( + settingsHeaderStart, + settingsHeaderEnd, + ); + + expect(style, contains('class BusyMaxGtkHeaderIcon')); + expect(style, contains('BusyMaxLinuxHeaderStyle.symbolicIconSize')); + expect(style, isNot(contains('BusyMaxLinuxHeaderGlyphs'))); + expect(style, isNot(contains('YaruIcons.'))); + expect(toolbar, isNot(contains('_modeHeaderIcon'))); + expect(toolbar, isNot(contains('_modeMenuIcon'))); + expect(toolbar, contains('resolvedNativeHeaderIconName(')); + for (final forbidden in const [ + 'Icons.calendar_view_day_outlined', + 'Icons.view_week_outlined', + 'Icons.calendar_view_month', + 'Icons.calendar_today_outlined', + 'Icons.view_agenda_outlined', + 'YaruIcons.plus', + 'YaruIcons.view_more', + ]) { + expect( + topLevelToolbar, + isNot(contains(forbidden)), + reason: 'Top-level Schedule header must not use $forbidden.', + ); + } + expect(workspace, isNot(contains('icon: const Icon(Icons.filter_list)'))); + expect(workspace, isNot(contains('BusyMaxLinuxHeaderGlyphs'))); + expect(settingsHeader, isNot(contains('Icon('))); + expect(settingsHeader, isNot(contains('BusyMaxLinuxHeaderGlyphs'))); + }); + + test('schedule Search retains one Flutter header architecture', () { + final toolbar = File( + 'lib/src/features/schedule/presentation/schedule_toolbar.dart', + ).readAsStringSync(); + final linuxHeader = File( + 'lib/src/app/linux/linux_header_style.dart', + ).readAsStringSync(); + final workspace = File( + 'lib/src/features/schedule/presentation/schedule_workspace.dart', + ).readAsStringSync(); + final iconService = File( + 'lib/src/platform/gtk_header_icon_service.dart', + ).readAsStringSync(); + final runner = File('linux/runner/my_application.cc').readAsStringSync(); + final nativeIcons = File( + 'linux/runner/gtk_header_icons.cc', + ).readAsStringSync(); + final nativeSearchTheme = File( + 'linux/runner/gtk_search_entry_theme.cc', + ).readAsStringSync(); - expect(source, isNot(contains('GtkWidget* brand_box'))); + expect(workspace, contains('final header = ScheduleToolbar(')); + expect(workspace, isNot(contains("'schedule-search-close-button'"))); expect( - source, - isNot( - contains('gtk_container_set_border_width(GTK_CONTAINER(brand_box)'), - ), + workspace, + isNot(contains("'schedule-search-titlebar-drag-area'")), ); - expect(source, isNot(contains('create_header_logo()'))); - expect(source, contains('header_sidebar_brand_box')); - expect(source, isNot(contains('header_title_balance_spacer'))); - expect(source, contains('header_title_box')); - expect(source, isNot(contains('kHeaderWindowControlsBalanceWidth'))); - expect(source, contains('kHeaderOnboardingContentWidth')); - expect(source, contains('kHeaderOnboardingSideWidth')); - expect(source, contains('header_onboarding_content_width')); - expect(source, contains('"contentWidth"')); - expect(source, contains('recenter_onboarding_header_controls_cb')); - expect(source, contains('header_bar_size_allocate_cb')); - expect(source, contains('onboarding_back_slot')); - expect(source, contains('onboarding_back_button')); - expect(source, contains('onboarding_continue_slot')); - expect(source, contains('onboarding_continue_button')); - expect(source, contains('"continueSetup"')); - expect(source, contains('set_header_onboarding_controls')); - expect(signIn, contains('await _clearOnboardingHeaderBar();')); - expect(signIn, contains('Future _clearOnboardingHeaderBar()')); - expect(signIn, contains('var _finishingSetup = false;')); - expect(signIn, contains('var _headerBarUpdateGeneration = 0;')); - expect(signIn, contains('if (_finishingSetup)')); - expect(signIn, contains('generation != _headerBarUpdateGeneration')); - expect(signIn, contains('_headerBarUpdateGeneration++;')); - expect(signIn, contains('force: true')); - expect(schedule, contains('if (_headerBarSession.isAvailable)')); - expect(schedule, contains('_headerBarSession.setOnboardingControls(')); - expect(schedule, contains('force: true')); - expect(source, contains('gtk_header_bar_set_custom_title(header_bar')); - expect(source, isNot(contains('update_header_title_balance_spacer'))); - expect(source, contains('update_header_title_box_geometry')); - expect(source, contains('self->header_onboarding_controls_visible')); + expect(toolbar, contains('title: BusyMaxBinaryPresentation(')); + expect(toolbar, contains('alternateActive: searchActive')); expect( - source, - contains( - 'gtk_widget_set_size_request(self->header_title_box, width, -1)', - ), + toolbar, + contains('BusyMaxLinuxHeaderCenterAllocation.fillBetweenControls'), ); expect( - source, + linuxHeader, contains( - 'gtk_header_bar_set_custom_title(header_bar, self->header_title_box)', - ), + 'this.centerAllocation = ' + 'BusyMaxLinuxHeaderCenterAllocation.centered', + ), + ); + expect(toolbar, contains('selected: searchActive')); + expect(toolbar, contains('BusyMaxLinuxHeaderSearchField(')); + expect(toolbar, isNot(contains('BusyMaxLinuxHeaderIcon.close'))); + final searchStart = linuxHeader.indexOf( + 'class BusyMaxLinuxHeaderSearchField', + ); + final searchEnd = linuxHeader.indexOf( + 'ButtonStyle busyMaxLinuxHeaderTextButtonStyle', + searchStart, + ); + expect(searchStart, isNonNegative); + expect(searchEnd, greaterThan(searchStart)); + final headerSearch = linuxHeader.substring(searchStart, searchEnd); + expect(linuxHeader, contains('searchEntryHeight = 32')); + expect(linuxHeader, contains('searchEntryRadius = 9')); + expect( + headerSearch, + contains('BusyMaxNativeSearchEntryTheme.of(context)'), + ); + expect(headerSearch, contains('InputBorder.none')); + expect(headerSearch, contains('BusyMaxLinuxHeaderIcon.searchEntryFind')); + expect(headerSearch, contains('BusyMaxLinuxHeaderIcon.searchClear')); + expect(headerSearch, contains('if (!_isEmpty) ...[')); + expect(headerSearch, contains('onChanged: _handleTextChanged')); + expect( + linuxHeader, + contains('searchChangedDelay = Duration(milliseconds: 150)'), + ); + expect(headerSearch, contains('void _cancelPendingSearchChanged()')); + final disposeStart = headerSearch.indexOf('void dispose()'); + final disposeEnd = headerSearch.indexOf( + 'void _handleControllerChanged()', + disposeStart, ); + expect(disposeStart, isNonNegative); + expect(disposeEnd, greaterThan(disposeStart)); expect( - source, - contains( - 'gtk_label_set_xalign(GTK_LABEL(self->header_title_label), 0.5)', - ), + headerSearch.substring(disposeStart, disposeEnd), + contains('_cancelPendingSearchChanged();'), ); - final headerTitleStart = source.indexOf( - 'track_widget_pointer(&self->header_title_label', - ); - final searchEntryStart = source.indexOf( - 'track_widget_pointer(&self->search_entry', - headerTitleStart, - ); - expect(headerTitleStart, isNonNegative); - expect(searchEntryStart, greaterThan(headerTitleStart)); expect( - source.substring(headerTitleStart, searchEntryStart), - contains('GTK_STYLE_CLASS_TITLE'), + headerSearch, + isNot(contains('math.min(constraints.maxWidth, naturalWidth)')), ); - expect(source, isNot(contains('header_brand_logo'))); - expect(source, contains('header_brand_label')); - expect(source, contains('settings_menu_button')); - expect(source, contains('settings_menu')); + expect(toolbar, contains('icon: BusyMaxLinuxHeaderIcon.search')); + expect(iconService, contains("const ['system-search-symbolic']")); + expect(iconService, contains("const ['edit-find-symbolic']")); + expect(iconService, contains("const ['edit-clear-symbolic']")); + expect(headerSearch, isNot(contains('theme.colorScheme.isHighContrast'))); expect( - source, - contains( - 'gtk_box_pack_start(GTK_BOX(end_box), self->search_button, ' - 'FALSE, FALSE, 0)', - ), + headerSearch, + isNot(contains('BusyMaxLinuxHeaderStyle.symbolicIconSize * 2')), ); + for (final forbidden in const [ + 'BusyMaxSearchField(', + 'YaruSearchField(', + 'BusyMaxSizes.headerIconButton', + 'hintText:', + 'MaterialLocalizations.of(context).searchFieldLabel', + 'BusyMaxHeaderIconButton(', + 'Icons.search', + 'Icons.clear', + ]) { + expect( + headerSearch, + isNot(contains(forbidden)), + reason: 'Linux header Search must not use $forbidden.', + ); + } + expect(iconService, contains("'allowMissing': icon.allowMissing")); expect( - source, - contains( - 'gtk_box_pack_start(GTK_BOX(end_box), self->settings_menu_button, ' - 'FALSE, FALSE,', - ), + runner, + contains('fl_value_lookup_string(request, "allowMissing")'), ); + expect(nativeIcons, contains('if (!allow_missing)')); + expect(nativeSearchTheme, contains('gtk_search_entry_new()')); + expect(nativeSearchTheme, contains('GTK_STYLE_PROPERTY_BORDER_RADIUS')); + expect(nativeSearchTheme, contains('GTK_STATE_FLAG_BACKDROP')); + expect(nativeSearchTheme, contains('GTK_STATE_FLAG_FOCUSED')); + expect(nativeSearchTheme, contains('gtk_widget_path_append_type')); + expect(nativeSearchTheme, contains('GTK_STYLE_CLASS_LEFT')); + expect(nativeSearchTheme, contains('GTK_STYLE_CLASS_RIGHT')); expect( - source, - contains( - 'set_widget_visible(self->settings_menu_button,\n' - ' schedule_controls_visible || ' - 'self->header_back_visible);', - ), + nativeSearchTheme, + contains('busymax_apply_yaru_search_entry_compatibility'), ); + expect(nativeSearchTheme, contains('has_inner_focus = TRUE')); + expect(nativeSearchTheme, contains('inner_focus_width =')); expect( - headerBarSource, - isNot( - contains( - 'gtk_box_pack_start(GTK_BOX(self->header_sidebar_brand_box),\n' - ' self->search_button', - ), - ), + nativeSearchTheme, + isNot(contains('kBusyMaxYaruFocusedBorderWidth')), ); expect( - headerBarSource, - isNot( - contains( - 'gtk_box_pack_end(GTK_BOX(self->header_sidebar_brand_box),\n' - ' self->settings_menu_button', - ), - ), + nativeSearchTheme, + isNot(contains('focused.border_width.top = std::max')), ); + expect(runner, contains('"hasInnerFocus"')); + expect(runner, contains('"innerFocusColor"')); + expect(runner, contains('"innerFocusWidth"')); + expect(headerSearch, contains('searchStyle.hasInnerFocus')); + expect(headerSearch, contains('searchStyle.innerFocusColor')); + expect(headerSearch, contains('searchStyle.innerFocusWidth')); expect( - source, - contains( - 'append_header_action_item(menu, self->header_settings_label,', - ), + linuxHeader, + contains('leftOccupiedEdge + BusyMaxSpacing.headerInset'), ); expect( - source, - contains( - 'append_header_action_item(menu, self->header_report_issue_label,', - ), + linuxHeader, + contains('rightOccupiedEdge - BusyMaxSpacing.headerInset'), ); + expect(runner, contains('"searchEntry"')); + }); + + test('native GTK window preferences notify and clean up safely', () { + final runner = File('linux/runner/my_application.cc').readAsStringSync(); + final helper = File( + 'linux/runner/gtk_window_preferences.cc', + ).readAsStringSync(); + for (final property in [ + 'gtk-decoration-layout', + 'gtk-titlebar-double-click', + 'gtk-titlebar-middle-click', + 'gtk-titlebar-right-click', + ]) { + expect(helper, contains('"$property"')); + expect(helper, contains('"notify::$property"')); + } + expect(helper, contains('BusyMaxGtkWindowPreferencesWatcher::Read')); + expect(helper, contains('BusyMaxGtkWindowPreferencesWatcher::Start')); + expect(helper, contains('BusyMaxGtkWindowPreferencesWatcher::Stop')); + expect(helper, contains('g_signal_handler_disconnect')); + expect(runner, contains('gtk_window_preferences_listen_cb')); + expect(runner, contains('send_gtk_window_preferences_event(')); + expect(runner, contains('gtk_window_preferences_cancel_cb')); + expect(runner, contains('self->gtk_window_preferences->Stop();')); expect( - source.indexOf('self->header_report_issue_label'), - lessThan(source.indexOf('self->header_about_label')), + runner, + contains('g_clear_object(&self->gtk_window_preferences_event_channel)'), ); - expect( - source, - contains( - 'create_header_bridge_action(self, "report-issue", "reportIssue")', - ), + }); + + test('Linux content menus use native GTK popup menus on mapped host', () { + final runner = File('linux/runner/my_application.cc').readAsStringSync(); + final service = File( + 'lib/src/platform/native_menu_service.dart', + ).readAsStringSync(); + final start = runner.indexOf('constexpr char kNativeMenuActionNamespace'); + final end = runner.indexOf('static void respond_success', start); + + expect(start, isNonNegative); + expect(end, greaterThan(start)); + final nativeMenu = runner.substring(start, end); + final disposeStart = nativeMenu.indexOf( + 'static void native_menu_session_dispose', ); - expect( - source, - contains('create_header_bridge_action(self, "about", "aboutBusyMax")'), + final disposeEnd = nativeMenu.indexOf( + 'static gboolean native_menu_cleanup_idle_cb', + disposeStart, ); - expect(source, isNot(contains('settingsAccounts'))); - expect(source, isNot(contains('settingsDiagnostics'))); - expect(source, contains('gtk_label_new(kApplicationDisplayName)')); - expect(source, contains('GtkWidget* titlebar_box;')); - expect(source, contains('busymax-titlebar')); + expect(disposeStart, isNonNegative); + expect(disposeEnd, greaterThan(disposeStart)); + final dispose = nativeMenu.substring(disposeStart, disposeEnd); + + expect(runner, contains('"busymax/native_menus"')); + expect(nativeMenu, isNot(contains('struct NativeMenuHostWidgets'))); + expect(nativeMenu, isNot(contains('gtk_event_box_set_above_child('))); + expect(nativeMenu, isNot(contains('input_layer'))); + expect(nativeMenu, isNot(contains('menu_layer'))); + expect(nativeMenu, contains('GMenu* model;')); + expect(nativeMenu, contains('GSimpleActionGroup* action_group;')); + expect(nativeMenu, contains('GtkWidget* menu;')); + expect(nativeMenu, contains('g_simple_action_new_stateful(')); + expect(nativeMenu, contains('g_menu_item_set_action_and_target_value(')); + expect(nativeMenu, contains('g_menu_item_set_icon(item, icon)')); + expect(nativeMenu, contains('set_menu_item_accelerator(item, shortcut)')); + expect(nativeMenu, contains('gtk_menu_new_from_model(')); expect( - source, + nativeMenu, contains( - 'gtk_box_pack_start(GTK_BOX(self->titlebar_box),\n' - ' self->header_sidebar_brand_box', + 'gtk_menu_attach_to_widget(GTK_MENU(session->menu), data->view', ), ); + expect(nativeMenu, contains('GTK_IS_MENU(session->menu)')); expect( - source, + nativeMenu, contains( - 'gtk_box_pack_start(GTK_BOX(self->titlebar_box), GTK_WIDGET(header_bar)', - ), - ); - expect(source, contains('hdy_window_handle_new()')); - expect( - source, - contains('gtk_container_add(GTK_CONTAINER(self->titlebar_handle),'), - ); - expect(source, isNot(contains('gtk_window_set_titlebar(window,'))); - expect( - source, - isNot( - contains( - 'gtk_header_bar_pack_start(header_bar, self->header_sidebar_brand_box)', - ), + 'gtk_widget_insert_action_group(\n' + ' data->view, kNativeMenuActionNamespace', ), ); - expect(source, isNot(contains('busymax-sidebar-header'))); - expect(source, isNot(contains('sidebar_visible_toggle_button'))); - expect(source, contains('GtkWidget* back_button;')); - expect(source, contains('set_header_back_visible')); - expect(source, contains('setBackVisible')); - expect( - source, - contains('connect_header_bar_action(self, self->back_button, "back")'), - ); - expect(source, contains('sidebar_collapsed_toggle_button')); - expect(source, contains('header_bar_sidebar_width')); - expect(source, contains('header_bar_sidebar_visible')); - expect(source, contains('header_sidebar_effective_width')); - expect(source, contains('update_header_sidebar_brand_geometry')); - expect(source, contains('kHeaderMainContentStartInset')); expect( - source, + nativeMenu, contains( - 'constexpr gint kHeaderSidebarContentInset = kHeaderButtonSpacing;', + 'gtk_widget_translate_coordinates(\n' + ' data->view, toplevel, anchor.x, anchor.y', ), ); + expect(nativeMenu, contains('gtk_menu_popup_at_rect(')); + expect(nativeMenu, contains('rect_window, &window_anchor')); + expect(nativeMenu, contains('GDK_GRAVITY_SOUTH_WEST')); + expect(nativeMenu, contains('GDK_GRAVITY_NORTH_WEST')); + expect(nativeMenu, contains('GDK_ANCHOR_FLIP_Y')); + expect(nativeMenu, contains('GDK_ANCHOR_SLIDE')); + expect(nativeMenu, contains('GDK_ANCHOR_RESIZE')); + expect(nativeMenu, contains('native_menu_deactivate_cb')); expect( - source, - contains( - 'constexpr gint kHeaderMainContentStartInset = ' - 'kHeaderSidebarContentInset;', - ), + nativeMenu, + contains('"deactivate", G_CALLBACK(native_menu_deactivate_cb)'), ); + expect(nativeMenu, contains('gtk_menu_shell_deactivate(')); + expect(nativeMenu, contains('native_menu_action_activated_cb')); + expect(nativeMenu, contains('native_menu_selection_activated_cb')); + expect(nativeMenu, isNot(contains('gtk_button_new()'))); + expect(nativeMenu, isNot(contains('gtk_radio_button_new('))); + expect(nativeMenu, isNot(contains('gtk_toggle_button_new()'))); + expect(nativeMenu, isNot(contains('gtk_drawing_area_new()'))); + expect(nativeMenu, isNot(contains('gtk_cell_renderer_render('))); + expect(nativeMenu, isNot(contains('native_menu_item_clicked_cb'))); + expect(nativeMenu, isNot(contains('"object-select-symbolic"'))); + expect(nativeMenu, isNot(contains('"radio-symbolic"'))); + expect(nativeMenu, isNot(contains('"radio-checked-symbolic"'))); expect( - source, + nativeMenu, contains( - 'gtk_widget_set_margin_start(self->header_start_box,\n' - ' kHeaderMainContentStartInset)', - ), - ); - expect(source, contains('padding-left: 0;')); - expect(source, contains('"window#busymax-window,"')); - expect(source, contains('"background-color: %s;"')); - expect( - source, - contains('gtk_widget_set_name(GTK_WIDGET(window), "busymax-window")'), - ); - expect(source, contains('set_main_flutter_view_background(self)')); - expect( - source, - isNot(contains('window#busymax-window decoration')), - reason: 'The compatibility rule must remain direct-child scoped.', - ); - expect(source, isNot(contains('main_window_transparent_backing'))); - expect(source, isNot(contains('clear_transparent_window_cb'))); - expect(source, isNot(contains('CAIRO_OPERATOR_CLEAR'))); - expect( - source, - isNot( - contains('gtk_widget_set_app_paintable(GTK_WIDGET(window), TRUE)'), + 'gtk_menu_shell_select_first(GTK_MENU_SHELL(session->menu), TRUE)', ), ); - expect(source, isNot(contains('configure_rounded_window_shape'))); - expect(source, isNot(contains('create_rounded_window_region'))); - expect(source, isNot(contains('gdk_window_shape_combine_region'))); - expect(source, isNot(contains('rounded_window_size_allocate_cb'))); - expect(source, isNot(contains('rounded_window_state_event_cb'))); - expect(source, isNot(contains('kNativeWindowRadius'))); - expect(source, isNot(contains('border-top-left-radius: %dpx;'))); - expect(source, isNot(contains('border-top-right-radius: %dpx;'))); - expect(source, isNot(contains('kHeaderSidebarEdgeCompensation'))); - expect(source, isNot(contains('-kHeaderSidebarEdgeCompensation'))); - expect(source, isNot(contains('linear-gradient(to right'))); - expect(source, isNot(contains('GtkWidget* sidebar_toggle_button;'))); - expect(source, isNot(contains('self->sidebar_toggle_button'))); - expect(source, isNot(contains('kHeaderButtonRadius'))); - expect(source, contains('kDefaultTooltipBackground')); - expect(source, contains('kDefaultTooltipForeground')); - expect(source, contains('kDefaultTooltipBorder')); - expect(source, contains('kDefaultTooltipRadius')); - expect(source, contains('kTooltipBorderWidth')); - expect(source, contains('kGtkTooltipContainerInset')); - expect(source, contains('"tooltip.background {"')); - expect(source, contains('"tooltip decoration,"')); - expect(source, contains('"tooltip.csd decoration {"')); - expect(source, contains('"tooltip box,"')); - expect(source, contains('"tooltip.background box {"')); - expect(source, contains('"tooltip label,"')); - expect(source, contains('"tooltip.background label {"')); - expect(source, contains('"min-width: 0;"')); - expect(source, contains('tooltip_label_horizontal_padding')); - expect(source, contains('tooltip_label_vertical_padding')); - expect(source, contains('tooltip_label_minimum_height')); - expect(source, contains('self->header_bar_tooltip_minimum_height -')); - expect(source, contains('kGtkTooltipContainerInset * 2')); - expect(source, contains('tooltip_label_vertical_padding * 2')); - expect(source, isNot(contains('kHeaderTooltipVerticalPadding'))); - expect(source, isNot(contains('kHeaderTooltipHorizontalPadding'))); - expect(source, isNot(contains('kYaruGtk3TooltipVerticalPadding'))); - expect(source, isNot(contains('kYaruGtk3TooltipHorizontalPadding'))); - expect(source, isNot(contains('kYaruGtk3TooltipRadius'))); - expect(source, isNot(contains('"padding: %dpx %dpx;"'))); - expect(source, contains('kHeaderControlStyleClass')); - expect(source, isNot(contains('busymax-header-menu-control'))); - expect(source, isNot(contains('"border-width: 0;"'))); - expect(source, isNot(contains('"outline-style: none;"'))); - expect(source, isNot(contains('busymax-brand-action-button'))); - expect(source, isNot(contains('busymax-sidebar-toggle'))); - expect(source, isNot(contains('busymax-header-button'))); - expect(source, isNot(contains('busymax-header-icon-button'))); expect( - source, - contains( - 'gtk_widget_set_size_request(button, kHeaderButtonHeight,\n' - ' kHeaderButtonHeight)', - ), + nativeMenu, + contains('gtk_menu_shell_deselect(GTK_MENU_SHELL(session->menu))'), ); + expect(nativeMenu, isNot(contains('gtk_overlay_add_overlay('))); + expect(nativeMenu, isNot(contains('gtk_fixed_move('))); + expect(nativeMenu, isNot(contains('gtk_menu_button_new()'))); + expect(nativeMenu, isNot(contains('gtk_menu_button_set_menu_model('))); + expect(nativeMenu, isNot(contains('gtk_menu_button_get_popup('))); + expect(nativeMenu, isNot(contains('gtk_popover_new_from_model('))); + expect(nativeMenu, isNot(contains('gtk_menu_button_set_popover('))); + expect(nativeMenu, isNot(contains('gtk_menu_button_get_popover('))); + expect(nativeMenu, isNot(contains('gtk_popover_'))); + expect(nativeMenu, isNot(contains('GTK_IS_MODEL_BUTTON'))); + expect(nativeMenu, isNot(contains('style_native_popover'))); + expect(nativeMenu, isNot(contains('add_model_button_presentation'))); + expect(nativeMenu, isNot(contains('ensure_native_menu_hover_tracking'))); + expect(nativeMenu, isNot(contains('GTK_STATE_FLAG_PRELIGHT'))); + expect(nativeMenu, isNot(contains('gtk_widget_set_state_flags'))); + expect(nativeMenu, isNot(contains('gdk_display_flush'))); + expect(nativeMenu, isNot(contains('wl_display_'))); + expect(nativeMenu, contains('g_object_ref(G_OBJECT(method_call))')); + expect(nativeMenu, isNot(contains('gtk_popover_bind_model('))); expect( - source, - contains('gtk_widget_set_valign(button, GTK_ALIGN_CENTER)'), + dispose, + contains('gtk_menu_shell_deactivate(GTK_MENU_SHELL(session->menu))'), ); + expect(dispose, contains('gtk_menu_detach(GTK_MENU(session->menu))')); + expect(dispose, contains('kNativeMenuActionNamespace, nullptr')); + expect(dispose, isNot(contains('gtk_widget_destroy(session->menu)'))); + expect(dispose, contains('g_clear_object(&session->menu)')); + final deactivateIndex = dispose.indexOf('gtk_menu_shell_deactivate('); + final detachIndex = dispose.indexOf('gtk_menu_detach('); + final respondIndex = dispose.indexOf('native_menu_session_respond('); + final freeIndex = dispose.indexOf('g_free(session)'); + expect(deactivateIndex, isNonNegative); + expect(detachIndex, isNonNegative); + expect(respondIndex, isNonNegative); + expect(freeIndex, isNonNegative); + expect(deactivateIndex, lessThan(detachIndex)); + expect(detachIndex, lessThan(respondIndex)); + expect(respondIndex, lessThan(freeIndex)); expect( - source, - contains('gtk_widget_set_valign(brand_center_box, GTK_ALIGN_CENTER)'), + 'native_menu_session_respond('.allMatches(nativeMenu), + hasLength(2), ); expect( - source, - isNot( - contains( - '.busymax-titlebar button.busymax-header-view-mode-button:hover', - ), - ), + nativeMenu, + contains('g_idle_add_full(\n G_PRIORITY_DEFAULT_IDLE'), ); - expect(source, isNot(contains('transition: none;'))); - expect(source, isNot(contains('gtk_popover_set_position'))); - expect(source, contains('GTK_POS_BOTTOM')); - expect(source, isNot(contains('gtk_popover_popdown'))); - expect(source, contains('gtk_menu_button_new()')); + expect(nativeMenu, isNot(contains('"unmap"'))); + expect(nativeMenu, isNot(contains('gtk_dialog_run('))); + expect(nativeMenu, isNot(contains('gtk_menu_new('))); + expect(nativeMenu, isNot(contains('gtk_widget_override'))); expect( - source, - contains( - 'gtk_menu_button_set_use_popover(GTK_MENU_BUTTON(button), FALSE)', - ), - ); - expect(source, contains('gtk_menu_button_set_menu_model')); - expect(source, contains('gtk_menu_button_get_popup')); - expect(source, contains('GTK_IS_MENU(menu)')); - expect(source, contains('gtk_menu_shell_deactivate')); - expect(source, contains('close_header_menu_button')); - expect(source, contains('gtk_toggle_button_set_active')); - expect(source, contains('g_menu_new()')); - expect(source, contains('g_menu_append_item(menu, item)')); - expect( - source, - contains('g_menu_item_set_action_and_target(item, "header.view-mode"'), - ); - expect(source, contains('g_simple_action_new_stateful')); - expect(source, contains('g_action_map_add_action')); - expect(source, contains('gtk_widget_insert_action_group')); - expect(source, isNot(contains('popdown_header_popover'))); - expect(source, isNot(contains('gtk_popover_set_relative_to'))); - expect(source, isNot(contains('"busymax-popover-open"'))); - expect(source, isNot(contains('header_popover_is_open'))); - expect(source, isNot(contains('set_header_popover_open'))); - expect(source, isNot(contains('header_popover_closed_cb'))); - expect(source, isNot(contains('g_signal_connect(popover, "closed"'))); - expect(source, isNot(contains('popup_header_menu'))); - expect(source, isNot(contains('gtk_widget_get_mapped(popup)'))); - expect(source, isNot(contains('gtk_widget_get_visible(popup)'))); - expect(source, isNot(contains('"busymax-header-popover"'))); - expect(source, isNot(contains('"busymax-native-popover"'))); - expect(source, isNot(contains('"busymax-header-menu-depth"'))); - expect(source, isNot(contains('header_bar_popover_background_color'))); - expect(source, isNot(contains('header_bar_popover_shadow_color'))); - expect(source, isNot(contains('header_bar_menu_hover_color'))); - expect(source, isNot(contains('header_bar_floating_border_color'))); - expect(source, isNot(contains('"popoverBackgroundColor"'))); - expect(source, isNot(contains('"menuHoverColor"'))); - expect(source, isNot(contains('"popoverShadowColor"'))); - expect(source, contains('"dialogBackgroundColor"')); - expect(source, contains('"dialogOutlineColor"')); - expect(source, isNot(contains('"floatingBorderColor"'))); - expect(source, isNot(contains('"busymax-header-popover-row"'))); - expect(source, isNot(contains('kHeaderPopoverRowSpacing'))); - expect(source, isNot(contains('busymax-keyboard-focus'))); - expect(source, isNot(contains('gtk_window_get_focus_visible'))); - expect(source, isNot(contains('configure_header_popover_row'))); - expect(source, isNot(contains('gtk_widget_grab_focus(first_item)'))); - expect(headerBarSource, isNot(contains('"object-select-symbolic"'))); - expect(source, isNot(contains('gtk_widget_set_opacity(check_widget'))); - expect(source, isNot(contains('gtk_model_button_new()'))); - expect(source, isNot(contains('gtk_check_menu_item_new'))); - expect(source, isNot(contains('GTK_BUTTON_ROLE_CHECK'))); - expect(source, isNot(contains('GTK_BUTTON_ROLE_NORMAL'))); - expect(source, isNot(contains('g_object_set(item, "text"'))); - expect(source, isNot(contains('g_object_set(item, "active"'))); - expect( - source, - isNot(contains('gtk_box_pack_start(GTK_BOX(view_mode_menu_box)')), - ); - expect( - source, - isNot(contains('gtk_box_pack_start(GTK_BOX(settings_menu_box)')), - ); - expect(source, isNot(contains('.busymax-header-menu,'))); - expect(source, isNot(contains('menu.background.busymax-header-menu'))); - expect(source, isNot(contains('menuitem.busymax-header-view-mode-item'))); - expect(source, isNot(contains('menuitem.busymax-header-settings-item'))); - expect(source, isNot(contains('"*:hover {"'))); - expect(source, isNot(contains('busymax-header-view-mode-popover'))); - expect(source, isNot(contains('busymax-header-settings-popover'))); - expect(source, isNot(contains('box-shadow: 0 8px 24px'))); - expect( - source, - isNot( - contains( - 'headerbar.busymax-flat-headerbar combobox.busymax-header-combo button.combo,', - ), - ), - ); - expect(source, isNot(contains('combobox.busymax-header-combo menu,'))); - expect(source, isNot(contains('combobox.busymax-header-combo button {'))); - expect(source, contains('background-color: transparent;')); - expect(source, contains('padding-left: 0;')); - expect(source, contains('kHeaderButtonHeight')); - expect(source, contains('kHeaderButtonSpacing')); - expect(source, isNot(contains('kHeaderMenuPadding'))); - expect(source, isNot(contains('padding: 4px;'))); - expect(source, contains('setLocalizedLabels')); - expect(source, contains('setSidebarWidth')); - expect(source, contains('setTextDirection')); - expect(source, contains('setTheme')); - expect(source, contains('kHeaderBarStateSchemaVersion = 3')); - expect(source, contains('fl_lookup_int_arg(args, "schemaVersion"')); - expect( - source, - contains('schema_version != kHeaderBarStateSchemaVersion'), - ); - expect(source, contains('args, "canCreateEvent"')); - expect(source, contains('args, "canCreateTask"')); - expect(source, contains('args, "searchQuery"')); - expect(source, contains('args, "showSidebarPanel"')); - expect(source, contains('args, "hideSidebarPanel"')); - expect(source, contains('args, "sidebarShortcut"')); - expect(source, contains('update_header_sidebar_presentation')); - expect(source, isNot(contains('args, "sidebarOverlaysContent"'))); - expect(source, isNot(contains('args, "compactWindow"'))); - expect(source, contains('gtk_search_entry_new()')); - expect(source, contains('PANGO_ELLIPSIZE_NONE')); - expect(source, contains('update_header_title_fit')); - expect(source, contains('gtk_stack_add_named')); - expect( - source, - contains( - 'effective_active ? self->search_entry : self->header_title_label', - ), - ); - expect( - source, - contains( - 'gtk_stack_set_visible_child(GTK_STACK(self->header_title_stack),', - ), - ); - expect(source, contains('if (effective_active) {')); - expect(source, contains('focus_header_search_entry(self);')); - expect(source, contains('"search-changed"')); - expect(source, contains('"searchFocusChanged"')); - expect(source, contains('"searchCleared"')); - expect(source, contains('"stop-search"')); - expect(source, contains('"searchEscapePressed"')); - expect(source, contains('"icon-release"')); - expect(source, contains('cache_header_search_query')); - expect(source, contains('focus_header_search_entry')); - expect(source, contains('strcmp(method, "focusSearch") == 0')); - expect(source, contains('gtk_entry_set_placeholder_text')); - expect(source, contains('clear_widget_pointer(&self->search_entry)')); - expect(source, contains('g_clear_pointer(&self->header_search_query')); - expect(source, contains('"busymax-header-search-entry"')); - expect(source, contains('kHeaderSearchEntryStyleClass')); - expect(source, contains('set_header_create_capabilities')); - expect(source, contains('strcmp(method, "showCreateMenu") == 0')); - expect(source, contains('setModalBarrierVisible')); - expect(source, contains('setModalBarrierDepth')); - expect(source, contains('setModalBarrierState')); - expect(source, contains('modal_barrier_color_for_depth')); - expect( - source, - contains('1.0 - std::pow(1.0 - barrier.alpha, effective_depth)'), - ); - expect(source, contains('busymax-modal-barrier')); - expect( - source, - isNot(contains('gtk_widget_set_sensitive(self->titlebar_handle')), - ); - expect(source, contains('GtkWidget* titlebar_overlay;')); - expect(source, contains('GtkWidget* titlebar_modal_barrier;')); - expect(source, contains('gtk_overlay_new()')); - expect(source, contains('gtk_event_box_new()')); - expect(source, contains('gtk_overlay_add_overlay')); - expect(source, contains('gtk_overlay_set_overlay_pass_through')); - expect( - source, - contains( - 'gtk_widget_set_halign(self->titlebar_modal_barrier, GTK_ALIGN_FILL)', - ), - ); - expect( - source, - contains( - 'gtk_widget_set_valign(self->titlebar_modal_barrier, GTK_ALIGN_FILL)', - ), - ); - expect( - source, - isNot( - contains( - 'gtk_widget_set_hexpand(self->titlebar_modal_barrier, TRUE)', - ), - ), - ); - expect( - source, - isNot( - contains( - 'gtk_widget_set_vexpand(self->titlebar_modal_barrier, TRUE)', - ), - ), - ); - expect(source, contains('consume_header_bar_modal_input_cb')); - expect(source, contains('case GDK_BUTTON_PRESS:')); - expect(source, contains('return TRUE;')); - expect(source, contains('gtk_widget_set_no_show_all')); - expect( - source, - contains( - 'gtk_widget_set_visible(self->titlebar_modal_barrier, visible)', - ), - ); - expect(source, isNot(contains('setBackgroundColor'))); - expect(source, isNot(contains('setSidebarBackgroundColor'))); - expect(source, contains('is_css_rgba_color')); - expect(source, contains('is_css_color_token')); - expect(source, contains('header_bar_foreground_color')); - expect(source, isNot(contains('header_bar_muted_foreground_color'))); - expect(source, isNot(contains('header_bar_disabled_foreground_color'))); - expect(source, isNot(contains('header_bar_control_hover_color'))); - expect(source, isNot(contains('header_bar_popover_background_color'))); - expect(source, isNot(contains('header_bar_floating_border_color'))); - expect(source, isNot(contains('header_bar_border_color'))); - expect(source, contains('header_bar_sidebar_border_color')); - expect(source, contains('border-right: 1px solid %s;')); - expect(source, contains('"rgba(16,16,16,0.35)"')); - expect(source, isNot(contains('"rgba(255,255,255,0.10)"'))); - expect(source, isNot(contains('modal_sidebar_border_css_color'))); - expect(source, isNot(contains('composite_rgba'))); - expect(source, isNot(contains('header_bar_shade_color'))); - expect(source, contains('header_bar_modal_barrier_color')); - expect(source, isNot(contains('header_bar_accent_color'))); - expect(source, isNot(contains('header_bar_accent_foreground_color'))); - expect(source, isNot(contains('kHeaderMenuFallbackBackgroundColor'))); - expect(source, isNot(contains('kHeaderMenuFallbackForegroundColor'))); - expect(source, contains('GTK_STYLE_CLASS_FLAT')); - expect(source, isNot(contains('GTK_STYLE_CLASS_LINKED'))); - expect(source, isNot(contains('gtk_check_menu_item_new_with_label'))); - expect(source, isNot(contains('gtk_check_menu_item_set_active'))); - expect( - source, - isNot(contains('menuitem.busymax-header-view-mode-item:checked')), - ); - expect(source, isNot(contains('busymax-header-view-mode-item-active'))); - expect(source, isNot(contains('create_header_popup_window'))); - expect(source, contains('gtk_menu_button_set_menu_model')); - expect(source, isNot(contains('GTK_STYLE_CLASS_SUGGESTED_ACTION'))); - expect(source, contains('kHeaderOnboardingTextButtonStyleClass')); - expect(source, contains('"padding: 0;"')); - expect(source, contains('"background-color: transparent;"')); - expect(source, isNot(contains('busymax-header-primary-button'))); - expect( - source, - isNot(contains('fl_lookup_string_arg(args, "accentColor")')), - ); - expect( - source, - isNot(contains('fl_lookup_string_arg(args, "accentForegroundColor")')), - ); - expect(source, isNot(contains('gtk_menu_new()'))); - expect(source, isNot(contains('gtk_menu_popup_at_widget'))); - expect(source, isNot(contains('gtk_menu_shell_append'))); - expect(source, isNot(contains('gtk_menu_popdown'))); - expect( - headerBarSource, - isNot(contains('gtk_window_set_skip_taskbar_hint')), - ); - expect( - headerBarSource, - isNot(contains('gtk_window_set_skip_pager_hint')), - ); - expect(source, isNot(contains('create_header_popup_box'))); - expect(source, isNot(contains('draw_header_popup_background_cb'))); - expect('gtk_event_box_new()'.allMatches(source).length, 1); - expect(source, isNot(contains('gtk_widget_set_app_paintable(popup'))); - expect(headerBarSource, isNot(contains('gtk_window_move'))); - expect(source, isNot(contains('override_header_menu_colors'))); - expect(source, isNot(contains('GTK_STYLE_PROVIDER_PRIORITY_USER'))); - expect(source, isNot(contains('add_header_menu_provider_to_widget'))); - expect(source, isNot(contains('gtk_widget_get_toplevel(menu)'))); - expect(source, isNot(contains('busymax-application'))); - expect(source, contains('gtk_widget_insert_action_group')); - expect(source, isNot(contains('gtk_widget_override_background_color'))); - expect( - source, - isNot(contains('gtk_popover_new(self->view_mode_button)')), - ); - expect(source, contains('GtkWidget* view_mode_button;')); - expect(source, contains('GtkWidget* view_mode_menu;')); - expect(source, contains('GtkWidget* flutter_view;')); - expect(source, contains('focus_flutter_view')); - expect(source, contains('gtk_widget_grab_focus(self->flutter_view)')); - expect(source, contains('track_widget_pointer(&self->flutter_view')); - expect(source, contains('clear_widget_pointer(&self->flutter_view)')); - expect(source, contains('kGtkSettingsChannel')); - expect(source, contains('io.busystack.busymax/gtk_settings')); - expect(source, contains('getGtkFont')); - expect(source, contains('getGtkThemeColors')); - expect(source, contains('kGtkThemeColorsEventChannel')); - expect(source, contains('io.busystack.busymax/gtk_theme_colors')); - expect(source, contains('notify::gtk-theme-name')); - expect(source, contains('notify::gtk-application-prefer-dark-theme')); - expect(source, contains('get_gtk_theme_colors')); - expect(source, contains('apply_gtk_theme_to_bootstrap_chrome')); - expect(source, contains('lookup_context_color')); - expect(source, contains('theme_bg_color')); - expect( - source, - isNot( - contains('lookup_context_color(window_context, "theme_base_color"'), - ), - ); - expect( - source, - contains( - 'lookup_context_color(window_context, "view_bg_color", &view_color);', - ), - ); - expect( - source, - contains( - 'lookup_context_color(window_context, "shade_color", &shade_color);', - ), - ); - expect( - source, - isNot(contains('lookup_context_color(window_context, "wm_shadow"')), - ); - expect(source, isNot(contains('shade_color = border_color'))); - expect(source, isNot(contains('gtk_text_view_new()'))); - expect(source, isNot(contains('sample_widget_background(view'))); - expect(source, contains('"window_bg_color"')); - expect(source, contains('"sidebar_bg_color"')); - expect(source, contains('"secondary_sidebar_bg_color"')); - expect(source, contains('"headerbar_bg_color"')); - expect(source, contains('"card_bg_color"')); - expect(source, contains('"dialog_bg_color"')); - expect(source, contains('"popover_bg_color"')); - expect( - source, - isNot(contains('GtkWidget* popover = gtk_popover_new(nullptr);')), - ); - expect(source, isNot(contains('GtkWidget* sidebar = gtk_box_new('))); - - final activateStart = source.indexOf( - 'static void my_application_activate(', - ); - final windowCreation = source.indexOf( - 'hdy_application_window_new()', - activateStart, - ); - final bootstrapTheme = source.indexOf( - 'apply_gtk_theme_to_bootstrap_chrome(self);', - activateStart, - ); - expect(activateStart, isNonNegative); - expect(bootstrapTheme, greaterThan(activateStart)); - expect(windowCreation, greaterThan(bootstrapTheme)); - - final themeNotifyStart = source.indexOf( - 'static void gtk_theme_colors_notify_cb(', - ); - final themeNotifyEnd = source.indexOf( - 'static void connect_gtk_theme_colors_signals(', - themeNotifyStart, - ); - final themeNotify = source.substring(themeNotifyStart, themeNotifyEnd); - expect(themeNotify, contains('if (!self->header_bar_theme_received)')); - expect( - themeNotify, - contains('apply_gtk_theme_to_bootstrap_chrome(self);'), - ); - expect( - source, - isNot(contains('GtkWidget* header = gtk_header_bar_new()')), - ); - expect( - source, - isNot(contains('GtkWidget* card = gtk_frame_new(nullptr)')), - ); - expect(source, isNot(contains('sample_widget_background(sidebar'))); - expect(source, isNot(contains('sample_widget_background(header'))); - expect(source, isNot(contains('sample_widget_background(card'))); - expect(source, isNot(contains('sample_widget_background(dialog'))); - expect(source, isNot(contains('sample_widget_background(popover'))); - expect( - source, - contains('Optional modern surface roles must remain semantic.'), - ); - expect(source, contains('gtk_style_context_get_property')); - expect(source, contains('"background-color"')); - expect(source, isNot(contains('gtk_style_context_get_background_color'))); - expect(source, contains('parse_gtk_font_name')); - expect(source, contains('gtk-font-name')); - expect(source, contains('pango_font_description_from_string')); - expect(source, contains('pango_font_description_get_family')); - expect(source, contains('PANGO_SCALE')); - expect(source, contains('register_gtk_settings_channel')); - expect(source, isNot(contains('GtkWidget* day_button;'))); - expect(source, isNot(contains('GtkWidget* week_button;'))); - expect(source, isNot(contains('GtkWidget* month_button;'))); - expect(source, isNot(contains('GtkWidget* year_button;'))); - expect(source, isNot(contains('GtkWidget* agenda_button;'))); - expect(source, isNot(contains('create_header_text_button("Today"'))); - expect(source, isNot(contains('create_header_toggle_text_button("Day"'))); - expect( - source, - isNot(contains('create_header_toggle_text_button("Week"')), - ); - expect( - source, - isNot(contains('create_header_toggle_text_button("Month"')), - ); - expect( - source, - isNot(contains('create_header_toggle_text_button("Year"')), - ); - expect( - source, - isNot(contains('create_header_toggle_text_button("Agenda"')), - ); - expect( - source, - contains( - 'append_header_view_mode_item(menu, self->header_year_label, "year",', - ), - ); - expect(source, contains('GtkWidget* view_mode_icon;')); - expect(source, isNot(contains('GtkWidget* view_mode_label;'))); - expect( - source, - contains( - 'gtk_image_set_from_icon_name(GTK_IMAGE(self->view_mode_icon),', - ), - ); - expect(source, contains('header_view_mode_icon_name("week")')); - expect(source, contains('return "view-continuous-symbolic";')); - expect(source, contains('return "calendar-week-symbolic";')); - expect(source, contains('return "calendar-month-symbolic";')); - expect(source, contains('return "view-app-grid-symbolic";')); - expect(source, contains('return "view-list-symbolic";')); - expect(source, contains('"pan-down-symbolic"')); - expect(source, contains('return "viewModeYear"')); - expect(source, contains('list-add-symbolic')); - expect( - source, - contains( - 'append_header_action_item(menu, self->header_create_event_label,', - ), - ); - expect( - source, - contains( - 'append_header_action_item(menu, self->header_create_task_label,', - ), - ); - expect(source, contains('kMenuAccelAttribute')); - expect(source, isNot(contains('kMenuShortcutAttribute'))); - expect(source, isNot(contains('kMenuIconAttribute'))); - expect(source, contains('g_menu_item_set_icon(item, icon)')); - expect(source, contains('set_menu_item_accelerator(item, shortcut)')); - expect(source, contains('gtk_accelerator_from_shortcut_label')); - expect(source, isNot(contains('decorate_model_menu_shortcuts'))); - expect(source, contains('gtk_menu_button_set_menu_model')); - expect(source, contains('g_simple_action_set_enabled')); - expect(source, isNot(contains('self->create_button, "create"'))); - expect(source, contains('open-menu-symbolic')); - expect(source, isNot(contains('create_header_popover_action_item'))); - expect(source, isNot(contains('button.busymax-header-popover-row'))); - expect( - source, - isNot( - contains( - 'button.busymax-header-button.busymax-sidebar-toggle:checked', - ), - ), - ); - expect(source, isNot(contains('"newItem"'))); - expect(source, isNot(contains('"openMenu"'))); - }); - - test('Linux content menus use native GTK popup menus on mapped host', () { - final runner = File('linux/runner/my_application.cc').readAsStringSync(); - final service = File( - 'lib/src/platform/native_menu_service.dart', - ).readAsStringSync(); - final start = runner.indexOf('constexpr char kNativeMenuActionNamespace'); - final end = runner.indexOf('static void respond_success', start); - - expect(start, isNonNegative); - expect(end, greaterThan(start)); - final nativeMenu = runner.substring(start, end); - final disposeStart = nativeMenu.indexOf( - 'static void native_menu_session_dispose', - ); - final disposeEnd = nativeMenu.indexOf( - 'static gboolean native_menu_cleanup_idle_cb', - disposeStart, - ); - expect(disposeStart, isNonNegative); - expect(disposeEnd, greaterThan(disposeStart)); - final dispose = nativeMenu.substring(disposeStart, disposeEnd); - - expect(runner, contains('"busymax/native_menus"')); - expect(nativeMenu, isNot(contains('struct NativeMenuHostWidgets'))); - expect(nativeMenu, isNot(contains('gtk_event_box_set_above_child('))); - expect(nativeMenu, isNot(contains('input_layer'))); - expect(nativeMenu, isNot(contains('menu_layer'))); - expect(nativeMenu, contains('GMenu* model;')); - expect(nativeMenu, contains('GSimpleActionGroup* action_group;')); - expect(nativeMenu, contains('GtkWidget* menu;')); - expect(nativeMenu, contains('g_simple_action_new_stateful(')); - expect(nativeMenu, contains('g_menu_item_set_action_and_target_value(')); - expect(nativeMenu, contains('g_menu_item_set_icon(item, icon)')); - expect(nativeMenu, contains('set_menu_item_accelerator(item, shortcut)')); - expect(nativeMenu, contains('gtk_menu_new_from_model(')); - expect( - nativeMenu, - contains( - 'gtk_menu_attach_to_widget(GTK_MENU(session->menu), data->view', - ), - ); - expect(nativeMenu, contains('GTK_IS_MENU(session->menu)')); - expect( - nativeMenu, - contains( - 'gtk_widget_insert_action_group(\n' - ' data->view, kNativeMenuActionNamespace', - ), - ); - expect( - nativeMenu, - contains( - 'gtk_widget_translate_coordinates(\n' - ' data->view, toplevel, anchor.x, anchor.y', - ), - ); - expect(nativeMenu, contains('gtk_menu_popup_at_rect(')); - expect(nativeMenu, contains('rect_window, &window_anchor')); - expect(nativeMenu, contains('GDK_GRAVITY_SOUTH_WEST')); - expect(nativeMenu, contains('GDK_GRAVITY_NORTH_WEST')); - expect(nativeMenu, contains('GDK_ANCHOR_FLIP_Y')); - expect(nativeMenu, contains('GDK_ANCHOR_SLIDE')); - expect(nativeMenu, contains('GDK_ANCHOR_RESIZE')); - expect(nativeMenu, contains('native_menu_deactivate_cb')); - expect( - nativeMenu, - contains('"deactivate", G_CALLBACK(native_menu_deactivate_cb)'), - ); - expect(nativeMenu, contains('gtk_menu_shell_deactivate(')); - expect(nativeMenu, contains('native_menu_action_activated_cb')); - expect(nativeMenu, contains('native_menu_selection_activated_cb')); - expect(nativeMenu, isNot(contains('gtk_button_new()'))); - expect(nativeMenu, isNot(contains('gtk_radio_button_new('))); - expect(nativeMenu, isNot(contains('gtk_toggle_button_new()'))); - expect(nativeMenu, isNot(contains('gtk_drawing_area_new()'))); - expect(nativeMenu, isNot(contains('gtk_cell_renderer_render('))); - expect(nativeMenu, isNot(contains('native_menu_item_clicked_cb'))); - expect(nativeMenu, isNot(contains('"object-select-symbolic"'))); - expect(nativeMenu, isNot(contains('"radio-symbolic"'))); - expect(nativeMenu, isNot(contains('"radio-checked-symbolic"'))); - expect( - nativeMenu, - contains( - 'gtk_menu_shell_select_first(GTK_MENU_SHELL(session->menu), TRUE)', - ), - ); - expect( - nativeMenu, - contains('gtk_menu_shell_deselect(GTK_MENU_SHELL(session->menu))'), - ); - expect(nativeMenu, isNot(contains('gtk_overlay_add_overlay('))); - expect(nativeMenu, isNot(contains('gtk_fixed_move('))); - expect(nativeMenu, isNot(contains('gtk_menu_button_new()'))); - expect(nativeMenu, isNot(contains('gtk_menu_button_set_menu_model('))); - expect(nativeMenu, isNot(contains('gtk_menu_button_get_popup('))); - expect(nativeMenu, isNot(contains('gtk_popover_new_from_model('))); - expect(nativeMenu, isNot(contains('gtk_menu_button_set_popover('))); - expect(nativeMenu, isNot(contains('gtk_menu_button_get_popover('))); - expect(nativeMenu, isNot(contains('gtk_popover_'))); - expect(nativeMenu, isNot(contains('GTK_IS_MODEL_BUTTON'))); - expect(nativeMenu, isNot(contains('style_native_popover'))); - expect(nativeMenu, isNot(contains('add_model_button_presentation'))); - expect(nativeMenu, isNot(contains('ensure_native_menu_hover_tracking'))); - expect(nativeMenu, isNot(contains('GTK_STATE_FLAG_PRELIGHT'))); - expect(nativeMenu, isNot(contains('gtk_widget_set_state_flags'))); - expect(nativeMenu, isNot(contains('gdk_display_flush'))); - expect(nativeMenu, isNot(contains('wl_display_'))); - expect(nativeMenu, contains('g_object_ref(G_OBJECT(method_call))')); - expect(nativeMenu, isNot(contains('gtk_popover_bind_model('))); - expect( - dispose, - contains('gtk_menu_shell_deactivate(GTK_MENU_SHELL(session->menu))'), - ); - expect(dispose, contains('gtk_menu_detach(GTK_MENU(session->menu))')); - expect(dispose, contains('kNativeMenuActionNamespace, nullptr')); - expect(dispose, isNot(contains('gtk_widget_destroy(session->menu)'))); - expect(dispose, contains('g_clear_object(&session->menu)')); - final deactivateIndex = dispose.indexOf('gtk_menu_shell_deactivate('); - final detachIndex = dispose.indexOf('gtk_menu_detach('); - final respondIndex = dispose.indexOf('native_menu_session_respond('); - final freeIndex = dispose.indexOf('g_free(session)'); - expect(deactivateIndex, isNonNegative); - expect(detachIndex, isNonNegative); - expect(respondIndex, isNonNegative); - expect(freeIndex, isNonNegative); - expect(deactivateIndex, lessThan(detachIndex)); - expect(detachIndex, lessThan(respondIndex)); - expect(respondIndex, lessThan(freeIndex)); - expect( - 'native_menu_session_respond('.allMatches(nativeMenu), - hasLength(2), - ); - expect( - nativeMenu, - contains('g_idle_add_full(\n G_PRIORITY_DEFAULT_IDLE'), - ); - expect(nativeMenu, isNot(contains('"unmap"'))); - expect(nativeMenu, isNot(contains('gtk_dialog_run('))); - expect(nativeMenu, isNot(contains('gtk_menu_new('))); - expect(nativeMenu, isNot(contains('gtk_widget_override'))); - expect( - service, - contains("const nativeMenuChannelName = 'busymax/native_menus'"), + service, + contains("const nativeMenuChannelName = 'busymax/native_menus'"), ); expect(service, contains("invokeMethod('show'")); expect(service, contains("invokeMethod('dismiss'")); @@ -1344,6 +836,61 @@ void main() { expect(confirmBody, isNot(contains('return AlertDialog('))); }); + test('Linux DAV feature dialogs reuse BusyMax presentation', () { + final collection = File( + 'lib/src/dav/presentation/nextcloud_collection_dialog.dart', + ).readAsStringSync(); + final scheduling = File( + 'lib/src/dav/presentation/nextcloud_scheduling_dialog.dart', + ).readAsStringSync(); + final accounts = File( + 'lib/src/dav/auth/dav_account_dialogs.dart', + ).readAsStringSync(); + final settings = File( + 'lib/src/features/settings/presentation/settings_screen.dart', + ).readAsStringSync(); + final importFlow = File( + 'lib/src/features/calendar/presentation/ical_import_flow.dart', + ).readAsStringSync(); + + for (final source in [collection, scheduling]) { + expect(source, contains('showBusyMaxModalDialog(')); + expect(source, contains('BusyMaxDialogShell(')); + expect(source, isNot(contains('showDialog<'))); + expect(source, isNot(contains('AlertDialog('))); + } + expect(collection, contains('BusyMaxEditorHeader(')); + expect(collection, contains('showBusyMaxConfirm(')); + expect(scheduling, contains('showBusyMaxConfirm(')); + + expect(accounts, contains('BusyMaxGroupedList(')); + expect(accounts, contains('busyMaxGroupedTextFieldDecoration(')); + expect(accounts, contains("Key('nextcloud-server-field')")); + expect(accounts, contains("Key('apple-account-email-field')")); + + final subscriptionStart = settings.indexOf('class _WebCalAddDialog'); + final subscriptionEnd = settings.indexOf( + 'String _refreshModeLabel', + subscriptionStart, + ); + final subscription = settings.substring( + subscriptionStart, + subscriptionEnd, + ); + expect(subscription, contains('BusyMaxGroupedList(')); + expect(subscription, contains('busyMaxGroupedTextFieldDecoration(')); + expect(subscription, contains('BusyMaxComboRow(')); + + final previewStart = importFlow.indexOf('class _IcalImportPreviewDialog'); + final previewEnd = importFlow.indexOf( + 'class _IcalImportReportDialog', + previewStart, + ); + final preview = importFlow.substring(previewStart, previewEnd); + expect(preview, contains('YaruCheckboxListTile(')); + expect(preview, isNot(contains('\n CheckboxListTile('))); + }); + test( 'schedule search filters use BusyMax rows without a dedicated native subsystem', () { @@ -1446,47 +993,19 @@ void main() { 'G_CALLBACK(native_time_zone_parent_is_active_notify_cb), window', ), ); + expect(nativeSelector, isNot(contains('header_focus_transient_window'))); expect( - nativeSelector, - contains( - 'application->header_focus_transient_window = GTK_WINDOW(window)', - ), - ); - expect( - nativeSelector, - contains( - 'window, "notify::is-active",\n' - ' G_CALLBACK(header_focus_window_is_active_notify_cb), ' - 'application', - ), - ); - expect( - runner, - contains('static void native_time_zone_parent_is_active_notify_cb('), - ); - final headerFocusStart = runner.indexOf( - 'static gboolean refresh_header_bar_focus_state_cb(', - ); - final headerFocusEnd = runner.indexOf( - 'static void set_header_bar_modal_barrier_visible(', - headerFocusStart, + nativeSelector, + contains('gtk_window_set_transient_for(GTK_WINDOW(window), parent)'), ); - expect(headerFocusStart, isNonNegative); - expect(headerFocusEnd, greaterThan(headerFocusStart)); - final headerFocus = runner.substring(headerFocusStart, headerFocusEnd); expect( - headerFocus, - isNot(contains('self->header_bar_modal_barrier_visible ||')), + nativeSelector, + contains('gtk_window_set_modal(GTK_WINDOW(window), TRUE)'), ); - expect(headerFocus, contains('gtk_window_is_active(self->main_window)')); expect( - headerFocus, - contains('gtk_window_is_active(self->header_focus_transient_window)'), + runner, + contains('static void native_time_zone_parent_is_active_notify_cb('), ); - expect(headerFocus, contains('kHeaderApplicationActiveStyleClass')); - expect(headerFocus, contains('kHeaderApplicationBackdropStyleClass')); - expect(headerFocus, contains('g_idle_add_full(')); - expect(headerFocus, contains('gtk_widget_reset_style(')); final activationCallbackStart = runner.indexOf( 'static void native_time_zone_parent_is_active_notify_cb(', ); @@ -1715,448 +1234,36 @@ void main() { expect(shell, isNot(contains('BorderSide('))); }); - test('native headerbar CSS uses scoped semantic surfaces and states', () { - final source = File('linux/runner/my_application.cc').readAsStringSync(); - final refreshHeaderCssStart = source.indexOf( - 'static void refresh_header_bar_css', - ); - final headerCssStart = source.indexOf( - 'g_autofree gchar* css = g_strdup_printf(', - refreshHeaderCssStart, - ); - final headerCssEnd = source.indexOf( - 'g_autoptr(GError) error = nullptr;', - headerCssStart, - ); - expect(headerCssStart, isNonNegative); - expect(headerCssEnd, isNonNegative); - final headerCss = source.substring(headerCssStart, headerCssEnd); - final nativeDialogCssStart = source.indexOf( - 'g_autofree gchar* native_dialog_css =', - ); - final nativeDialogCssEnd = source.indexOf( - 'g_autofree gchar* native_time_zone_dialog_css =', - nativeDialogCssStart, - ); - final nativeTimeZoneDialogCssStart = nativeDialogCssEnd; - final nativeTimeZoneDialogCssEnd = source.indexOf( - 'g_autofree gchar* modal_barrier_color', - nativeTimeZoneDialogCssStart, - ); - expect(nativeDialogCssStart, isNonNegative); - expect(nativeDialogCssEnd, isNonNegative); - expect(nativeTimeZoneDialogCssStart, isNonNegative); - expect(nativeTimeZoneDialogCssEnd, isNonNegative); - final nativeDialogCss = source.substring( - nativeDialogCssStart, - nativeDialogCssEnd, - ); - final nativeTimeZoneDialogCss = source.substring( - nativeTimeZoneDialogCssStart, - nativeTimeZoneDialogCssEnd, - ); - final nativeGroupedListCssStart = source.indexOf( - 'static GtkCssProvider* create_native_grouped_list_provider', - ); - final nativeGroupedListCssEnd = source.indexOf( - 'static void handle_native_time_zone_selection', - nativeGroupedListCssStart, - ); - expect(nativeGroupedListCssStart, isNonNegative); - expect(nativeGroupedListCssEnd, greaterThan(nativeGroupedListCssStart)); - final nativeGroupedListCss = source.substring( - nativeGroupedListCssStart, - nativeGroupedListCssEnd, - ); - final nativeSearchGeometryCssStart = source.indexOf( - 'g_autofree gchar* native_search_geometry_css =', - ); - final nativeSearchGeometryCssEnd = source.indexOf( - 'const gchar* tooltip_background =', - nativeSearchGeometryCssStart, - ); - expect(nativeSearchGeometryCssStart, isNonNegative); - expect( - nativeSearchGeometryCssEnd, - greaterThan(nativeSearchGeometryCssStart), - ); - final nativeSearchGeometryCss = source.substring( - nativeSearchGeometryCssStart, - nativeSearchGeometryCssEnd, - ); - final tooltipCssStart = nativeSearchGeometryCssEnd; - final tooltipCssEnd = source.indexOf( - 'g_autofree gchar* header_focus_css =', - tooltipCssStart, - ); - expect(tooltipCssStart, isNonNegative); - expect(tooltipCssEnd, greaterThan(tooltipCssStart)); - final tooltipCss = source.substring(tooltipCssStart, tooltipCssEnd); - final yaruDecorationCssStart = source.indexOf( - 'g_autofree gchar* yaru_window_decoration_css =', - ); - final yaruDecorationCssEnd = source.indexOf( - 'GtkWidget* header_bar =', - yaruDecorationCssStart, - ); - expect(yaruDecorationCssStart, isNonNegative); - expect(yaruDecorationCssEnd, isNonNegative); - final yaruDecorationCss = source.substring( - yaruDecorationCssStart, - yaruDecorationCssEnd, - ); - - expect(source, contains('"busymax-header-title"')); - expect(source, contains('".busymax-titlebar .busymax-header-title {"')); - expect(headerCss, contains('"window#busymax-window,"')); - expect( - headerCss, - contains('".busymax-titlebar .busymax-header-brand {"'), - ); - expect(headerCss, contains('"border-right: 1px solid %s;"')); - expect(headerCss, isNot(contains('"border-right-color: %s;"'))); - expect( - headerCss, - contains( - '".busymax-titlebar.%s "\n' - ' ".busymax-header-control,"', - ), - ); - expect( - headerCss, - contains( - '".busymax-titlebar .%s,"\n' - ' ".busymax-titlebar .%s:backdrop {"', - ), - ); - expect(headerCss, contains('"color: alpha(%s, %.2f);"')); - expect(source, contains('kHeaderBackdropForegroundOpacity = 0.50')); - expect(source, contains('kHeaderDisabledForegroundOpacity = 0.38')); - expect( - source, - contains( - 'kHeaderDisabledBackdropForegroundOpacity =\n' - ' kHeaderDisabledForegroundOpacity * ' - 'kHeaderBackdropForegroundOpacity', - ), - ); - expect( - headerCss, - contains('".busymax-header-control:disabled:backdrop,"'), - ); - expect( - headerCss, - contains('"headerbar button.titlebutton:disabled:backdrop {"'), - ); - expect(source, contains('modal_barrier_color_for_depth(')); - expect(source, contains('header_bar_modal_barrier_shade_depth')); - expect(source, contains('self->header_bar_modal_barrier_color')); - expect(source, contains('kDefaultModalBarrierColor')); - expect(headerCss, isNot(contains('linear-gradient(%s, %s)'))); - expect(headerCss, isNot(contains('".busymax-titlebar,"'))); - expect(source, contains('kDefaultWindowBackgroundColor[] = "#2C2C2C"')); - expect( - source, - contains('kDefaultHeaderBarBackgroundColor[] = "#272727"'), - ); - expect( - source, - contains('kDefaultHeaderBarSidebarBackgroundColor[] = "#393939"'), - ); - expect(source, contains('set_flutter_view_background_color')); - expect(source, contains('header_bar_window_background_color')); - expect(source, contains('"windowBackgroundColor"')); - expect( - source, - contains('css_color_or(self->header_bar_window_background_color,'), - ); - expect( - source, - isNot(contains('gdk_rgba_parse(&background_color, "#00000000")')), - ); - expect(source, contains('"backgroundColor"')); - expect(source, contains('"sidebarBackgroundColor"')); - expect(source, contains('"sidebarBorderColor"')); - expect(source, contains('"foregroundColor"')); - expect(source, isNot(contains('"popoverBackgroundColor"'))); - expect(source, isNot(contains('"menuHoverColor"'))); - expect(source, isNot(contains('"popoverShadowColor"'))); - expect(source, contains('"dialogBackgroundColor"')); - expect(source, isNot(contains('"floatingBorderColor"'))); - expect(source, contains('"highContrast"')); - expect(source, isNot(contains('"shadeColor"'))); - expect(source, contains('"modalBarrierColor"')); - expect( - source, - isNot(contains('fl_lookup_string_arg(args, "controlColor")')), - ); - expect( - source, - isNot(contains('fl_lookup_string_arg(args, "controlHoverColor")')), - ); - expect( - source, - isNot(contains('fl_lookup_string_arg(args, "controlActiveColor")')), - ); - expect( - source, - isNot(contains('fl_lookup_string_arg(args, "popoverBackgroundColor")')), - ); - expect( - source, - isNot(contains('fl_lookup_string_arg(args, "menuHoverColor")')), - ); - expect( - source, - isNot(contains('fl_lookup_string_arg(args, "popoverShadowColor")')), - ); - expect( - source, - contains('fl_lookup_string_arg(args, "dialogBackgroundColor")'), - ); - expect( - source, - contains('fl_lookup_string_arg(args, "dialogOutlineColor")'), - ); - expect( - source, - isNot(contains('fl_lookup_string_arg(args, "floatingBorderColor")')), - ); - expect( - source, - contains('fl_lookup_optional_bool_arg(args, "highContrast"'), - ); - expect(source, contains('"background-color: %s;"')); - expect(source, isNot(contains('native_popover_css'))); - expect(source, isNot(contains('kNativePopoverStyleClass'))); - expect(source, isNot(contains('kHeaderMenuDepthStyleClass'))); - expect(source, isNot(contains('modelbutton:hover'))); - expect( - nativeSearchGeometryCss, - contains('use_legacy_yaru_compatibility'), - ); - expect(nativeSearchGeometryCss, contains('"entry.search.%s {"')); - expect(nativeSearchGeometryCss, contains('"border-radius: 9px;"')); - expect(nativeSearchGeometryCss, contains('kHeaderSearchEntryStyleClass')); - expect(nativeSearchGeometryCss, isNot(contains('background'))); - expect(nativeSearchGeometryCss, isNot(contains('border-color'))); - expect(nativeSearchGeometryCss, isNot(contains('"border:'))); - expect(nativeSearchGeometryCss, isNot(contains('box-shadow'))); - expect(nativeSearchGeometryCss, isNot(contains('padding'))); - expect(nativeSearchGeometryCss, isNot(contains('min-height'))); - expect(nativeSearchGeometryCss, isNot(contains('#'))); - expect(nativeSearchGeometryCss, isNot(contains('rgba('))); - expect(source, isNot(contains('kNativeMenuContentPadding'))); - expect( - source, - isNot(contains('g_autofree gchar* native_menu_geometry_css')), - ); - expect( - source, - isNot(contains('g_autofree gchar* native_menu_state_css')), - ); - expect( - source, - isNot(contains('g_autofree gchar* header_menu_shadow_css')), - ); - expect(source, isNot(contains('kNativeMenuRadioLtrStyleClass'))); - expect(source, isNot(contains('kNativeMenuRadioRtlStyleClass'))); - expect(tooltipCss, contains('"tooltip.background {"')); - expect(tooltipCss, contains('"tooltip decoration,"')); - expect(tooltipCss, contains('"tooltip.csd decoration {"')); - expect(tooltipCss, contains('"tooltip box,"')); - expect(tooltipCss, contains('"tooltip.background box {"')); - expect(tooltipCss, contains('"tooltip label,"')); - expect(tooltipCss, contains('"tooltip.background label {"')); - expect(tooltipCss, contains('"min-width: 0;"')); - expect(tooltipCss, contains('tooltip_label_horizontal_padding')); - expect(tooltipCss, contains('tooltip_label_vertical_padding')); - expect(tooltipCss, contains('tooltip_label_minimum_height')); - expect(tooltipCss, contains('"padding: %.2fpx %.2fpx;"')); - expect( - '"padding: %.2fpx %.2fpx;"'.allMatches(tooltipCss).length, - 1, - reason: 'Only the label contributes tooltip content padding', - ); - expect(tooltipCss, contains('"font-size: %.2fpx;"')); - expect( - '"border-radius: %.2fpx;"'.allMatches(tooltipCss).length, - 2, - reason: 'Only the painted tooltip and native window clip are rounded', - ); - expect(source, contains('"busymax-native-dialog"')); - expect(source, contains('style_native_dialog(GtkWidget* dialog)')); - expect( - 'style_native_dialog(dialog);'.allMatches(source).length, - 1, - reason: 'the remaining native time picker uses dialog styling', - ); - expect(source, contains('style_native_dialog(window);')); - expect( - nativeDialogCss, - contains('g_autofree gchar* native_dialog_css ='), - ); - expect( - nativeDialogCss, - contains( - '".%s headerbar,"\n' - ' ".%s headerbar:backdrop {"', - ), - ); - expect( - 'dialog_background_color'.allMatches(nativeDialogCss).length, - 3, - reason: 'legacy native pickers share one dialog surface token', - ); - expect(nativeDialogCss, isNot(contains('window_background_color'))); - expect(nativeDialogCss, contains('"border-bottom-width: 0;"')); - expect(nativeDialogCss, contains('"border-bottom-style: none;"')); - expect(nativeDialogCss, contains('"border-bottom-color: transparent;"')); - expect(nativeDialogCss, contains('"box-shadow: inset 0 0 0 1px %s;"')); - expect( - yaruDecorationCss, - contains( - '"messagedialog.%s.csd:not(.solid-csd):"\n' - ' "not(.maximized):not(.fullscreen) > decoration {"', - ), - ); - expect(source, contains('current_gtk_theme_uses_legacy_yaru_shadow()')); - expect(yaruDecorationCss, contains('use_legacy_yaru_compatibility')); - expect(source, contains('!self->header_bar_high_contrast')); - expect( - yaruDecorationCss, - contains('"box-shadow: 0 0 14px 2px rgba(0,0,6,0.03),"'), - ); - expect(yaruDecorationCss, contains('"0 0 5px 2px rgba(0,0,6,0.10),"')); - expect(yaruDecorationCss, contains('"0 0 0 1px rgba(0,0,0,0.05);"')); - expect(yaruDecorationCss, contains('kNativeDialogStyleClass')); - expect(yaruDecorationCss, isNot(contains('"border:'))); - expect( - nativeDialogCss, - contains( - 'kNativeDialogStyleClass, kNativeDialogStyleClass,\n' - ' dialog_background_color', - ), - ); - expect(nativeDialogCss, isNot(contains('"border:'))); - expect('border-radius: %dpx;'.allMatches(nativeDialogCss).length, 1); - expect( - nativeTimeZoneDialogCss, - contains('g_autofree gchar* native_time_zone_dialog_css ='), - ); - expect( - nativeTimeZoneDialogCss, - contains('kNativeTimeZoneDialogStyleClass'), - ); - expect( - nativeTimeZoneDialogCss, - contains( - '"window.%s.%s.csd:not(.solid-csd):not(.maximized):' - 'not(.fullscreen),"', - ), - ); - expect( - nativeTimeZoneDialogCss, - contains('"not(.fullscreen) > decoration:backdrop,"'), - ); - expect( - nativeTimeZoneDialogCss, - contains('"not(.fullscreen) > decoration-overlay:backdrop {"'), - ); - expect( - nativeTimeZoneDialogCss, - contains('"border-radius: 0 0 %dpx %dpx;"'), - ); - expect(nativeTimeZoneDialogCss, contains('"box-shadow: none;"')); - expect('"border: none;"'.allMatches(nativeTimeZoneDialogCss).length, 2); - expect(nativeTimeZoneDialogCss, isNot(contains('shade('))); - expect( - nativeTimeZoneDialogCss, - isNot(contains('kNativeTimeZoneGroupStyleClass')), - ); - expect( - nativeTimeZoneDialogCss, - isNot(contains('kNativeTimeZoneRowStyleClass')), - ); - expect(nativeGroupedListCss, contains('style->surface_color')); - expect(nativeGroupedListCss, contains('style->divider_color')); - expect(nativeGroupedListCss, contains('style->section_header_color')); - expect(nativeGroupedListCss, contains('style->primary_text_color')); - expect(nativeGroupedListCss, contains('style->secondary_text_color')); - expect(nativeGroupedListCss, contains('style->hover_color')); - expect(nativeGroupedListCss, contains('style->shadow_color')); - expect(nativeGroupedListCss, contains('style->outline_color')); - expect(nativeGroupedListCss, contains('style->section_top_spacing')); - expect( - nativeGroupedListCss, - isNot(contains('style->section_horizontal_padding')), - ); - expect(nativeGroupedListCss, contains('style->title_bottom_spacing')); - expect(nativeGroupedListCss, contains('"window.%s row.%s,"')); - expect( - nativeGroupedListCss, - contains('"window.%s row.%s:hover:not(:disabled) {"'), - ); - expect(nativeGroupedListCss, isNot(contains('"window.%s .%s row,"'))); - expect(source, isNot(contains('kNativeTimeZonePrimaryTextOpacity'))); - expect(source, isNot(contains('kNativeTimeZoneSecondaryTextOpacity'))); - expect( - source, - contains('constexpr gint kNativeDialogCornerRadius = 14;'), - ); - expect(source, isNot(contains('"busymax-native-dialog-cancel"'))); - expect(source, isNot(contains('"busymax-native-dialog-destructive"'))); - expect(source, isNot(contains('"busymax-native-dialog-actions"'))); - expect(source, isNot(contains('style_native_popover'))); - expect(source, isNot(contains('activate_native_menu_host('))); - expect( - source, - contains( - 'gtk_menu_button_set_use_popover(GTK_MENU_BUTTON(button), FALSE)', - ), - ); - expect(source, contains('gtk_menu_button_get_popup')); - expect(source, contains('GTK_IS_MENU(menu)')); - expect(source, isNot(contains('gtk_popover_'))); - expect(headerCss, contains('headerbar button.titlebutton')); - expect(source, contains('kHeaderControlStyleClass')); - expect(source, isNot(contains('kHeaderMenuControlStyleClass'))); - expect(source, contains('style_header_control(button)')); - expect( - source, - contains('style_header_control(self->settings_menu_button)'), - ); - expect(source, contains('style_header_control(self->view_mode_button)')); - expect(source, contains('style_header_control(self->create_button)')); - expect( - headerCss, - contains( - '".busymax-header-control:not(.suggested-action):not(:disabled) {"', - ), - ); - expect(headerCss, isNot(contains('busymax-header-menu-control'))); - expect(headerCss, contains('"background-color: transparent;"')); - expect(headerCss, contains('"background-image: none;"')); - expect(headerCss, contains('alpha(currentColor, 0.07)')); - expect(headerCss, contains('alpha(currentColor, 0.16)')); - expect(headerCss, contains('alpha(currentColor, 0.10)')); - expect(headerCss, contains('alpha(currentColor, 0.13)')); - expect(headerCss, contains('alpha(currentColor, 0.19)')); - expect(headerCss, isNot(contains('alpha(currentColor, 0.15)'))); - expect(headerCss, isNot(contains('alpha(currentColor, 0.30)'))); - expect(headerCss, isNot(contains('#151515'))); - expect(headerCss, isNot(contains('popover.busymax'))); - expect(headerCss, contains('tooltip_css')); - expect(headerCss, contains(':hover')); - expect(headerCss, contains(':active')); - expect(headerCss, contains(':checked')); - expect(headerCss, isNot(contains(':focus'))); - expect(headerCss, contains(':disabled')); - expect(headerCss, isNot(contains('box-shadow: inset'))); - expect(headerCss, isNot(contains('transition: none'))); - expect(headerCss, isNot(contains('text-shadow: none'))); - expect(headerCss, isNot(contains('-gtk-icon-shadow: none'))); - }); + test( + 'retained native surfaces are themed without a native main header', + () { + final source = File( + 'linux/runner/my_application.cc', + ).readAsStringSync(); + final start = source.indexOf('static void refresh_native_surface_css'); + final end = source.indexOf( + 'static void set_native_surface_theme', + start, + ); + + expect(start, isNonNegative); + expect(end, greaterThan(start)); + final nativeSurfaceCss = source.substring(start, end); + expect(nativeSurfaceCss, contains('kNativeDialogStyleClass')); + expect(nativeSurfaceCss, contains('kNativeTimeZoneDialogStyleClass')); + expect(nativeSurfaceCss, contains('tooltip')); + expect(nativeSurfaceCss, contains('window#busymax-window')); + expect(nativeSurfaceCss, isNot(contains('busymax-titlebar'))); + expect(nativeSurfaceCss, isNot(contains('busymax-header-control'))); + expect(source, isNot(contains('refresh_header_bar_css'))); + expect(source, isNot(contains('kHeaderControlStyleClass'))); + expect(source, contains('native_surface_css_provider')); + expect( + source, + contains('g_clear_object(&self->native_surface_css_provider)'), + ); + }, + ); test('native GTK theme sampling does not export fake disabled colors', () { final source = File('linux/runner/my_application.cc').readAsStringSync(); @@ -2202,141 +1309,150 @@ void main() { ); }); - test('native GTK theme colors are streamed to Flutter', () { - final source = File('linux/runner/my_application.cc').readAsStringSync(); - final headerBarService = File( - 'lib/src/platform/linux_header_bar_service.dart', - ).readAsStringSync(); - final gtkFontService = File( - 'lib/src/platform/gtk_font_service.dart', - ).readAsStringSync(); - final app = File('lib/src/app/busymax_app.dart').readAsStringSync(); - final main = File('lib/main_linux.dart').readAsStringSync(); + test( + 'native GTK theme colors and retained-surface styles stay separate', + () { + final source = File( + 'linux/runner/my_application.cc', + ).readAsStringSync(); + final nativeStyle = File( + 'lib/src/platform/native_style.dart', + ).readAsStringSync(); + final gtkFontService = File( + 'lib/src/platform/gtk_font_service.dart', + ).readAsStringSync(); + final app = File('lib/src/app/busymax_app.dart').readAsStringSync(); + final main = File('lib/main_linux.dart').readAsStringSync(); - expect(source, contains('kGtkThemeColorsEventChannel')); - expect(source, contains('io.busystack.busymax/gtk_theme_colors')); - expect( - source, - contains('FlEventChannel* gtk_theme_colors_event_channel'), - ); - expect(source, contains('gtk_theme_colors_listen_cb')); - expect(source, contains('gtk_theme_colors_cancel_cb')); - expect(source, contains('notify::gtk-theme-name')); - expect(source, contains('notify::gtk-application-prefer-dark-theme')); - expect(source, contains('send_gtk_theme_colors_event')); - expect(source, contains('connect_gtk_theme_colors_signals')); - expect(source, contains('disconnect_gtk_theme_colors_signals')); - final notifyStart = source.indexOf( - 'static void gtk_theme_colors_notify_cb(', - ); - final listenStart = source.indexOf( - 'static FlMethodErrorResponse* gtk_theme_colors_listen_cb(', - ); - final cancelStart = source.indexOf( - 'static FlMethodErrorResponse* gtk_theme_colors_cancel_cb(', - ); - final registerStart = source.indexOf( - 'static void register_gtk_settings_channel(', - ); - final startupStart = source.indexOf( - 'static void my_application_startup(GApplication* application)', - ); - final shutdownStart = source.indexOf( - 'static void my_application_shutdown(GApplication* application)', - ); - expect(notifyStart, isNonNegative); - expect(listenStart, greaterThan(notifyStart)); - expect(cancelStart, greaterThan(listenStart)); - expect(registerStart, greaterThan(cancelStart)); - expect(startupStart, greaterThan(registerStart)); - expect(shutdownStart, greaterThan(startupStart)); - final notify = source.substring(notifyStart, listenStart); - final cancel = source.substring(cancelStart, registerStart); - final startup = source.substring(startupStart, shutdownStart); - expect(notify, contains('refresh_header_bar_css(self)')); - expect(notify, contains('send_gtk_theme_colors_event(self)')); - expect(cancel, isNot(contains('disconnect_gtk_theme_colors_signals'))); - expect(startup, contains('connect_gtk_theme_colors_signals(')); - expect( - source, - contains('g_clear_object(&self->gtk_theme_colors_event_channel)'), - ); - expect(headerBarService, contains('required this.preferDark')); - expect(headerBarService, contains("'preferDark': preferDark")); - expect(app, contains('preferDark: theme.brightness == Brightness.dark')); - expect(app, isNot(contains('popoverShadowColor:'))); - expect( - app, - isNot(contains('BusyMaxAlpha.nativeHeaderMenuShadowOpacity')), - ); - expect(source, contains('static void set_gtk_theme_preference')); - expect( - source, - contains('"gtk-application-prefer-dark-theme", prefer_dark'), - ); - expect( - source, - isNot(contains('g_object_set(settings, "gtk-theme-name"')), - ); - expect( - source, - isNot(contains('g_object_set(settings, "gtk-icon-theme-name"')), - ); - expect(source, isNot(contains('gtk_icon_theme_set_custom_theme'))); - expect(source, contains('theme_selected_bg_color')); - expect(source, contains('set_theme_color(result, "accent"')); - expect(source, contains('set_theme_color(result, "accentForeground"')); - expect(source, contains('set_theme_color(result, "divider"')); - expect(source, contains('set_theme_color(result, "cardShade"')); - expect(source, contains('set_theme_color(result, "floatingBorder"')); - expect( - source, - contains('lookup_context_color(window_context, "card_shade_color"'), - ); - expect( - source, - contains('lookup_context_color(window_context, "popover_border_color"'), - ); - expect( - source, - contains( - 'lookup_context_color(window_context, "floating_border_color"', - ), - ); - expect(source, isNot(contains('sample_widget_border_color('))); - expect(source, contains('gtk_separator_new(GTK_ORIENTATION_HORIZONTAL)')); - expect(source, contains('sample_widget_background(separator')); - expect(source, isNot(contains('divider_color.alpha *='))); - expect(source, contains('GTK_STYLE_CLASS_DIM_LABEL')); - expect(source, contains('"opacity", &opacity')); - expect(source, contains('"setGtkThemePreference"')); - expect(source, contains('set_gtk_theme_preference(fl_method_bool_arg')); - expect(gtkFontService, contains('final Color? accent;')); - expect(gtkFontService, contains("accent: _parseColor(value['accent'])")); - expect(gtkFontService, contains('final Color? accentForeground;')); - expect( - app, - contains( - 'gtkThemeColors?.accent ?? ubuntuAccentColor ?? systemColor.accent', - ), - ); - expect(source, contains('fl_lookup_optional_bool_arg')); - expect( - source, - contains('fl_lookup_optional_bool_arg(args, "preferDark"'), - ); - expect(source, contains('set_gtk_theme_preference(prefer_dark);')); - expect(source, isNot(contains('prefer_dark_gtk_theme'))); - expect(source, isNot(contains('set_gtk_theme_preference(TRUE)'))); - final initialThemeStart = main.indexOf( - 'await _applyInitialNativeHeaderBarTheme(', - ); - final runAppStart = main.indexOf('runApp('); - expect(initialThemeStart, isNonNegative); - expect(runAppStart, greaterThan(initialThemeStart)); - expect(main, contains('busyMaxHeaderBarThemeFor(')); - expect(main, contains('await headerBarService.setTheme(')); - }); + expect(source, contains('kGtkThemeColorsEventChannel')); + expect(source, contains('io.busystack.busymax/gtk_theme_colors')); + expect( + source, + contains('FlEventChannel* gtk_theme_colors_event_channel'), + ); + expect(source, contains('gtk_theme_colors_listen_cb')); + expect(source, contains('gtk_theme_colors_cancel_cb')); + expect(source, contains('notify::gtk-theme-name')); + expect(source, contains('notify::gtk-application-prefer-dark-theme')); + expect(source, contains('send_gtk_theme_colors_event')); + expect(source, contains('connect_gtk_theme_colors_signals')); + expect(source, contains('disconnect_gtk_theme_colors_signals')); + final notifyStart = source.indexOf( + 'static void gtk_theme_colors_notify_cb(', + ); + final listenStart = source.indexOf( + 'static FlMethodErrorResponse* gtk_theme_colors_listen_cb(', + ); + final cancelStart = source.indexOf( + 'static FlMethodErrorResponse* gtk_theme_colors_cancel_cb(', + ); + final registerStart = source.indexOf( + 'static void register_gtk_settings_channel(', + ); + final startupStart = source.indexOf( + 'static void my_application_startup(GApplication* application)', + ); + final shutdownStart = source.indexOf( + 'static void my_application_shutdown(GApplication* application)', + ); + expect(notifyStart, isNonNegative); + expect(listenStart, greaterThan(notifyStart)); + expect(cancelStart, greaterThan(listenStart)); + expect(registerStart, greaterThan(cancelStart)); + expect(startupStart, greaterThan(registerStart)); + expect(shutdownStart, greaterThan(startupStart)); + final notify = source.substring(notifyStart, listenStart); + final cancel = source.substring(cancelStart, registerStart); + final startup = source.substring(startupStart, shutdownStart); + expect(notify, contains('refresh_native_surface_css(self)')); + expect(notify, contains('send_gtk_theme_colors_event(self)')); + expect(cancel, isNot(contains('disconnect_gtk_theme_colors_signals'))); + expect(startup, contains('connect_gtk_theme_colors_signals(')); + expect( + source, + contains('g_clear_object(&self->gtk_theme_colors_event_channel)'), + ); + expect(nativeStyle, contains("'setNativeSurfaceTheme'")); + expect(nativeStyle, isNot(contains('preferDark'))); + expect(app, isNot(contains('preferDark: theme.brightness'))); + expect(app, isNot(contains('popoverShadowColor:'))); + expect( + app, + isNot(contains('BusyMaxAlpha.nativeHeaderMenuShadowOpacity')), + ); + expect(source, contains('static void set_gtk_theme_preference')); + expect( + source, + contains('"gtk-application-prefer-dark-theme", prefer_dark'), + ); + expect( + source, + isNot(contains('g_object_set(settings, "gtk-theme-name"')), + ); + expect( + source, + isNot(contains('g_object_set(settings, "gtk-icon-theme-name"')), + ); + expect(source, isNot(contains('gtk_icon_theme_set_custom_theme'))); + expect(source, contains('theme_selected_bg_color')); + expect(source, contains('set_theme_color(result, "accent"')); + expect(source, contains('set_theme_color(result, "accentForeground"')); + expect(source, contains('set_theme_color(result, "divider"')); + expect(source, contains('set_theme_color(result, "cardShade"')); + expect(source, contains('set_theme_color(result, "floatingBorder"')); + expect( + source, + contains('lookup_context_color(window_context, "card_shade_color"'), + ); + expect( + source, + contains( + 'lookup_context_color(window_context, "popover_border_color"', + ), + ); + expect( + source, + contains( + 'lookup_context_color(window_context, "floating_border_color"', + ), + ); + expect(source, isNot(contains('sample_widget_border_color('))); + expect( + source, + contains('gtk_separator_new(GTK_ORIENTATION_HORIZONTAL)'), + ); + expect(source, contains('sample_widget_background(separator')); + expect(source, isNot(contains('divider_color.alpha *='))); + expect(source, contains('GTK_STYLE_CLASS_DIM_LABEL')); + expect(source, contains('"opacity", &opacity')); + expect(source, contains('"setGtkThemePreference"')); + expect(source, contains('set_gtk_theme_preference(fl_method_bool_arg')); + expect(gtkFontService, contains('final Color? accent;')); + expect( + gtkFontService, + contains("accent: _parseColor(value['accent'])"), + ); + expect(gtkFontService, contains('final Color? accentForeground;')); + expect( + app, + contains( + 'gtkThemeColors?.accent ?? ubuntuAccentColor ?? systemColor.accent', + ), + ); + expect(source, contains('setNativeSurfaceTheme')); + expect(source, contains('set_native_surface_theme(self, args)')); + expect(source, isNot(contains('prefer_dark_gtk_theme'))); + expect(source, isNot(contains('set_gtk_theme_preference(TRUE)'))); + final initialThemeStart = main.indexOf( + 'await _applyInitialNativeSurfaceTheme(', + ); + final runAppStart = main.indexOf('runApp('); + expect(initialThemeStart, isNonNegative); + expect(runAppStart, greaterThan(initialThemeStart)); + expect(main, contains('busyMaxNativeSurfaceThemeFor(')); + expect(main, contains('NativeSurfaceStyleService().setTheme(')); + }, + ); test('app code does not bypass centralized typography', () { final matches = []; @@ -2599,6 +1715,7 @@ bool _hasRawIconButton(File file, String line) { return line.contains('IconButton(') && !line.contains('YaruIconButton(') && !line.contains('BusyMaxHeaderIconButton(') && + !line.contains('BusyMaxLinuxHeaderIconButton(') && !line.contains('BusyMaxPopoverIconButton('); } diff --git a/test/app/theme_localization_test.dart b/test/app/theme_localization_test.dart index 859a58e8..0c9bb486 100644 --- a/test/app/theme_localization_test.dart +++ b/test/app/theme_localization_test.dart @@ -16,6 +16,7 @@ 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'; import 'package:busymax/src/app/busymax_design.dart'; +import 'package:busymax/src/app/busymax_dialogs.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'; @@ -33,33 +34,29 @@ import 'package:connectivity_plus/connectivity_plus.dart'; import '../test_localized_app.dart'; void main() { - test('native header theme uses the exact first-frame semantic palette', () { + test('retained native surfaces use the first-frame semantic palette', () { final theme = _buildBusyMaxTheme(brightness: Brightness.light); final colors = theme.extension()!; - final headerTheme = busyMaxHeaderBarThemeFor(theme, highContrast: false); - - expect(headerTheme.preferDark, isFalse); - expect(headerTheme.highContrast, isFalse); - expect(headerTheme.windowBackgroundColor, colors.window); - expect(headerTheme.backgroundColor, colors.window); - expect(headerTheme.sidebarBackgroundColor, colors.sidebar); - expect(headerTheme.foregroundColor, colors.foreground); - expect(headerTheme.dialogBackgroundColor, colors.dialog); - expect(headerTheme.modalBarrierColor, colors.shade); - expect(headerTheme.tooltip.backgroundColor, BusyMaxTooltipStyle.background); - expect(headerTheme.tooltip.foregroundColor, BusyMaxTooltipStyle.foreground); - expect(headerTheme.tooltip.borderColor, BusyMaxTooltipStyle.border); - expect(headerTheme.tooltip.borderRadius, BusyMaxRadius.tooltip); - expect( - headerTheme.tooltip.fontSize, - theme.tooltipTheme.textStyle?.fontSize, - ); - expect( - headerTheme.tooltip.horizontalPadding, + final nativeTheme = busyMaxNativeSurfaceThemeFor( + theme, + highContrast: false, + ); + + expect(nativeTheme.highContrast, isFalse); + expect(nativeTheme.windowBackgroundColor, colors.window); + expect(nativeTheme.dialogBackgroundColor, colors.dialog); + expect(nativeTheme.dialogOutlineColor, colors.dialogOutline); + expect(nativeTheme.tooltipBackgroundColor, BusyMaxTooltipStyle.background); + expect(nativeTheme.tooltipForegroundColor, BusyMaxTooltipStyle.foreground); + expect(nativeTheme.tooltipBorderColor, BusyMaxTooltipStyle.border); + expect(nativeTheme.tooltipRadius, BusyMaxRadius.tooltip); + expect(nativeTheme.tooltipFontSize, theme.tooltipTheme.textStyle?.fontSize); + expect( + nativeTheme.tooltipHorizontalPadding, BusyMaxSpacing.tooltipHorizontal, ); - expect(headerTheme.tooltip.verticalPadding, BusyMaxSpacing.tooltipVertical); - expect(headerTheme.tooltip.minimumHeight, BusyMaxSizes.tooltipMinHeight); + expect(nativeTheme.tooltipVerticalPadding, BusyMaxSpacing.tooltipVertical); + expect(nativeTheme.tooltipMinimumHeight, BusyMaxSizes.tooltipMinHeight); }); test('builds with system accent and tokenized control surfaces', () { @@ -162,11 +159,10 @@ void main() { {WidgetState.focused}, {WidgetState.pressed}, ]) { - expect( - pair.$1?.overlayColor?.resolve(states), - pair.$2?.overlayColor?.resolve(states), - ); + expect(pair.$1?.overlayColor?.resolve(states), Colors.transparent); } + expect(pair.$1?.splashFactory, NoSplash.splashFactory); + expect(pair.$1?.backgroundBuilder, isNotNull); } }); @@ -297,15 +293,12 @@ void main() { (theme.textButtonTheme.style, base.textButtonTheme.style), ]) { final padding = pair.$1?.padding?.resolve(const {})! as EdgeInsets; - final basePadding = pair.$2?.padding?.resolve(const {})! as EdgeInsets; - expect(padding.horizontal, basePadding.horizontal); - expect(padding.vertical, 0); - expect( - pair.$1?.minimumSize?.resolve(const {}), - pair.$2?.minimumSize?.resolve(const {}), - ); + expect(padding, const EdgeInsets.symmetric(horizontal: 17, vertical: 5)); + expect(pair.$1?.minimumSize?.resolve(const {}), const Size(34, 34)); expect(pair.$1?.visualDensity, VisualDensity.standard); expect(pair.$1?.tapTargetSize, MaterialTapTargetSize.shrinkWrap); + expect(pair.$1?.elevation?.resolve(const {}), 0); + expect(pair.$1?.surfaceTintColor?.resolve(const {}), Colors.transparent); } final checkboxShape = theme.checkboxTheme.shape! as RoundedRectangleBorder; @@ -842,19 +835,18 @@ void main() { scale: scale, ); - for (final pair in [ - (theme.outlinedButtonTheme.style, base.outlinedButtonTheme.style), - (theme.filledButtonTheme.style, base.filledButtonTheme.style), - (theme.elevatedButtonTheme.style, base.elevatedButtonTheme.style), - (theme.textButtonTheme.style, base.textButtonTheme.style), + for (final style in [ + theme.outlinedButtonTheme.style, + theme.filledButtonTheme.style, + theme.elevatedButtonTheme.style, + theme.textButtonTheme.style, ]) { - _expectComponentStyleUsesTypography( - pair.$1?.textStyle?.resolve(buttonStates), - baseStyle: pair.$2?.textStyle?.resolve(buttonStates), - fallback: textTheme.labelLarge, - family: gtkFamily, - scale: scale, - ); + final actual = style?.textStyle?.resolve(buttonStates); + expect(actual?.fontFamily, gtkFamily); + expect(actual?.fontSize, textTheme.labelLarge?.fontSize); + expect(actual?.fontWeight, FontWeight.bold); + expect(actual?.letterSpacing, textTheme.labelLarge?.letterSpacing); + expect(actual?.height, textTheme.labelLarge?.height); } _expectComponentStyleUsesTypography( @@ -1718,40 +1710,18 @@ void main() { expect(store.json['scheduleViewMode'], 'month'); }); - test('native headerbar receives semantic surface colors', () { + test('retained native surfaces receive semantic colors', () { final source = File('lib/src/app/busymax_app.dart').readAsStringSync(); - final synchronizer = File( - 'lib/src/platform/linux_header_bar_configuration_synchronizer.dart', - ).readAsStringSync(); expect( source, contains('final colors = theme.extension()!;'), ); - expect(source, contains('_headerBarConfigurationSynchronizer.schedule(')); - expect(synchronizer, contains('await service.setTheme(')); + expect(source, contains('NativeSurfaceStyleService().setTheme(value)')); expect(source, contains('windowBackgroundColor: colors.window')); - expect(source, contains('backgroundColor: colors.window')); - expect(source, isNot(contains('backgroundColor: colors.headerbarFlat'))); - expect(source, isNot(contains('backgroundColor: colors.headerbar,'))); - expect(source, contains('sidebarBackgroundColor: colors.sidebar')); - expect(source, contains('foregroundColor: colors.foreground')); - expect(source, contains('sidebarBorderColor: colors.sidebarBorder')); - expect(source, isNot(contains('popoverBackgroundColor:'))); - expect(source, isNot(contains('menuHoverColor:'))); - expect(source, isNot(contains('popoverShadowColor:'))); expect(source, contains('dialogBackgroundColor: colors.dialog')); - expect(source, isNot(contains('floatingBorderColor:'))); - expect(source, contains('modalBarrierColor: colors.shade')); - expect(source, isNot(contains('controlHoverColor: colors.controlHover'))); - expect(source, isNot(contains('accentColor: colorScheme.primary'))); - expect(source, contains('menu: l10n.mainMenu')); - expect(source, contains('settings: l10n.settings')); - expect(source, contains('keyboardShortcuts: l10n.keyboardShortcuts')); - expect(source, contains('aboutBusyMax: l10n.aboutBusyMax')); - expect(source, isNot(contains('menu: materialL10n.moreButtonTooltip'))); - expect(source, isNot(contains('setBackgroundColor('))); - expect(source, isNot(contains('setSidebarBackgroundColor('))); + expect(source, contains('dialogOutlineColor: colors.dialogOutline')); + expect(source, isNot(contains('LinuxHeaderBarService'))); }); test('root window uses semantic backing while GTK owns window geometry', () { @@ -1772,16 +1742,14 @@ void main() { source, isNot(contains('color: BusyMaxSurfaceColors.of(context).view')), ); - expect(source, contains('final title = context.l10n.onboardingSetupTitle')); - expect(source, contains('.claimSession()')); - expect(source, contains('_headerBarSession.updateState(')); - expect(source, contains('BusyMaxHeaderBarState(')); - expect(source, contains('title: title')); - expect(source, isNot(contains('class _OnboardingHeader'))); + expect(source, contains('title: l10n.onboardingSetupTitle')); + expect(source, isNot(contains('.claimSession()'))); + expect(source, isNot(contains('BusyMaxHeaderBarState('))); + expect(source, contains('class _OnboardingHeader')); expect(source, isNot(contains('class _OnboardingProgressDots'))); expect(source, isNot(contains('Border(top: BorderSide'))); - expect(source, contains("key: const ValueKey('onboarding-content-rail')")); - expect(source, contains('busyMaxOnboardingContentMaxWidth')); + expect(source, contains("'onboarding-content-rail'")); + expect(source, contains('BusyMaxSizes.onboardingContentMaxWidth')); expect(source, contains('width: contentRailWidth')); }); @@ -2221,6 +2189,47 @@ void main() { expect(windowService.quitAppCalls, 1); }); + testWidgets('tray Quit honors the active window-close guard', (tester) async { + final database = AppDatabase.memoryForTests(); + addTearDown(database.close); + final windowService = _RecordingWindowService()..visible = false; + 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(); + + unawaited( + showBusyMaxModalDialog( + rootNavigatorKey.currentContext!, + builder: (_) => const AlertDialog(content: Text('Guarded dialog')), + ), + ); + await tester.pumpAndSettle(); + + await trayService.configuration.actions.quitBusyMax(); + expect(windowService.showWindowCalls, 1); + expect(windowService.quitAppCalls, 0); + + Navigator.of(tester.element(find.text('Guarded dialog'))).pop(); + await tester.pumpAndSettle(); + 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); diff --git a/test/dav/auth/dav_account_dialogs_test.dart b/test/dav/auth/dav_account_dialogs_test.dart index e20f9d64..62b58acb 100644 --- a/test/dav/auth/dav_account_dialogs_test.dart +++ b/test/dav/auth/dav_account_dialogs_test.dart @@ -1,6 +1,9 @@ +import 'package:busymax/src/app/busymax_design.dart'; +import 'package:busymax/src/app/busymax_yaru_theme.dart'; import 'package:busymax/src/dav/auth/dav_account_dialogs.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:yaru/yaru.dart'; import '../../test_localized_app.dart'; @@ -8,21 +11,14 @@ void main() { testWidgets('Nextcloud form explains and accepts a copied CalDAV address', ( tester, ) async { - late BuildContext hostContext; - await tester.pumpWidget( - localizedTestApp( - child: Builder( - builder: (context) { - hostContext = context; - return const SizedBox.shrink(); - }, - ), - ), - ); + final hostContext = await _pumpHost(tester); final result = showNextcloudServerDialog(hostContext); await tester.pumpAndSettle(); + expect(find.byType(BusyMaxDialogShell), findsOneWidget); + expect(find.byType(BusyMaxGroupedList), findsOneWidget); + expect(find.byType(YaruListTile), findsOneWidget); final field = tester.widget( find.byKey(const Key('nextcloud-server-field')), ); @@ -32,13 +28,30 @@ void main() { 'https://cloud.example.com/remote.php/dav', ); expect( - field.decoration?.helperText, - 'Enter your Nextcloud server URL, or paste the primary CalDAV address ' - 'copied from Nextcloud.', + find.text( + 'Enter your Nextcloud server URL, or paste the primary CalDAV address ' + 'copied from Nextcloud.', + ), + findsOneWidget, + ); + expect(field.decoration?.helperText, isNull); + expect(field.decoration?.border, InputBorder.none); + expect(field.decoration?.filled, isFalse); + final serverHelp = tester.widget( + find.text( + 'Enter your Nextcloud server URL, or paste the primary CalDAV address ' + 'copied from Nextcloud.', + ), ); - expect(field.decoration?.helperMaxLines, 2); + final authorizationHelp = tester.widget( + find.text( + 'BusyMax will open your browser. Approve access there, then return to ' + 'BusyMax.', + ), + ); + expect(serverHelp.style, authorizationHelp.style); - const copiedAddress = 'https://cloud.example.test/remote.php/dav'; + const copiedAddress = ' https://cloud.example.test/remote.php/dav '; await tester.enterText( find.byKey(const Key('nextcloud-server-field')), copiedAddress, @@ -46,6 +59,127 @@ void main() { await tester.tap(find.text('Connect')); await tester.pumpAndSettle(); - expect(await result, copiedAddress); + expect(await result, copiedAddress.trim()); + }); + + testWidgets('Nextcloud validates keyboard submission, prefill, and cancel', ( + tester, + ) async { + final hostContext = await _pumpHost(tester); + final result = showNextcloudServerDialog( + hostContext, + initialServer: 'https://cloud.example.test', + ); + await tester.pumpAndSettle(); + + final finder = find.byKey(const Key('nextcloud-server-field')); + expect( + tester.widget(finder).controller!.text, + 'https://cloud.example.test', + ); + await tester.enterText(finder, ' '); + await tester.testTextInput.receiveAction(TextInputAction.done); + await tester.pumpAndSettle(); + + expect(finder, findsOneWidget); + expect(tester.widget(finder).decoration?.errorText, isNotEmpty); + await tester.tap(find.text('Cancel')); + await tester.pumpAndSettle(); + expect(await result, isNull); }); + + testWidgets( + 'Apple form preserves reconnect focus, validation, and redaction', + (tester) async { + final hostContext = await _pumpHost(tester); + final result = showAppleICloudCredentialDialog( + hostContext, + fixedEmail: 'person@example.test', + ); + await tester.pumpAndSettle(); + + expect(find.byType(BusyMaxGroupedList), findsOneWidget); + expect(find.byType(YaruListTile), findsNWidgets(2)); + final emailFinder = find.byKey(const Key('apple-account-email-field')); + final passwordFinder = find.byKey( + const Key('apple-app-specific-password-field'), + ); + final email = tester.widget(emailFinder); + final password = tester.widget(passwordFinder); + expect(email.readOnly, isTrue); + expect(email.controller!.text, 'person@example.test'); + expect(email.autofocus, isFalse); + expect(password.autofocus, isTrue); + expect(password.obscureText, isTrue); + expect(password.enableSuggestions, isFalse); + expect(password.autocorrect, isFalse); + expect(password.decoration?.border, InputBorder.none); + await tester.tap(find.text('Connect')); + await tester.pumpAndSettle(); + expect( + tester.widget(passwordFinder).decoration?.errorText, + isNotEmpty, + ); + + await tester.enterText(passwordFinder, ' app-password '); + await tester.testTextInput.receiveAction(TextInputAction.done); + await tester.pumpAndSettle(); + final input = await result; + expect(input?.email, 'person@example.test'); + expect(input?.password, 'app-password'); + expect(input.toString(), contains('[REDACTED]')); + expect(input.toString(), isNot(contains('app-password'))); + }, + ); + + testWidgets('account form remains usable in a narrow dark RTL window', ( + tester, + ) async { + await tester.binding.setSurfaceSize(const Size(420, 520)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + final hostContext = await _pumpHost( + tester, + locale: const Locale('ar'), + brightness: Brightness.dark, + textScaler: const TextScaler.linear(1.4), + ); + final result = showNextcloudServerDialog(hostContext); + await tester.pumpAndSettle(); + + expect( + Directionality.of(tester.element(find.byType(BusyMaxDialogShell))), + TextDirection.rtl, + ); + expect(find.byType(BusyMaxGroupedList), findsOneWidget); + expect(tester.takeException(), isNull); + await tester.tap(find.text('إلغاء')); + await tester.pumpAndSettle(); + expect(await result, isNull); + }); +} + +Future _pumpHost( + WidgetTester tester, { + Locale locale = const Locale('en'), + Brightness brightness = Brightness.light, + TextScaler? textScaler, +}) async { + late BuildContext hostContext; + await tester.pumpWidget( + localizedTestApp( + locale: locale, + textScaler: textScaler, + theme: BusyMaxYaruTheme.build( + brightness: brightness, + accentColor: YaruColors.orange, + ), + child: Builder( + builder: (context) { + hostContext = context; + return const SizedBox.shrink(); + }, + ), + ), + ); + return hostContext; } diff --git a/test/dav/ical/ical_recurrence_test.dart b/test/dav/ical/ical_recurrence_test.dart index 482ff86f..16903f9c 100644 --- a/test/dav/ical/ical_recurrence_test.dart +++ b/test/dav/ical/ical_recurrence_test.dart @@ -567,6 +567,42 @@ END:VCALENDAR\r ), ); }); + + test('bounds a large Cartesian period before BYSETPOS materialization', () { + final document = IcalSemanticDocument.parse( + _eventWithRule( + start: 'DTSTART:20260101T000000Z', + end: 'DTEND:20260101T000001Z', + rule: + 'RRULE:FREQ=YEARLY;COUNT=1;' + 'BYDAY=MO,TU,WE,TH,FR,SA,SU;' + 'BYHOUR=${List.generate(24, (value) => value).join(',')};' + 'BYMINUTE=${List.generate(60, (value) => value).join(',')};' + 'BYSECOND=${List.generate(60, (value) => value).join(',')};' + 'BYSETPOS=1', + ), + ); + + expect( + () => + IcalRecurrenceExpander( + limits: const IcalRecurrenceLimits( + maximumCandidatesPerPeriod: 1000, + ), + ).expand( + document, + rangeStartUtc: DateTime.utc(2026), + rangeEndUtc: DateTime.utc(2027), + ), + throwsA( + isA().having( + (error) => error.code, + 'code', + 'IcalRecurrenceCandidateLimitExceeded', + ), + ), + ); + }); } String _eventWithRule({ diff --git a/test/dav/mutation/dav_pending_operations_test.dart b/test/dav/mutation/dav_pending_operations_test.dart index c4d1ca40..9f2e85f7 100644 --- a/test/dav/mutation/dav_pending_operations_test.dart +++ b/test/dav/mutation/dav_pending_operations_test.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:convert'; import 'dart:math'; @@ -1492,6 +1493,68 @@ void main() { expect(pending.requestJson, contains('SUMMARY:Corrected task')); }); + test( + 'an in-progress create cannot be edited or cancelled as unsent', + () async { + const projectionId = 'local-created-event'; + const uid = 'claimed-create@example.test'; + final original = _eventWithUid('Original', uid); + await queue.enqueueCreate( + accountId: 'account', + collectionId: 'collection', + localProjectionId: projectionId, + object: DavNewObject( + uid: uid, + initialMemberName: 'claimed-create.ics', + rawIcs: original, + componentType: 'VEVENT', + ), + ); + final started = Completer(); + final release = Completer(); + final remote = _FakeMutationRemote( + put: ({required rawIcs, required ifMatch, required ifNoneMatch}) async { + started.complete(); + await release.future; + expect(rawIcs, contains('SUMMARY:Original')); + return _success; + }, + fetcher: (href) async => _live(href, '"created"', original), + ); + final replay = _replayer( + database, + objectRepository, + remote, + ).replayDueOperations(); + await started.future; + + expect( + await queue.updateUnsentCreate( + accountId: 'account', + collectionId: 'collection', + localProjectionId: projectionId, + patch: DavMutationPatch( + target: const IcalComponentKey(componentType: 'VEVENT', uid: uid), + scope: DavMutationScope.object, + operations: [DavPatchOperation.setText('SUMMARY', 'Obsolete edit')], + ), + ), + isFalse, + ); + expect( + await queue.cancelUnsentCreate( + accountId: 'account', + collectionId: 'collection', + localProjectionId: projectionId, + ), + isFalse, + ); + + release.complete(); + expect((await replay).appliedCount, 1); + }, + ); + test( 'MOVE replays to the same filename and commits destination projection', () async { diff --git a/test/dav/nextcloud/nextcloud_dialogs_test.dart b/test/dav/nextcloud/nextcloud_dialogs_test.dart index 725480e7..4f1d3b0e 100644 --- a/test/dav/nextcloud/nextcloud_dialogs_test.dart +++ b/test/dav/nextcloud/nextcloud_dialogs_test.dart @@ -1,16 +1,46 @@ import 'package:busymax/l10n/generated/app_localizations.dart'; import 'package:busymax/src/app/app_bootstrap.dart'; +import 'package:busymax/src/app/busymax_design.dart'; +import 'package:busymax/src/app/busymax_yaru_theme.dart'; import 'package:busymax/src/dav/presentation/nextcloud_collection_dialog.dart'; import 'package:busymax/src/ui/windows/windows_nextcloud_dialogs.dart'; import 'package:fluent_ui/fluent_ui.dart' as fluent; 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 'package:yaru/yaru.dart'; import '../../support/memory_settings_store.dart'; +import '../../test_localized_app.dart'; import 'nextcloud_admin_fixture.dart'; void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + setUp(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler( + const MethodChannel('yaru_window'), + (call) async => call.method == 'state' ? {} : null, + ); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler( + const MethodChannel('yaru_window/events'), + (call) async => null, + ); + }); + + tearDown(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(const MethodChannel('yaru_window'), null); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler( + const MethodChannel('yaru_window/events'), + null, + ); + }); + for (final windows in [false, true]) { final platform = windows ? 'Windows' : 'Linux'; testWidgets( @@ -28,6 +58,9 @@ void main() { } else { expect(tester.widget(title).enabled, isTrue); expect(find.byType(fluent.ContentDialog), findsNothing); + expect(find.byType(BusyMaxDialogShell), findsOneWidget); + expect(find.byType(BusyMaxGroupedList), findsWidgets); + expect(find.byType(AlertDialog), findsNothing); } expect(fixture.requests.where((r) => r.method == 'PROPPATCH'), isEmpty); await tester.enterText(title, 'New name'); @@ -39,6 +72,12 @@ void main() { fixture.requests.where((r) => r.method == 'PROPPATCH'), hasLength(1), ); + if (!windows) { + expect( + find.byKey(const ValueKey('nextcloud-collection-dialog')), + findsOneWidget, + ); + } expect( await tester.runAsync( () => fixture.database.select(fixture.database.pendingOps).get(), @@ -70,6 +109,173 @@ void main() { await tester.pumpWidget(const SizedBox.shrink()); }); } + + testWidgets('nested collection confirmation keeps the parent modal active', ( + tester, + ) async { + final fixture = NextcloudAdminFixture(); + await tester.runAsync(fixture.seed); + addTearDown(fixture.close); + await _pump(tester, fixture, false); + + await tester.enterText( + find.byKey(const ValueKey('nextcloud-displayname')), + 'Unsaved', + ); + await tester.tap(find.text('Close')); + await tester.pumpAndSettle(); + expect(find.byType(BusyMaxConfirmDialog), findsOneWidget); + expect(find.byType(AnimatedModalBarrier), findsWidgets); + + await tester.tap(find.text('Cancel')); + await tester.pumpAndSettle(); + expect( + find.byKey(const ValueKey('nextcloud-collection-dialog')), + findsOneWidget, + ); + expect(find.byType(BusyMaxConfirmDialog), findsNothing); + + await tester.tap(find.text('Close')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Discard').last); + await tester.pumpAndSettle(); + expect( + find.byKey(const ValueKey('nextcloud-collection-dialog')), + findsNothing, + ); + expect(find.byType(AnimatedModalBarrier), findsNothing); + expect( + fixture.requests.where((request) => request.method == 'PROPPATCH'), + isEmpty, + ); + }); + + testWidgets('trash uses shared rows and confirms permanent deletion', ( + tester, + ) async { + final fixture = NextcloudAdminFixture(); + await tester.runAsync(fixture.seed); + addTearDown(fixture.close); + await _pumpTrash(tester, fixture); + + expect(find.byType(BusyMaxDialogShell), findsOneWidget); + expect(find.byType(BusyMaxGroupedList), findsOneWidget); + expect(find.text('Deleted event'), findsOneWidget); + expect(find.text('Deleted task'), findsOneWidget); + expect(find.textContaining('30'), findsOneWidget); + expect(find.byType(AlertDialog), findsNothing); + + final deleteActions = find.widgetWithText( + BusyMaxActionRow, + 'Permanently delete', + ); + await tester.tap(deleteActions.first); + await tester.pumpAndSettle(); + var confirmation = find.byType(BusyMaxConfirmDialog); + expect(confirmation, findsOneWidget); + expect( + find.descendant( + of: confirmation, + matching: find.textContaining('Deleted event'), + ), + findsOneWidget, + ); + expect( + find.descendant( + of: confirmation, + matching: find.textContaining('Deleted task'), + ), + findsNothing, + ); + await tester.tap(find.text('Cancel')); + await tester.pumpAndSettle(); + expect( + fixture.requests.where((request) => request.method == 'DELETE'), + isEmpty, + ); + + await tester.tap(deleteActions.last); + await tester.pumpAndSettle(); + confirmation = find.byType(BusyMaxConfirmDialog); + expect( + find.descendant( + of: confirmation, + matching: find.textContaining('Deleted task'), + ), + findsOneWidget, + ); + expect( + find.descendant( + of: confirmation, + matching: find.textContaining('Deleted event'), + ), + findsNothing, + ); + await tester.tap( + find.descendant( + of: confirmation, + matching: find.widgetWithText(ElevatedButton, 'Permanently delete'), + ), + ); + for (var attempt = 0; attempt < 20; attempt++) { + await tester.runAsync( + () => Future.delayed(const Duration(milliseconds: 50)), + ); + await tester.pump(const Duration(milliseconds: 100)); + if (fixture.requests.any((request) => request.method == 'DELETE') && + find.byType(YaruCircularProgressIndicator).evaluate().isEmpty) { + break; + } + } + final deletes = fixture.requests + .where((request) => request.method == 'DELETE') + .toList(); + expect(deletes, hasLength(1)); + expect(deletes.single.url.path, endsWith('/objects/42.ics')); + expect(tester.takeException(), isNull); + }); + + testWidgets('trash disables operations denied by the server', (tester) async { + final fixture = NextcloudAdminFixture()..denyTrashObject = true; + await tester.runAsync(fixture.seed); + addTearDown(fixture.close); + await _pumpTrash(tester, fixture); + + final restore = tester.widget( + find.widgetWithText(BusyMaxActionRow, 'Restore').first, + ); + final delete = tester.widget( + find.widgetWithText(BusyMaxActionRow, 'Permanently delete').first, + ); + expect(restore.enabled, isFalse); + expect(delete.enabled, isFalse); + }); + + testWidgets('collection dialog scrolls in a narrow enlarged RTL window', ( + tester, + ) async { + final fixture = NextcloudAdminFixture(); + await tester.runAsync(fixture.seed); + addTearDown(fixture.close); + await _pump( + tester, + fixture, + false, + size: const Size(430, 420), + brightness: Brightness.dark, + locale: const Locale('ar'), + textScaler: const TextScaler.linear(1.5), + ); + + final dialog = find.byKey(const ValueKey('nextcloud-collection-dialog')); + expect(dialog, findsOneWidget); + expect(Directionality.of(tester.element(dialog)), TextDirection.rtl); + expect( + find.descendant(of: dialog, matching: find.byType(SingleChildScrollView)), + findsOneWidget, + ); + expect(tester.takeException(), isNull); + }); } Future _settle(WidgetTester tester) async { @@ -85,9 +291,13 @@ Future _settle(WidgetTester tester) async { Future _pump( WidgetTester tester, NextcloudAdminFixture fixture, - bool windows, -) async { - await tester.binding.setSurfaceSize(const Size(1000, 850)); + bool windows, { + Size size = const Size(1000, 850), + Brightness brightness = Brightness.light, + Locale locale = const Locale('en'), + TextScaler? textScaler, +}) async { + await tester.binding.setSurfaceSize(size); addTearDown(() => tester.binding.setSurfaceSize(null)); final home = Builder( builder: (context) => Center( @@ -124,13 +334,56 @@ Future _pump( supportedLocales: AppLocalizations.supportedLocales, home: home, ) - : MaterialApp( - localizationsDelegates: AppLocalizations.localizationsDelegates, - supportedLocales: AppLocalizations.supportedLocales, - home: Scaffold(body: home), + : localizedTestApp( + locale: locale, + textScaler: textScaler, + theme: BusyMaxYaruTheme.build( + brightness: brightness, + accentColor: YaruColors.orange, + ), + child: Scaffold(body: home), ), ), ); await tester.tap(find.text('Open')); await _settle(tester); } + +Future _pumpTrash( + WidgetTester tester, + NextcloudAdminFixture fixture, +) async { + await tester.binding.setSurfaceSize(const Size(760, 700)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + await tester.pumpWidget( + ProviderScope( + overrides: [ + localSettingsStoreProvider.overrideWithValue(MemorySettingsStore()), + nextcloudTrashServiceProvider( + 'account', + ).overrideWithValue(fixture.trash), + ], + child: localizedTestApp( + theme: BusyMaxYaruTheme.build( + brightness: Brightness.dark, + accentColor: YaruColors.orange, + ), + child: Scaffold( + body: Builder( + builder: (context) => Center( + child: BusyMaxPushButton.standard( + onPressed: () => showLinuxNextcloudTrashDialog( + context, + accountId: 'account', + ), + child: const Text('Open trash'), + ), + ), + ), + ), + ), + ), + ); + await tester.tap(find.text('Open trash')); + await _settle(tester); +} diff --git a/test/dav/nextcloud/nextcloud_scheduling_dialogs_test.dart b/test/dav/nextcloud/nextcloud_scheduling_dialogs_test.dart index a569f4b6..52907176 100644 --- a/test/dav/nextcloud/nextcloud_scheduling_dialogs_test.dart +++ b/test/dav/nextcloud/nextcloud_scheduling_dialogs_test.dart @@ -1,5 +1,9 @@ +import 'dart:async'; + import 'package:busymax/l10n/generated/app_localizations.dart'; import 'package:busymax/src/app/app_bootstrap.dart'; +import 'package:busymax/src/app/busymax_design.dart'; +import 'package:busymax/src/app/busymax_yaru_theme.dart'; import 'package:busymax/src/dav/nextcloud/nextcloud_scheduling_service.dart'; import 'package:busymax/src/dav/presentation/nextcloud_scheduling_dialog.dart'; import 'package:busymax/src/features/calendar/presentation/event_editor_draft.dart'; @@ -7,11 +11,39 @@ import 'package:busymax/src/ui/windows/windows_nextcloud_scheduling_dialog.dart' import 'package:fluent_ui/fluent_ui.dart' as fluent; 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 'package:yaru/yaru.dart'; import '../../support/memory_settings_store.dart'; +import '../../test_localized_app.dart'; import 'nextcloud_scheduling_fixture.dart'; void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + setUp(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler( + const MethodChannel('yaru_window'), + (call) async => call.method == 'state' ? {} : null, + ); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler( + const MethodChannel('yaru_window/events'), + (call) async => null, + ); + }); + + tearDown(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(const MethodChannel('yaru_window'), null); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler( + const MethodChannel('yaru_window/events'), + null, + ); + }); + for (final windows in [false, true]) { final platform = windows ? 'Windows' : 'Linux'; testWidgets( @@ -22,6 +54,11 @@ void main() { addTearDown(fixture.close); await _pump(tester, fixture, windows); expect(find.text('Invitation'), findsOneWidget); + if (!windows) { + expect(find.byType(BusyMaxDialogShell), findsOneWidget); + expect(find.byType(BusyMaxGroupedList), findsOneWidget); + expect(find.byType(AlertDialog), findsNothing); + } await tester.tap(find.text('Acknowledge message')); await tester.pumpAndSettle(); await tester.tap(find.text('Cancel')); @@ -88,6 +125,100 @@ void main() { }, ); } + + testWidgets('acknowledgment keeps the scheduling modal barrier active', ( + tester, + ) async { + final fixture = SchedulingFixture(); + await tester.runAsync(fixture.seedScheduling); + addTearDown(fixture.close); + await _pump(tester, fixture, false); + + await tester.tap(find.text('Acknowledge message')); + await tester.pumpAndSettle(); + expect(find.byType(BusyMaxConfirmDialog), findsOneWidget); + expect(find.byType(AnimatedModalBarrier), findsWidgets); + + await tester.tap(find.text('Cancel')); + await tester.pumpAndSettle(); + expect( + find.byKey(const ValueKey('nextcloud-scheduling-dialog')), + findsOneWidget, + ); + expect(find.byType(BusyMaxConfirmDialog), findsNothing); + expect( + fixture.requests.where((request) => request.method == 'DELETE'), + isEmpty, + ); + + await tester.tap(find.text('Close')); + await tester.pumpAndSettle(); + expect(find.byType(AnimatedModalBarrier), findsNothing); + }); + + testWidgets('Linux scheduling dialog presents loading and error states', ( + tester, + ) async { + final fixture = SchedulingFixture() + ..failInbox = true + ..inboxGate = Completer(); + await tester.runAsync(fixture.seedScheduling); + addTearDown(() { + if (!fixture.inboxGate!.isCompleted) fixture.inboxGate!.complete(); + return fixture.close(); + }); + await _pump(tester, fixture, false, waitForLoad: false); + + expect(find.byType(YaruCircularProgressIndicator), findsOneWidget); + fixture.inboxGate!.complete(); + for (var attempt = 0; attempt < 20; attempt++) { + await tester.runAsync( + () => Future.delayed(const Duration(milliseconds: 25)), + ); + await tester.pump(const Duration(milliseconds: 50)); + if (find.byType(YaruCircularProgressIndicator).evaluate().isEmpty) { + break; + } + } + + expect(find.byType(YaruCircularProgressIndicator), findsNothing); + expect( + find.text( + 'Nextcloud could not be reached. Cached items and pending work are unchanged.', + ), + findsOneWidget, + ); + expect( + find.byKey(const ValueKey('nextcloud-scheduling-dialog')), + findsOneWidget, + ); + }); + + testWidgets('scheduling dialog remains usable in narrow enlarged RTL', ( + tester, + ) async { + final fixture = SchedulingFixture(); + await tester.runAsync(fixture.seedScheduling); + addTearDown(fixture.close); + await _pump( + tester, + fixture, + false, + size: const Size(430, 400), + brightness: Brightness.dark, + locale: const Locale('ar'), + textScaler: const TextScaler.linear(1.5), + ); + + final dialog = find.byKey(const ValueKey('nextcloud-scheduling-dialog')); + expect(dialog, findsOneWidget); + expect(Directionality.of(tester.element(dialog)), TextDirection.rtl); + expect( + find.descendant(of: dialog, matching: find.byType(SingleChildScrollView)), + findsOneWidget, + ); + expect(tester.takeException(), isNull); + }); } Future _settle(WidgetTester tester) async { @@ -105,8 +236,13 @@ Future _pump( SchedulingFixture fixture, bool windows, { EventEditorDraft? draft, + bool waitForLoad = true, + Size size = const Size(760, 640), + Brightness brightness = Brightness.light, + Locale locale = const Locale('en'), + TextScaler? textScaler, }) async { - await tester.binding.setSurfaceSize(const Size(760, 640)); + await tester.binding.setSurfaceSize(size); addTearDown(() => tester.binding.setSurfaceSize(null)); final home = Builder( builder: (context) => Center( @@ -149,14 +285,21 @@ Future _pump( supportedLocales: AppLocalizations.supportedLocales, home: home, ) - : MaterialApp( - locale: const Locale('en'), - localizationsDelegates: AppLocalizations.localizationsDelegates, - supportedLocales: AppLocalizations.supportedLocales, - home: Scaffold(body: home), + : localizedTestApp( + locale: locale, + textScaler: textScaler, + theme: BusyMaxYaruTheme.build( + brightness: brightness, + accentColor: YaruColors.orange, + ), + child: Scaffold(body: home), ), ), ); await tester.tap(find.text('Open')); - await _settle(tester); + if (waitForLoad) { + await _settle(tester); + } else { + await tester.pump(); + } } diff --git a/test/dav/nextcloud/nextcloud_scheduling_fixture.dart b/test/dav/nextcloud/nextcloud_scheduling_fixture.dart index d3c465e9..b41520da 100644 --- a/test/dav/nextcloud/nextcloud_scheduling_fixture.dart +++ b/test/dav/nextcloud/nextcloud_scheduling_fixture.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:convert'; import 'package:busymax/src/dav/discovery/dav_discovery_models.dart'; import 'package:busymax/src/db/app_database.dart'; @@ -9,6 +10,8 @@ import 'nextcloud_admin_fixture.dart'; class SchedulingFixture extends NextcloudAdminFixture { String requestStatus = '2.0;Success', messageEtag = '"message-1"'; bool duplicate = false, acknowledged = false; + bool failInbox = false; + Completer? inboxGate; String busyData = '''BEGIN:VCALENDAR VERSION:2.0 PRODID:-//QA//EN @@ -72,6 +75,8 @@ END:VCALENDAR } if (request.method == 'PROPFIND' && request.url.path.endsWith('/inbox/')) { requests.add(request); + await inboxGate?.future; + if (failInbox) return http.Response('', 503); final raw = 'BEGIN:VCALENDAR\r\nVERSION:2.0\r\nMETHOD:REQUEST\r\nBEGIN:VEVENT\r\nUID:same-uid\r\nSUMMARY:Invitation\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n'; final message = acknowledged diff --git a/test/features/auth/data/auth_repository_test.dart b/test/features/auth/data/auth_repository_test.dart index 468f961f..4f15bce8 100644 --- a/test/features/auth/data/auth_repository_test.dart +++ b/test/features/auth/data/auth_repository_test.dart @@ -14,6 +14,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/microsoft_todo/oauth/microsoft_oauth_service.dart'; +import 'package:busymax/src/microsoft_todo/api/microsoft_todo_api_models.dart'; import 'package:busymax/src/providers/busy_provider.dart'; void main() { @@ -110,6 +111,65 @@ void main() { expect(await database.select(database.accounts).get(), isEmpty); }); + for (final qualified in [false, true]) { + test( + 'Microsoft sign-in accepts ${qualified ? 'qualified' : 'unqualified'} Graph scopes', + () async { + final microsoftOAuth = _FakeMicrosoftOAuthService() + ..nextTokenSet = _tokenSet( + scopes: { + if (qualified) + 'https://graph.microsoft.com/User.Read' + else + 'User.Read', + if (qualified) + 'https://graph.microsoft.com/Tasks.ReadWrite' + else + 'Tasks.ReadWrite', + if (qualified) + 'https://graph.microsoft.com/Calendars.ReadWrite' + else + 'Calendars.ReadWrite', + }, + ); + repository = AuthRepository( + oAuth: oAuth, + database: database, + microsoftOAuth: microsoftOAuth, + nowUtc: () => DateTime.utc(2026, 6, 4), + ); + + final state = await repository.signInWithMicrosoft(); + + expect(state.accountId, 'microsoft:user-1'); + expect(microsoftOAuth.signOutAccountIds, isEmpty); + }, + ); + } + + test('Microsoft sign-in rejects a genuinely missing permission', () async { + final microsoftOAuth = _FakeMicrosoftOAuthService() + ..nextTokenSet = _tokenSet(scopes: {'User.Read', 'Tasks.ReadWrite'}); + repository = AuthRepository( + oAuth: oAuth, + database: database, + microsoftOAuth: microsoftOAuth, + nowUtc: () => DateTime.utc(2026, 6, 4), + ); + + await expectLater( + repository.signInWithMicrosoft(), + throwsA( + isA().having( + (error) => error.code, + 'code', + 'MicrosoftOAuthMissingRequiredScope', + ), + ), + ); + expect(microsoftOAuth.signOutAccountIds, ['microsoft:user-1']); + }); + test('revocation failure does not mask missing-scope guidance', () async { oAuth.nextTokenSet = _tokenSet(scopes: {googleTasksReadOnlyScope}); oAuth.revokeAndSignOutError = StateError('revocation unavailable'); @@ -173,13 +233,31 @@ void main() { .watchVisibleAccounts() .first; - expect(oAuth.clearedAccountId, 'google:g'); + expect(oAuth.clearedAccountId, null); expect(account.authState, accountAuthStateReauthRequired); expect(signedInAccounts, isEmpty); expect(visibleAccounts.single.needsReconnect, isTrue); }, ); + test('successful Google reconnect reuses the account row', () async { + await _insertAccount( + database, + 'account-1', + BusyProvider.google, + authState: accountAuthStateReauthRequired, + ); + + final state = await repository.signIn(); + + final accounts = await database.select(database.accounts).get(); + expect(state.accountId, 'account-1'); + expect(accounts, hasLength(1)); + expect(accounts.single.id, 'account-1'); + expect(accounts.single.authState, accountAuthStateSignedIn); + expect(oAuth.clearedAccountId, null); + }); + test('markReconnectRequired removes only target notifications', () async { await _insertAccount(database, 'google-a', BusyProvider.google); await _insertAccount(database, 'google-b', BusyProvider.google); @@ -456,8 +534,9 @@ OAuthTokenSet _tokenSet({Set? scopes}) { Future _insertAccount( AppDatabase database, String id, - BusyProvider provider, -) { + BusyProvider provider, { + String authState = accountAuthStateSignedIn, +}) { return database .into(database.accounts) .insert( @@ -469,7 +548,7 @@ Future _insertAccount( : 'https://accounts.google.com', providerAccountId: id, credentialKind: 'oauth', - authState: const Value('signed_in'), + authState: Value(authState), createdAtUtc: '2026-06-04T00:00:00.000Z', updatedAtUtc: '2026-06-04T00:00:00.000Z', ), @@ -504,6 +583,21 @@ class _FakeMicrosoftOAuthService extends MicrosoftOAuthService { ); final signOutAccountIds = []; + OAuthTokenSet nextTokenSet = _tokenSet(); + + @override + Future signInWithMicrosoft() async { + return MicrosoftOAuthSignInResult( + accountId: 'microsoft:user-1', + tokenSet: nextTokenSet, + user: const MicrosoftTodoUserDto( + id: 'user-1', + displayName: 'Microsoft User', + mail: 'user@example.test', + rawJson: {'id': 'user-1'}, + ), + ); + } @override Future signOutAccount(String accountId) async { diff --git a/test/features/auth/presentation/auth_routing_test.dart b/test/features/auth/presentation/auth_routing_test.dart index a9e4b918..178b2bae 100644 --- a/test/features/auth/presentation/auth_routing_test.dart +++ b/test/features/auth/presentation/auth_routing_test.dart @@ -9,8 +9,10 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:go_router/go_router.dart'; import 'package:busymax/src/app/app_bootstrap.dart'; +import 'package:busymax/src/app/app_router.dart'; import 'package:busymax/src/app/busymax_app.dart'; import 'package:busymax/src/app/busymax_design.dart'; +import 'package:busymax/src/app/linux/linux_header_style.dart'; 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'; @@ -22,8 +24,6 @@ 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/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'; @@ -99,7 +99,10 @@ void main() { await _pumpApp(tester, database: database, oAuth: oAuth); await tester.pumpAndSettle(); - void expectAlignedActions({required double expectedRailWidth}) { + void expectAlignedActions({ + required double expectedRailWidth, + required bool enoughRoomForCenteredHeader, + }) { final rail = tester.getRect( find.byKey(const ValueKey('onboarding-content-rail')), ); @@ -111,35 +114,67 @@ void main() { ); expect(rail.width, closeTo(expectedRailWidth, 0.01)); - expect(back.left, closeTo(rail.left, 0.01)); - expect(continueButton.right, closeTo(rail.right, 0.01)); + if (enoughRoomForCenteredHeader) { + expect(back.left, closeTo(rail.left, 0.01)); + expect(continueButton.right, closeTo(rail.right, 0.01)); + } else { + expect(back.left, lessThanOrEqualTo(rail.left)); + expect(continueButton.right, lessThan(rail.right)); + } } - expectAlignedActions(expectedRailWidth: 480); - for (final key in const [ - ValueKey('onboarding-back-button'), - ValueKey('onboarding-continue-button'), - ]) { - final button = tester.widget(find.byKey(key)); - expect( - button.style?.padding?.resolve(const {}), - EdgeInsets.zero, - ); - expect( - button.style?.minimumSize?.resolve(const {}), - Size.zero, - ); - expect( - button.style?.backgroundColor?.resolve(const {}), - Colors.transparent, - ); - expect(button.style?.tapTargetSize, MaterialTapTargetSize.shrinkWrap); - } + expectAlignedActions( + expectedRailWidth: 480, + enoughRoomForCenteredHeader: true, + ); + final headerTitle = find.byKey(const ValueKey('onboarding-header-title')); + final header = find.byType(BusyMaxLinuxHeaderLayout); + expect( + tester.getRect(headerTitle).center.dx, + closeTo(tester.getRect(header).center.dx, .01), + ); + final headerText = tester.widget( + find.descendant(of: headerTitle, matching: find.byType(Text)), + ); + final bodyStyle = Theme.of( + tester.element(headerTitle), + ).textTheme.bodyMedium; + expect(headerText.style?.fontSize, bodyStyle?.fontSize); + expect(headerText.style?.fontWeight, FontWeight.bold); + expect( + tester + .getSize(find.byKey(const ValueKey('onboarding-back-button'))) + .height, + BusyMaxSizes.headerIconButton, + ); + expect( + tester + .getSize(find.byKey(const ValueKey('onboarding-continue-button'))) + .height, + BusyMaxSizes.headerIconButton, + ); + final back = tester.widget( + find.descendant( + of: find.byKey(const ValueKey('onboarding-back-button')), + matching: find.byType(FilledButton), + ), + ); + final continueButton = tester.widget( + find.descendant( + of: find.byKey(const ValueKey('onboarding-continue-button')), + matching: find.byType(ElevatedButton), + ), + ); + expect(back.onPressed, null); + expect(continueButton.onPressed, null); tester.view.physicalSize = const Size(420, 720); await tester.pumpAndSettle(); - expectAlignedActions(expectedRailWidth: 380); + expectAlignedActions( + expectedRailWidth: 380, + enoughRoomForCenteredHeader: false, + ); expect(tester.takeException(), null); await _disposeApp(tester); }); @@ -186,7 +221,7 @@ void main() { 'lib/src/features/auth/presentation/sign_in_screen.dart', ).readAsStringSync(); final start = source.indexOf('class _ProviderSignInButton'); - final end = source.indexOf('class _OnboardingFooter'); + final end = source.indexOf('class _OnboardingHeader'); final providerButton = source.substring(start, end); expect(providerButton, contains('BusyMaxGroupedList')); @@ -234,11 +269,34 @@ void main() { expect(find.text('Accounts'), findsOneWidget); expect(find.text('Test User'), findsOneWidget); expect(find.text('user@example.com'), findsOneWidget); + expect( + tester + .widget( + find.descendant( + of: find.byKey(const ValueKey('onboarding-continue-button')), + matching: find.byType(ElevatedButton), + ), + ) + .onPressed, + isNot(null), + ); await tester.tap(find.text('Continue')); await tester.pumpAndSettle(); expect(find.text('Choose system settings'), findsOneWidget); + expect(find.text('Finish setup'), findsOneWidget); + expect( + tester + .widget( + find.descendant( + of: find.byKey(const ValueKey('onboarding-back-button')), + matching: find.byType(FilledButton), + ), + ) + .onPressed, + isNot(null), + ); expect(find.text('Notification detail level'), findsOneWidget); expect(find.text('Detailed notification text'), findsNothing); @@ -250,6 +308,25 @@ void main() { await _disposeApp(tester); }); + testWidgets('Alt+Left follows onboarding Back availability', (tester) async { + await _pumpApp(tester, database: database, oAuth: oAuth); + await tester.pumpAndSettle(); + + await _sendAltLeft(tester); + expect(find.text('Connect accounts'), findsOneWidget); + + await tester.tap(find.text('Add Google account')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Continue')); + await tester.pumpAndSettle(); + expect(find.text('Choose system settings'), findsOneWidget); + + await _sendAltLeft(tester); + expect(find.text('Connect accounts'), findsOneWidget); + expect(find.text('Choose system settings'), findsNothing); + await _disposeApp(tester); + }); + testWidgets('Tasks navigation stays on schedule calendar surface', ( tester, ) async { @@ -445,6 +522,204 @@ void main() { await _disposeApp(tester); }); + testWidgets('auth refresh preserves router, navigator, and Settings URI', ( + tester, + ) async { + await _insertAccount( + database, + id: 'google:existing', + provider: BusyProvider.google, + ); + await _pumpApp(tester, database: database, oAuth: oAuth); + await tester.pumpAndSettle(); + await _openSettings(tester); + final container = ProviderScope.containerOf( + tester.element(find.byType(SettingsScreen)), + ); + final router = container.read(appRouterProvider); + final navigator = rootNavigatorKey.currentState; + + await container.read(authSessionControllerProvider.notifier).load(); + await tester.pumpAndSettle(); + + expect(container.read(appRouterProvider), same(router)); + expect(rootNavigatorKey.currentState, same(navigator)); + expect(router.routeInformationProvider.value.uri.path, '/settings'); + expect( + router.routeInformationProvider.value.uri.queryParameters['page'], + 'accounts', + ); + expect(find.byType(SettingsScreen), findsOneWidget); + expect(find.byType(ScheduleWorkspace), findsNothing); + await _disposeApp(tester); + }); + + testWidgets('provider teardown disposes its GoRouter', (tester) async { + await _insertAccount( + database, + id: 'google:existing', + provider: BusyProvider.google, + ); + await _pumpApp(tester, database: database, oAuth: oAuth); + await tester.pumpAndSettle(); + + final context = tester.element(find.byType(ScheduleWorkspace)); + final container = ProviderScope.containerOf(context); + final router = container.read(appRouterProvider); + final routeInformationProvider = router.routeInformationProvider; + + await _disposeApp(tester); + + expect( + () => routeInformationProvider.addListener(() {}), + throwsFlutterError, + ); + }); + + testWidgets('WebCal refresh preserves router and Settings URI', ( + tester, + ) async { + await _insertAccount( + database, + id: 'google:existing', + provider: BusyProvider.google, + ); + await _pumpApp(tester, database: database, oAuth: oAuth); + await tester.pumpAndSettle(); + await _openSettings(tester); + final container = ProviderScope.containerOf( + tester.element(find.byType(SettingsScreen)), + ); + final router = container.read(appRouterProvider); + final navigator = rootNavigatorKey.currentState; + + container.invalidate(webCalSubscriptionsProvider); + await tester.pumpAndSettle(); + + expect(container.read(appRouterProvider), same(router)); + expect(rootNavigatorKey.currentState, same(navigator)); + expect(router.routeInformationProvider.value.uri.path, '/settings'); + expect(find.byType(SettingsScreen), findsOneWidget); + expect(find.byType(ScheduleWorkspace), findsNothing); + await _disposeApp(tester); + }); + + testWidgets('removing one of two accounts keeps real Settings route', ( + tester, + ) async { + await _insertAccount( + database, + id: 'google:remove', + provider: BusyProvider.google, + ); + await _insertAccount( + database, + id: 'microsoft:remain', + provider: BusyProvider.microsoft, + ); + await _pumpApp(tester, database: database, oAuth: oAuth); + await tester.pumpAndSettle(); + await _openSettings(tester); + final container = ProviderScope.containerOf( + tester.element(find.byType(SettingsScreen)), + ); + final router = container.read(appRouterProvider); + + await tester.tap(find.text('Remove account…').first); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('confirm-account-removal'))); + await tester.pumpAndSettle(); + + final accounts = await database.select(database.accounts).get(); + expect(accounts.map((account) => account.id), ['microsoft:remain']); + expect(container.read(selectedAccountIdProvider), 'microsoft:remain'); + expect(container.read(appRouterProvider), same(router)); + expect(router.routeInformationProvider.value.uri.path, '/settings'); + expect(find.byType(SettingsScreen), findsOneWidget); + expect(find.byType(ScheduleWorkspace), findsNothing); + await _disposeApp(tester); + }); + + testWidgets( + 'removing an account from pushed Settings preserves return history', + (tester) async { + tester.view.devicePixelRatio = 1; + tester.view.physicalSize = const Size(1200, 850); + addTearDown(tester.view.resetDevicePixelRatio); + addTearDown(tester.view.resetPhysicalSize); + await _insertAccount( + database, + id: 'google:remove', + provider: BusyProvider.google, + ); + await _insertAccount( + database, + id: 'microsoft:remain', + provider: BusyProvider.microsoft, + ); + await _pumpApp(tester, database: database, oAuth: oAuth); + await tester.pumpAndSettle(); + + final workspaceFinder = find.byType( + ScheduleWorkspace, + skipOffstage: false, + ); + final workspaceState = tester.state(workspaceFinder); + final workspaceRoute = ModalRoute.of(workspaceState.context)!; + final container = ProviderScope.containerOf(workspaceState.context); + final router = container.read(appRouterProvider); + final navigator = rootNavigatorKey.currentState; + expect(workspaceRoute.isCurrent, isTrue); + + unawaited(router.push('/settings')); + await tester.pumpAndSettle(); + expect(workspaceFinder, findsOneWidget); + expect(find.byType(ScheduleWorkspace), findsNothing); + expect(workspaceRoute.isCurrent, isFalse); + await tester.tap( + find.byKey(const ValueKey('settings-navigation-accounts')), + ); + await tester.pumpAndSettle(); + + final settingsFinder = find.byType(SettingsScreen); + final settingsState = tester.state(settingsFinder); + final settingsUri = GoRouterState.of(tester.element(settingsFinder)).uri; + expect(settingsUri.path, '/settings'); + expect(settingsUri.queryParameters['page'], 'accounts'); + + await tester.tap(find.text('Remove account…').first); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('confirm-account-removal'))); + await tester.pumpAndSettle(); + + final accounts = await database.select(database.accounts).get(); + expect(accounts.map((account) => account.id), ['microsoft:remain']); + expect(container.read(selectedAccountIdProvider), 'microsoft:remain'); + expect(container.read(appRouterProvider), same(router)); + expect(rootNavigatorKey.currentState, same(navigator)); + expect(tester.state(settingsFinder), same(settingsState)); + final retainedSettingsUri = GoRouterState.of( + tester.element(settingsFinder), + ).uri; + expect(retainedSettingsUri.path, '/settings'); + expect(retainedSettingsUri.queryParameters['page'], 'accounts'); + expect(workspaceFinder, findsOneWidget); + expect(find.byType(ScheduleWorkspace), findsNothing); + expect(workspaceRoute.isCurrent, isFalse); + expect(router.canPop(), isTrue); + + await _sendAltLeft(tester); + + expect(settingsFinder, findsNothing); + expect(workspaceFinder, findsOneWidget); + expect(tester.state(workspaceFinder), same(workspaceState)); + expect(workspaceRoute.isCurrent, isTrue); + expect(router.routeInformationProvider.value.uri.path, '/schedule'); + expect(router.canPop(), isFalse); + await _disposeApp(tester); + }, + ); + testWidgets( 'adding an account keeps Settings open during and after cancellation', (tester) async { @@ -650,6 +925,14 @@ Future _completeOnboardingWithGoogle(WidgetTester tester) async { await tester.pumpAndSettle(); } +Future _sendAltLeft(WidgetTester tester) async { + await tester.sendKeyDownEvent(LogicalKeyboardKey.altLeft); + await tester.sendKeyDownEvent(LogicalKeyboardKey.arrowLeft); + await tester.sendKeyUpEvent(LogicalKeyboardKey.arrowLeft); + await tester.sendKeyUpEvent(LogicalKeyboardKey.altLeft); + await tester.pumpAndSettle(); +} + Future _disposeApp(WidgetTester tester) async { await tester.pumpWidget(const SizedBox.shrink()); await tester.pump(const Duration(milliseconds: 1)); @@ -717,11 +1000,6 @@ Future _pumpApp( signedInSyncRunnerProvider.overrideWithValue( onSignedIn ?? (accountId, initial) async {}, ), - linuxHeaderBarServiceProvider.overrideWith((ref) { - final service = LinuxHeaderBarService(isLinux: false); - ref.onDispose(service.dispose); - return service; - }), ], child: const BusyMaxApp(), ), diff --git a/test/features/calendar/data/calendar_repository_test.dart b/test/features/calendar/data/calendar_repository_test.dart index 65dea15a..ca2f5190 100644 --- a/test/features/calendar/data/calendar_repository_test.dart +++ b/test/features/calendar/data/calendar_repository_test.dart @@ -121,6 +121,117 @@ void main() { }, ); + test( + 'regular event with stale recurrence metadata moves without a scope prompt', + () async { + await _expectStaleRegularEventMovesWithoutScope( + repository, + database, + rawJson: '{"id":"regular-event"}', + ); + }, + ); + + test( + 'regular event with missing payload and stale recurrence metadata moves without a scope prompt', + () async { + await _expectStaleRegularEventMovesWithoutScope( + repository, + database, + rawJson: '{}', + ); + }, + ); + + test( + 'recovery baseline does not promote stale regular-event metadata to recurrence', + () async { + const start = '2026-06-08T09:00:00.000Z'; + const end = '2026-06-08T10:00:00.000Z'; + await _upsertSource(repository); + await repository.upsertEvent( + accountId: 'google:g', + event: const CalendarEventDto( + provider: BusyProvider.google, + providerCalendarId: 'calendar-1', + providerEventId: 'regular-recovery-event', + providerRecurringEventId: 'stale-series-id', + providerOriginalStartKey: start, + title: 'Regular event', + organizerJson: {'self': true}, + startDateTime: start, + startTimeZone: 'UTC', + endDateTime: end, + endTimeZone: 'UTC', + rawJson: { + 'id': 'regular-recovery-event', + 'summary': 'Regular event', + 'organizer': {'self': true}, + 'start': {'dateTime': start, 'timeZone': 'UTC'}, + 'end': {'dateTime': end, 'timeZone': 'UTC'}, + }, + ), + ); + final eventId = CalendarRepository.eventId( + accountId: 'google:g', + provider: BusyProvider.google, + providerCalendarId: 'calendar-1', + providerEventId: 'regular-recovery-event', + providerOriginalStartKey: start, + ); + final initial = (await repository.loadEventDetail(eventId))!; + expect(initial.requiresRecurringMutationScope, isFalse); + + await repository.updateLocalEvent( + EventEditorDraft.fromEventDetail( + initial, + ).copyWith(title: 'Optimistic title'), + ); + final discardedOperation = await database + .select(database.pendingOps) + .getSingle(); + await repository.restoreEventAfterMutationDiscard(discardedOperation); + await database.pendingOpsDao.deleteOp(discardedOperation.id); + + final restored = (await repository.loadEventDetail(eventId))!; + expect(restored.providerRecurringEventId, equals(null)); + expect(restored.requiresRecurringMutationScope, isFalse); + expect((restored.raw as Map).containsKey('recurringEventId'), isFalse); + + final item = (await ScheduleRepository(database).listItems( + range: ScheduleRange.day(DateTime(2026, 6, 8)), + )).whereType().single; + var scopeRequests = 0; + final result = + await ScheduleReschedulingCoordinator( + repository: repository, + chooseScope: (_, _) async { + scopeRequests++; + return RecurringEventMutationScope.singleOccurrence; + }, + chooseGuestUpdates: (_) async => throw StateError('No guests'), + requestSync: (_) async {}, + ).commit( + ScheduleRescheduleRequest( + item: item, + interval: ScheduleInterval( + item.start!.add(const Duration(minutes: 15)), + item.end!.add(const Duration(minutes: 15)), + ), + ), + ); + expect(result, ScheduleRescheduleResult.saved); + expect(scopeRequests, 0); + final request = + jsonDecode( + (await database.select(database.pendingOps).getSingle()) + .requestJson, + ) + as Map; + expect(request.containsKey(calendarEventRecurringScopeKey), isFalse); + }, + ); + test( 'Tokyo series loads through ScheduleRepository and reschedules across the host DST gap', () async { @@ -129,6 +240,15 @@ void main() { addTearDown(hostZone.restore); expect(DateTime(2026, 3, 8, 2, 30).hour, isNot(2)); await _upsertSource(repository); + await _upsertGoogleSeriesMaster( + repository, + database, + seriesId: 'tokyo-series', + title: 'Tokyo meeting', + start: '2026-03-01T02:30:00', + end: '2026-03-01T03:30:00', + timeZone: 'Asia/Tokyo', + ); await repository.upsertEvent( accountId: 'google:g', event: const CalendarEventDto( @@ -143,6 +263,19 @@ void main() { endDateTime: '2026-03-08T03:30:00', startTimeZone: 'Asia/Tokyo', endTimeZone: 'Asia/Tokyo', + rawJson: { + 'id': 'tokyo-occurrence', + 'recurringEventId': 'tokyo-series', + 'originalStartTime': {'dateTime': '2026-03-08T02:30:00+09:00'}, + 'start': { + 'dateTime': '2026-03-08T02:30:00', + 'timeZone': 'Asia/Tokyo', + }, + 'end': { + 'dateTime': '2026-03-08T03:30:00', + 'timeZone': 'Asia/Tokyo', + }, + }, ), ); final schedule = ScheduleRepository(database); @@ -203,8 +336,8 @@ void main() { () async { await _upsertSource(repository); final ids = [ - await _upsertGoogleOccurrence(repository, day: 8), - await _upsertGoogleOccurrence(repository, day: 15), + await _upsertGoogleOccurrence(repository, database, day: 8), + await _upsertGoogleOccurrence(repository, database, day: 15), ]; await database .update(database.calendarEvents) @@ -377,7 +510,7 @@ void main() { 'recurrence drag $decision honors scope and rechecks after dialogs', () async { await _upsertSource(repository); - final id = await _upsertGoogleOccurrence(repository, day: 8); + final id = await _upsertGoogleOccurrence(repository, database, day: 8); final detail = (await repository.loadEventDetail(id))!; final coordinator = ScheduleReschedulingCoordinator( repository: repository, @@ -986,8 +1119,26 @@ void main() { expect(detail.baselineRaw, raw); final draft = EventEditorDraft.fromEventDetail(detail); - expect(draft.start, DateTime(2026, 6, 8, 9, 30)); - expect(draft.end, DateTime(2026, 6, 8, 10, 30)); + expect( + ( + draft.start!.year, + draft.start!.month, + draft.start!.day, + draft.start!.hour, + draft.start!.minute, + ), + (2026, 6, 8, 9, 30), + ); + expect( + ( + draft.end!.year, + draft.end!.month, + draft.end!.day, + draft.end!.hour, + draft.end!.minute, + ), + (2026, 6, 8, 10, 30), + ); expect(draft.startTimeZone, 'Pacific Standard Time'); expect(draft.endTimeZone, 'Pacific Standard Time'); expect(draft.description, 'Full meeting notes'); @@ -1316,7 +1467,9 @@ void main() { await _upsertSource(repository); final eventIds = []; for (final day in [1, 8, 15]) { - eventIds.add(await _upsertGoogleOccurrence(repository, day: day)); + eventIds.add( + await _upsertGoogleOccurrence(repository, database, day: day), + ); } final detail = await repository.loadEventDetail(eventIds[1]); @@ -1328,9 +1481,13 @@ void main() { ); final rows = - await (database.select(database.calendarEvents)..orderBy([ - (row) => OrderingTerm.asc(row.providerOriginalStartKey), - ])) + await (database.select(database.calendarEvents) + ..where( + (row) => row.providerRecurringEventId.equals('series-master'), + ) + ..orderBy([ + (row) => OrderingTerm.asc(row.providerOriginalStartKey), + ])) .get(); expect(rows.map((row) => row.title), everyElement('Renamed series')); expect(rows.map((row) => row.syncStatus), everyElement('pending')); @@ -1350,8 +1507,11 @@ void main() { 'endTimeZone': 'UTC', }, }); - expect(operation.baselineUpdatedUtc, equals(null)); - expect(operation.baselineRawJson, equals(null)); + expect(operation.baselineUpdatedUtc, '2026-05-30T00:00:00.000Z'); + expect( + (jsonDecode(operation.baselineRawJson!) as Map)['id'], + 'series-master', + ); }, ); @@ -1361,7 +1521,9 @@ void main() { await _upsertSource(repository); final eventIds = []; for (final day in [1, 8, 15]) { - eventIds.add(await _upsertGoogleOccurrence(repository, day: day)); + eventIds.add( + await _upsertGoogleOccurrence(repository, database, day: day), + ); } final detail = await repository.loadEventDetail(eventIds[1]); @@ -1373,9 +1535,13 @@ void main() { ); final rows = - await (database.select(database.calendarEvents)..orderBy([ - (row) => OrderingTerm.asc(row.providerOriginalStartKey), - ])) + await (database.select(database.calendarEvents) + ..where( + (row) => row.providerRecurringEventId.equals('series-master'), + ) + ..orderBy([ + (row) => OrderingTerm.asc(row.providerOriginalStartKey), + ])) .get(); expect(rows.map((row) => row.location), [null, 'New room', 'New room']); expect(rows.map((row) => row.syncStatus), [ @@ -1967,9 +2133,11 @@ Future _upsertSource(CalendarRepository repository) { } Future _upsertGoogleOccurrence( - CalendarRepository repository, { + CalendarRepository repository, + AppDatabase database, { required int day, }) async { + await _upsertGoogleSeriesMaster(repository, database); 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'; @@ -2009,6 +2177,53 @@ Future _upsertGoogleOccurrence( ); } +Future _upsertGoogleSeriesMaster( + CalendarRepository repository, + AppDatabase database, { + String seriesId = 'series-master', + String title = 'Weekly planning', + String start = '2026-06-01T09:00:00.000Z', + String end = '2026-06-01T10:00:00.000Z', + String timeZone = 'UTC', +}) async { + final id = CalendarRepository.eventId( + accountId: 'google:g', + provider: BusyProvider.google, + providerCalendarId: 'calendar-1', + providerEventId: seriesId, + ); + if (await repository.loadEventDetail(id) != null) return; + await repository.upsertEvent( + accountId: 'google:g', + event: CalendarEventDto( + provider: BusyProvider.google, + providerCalendarId: 'calendar-1', + providerEventId: seriesId, + title: title, + organizerJson: const {'self': true}, + startDateTime: start, + startTimeZone: timeZone, + endDateTime: end, + endTimeZone: timeZone, + recurrenceJson: const ['RRULE:FREQ=WEEKLY;BYDAY=MO'], + updatedAtServer: '2026-05-30T00:00:00.000Z', + rawJson: { + 'id': seriesId, + 'summary': title, + 'start': {'dateTime': start, 'timeZone': timeZone}, + 'end': {'dateTime': end, 'timeZone': timeZone}, + 'recurrence': const ['RRULE:FREQ=WEEKLY;BYDAY=MO'], + 'updated': '2026-05-30T00:00:00.000Z', + }, + ), + ); + // Google instance expansion retains the master as a conflict baseline but + // retires it from the visible schedule projection. + await (database.update(database.calendarEvents) + ..where((row) => row.id.equals(id))) + .write(const CalendarEventsCompanion(isDeleted: Value(true))); +} + EventEditorDraft _newEventDraft() { return EventEditorDraft.newEvent( accountId: 'google:g', @@ -2102,3 +2317,55 @@ Future _seedScheduledEvent( hasLength(1), ); } + +Future _expectStaleRegularEventMovesWithoutScope( + CalendarRepository repository, + AppDatabase database, { + required String rawJson, +}) async { + await _seedScheduledEvent(repository, database); + await database + .update(database.calendarEvents) + .write( + CalendarEventsCompanion( + providerRecurringEventId: const Value('stale-series-id'), + providerOriginalStartKey: const Value('2026-06-08T09:00:00.000Z'), + rawJson: Value(rawJson), + ), + ); + + final item = (await ScheduleRepository(database).listItems( + range: ScheduleRange.day(DateTime(2026, 6, 8)), + )).whereType().single; + expect(item.providerRecurringEventId, equals(null)); + + var scopeRequests = 0; + final result = + await ScheduleReschedulingCoordinator( + repository: repository, + chooseScope: (_, _) async { + scopeRequests++; + return RecurringEventMutationScope.singleOccurrence; + }, + chooseGuestUpdates: (_) async => throw StateError('No guests'), + requestSync: (_) async {}, + ).commit( + ScheduleRescheduleRequest( + item: item, + interval: ScheduleInterval( + item.start!.add(const Duration(minutes: 15)), + item.end!.add(const Duration(minutes: 15)), + ), + ), + ); + + expect(result, ScheduleRescheduleResult.saved); + expect(scopeRequests, 0); + final request = + jsonDecode( + (await database.select(database.pendingOps).getSingle()) + .requestJson, + ) + as Map; + expect(request.containsKey(calendarEventRecurringScopeKey), isFalse); +} diff --git a/test/features/calendar/presentation/calendar_color_dialog_test.dart b/test/features/calendar/presentation/calendar_color_dialog_test.dart index e6e6363b..1da40a97 100644 --- a/test/features/calendar/presentation/calendar_color_dialog_test.dart +++ b/test/features/calendar/presentation/calendar_color_dialog_test.dart @@ -1,6 +1,5 @@ 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'; @@ -11,8 +10,6 @@ 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( @@ -25,7 +22,6 @@ void main() { provider: BusyProvider.google, currentBackgroundColor: '#3584e4', currentColorId: null, - headerBarService: headerBarService, ); }, child: const Text('Open'), diff --git a/test/features/calendar/presentation/event_editor_test.dart b/test/features/calendar/presentation/event_editor_test.dart index ba389d3e..65721a67 100644 --- a/test/features/calendar/presentation/event_editor_test.dart +++ b/test/features/calendar/presentation/event_editor_test.dart @@ -11,6 +11,7 @@ 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_window_close.dart'; import 'package:busymax/src/app/busymax_yaru_theme.dart'; import 'package:busymax/src/platform/native_dialog_service.dart'; import 'package:busymax/src/platform/native_menu_service.dart'; @@ -43,6 +44,34 @@ void main() { .setMockMethodCallHandler(_nativeMenuChannel, null); }); + testWidgets('new event opens with the title field focused', (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), + end: DateTime.utc(2026, 6, 8, 1), + ), + sources: _sources, + onCancel: () {}, + onSave: (_) {}, + ), + ), + ), + ); + await tester.pumpAndSettle(); + + final title = find.descendant( + of: find.byType(TextFormField).first, + matching: find.byType(EditableText), + ); + expect(tester.widget(title).focusNode.hasFocus, isTrue); + }); + testWidgets( 'event location is an ordinary text field and restoring it keeps its point', (tester) async { @@ -182,8 +211,10 @@ void main() { expect(saveButton.style?.minimumSize, isNull); final cancelContext = tester.element(find.text('Cancel')); expect( - cancelButton.style?.textStyle?.resolve(const {})?.fontWeight, - Theme.of(cancelContext).textTheme.titleSmall?.fontWeight, + Theme.of( + cancelContext, + ).filledButtonTheme.style?.textStyle?.resolve(const {})?.fontWeight, + FontWeight.bold, ); }); @@ -528,6 +559,57 @@ void main() { }, ); + testWidgets('dirty event resolves a destructive window close request', ( + tester, + ) async { + final closeCoordinator = BusyMaxWindowCloseCoordinator(); + var cancelled = false; + await tester.pumpWidget( + localizedTestApp( + child: BusyMaxWindowCloseScope( + coordinator: closeCoordinator, + 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: _sources, + onCancel: () => cancelled = true, + onSave: (_) {}, + ), + ), + ), + ), + ); + await tester.enterText(find.byType(TextFormField).first, 'Unsaved event'); + await tester.pump(); + + final cancelledClose = closeCoordinator.requestClose(); + await tester.pumpAndSettle(); + expect(find.text('Discard changes?'), findsOneWidget); + await tester.tap(find.text('Cancel').last); + await tester.pumpAndSettle(); + + expect(await cancelledClose, isFalse); + expect(cancelled, isFalse); + expect(find.text('Unsaved event'), findsOneWidget); + + final confirmedClose = closeCoordinator.requestClose(); + await tester.pumpAndSettle(); + await tester.tap(find.text('Discard')); + await tester.pumpAndSettle(); + + expect(await confirmedClose, isTrue); + expect(cancelled, isFalse); + }); + testWidgets('switching an invalid timed event to all-day clears validity', ( tester, ) async { @@ -2420,9 +2502,9 @@ void main() { expect(editor, contains('showBusyMaxEventEditorDialog')); expect(editor, contains('showBusyMaxModalEditorDialog')); expect(editor, isNot(contains('showDialog'))); - expect(dialogs, contains('await acquireBusyMaxModalBarrier(')); - expect(dialogs, contains('await releaseBusyMaxModalBarrier(')); - expect(dialogs, contains('shadesHeader: shadesHeader')); + expect(dialogs, contains('await route.completed')); + expect(dialogs, isNot(contains('LinuxHeaderBarService'))); + expect(dialogs, isNot(contains('setModalBarrierState'))); expect( dialogs, contains('barrierColor ?? busyMaxModalBarrierColor(context)'), @@ -2451,7 +2533,8 @@ void main() { expect(header, contains('AlignmentDirectional.centerEnd')); expect(header, contains('child: BusyMaxPushButton.suggested(')); expect(header, contains('heightFactor: 1')); - expect(header, contains('textTheme.titleSmall')); + expect(header, isNot(contains('textTheme.titleSmall'))); + expect(header, isNot(contains('style: actionStyle'))); expect(header, isNot(contains('child: FilledButton('))); expect(header, isNot(contains('child: ElevatedButton('))); expect(header, isNot(contains('NavigationToolbar('))); diff --git a/test/features/calendar/presentation/ical_import_flow_test.dart b/test/features/calendar/presentation/ical_import_flow_test.dart new file mode 100644 index 00000000..3548bc16 --- /dev/null +++ b/test/features/calendar/presentation/ical_import_flow_test.dart @@ -0,0 +1,131 @@ +import 'package:busymax/src/app/busymax_design.dart'; +import 'package:busymax/src/app/busymax_yaru_theme.dart'; +import 'package:busymax/src/features/calendar/data/calendar_repository.dart'; +import 'package:busymax/src/features/calendar/presentation/ical_import_flow.dart'; +import 'package:busymax/src/ical/ical_import_service.dart'; +import 'package:busymax/src/ical/ical_ingestion.dart'; +import 'package:busymax/src/providers/busy_provider.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:yaru/yaru.dart'; + +import '../../../test_localized_app.dart'; + +void main() { + testWidgets('Nextcloud import copies uses the Yaru checkbox selection', ( + tester, + ) async { + late BuildContext hostContext; + await tester.pumpWidget( + localizedTestApp( + theme: BusyMaxYaruTheme.build( + brightness: Brightness.dark, + accentColor: YaruColors.orange, + ), + child: Builder( + builder: (context) { + hostContext = context; + return const SizedBox.shrink(); + }, + ), + ), + ); + + const destination = CalendarSourceEntity( + id: 'source', + accountId: 'account', + provider: BusyProvider.nextcloud, + providerCalendarId: 'calendar', + summary: 'Work', + selected: true, + hidden: false, + readOnly: false, + isDeleted: false, + ); + final result = showIcalImportPreviewDialog( + hostContext, + preview: _preview(), + destinations: const [destination], + accountLabels: const {'account': 'Nextcloud'}, + ); + await tester.pumpAndSettle(); + + expect(find.byType(BusyMaxDialogShell), findsOneWidget); + expect(find.byType(CheckboxListTile), findsNothing); + final checkboxFinder = find.byKey( + const ValueKey('nextcloud-import-copies'), + ); + expect(checkboxFinder, findsOneWidget); + expect(tester.widget(checkboxFinder).value, isFalse); + + await tester.tap(checkboxFinder); + await tester.pump(); + expect(tester.widget(checkboxFinder).value, isTrue); + await tester.tap(find.byKey(const ValueKey('confirm-ics-import'))); + await tester.pumpAndSettle(); + + final selection = await result; + expect(selection?.destination, same(destination)); + expect(selection?.newCopies, isTrue); + }); + + testWidgets('import copies remains Nextcloud-only and cancellation is null', ( + tester, + ) async { + late BuildContext hostContext; + await tester.pumpWidget( + localizedTestApp( + child: Builder( + builder: (context) { + hostContext = context; + return const SizedBox.shrink(); + }, + ), + ), + ); + + const destination = CalendarSourceEntity( + id: 'source', + accountId: 'account', + provider: BusyProvider.google, + providerCalendarId: 'calendar', + summary: 'Work', + selected: true, + hidden: false, + readOnly: false, + isDeleted: false, + ); + final result = showIcalImportPreviewDialog( + hostContext, + preview: _preview(), + destinations: const [destination], + accountLabels: const {'account': 'Google'}, + ); + await tester.pumpAndSettle(); + + expect(find.byKey(const ValueKey('nextcloud-import-copies')), findsNothing); + await tester.tap(find.text('Cancel')); + await tester.pumpAndSettle(); + expect(await result, isNull); + }); +} + +IcalImportPreview _preview() { + final ingestion = IcalIngestion.parseString('''BEGIN:VCALENDAR\r +VERSION:2.0\r +PRODID:-//BusyMax Test//EN\r +BEGIN:VEVENT\r +UID:import-preview-event\r +DTSTART:20260920T160000Z\r +DTEND:20260920T170000Z\r +SUMMARY:Imported event\r +END:VEVENT\r +END:VCALENDAR\r +''', policy: IcalIngestionPolicy.fileImport); + return IcalImportPreview( + ingestion: ingestion, + eventCount: 1, + invalidEventCount: 0, + fieldsThatWillBeOmitted: const {}, + ); +} diff --git a/test/features/diagnostics/presentation/diagnostics_screen_test.dart b/test/features/diagnostics/presentation/diagnostics_screen_test.dart new file mode 100644 index 00000000..52870fb5 --- /dev/null +++ b/test/features/diagnostics/presentation/diagnostics_screen_test.dart @@ -0,0 +1,189 @@ +import 'package:busymax/src/app/app_bootstrap.dart'; +import 'package:busymax/src/app/busymax_design.dart'; +import 'package:busymax/src/core/auth/oauth_models.dart'; +import 'package:busymax/src/core/http/request_dispatch_exception.dart'; +import 'package:busymax/src/db/app_database.dart'; +import 'package:busymax/src/features/accounts/data/accounts_repository.dart'; +import 'package:busymax/src/features/diagnostics/presentation/diagnostics_screen.dart'; +import 'package:busymax/src/features/sync/pending_op_resolution_service.dart'; +import 'package:drift/drift.dart' hide isNotNull; +import 'package:drift/native.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../../../test_localized_app.dart'; + +void main() { + testWidgets( + 'retry for reconnect-required account stays blocked and reports reconnect', + (tester) async { + final database = AppDatabase(NativeDatabase.memory()); + addTearDown(database.close); + await _seedAccount(database, needsReconnect: true); + await _seedBlockedOperation(database, id: 'retry-op'); + var syncCalls = 0; + final service = PendingOpResolutionService( + database: database, + accountId: 'account', + syncTasks: () async => syncCalls += 1, + syncCalendar: () async => syncCalls += 1, + ); + + await _pumpDiagnostics(tester, database, service); + final before = await database.pendingOpsDao.getOp('retry-op'); + + _selectMenuAction(tester, 'Retry'); + await tester.pumpAndSettle(); + + final after = await database.pendingOpsDao.getOp('retry-op'); + expect(after?.state, before?.state); + expect(after?.nextAttemptAtUtc, before?.nextAttemptAtUtc); + expect(syncCalls, 0); + expect( + find.text('This account needs to be reconnected.'), + findsOneWidget, + ); + expect(find.text('Retry completed.'), findsNothing); + await _disposeDiagnostics(tester); + }, + ); + + testWidgets('discard failure uses a controlled sync message', (tester) async { + final database = AppDatabase(NativeDatabase.memory()); + addTearDown(database.close); + await _seedAccount(database); + await _seedBlockedOperation(database, id: 'discard-op'); + final service = _ThrowingPendingOpResolutionService(database); + + await _pumpDiagnostics(tester, database, service); + + _selectMenuAction(tester, 'Discard'); + await tester.pumpAndSettle(); + await tester.tap(find.text('Discard').last); + await tester.pumpAndSettle(); + + expect(find.text('This account needs to be reconnected.'), findsOneWidget); + expect(find.textContaining('KnownUnsentRequestException'), findsNothing); + expect(find.textContaining('OAuthRefreshException'), findsNothing); + expect(find.textContaining('provider-secret'), findsNothing); + expect(await database.pendingOpsDao.getOp('discard-op'), isNotNull); + await _disposeDiagnostics(tester); + }); +} + +Future _disposeDiagnostics(WidgetTester tester) async { + await tester.pumpWidget(const SizedBox.shrink()); + await tester.pump(); + await tester.pump(const Duration(seconds: 5)); +} + +Future _pumpDiagnostics( + WidgetTester tester, + AppDatabase database, + PendingOpResolutionService service, +) async { + await tester.pumpWidget( + ProviderScope( + overrides: [ + databaseProvider.overrideWithValue(database), + initialAppSettingsProvider.overrideWithValue(AppSettings.defaults()), + pendingOpResolutionServiceForAccountProvider.overrideWith( + (ref, accountId) => service, + ), + ], + child: localizedTestApp( + child: const Scaffold( + body: SingleChildScrollView( + child: DiagnosticsPanel(scrollable: false), + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); +} + +void _selectMenuAction(WidgetTester tester, String label) { + final menu = + tester.widget( + find.byWidgetPredicate((widget) => widget is BusyMaxMenuButton), + ) + as dynamic; + final entry = (menu.entries as List).singleWhere( + (candidate) => candidate.label == label, + ); + menu.onSelected(entry.value); +} + +Future _seedAccount( + AppDatabase database, { + bool needsReconnect = false, +}) async { + await database + .into(database.accounts) + .insert( + AccountsCompanion.insert( + id: 'account', + provider: 'google', + authority: 'https://accounts.google.com', + providerAccountId: 'provider-account', + credentialKind: 'oauth', + authState: Value( + needsReconnect + ? accountAuthStateReauthRequired + : accountAuthStateSignedIn, + ), + createdAtUtc: '2026-09-21T00:00:00.000Z', + updatedAtUtc: '2026-09-21T00:00:00.000Z', + ), + ); +} + +Future _seedBlockedOperation(AppDatabase database, {required String id}) { + return database.pendingOpsDao.enqueue( + PendingOpsCompanion.insert( + id: id, + accountId: 'account', + provider: const Value('google'), + entityType: 'task', + operation: 'patch_task', + taskListId: const Value('list'), + taskId: const Value('task'), + requestJson: '{}', + state: const Value('failed'), + retryClassification: const Value('permanent'), + nextAttemptAtUtc: const Value('9999-12-31T23:59:59.999Z'), + lastErrorCode: const Value('provider_rejected'), + createdAtUtc: '2026-09-21T00:00:00.000Z', + updatedAtUtc: '2026-09-21T00:00:00.000Z', + ), + ); +} + +final class _ThrowingPendingOpResolutionService + extends PendingOpResolutionService { + _ThrowingPendingOpResolutionService(AppDatabase database) + : super( + database: database, + accountId: 'account', + syncTasks: _noSync, + syncCalendar: _noSync, + ); + + @override + Future discard(String opId) async { + throw const KnownUnsentRequestException( + kind: RequestPreDispatchFailureKind.authentication, + cause: OAuthRefreshException( + 'OAuthRefreshFailed', + 'provider-secret', + statusCode: 400, + oauthError: 'invalid_grant', + oauthErrorDescription: 'provider-secret', + ), + ); + } + + static Future _noSync() async {} +} diff --git a/test/features/feedback/presentation/feedback_dialog_test.dart b/test/features/feedback/presentation/feedback_dialog_test.dart index 8189912a..b5baae75 100644 --- a/test/features/feedback/presentation/feedback_dialog_test.dart +++ b/test/features/feedback/presentation/feedback_dialog_test.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:ui' as ui; import 'package:busymax/src/app/busymax_design.dart'; import 'package:busymax/src/features/feedback/data/feedback_api_client.dart'; @@ -141,20 +142,49 @@ void main() { testWidgets('shows loading state and prevents duplicate submission', ( tester, ) async { + final semantics = tester.ensureSemantics(); final completion = Completer(); final service = _FakeFeedbackService((_) => completion.future); await _pumpDialog(tester, service); await _enterValidRequiredFields(tester); + final submitButton = find.byType(ElevatedButton); + var submitSemantics = tester.getSemantics(submitButton).getSemanticsData(); + expect(submitSemantics.label, 'Submit'); + expect(submitSemantics.flagsCollection.isEnabled, ui.Tristate.isTrue); + expect(submitSemantics.hasAction(ui.SemanticsAction.tap), isTrue); + await tester.tap(find.text('Submit')); await tester.pump(); expect(find.byType(CircularProgressIndicator), findsOneWidget); - expect(find.text('Submit'), findsNothing); + final submitLabel = find.text('Submit'); + expect(submitLabel, findsOneWidget); + expect( + tester + .widget( + find.ancestor(of: submitLabel, matching: find.byType(Opacity)), + ) + .opacity, + 0, + ); + expect( + tester + .widget( + find.ancestor(of: submitLabel, matching: find.byType(Opacity)), + ) + .alwaysIncludeSemantics, + isTrue, + ); + submitSemantics = tester.getSemantics(submitButton).getSemanticsData(); + expect(submitSemantics.label, 'Submit'); + expect(submitSemantics.flagsCollection.isEnabled, ui.Tristate.isFalse); + expect(submitSemantics.hasAction(ui.SemanticsAction.tap), isFalse); expect(service.submissions, hasLength(1)); completion.complete(const FeedbackReceipt(id: 'BM-100')); await tester.pumpAndSettle(); + semantics.dispose(); }); testWidgets('disables Cancel while a submission is in progress', ( diff --git a/test/features/notifications/desktop_notification_service_test.dart b/test/features/notifications/desktop_notification_service_test.dart index 278df310..e6910a47 100644 --- a/test/features/notifications/desktop_notification_service_test.dart +++ b/test/features/notifications/desktop_notification_service_test.dart @@ -3,6 +3,7 @@ import 'dart:io'; import 'package:busymax/src/app/app_settings.dart'; import 'package:busymax/src/core/auth/oauth_models.dart'; +import 'package:busymax/src/core/http/request_dispatch_exception.dart'; import 'package:busymax/src/features/notifications/desktop_notification_service.dart'; import 'package:busymax/src/features/tasks/domain/task_remote_error.dart'; import 'package:flutter/material.dart'; @@ -94,6 +95,37 @@ void main() { ); }); + test('wrapped Google invalid_grant requests reconnection', () async { + final backend = _FakeNotificationBackend(); + final service = DesktopNotificationService( + backend: backend, + settings: AppSettings.defaults(), + locale: const Locale('en'), + ); + + await service.notifySyncFailure( + const KnownUnsentRequestException( + kind: RequestPreDispatchFailureKind.authentication, + cause: OAuthRefreshException( + 'OAuthRefreshFailed', + 'Provider refresh failed.', + statusCode: 400, + oauthError: 'invalid_grant', + ), + ), + ); + + expect( + backend.notifications.single.body, + 'Background sync failed. ' + 'Reconnect this account to resume synchronization.', + ); + expect( + backend.notifications.single.body, + isNot(contains('temporarily unavailable')), + ); + }); + test('permission failures use a controlled permission message', () async { final backend = _FakeNotificationBackend(); final service = DesktopNotificationService( diff --git a/test/features/schedule/presentation/remembered_coordinate_details_test.dart b/test/features/schedule/presentation/remembered_coordinate_details_test.dart index 07828b0a..1a934786 100644 --- a/test/features/schedule/presentation/remembered_coordinate_details_test.dart +++ b/test/features/schedule/presentation/remembered_coordinate_details_test.dart @@ -12,8 +12,6 @@ import 'package:busymax/src/features/schedule/application/saved_schedule_locatio import 'package:busymax/src/features/schedule/presentation/schedule_workspace.dart'; import 'package:busymax/src/features/schedule/presentation/schedule_event_block.dart'; import 'package:busymax/src/ical/ical_import_service.dart'; -import 'package:busymax/src/platform/linux_header_bar_provider.dart'; -import 'package:busymax/src/platform/linux_header_bar_service.dart'; import 'package:busymax/src/providers/busy_provider.dart'; import 'package:busymax/src/schedule/schedule_repository.dart'; import 'package:busymax/src/schedule/schedule_range.dart'; @@ -62,11 +60,9 @@ void main() { return true; }, ); - final headerBar = LinuxHeaderBarService(isLinux: false); - addTearDown(headerBar.dispose); await tester.pumpWidget( ProviderScope( - overrides: _providerOverrides(harness, headerBar: headerBar), + overrides: _providerOverrides(harness), child: localizedTestApp( child: ScheduleWorkspace( initialScope: ScheduleScope.events, @@ -107,7 +103,7 @@ void main() { ); await tester.pumpWidget( ProviderScope( - overrides: _providerOverrides(harness, headerBar: headerBar), + overrides: _providerOverrides(harness), child: fluent.FluentApp( theme: fluent.FluentThemeData(), localizationsDelegates: const [AppLocalizations.delegate], @@ -154,7 +150,7 @@ void main() { await tester.pumpWidget( ProviderScope( - overrides: _providerOverrides(harness, headerBar: headerBar), + overrides: _providerOverrides(harness), child: localizedTestApp( child: ScheduleWorkspace( initialScope: ScheduleScope.events, @@ -177,7 +173,7 @@ void main() { await tester.pumpWidget( ProviderScope( - overrides: _providerOverrides(harness, headerBar: headerBar), + overrides: _providerOverrides(harness), child: fluent.FluentApp( theme: fluent.FluentThemeData(), localizationsDelegates: const [AppLocalizations.delegate], @@ -206,10 +202,7 @@ void main() { ); } -List _providerOverrides( - _ImportHarness harness, { - LinuxHeaderBarService? headerBar, -}) => [ +List _providerOverrides(_ImportHarness harness) => [ databaseProvider.overrideWithValue(harness.database), calendarRepositoryProvider.overrideWithValue(harness.calendarRepository), scheduleRepositoryProvider.overrideWithValue( @@ -220,8 +213,6 @@ List _providerOverrides( localTimeZoneProvider.overrideWithValue('UTC'), localSettingsStoreProvider.overrideWithValue(MemorySettingsStore()), initialAppSettingsProvider.overrideWithValue(AppSettings.defaults()), - if (headerBar != null) - linuxHeaderBarServiceProvider.overrideWithValue(headerBar), ]; Future<_ImportHarness> _importGeoOnlyGoogleEvent() async { diff --git a/test/features/schedule/presentation/schedule_search_platform_test.dart b/test/features/schedule/presentation/schedule_search_platform_test.dart index b328d6d8..ee9849f7 100644 --- a/test/features/schedule/presentation/schedule_search_platform_test.dart +++ b/test/features/schedule/presentation/schedule_search_platform_test.dart @@ -1,7 +1,10 @@ +import 'dart:async'; + import 'package:busymax/l10n/generated/app_localizations.dart'; import 'package:busymax/src/android/presentation/android_schedule_screen.dart'; import 'package:busymax/src/app/app_bootstrap.dart'; import 'package:busymax/src/app/busymax_design.dart'; +import 'package:busymax/src/app/linux/linux_header_style.dart'; import 'package:busymax/src/calendar_providers/calendar_sync_dto.dart'; import 'package:busymax/src/db/app_database.dart'; import 'package:busymax/src/features/accounts/data/accounts_repository.dart'; @@ -14,11 +17,10 @@ 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/l10n/week_preferences_scope.dart'; -import 'package:busymax/src/platform/linux_header_bar_provider.dart'; -import 'package:busymax/src/platform/linux_header_bar_service.dart'; import 'package:busymax/src/providers/busy_provider.dart'; import 'package:busymax/src/schedule/schedule_filters.dart'; import 'package:busymax/src/schedule/schedule_repository.dart'; +import 'package:busymax/src/schedule/schedule_range.dart'; import 'package:busymax/src/schedule/schedule_search_criteria.dart'; import 'package:busymax/src/schedule/schedule_view_mode.dart'; import 'package:busymax/src/ui/windows/windows_schedule_page.dart'; @@ -40,6 +42,135 @@ import '../../../support/memory_settings_store.dart'; import '../../../test_localized_app.dart'; void main() { + testWidgets( + 'Windows delayed normal query cannot rebuild the retained side as search', + (tester) async { + final normalRefresh = Completer(); + final refreshStarted = Completer(); + addTearDown(() { + if (!normalRefresh.isCompleted) normalRefresh.complete(); + }); + var coverageCalls = 0; + final fixture = await _mount( + tester, + 'windows', + ensureProjectionCoverage: (_) async { + coverageCalls += 1; + if (coverageCalls == 2) { + refreshStarted.complete(); + await normalRefresh.future; + } + }, + ); + await tester.pumpAndSettle(); + final planner = find.byType(WindowsScheduleMonthView); + final plannerScrollable = find + .descendant(of: planner, matching: find.byType(Scrollable)) + .first; + final originalPlannerState = tester.state( + plannerScrollable, + ); + + final event = await fixture.db + .select(fixture.db.calendarEvents) + .getSingle(); + await (fixture.db.update( + fixture.db.calendarEvents, + )..where((row) => row.id.equals(event.id))).write( + const CalendarEventsCompanion(title: Value('Refresh in progress')), + ); + for ( + var attempt = 0; + attempt < 20 && !refreshStarted.isCompleted; + attempt++ + ) { + await tester.pump(const Duration(milliseconds: 10)); + await tester.runAsync(() => Future.delayed(Duration.zero)); + } + expect(refreshStarted.isCompleted, isTrue); + await tester.pump(); + expect( + tester + .stateList( + find.descendant( + of: find.byType(WindowsScheduleMonthView), + matching: find.byType(Scrollable), + ), + ) + .any((state) => identical(state, originalPlannerState)), + isTrue, + reason: 'starting a replacement query must retain the planner', + ); + await _searchShortcut(tester); + await tester.pumpAndSettle(); + expect(find.byType(WindowsScheduleSearchPane), findsOneWidget); + expect( + find.byType(WindowsScheduleMonthView, skipOffstage: false), + findsOneWidget, + ); + expect( + tester + .stateList( + find.descendant( + of: find.byType(WindowsScheduleMonthView, skipOffstage: false), + matching: find.byType(Scrollable, skipOffstage: false), + skipOffstage: false, + ), + ) + .any((state) => identical(state, originalPlannerState)), + isTrue, + ); + + normalRefresh.complete(); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 20)); + expect( + tester + .stateList( + find.descendant( + of: find.byType(WindowsScheduleMonthView, skipOffstage: false), + matching: find.byType(Scrollable, skipOffstage: false), + skipOffstage: false, + ), + ) + .any((state) => identical(state, originalPlannerState)), + isTrue, + ); + + await tester.sendKeyEvent(LogicalKeyboardKey.escape); + await tester.pumpAndSettle(); + expect(find.byType(WindowsScheduleMonthView), findsOneWidget); + expect( + tester.state(plannerScrollable), + same(originalPlannerState), + ); + }, + ); + + testWidgets( + 'Windows empty Agenda keeps Load more and reveals the following horizon', + (tester) async { + final eventDate = DateTime.now().add(const Duration(days: 40)); + await _mount( + tester, + 'windows', + scheduleMode: ScheduleViewMode.agenda, + seedSearchItems: false, + additionalEventDate: eventDate, + ); + await tester.pumpAndSettle(); + + expect(find.text('Following horizon meeting'), findsNothing); + final loadMore = find.text('Load more agenda items'); + expect(loadMore, findsOneWidget); + await tester.tap(loadMore); + await tester.pumpAndSettle(); + + expect(find.text('Following horizon meeting'), findsOneWidget); + expect(tester.takeException(), isNull); + }, + ); + for (final platform in ['linux', 'windows']) { testWidgets( '$platform search swaps sidebar, lists all local matches, keeps temporary sources and restores Month', @@ -204,7 +335,7 @@ void main() { // Clearing only the entry retains Person and the temporary scope. final entry = platform == 'linux' ? find.descendant( - of: find.byType(BusyMaxSearchField), + of: find.byType(BusyMaxLinuxHeaderSearchField), matching: find.byType(TextField), ) : find @@ -256,35 +387,38 @@ void main() { await _searchShortcut(tester); expect( platform == 'linux' - ? find.byType(ScheduleSearchFilters) - : find.byType(WindowsScheduleSearchPane), + ? find.byType(ScheduleSearchFilters).hitTestable() + : find.byType(WindowsScheduleSearchPane).hitTestable(), findsNothing, ); await tester.sendKeyEvent(LogicalKeyboardKey.f9); await tester.pumpAndSettle(); expect( platform == 'linux' - ? find.byType(ScheduleSearchFilters) - : find.byType(WindowsScheduleSearchPane), + ? find.byType(ScheduleSearchFilters).hitTestable() + : find.byType(WindowsScheduleSearchPane).hitTestable(), findsOneWidget, ); if (platform == 'linux') { + final visibleFilters = find + .byType(ScheduleSearchFilters) + .hitTestable(); expect(find.byType(BusyMaxDialogShell), findsOneWidget); - expect(find.byType(BusyMaxSidebarSurface), findsNothing); expect( - tester - .widget( - find.byType(ScheduleSearchFilters), - ) - .sidebar, + find.byType(BusyMaxSidebarSurface).hitTestable(), + findsNothing, + ); + expect( + tester.widget(visibleFilters).sidebar, isFalse, ); - Navigator.of( - tester.element(find.byType(ScheduleSearchFilters)), - ).pop(); + Navigator.of(tester.element(visibleFilters)).pop(); await tester.pumpAndSettle(); expect(find.byType(BusyMaxDialogShell), findsNothing); - expect(find.byType(ScheduleSearchFilters), findsNothing); + expect( + find.byType(ScheduleSearchFilters).hitTestable(), + findsNothing, + ); expect( find.byKey(const ValueKey('schedule-search-results')), findsOneWidget, @@ -297,7 +431,10 @@ void main() { await tester.pumpAndSettle(); expect(find.byType(BusyMaxDialogShell), findsNothing); expect(find.byType(ScheduleSearchFilters), findsOneWidget); - expect(find.byType(BusyMaxSidebarSurface), findsOneWidget); + expect( + find.byType(BusyMaxSidebarSurface).hitTestable(), + findsOneWidget, + ); } expect( find.text('Custom range'), @@ -408,6 +545,62 @@ void main() { }, ); + for (final reducedMotion in [false, true]) { + testWidgets('Android search restores focus after reopening' + '${reducedMotion ? ' with reduced motion' : ''}', (tester) async { + if (reducedMotion) { + tester.binding.platformDispatcher.accessibilityFeaturesTestValue = + const FakeAccessibilityFeatures(disableAnimations: true); + addTearDown( + tester.binding.platformDispatcher.clearAccessibilityFeaturesTestValue, + ); + } + await _mount(tester, 'android', width: 480); + await tester.pumpAndSettle(); + + await tester.tap(find.byIcon(Icons.search)); + await tester.pumpAndSettle(); + final firstField = tester.widget(find.byType(TextField)); + final focusNode = firstField.focusNode!; + expect(focusNode.hasFocus, isTrue); + + await tester.tap(find.byIcon(Icons.close)); + await tester.pumpAndSettle(); + expect(focusNode.hasFocus, isFalse); + + await tester.tap(find.byIcon(Icons.search)); + await tester.pumpAndSettle(); + final reopenedField = tester.widget(find.byType(TextField)); + expect(reopenedField.focusNode, same(focusNode)); + expect(focusNode.hasFocus, isTrue); + }); + } + + testWidgets('closing Android search cancels its scheduled focus request', ( + tester, + ) async { + await _mount(tester, 'android', width: 480); + await tester.pumpAndSettle(); + var closedBeforeFocus = false; + tester.binding.addPostFrameCallback((_) { + final close = find.widgetWithIcon(IconButton, Icons.close); + if (close.evaluate().length != 1) return; + closedBeforeFocus = true; + tester.widget(close).onPressed!(); + }); + + await tester.tap(find.byIcon(Icons.search)); + await tester.pump(); + await tester.pumpAndSettle(); + + expect(closedBeforeFocus, isTrue); + expect(find.byIcon(Icons.search), findsOneWidget); + final retainedField = tester.widget( + find.byType(TextField, skipOffstage: false), + ); + expect(retainedField.focusNode!.hasFocus, isFalse); + }); + testWidgets( 'Android confirms an open date filter with the latest system weekday', (tester) async { @@ -472,7 +665,7 @@ void main() { await tester.sendKeyEvent(LogicalKeyboardKey.f9); await tester.pumpAndSettle(); - final pane = find.byType(WindowsScheduleSourcePane); + final pane = find.byType(WindowsScheduleSourcePane).hitTestable(); expect(pane, findsOneWidget); expect( tester.widget(pane).firstWeekday, @@ -501,6 +694,10 @@ Future<({AppDatabase db, ProviderContainer container})> _mount( String platform, { double width = 1280, ValueListenable? systemWeekday, + Future Function(ScheduleRange range)? ensureProjectionCoverage, + ScheduleViewMode scheduleMode = ScheduleViewMode.month, + bool seedSearchItems = true, + DateTime? additionalEventDate, }) async { tester.view ..physicalSize = Size(width, 900) @@ -542,43 +739,62 @@ Future<({AppDatabase db, ProviderContainer container})> _mount( summary: 'Work calendar', ), ); - await calendar.upsertEvent( - accountId: 'account', - event: const CalendarEventDto( - provider: BusyProvider.google, - providerCalendarId: 'calendar', - providerEventId: 'far', - title: 'Far future review', - allDay: true, - startDate: '2040-02-15', - endDate: '2040-02-16', - location: 'Office', - attendeesJson: [ - {'email': 'alex@example.com'}, - ], - ), - ); - for (final entry in [ - ('overdue', 'Overdue task', '2020-01-01', 'needsAction'), - ('done', 'Completed task', '2020-01-01', 'completed'), - ('undated', 'No due task', null, 'needsAction'), - ]) { - await db.tasksDao.upsertTask( - TasksCompanion.insert( - accountId: 'account', - taskListId: 'list', - id: entry.$1, - title: entry.$2, - dueUtc: Value(entry.$3), - status: Value(entry.$4), - taskLocation: const Value('Office'), - rawJson: '{}', - createdLocalAtUtc: now, - updatedLocalAtUtc: now, + if (seedSearchItems) { + await calendar.upsertEvent( + accountId: 'account', + event: const CalendarEventDto( + provider: BusyProvider.google, + providerCalendarId: 'calendar', + providerEventId: 'far', + title: 'Far future review', + allDay: true, + startDate: '2040-02-15', + endDate: '2040-02-16', + location: 'Office', + attendeesJson: [ + {'email': 'alex@example.com'}, + ], + ), + ); + for (final entry in [ + ('overdue', 'Overdue task', '2020-01-01', 'needsAction'), + ('done', 'Completed task', '2020-01-01', 'completed'), + ('undated', 'No due task', null, 'needsAction'), + ]) { + await db.tasksDao.upsertTask( + TasksCompanion.insert( + accountId: 'account', + taskListId: 'list', + id: entry.$1, + title: entry.$2, + dueUtc: Value(entry.$3), + status: Value(entry.$4), + taskLocation: const Value('Office'), + rawJson: '{}', + createdLocalAtUtc: now, + updatedLocalAtUtc: now, + ), + ); + } + } + if (additionalEventDate != null) { + final startDate = DateFormat('yyyy-MM-dd').format(additionalEventDate); + final endDate = DateFormat( + 'yyyy-MM-dd', + ).format(additionalEventDate.add(const Duration(days: 1))); + await calendar.upsertEvent( + accountId: 'account', + event: CalendarEventDto( + provider: BusyProvider.google, + providerCalendarId: 'calendar', + providerEventId: 'following-horizon', + title: 'Following horizon meeting', + allDay: true, + startDate: startDate, + endDate: endDate, ), ); } - final header = LinuxHeaderBarService(isLinux: false); final container = ProviderContainer( overrides: [ networkAvailabilityProvider.overrideWith( @@ -586,11 +802,10 @@ Future<({AppDatabase db, ProviderContainer container})> _mount( ), databaseProvider.overrideWithValue(db), localTimeZoneProvider.overrideWithValue('UTC'), - linuxHeaderBarServiceProvider.overrideWithValue(header), localSettingsStoreProvider.overrideWithValue(MemorySettingsStore()), initialAppSettingsProvider.overrideWithValue( AppSettings.defaults().copyWith( - scheduleViewMode: ScheduleViewMode.month, + scheduleViewMode: scheduleMode, androidScheduleViewMode: ScheduleViewMode.month, ), ), @@ -598,7 +813,12 @@ Future<({AppDatabase db, ProviderContainer container})> _mount( AccountsRepository(database: db), ), calendarRepositoryProvider.overrideWithValue(calendar), - scheduleRepositoryProvider.overrideWithValue(ScheduleRepository(db)), + scheduleRepositoryProvider.overrideWithValue( + ScheduleRepository( + db, + ensureProjectionCoverage: ensureProjectionCoverage, + ), + ), taskListsRepositoryForAccountProvider.overrideWith( (ref, id) => TaskListsRepository(database: db, accountId: id), ), @@ -610,7 +830,6 @@ Future<({AppDatabase db, ProviderContainer container})> _mount( addTearDown(() async { await tester.pumpWidget(const SizedBox.shrink()); container.dispose(); - header.dispose(); await db.close(); }); final platformApp = platform == 'windows' diff --git a/test/features/schedule/presentation/schedule_search_titlebar_test.dart b/test/features/schedule/presentation/schedule_search_titlebar_test.dart new file mode 100644 index 00000000..aed183dc --- /dev/null +++ b/test/features/schedule/presentation/schedule_search_titlebar_test.dart @@ -0,0 +1,844 @@ +import 'dart:async'; + +import 'package:busymax/src/app/app_bootstrap.dart'; +import 'package:busymax/src/app/busymax_design.dart'; +import 'package:busymax/src/app/common/busymax_motion_widgets.dart'; +import 'package:busymax/src/app/linux/linux_header_style.dart'; +import 'package:busymax/src/app/linux/linux_window_host.dart'; +import 'package:busymax/src/db/app_database.dart'; +import 'package:busymax/src/features/accounts/data/accounts_repository.dart'; +import 'package:busymax/src/features/schedule/presentation/schedule_workspace.dart'; +import 'package:busymax/src/features/schedule/presentation/schedule_toolbar.dart'; +import 'package:busymax/src/platform/gtk_window_preferences_service.dart'; +import 'package:busymax/src/platform/gtk_header_icon_service.dart'; +import 'package:busymax/src/platform/native_menu_service.dart'; +import 'package:busymax/src/schedule/schedule_filters.dart'; +import 'package:busymax/src/schedule/schedule_item.dart'; +import 'package:busymax/src/schedule/schedule_range.dart'; +import 'package:busymax/src/schedule/schedule_repository.dart'; +import 'package:busymax/src/schedule/schedule_view_mode.dart'; +import 'package:flutter/gestures.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'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + const yaruWindowChannel = MethodChannel('yaru_window'); + const yaruEventsChannel = EventChannel('yaru_window/events'); + const nativeMenuChannel = MethodChannel(nativeMenuChannelName); + late List windowCalls; + + setUp(() { + windowCalls = []; + final messenger = + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger; + messenger.setMockMethodCallHandler(yaruWindowChannel, (call) async { + windowCalls.add(call); + return call.method == 'state' ? {} : null; + }); + messenger.setMockStreamHandler( + yaruEventsChannel, + MockStreamHandler.inline(onListen: (_, _) {}), + ); + messenger.setMockMethodCallHandler(nativeMenuChannel, (_) async => null); + }); + + tearDown(() { + final messenger = + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger; + messenger.setMockMethodCallHandler(yaruWindowChannel, null); + messenger.setMockStreamHandler(yaruEventsChannel, null); + messenger.setMockMethodCallHandler(nativeMenuChannel, null); + }); + + testWidgets( + 'Search changes only the center and schedule control visibility', + (tester) async { + final semantics = tester.ensureSemantics(); + await _pumpWorkspace(tester, width: 1100); + + for (final key in const [ + 'schedule-sidebar-button', + 'schedule-today-button', + 'schedule-previous-button', + 'schedule-next-button', + 'schedule-view-button', + 'schedule-create-button', + 'schedule-refresh-button', + 'schedule-search-button', + 'busymax-main-menu-button', + ]) { + expect(find.byKey(ValueKey(key)).hitTestable(), findsOneWidget); + } + + await _openSearch(tester); + + expect(find.byType(ScheduleToolbar), findsOneWidget); + expect(find.byType(BusyMaxLinuxHeaderLayout), findsOneWidget); + expect( + find.byType(BusyMaxLinuxHeaderSearchField).hitTestable(), + findsOneWidget, + ); + for (final key in const [ + 'schedule-sidebar-button', + 'schedule-search-button', + 'busymax-main-menu-button', + ]) { + expect(find.byKey(ValueKey(key)).hitTestable(), findsOneWidget); + } + for (final key in const [ + 'schedule-today-button', + 'schedule-previous-button', + 'schedule-next-button', + 'schedule-view-button', + 'schedule-create-button', + 'schedule-refresh-button', + 'schedule-search-filter-button', + 'schedule-search-close-button', + 'schedule-search-titlebar-drag-area', + ]) { + expect(find.byKey(ValueKey(key)).hitTestable(), findsNothing); + } + final searchButton = tester.widget( + find.byKey(const ValueKey('schedule-search-button')), + ); + expect(searchButton.selected, isTrue); + expect(searchButton.icon, BusyMaxLinuxHeaderIcon.search); + expect( + tester + .widgetList( + find.descendant( + of: find.byType(BusyMaxLinuxHeaderSearchField), + matching: find.byType(BusyMaxGtkHeaderIcon), + ), + ) + .map((icon) => icon.icon), + [BusyMaxLinuxHeaderIcon.searchEntryFind], + ); + final headerRect = tester.getRect(find.byType(ScheduleToolbar)); + final fieldRect = tester.getRect( + find.byType(BusyMaxLinuxHeaderSearchField), + ); + expect(headerRect.height, BusyMaxSizes.toolbarHeight); + expect(fieldRect.height, BusyMaxLinuxHeaderStyle.searchEntryHeight); + expect(fieldRect.center.dy, closeTo(headerRect.center.dy, .5)); + _expectSearchFillsPhysicalInterval(tester, TextDirection.ltr); + expect( + tester + .widget( + find.byType(BusyMaxLinuxHeaderLayout), + ) + .centerAllocation, + BusyMaxLinuxHeaderCenterAllocation.fillBetweenControls, + ); + + final textField = tester.widget(_searchTextField()); + expect(textField.decoration?.hintText, isNull); + expect(find.text('Search'), findsNothing); + expect(find.bySemanticsLabel('Search'), findsOneWidget); + semantics.dispose(); + + await tester.tap(find.byTooltip('Search (Ctrl+F)')); + await tester.pumpAndSettle(); + expect( + find.byType(BusyMaxLinuxHeaderSearchField).hitTestable(), + findsNothing, + ); + + await _openSearch(tester); + await tester.sendKeyEvent(LogicalKeyboardKey.escape); + await tester.pumpAndSettle(); + expect( + find.byType(BusyMaxLinuxHeaderSearchField).hitTestable(), + findsNothing, + ); + }, + ); + + for (final width in const [1290.0, 1100.0, 650.0]) { + for (final direction in TextDirection.values) { + testWidgets('Search fills the physical interval at $width in ' + '${direction.name}', (tester) async { + await _pumpWorkspace(tester, width: width, direction: direction); + if (width == 1290) { + await tester.sendKeyEvent(LogicalKeyboardKey.f9); + await tester.pumpAndSettle(); + } + await _openSearch(tester); + + final headerRect = tester.getRect(find.byType(ScheduleToolbar)); + final fieldRect = tester.getRect( + find.byType(BusyMaxLinuxHeaderSearchField), + ); + if (width == 1290) { + expect(headerRect.width, width); + } + expect(headerRect.height, BusyMaxSizes.toolbarHeight); + expect(fieldRect.height, BusyMaxLinuxHeaderStyle.searchEntryHeight); + expect(fieldRect.center.dy, closeTo(headerRect.center.dy, .5)); + expect(fieldRect.width, lessThan(headerRect.width)); + _expectSearchFillsPhysicalInterval(tester, direction); + }); + } + } + + for (final testCase in const [ + _SystemControlCase('left', _leftSystemControls, true), + _SystemControlCase('right', _rightSystemControls, false), + ]) { + testWidgets('Search respects physical ${testCase.label} system controls', ( + tester, + ) async { + await _pumpWorkspace( + tester, + width: 1100, + preferences: testCase.preferences, + ); + await tester.sendKeyEvent(LogicalKeyboardKey.f9); + await tester.pumpAndSettle(); + await _openSearch(tester); + + _expectSearchFillsPhysicalInterval(tester, TextDirection.ltr); + final header = tester.getRect(find.byType(ScheduleToolbar)); + final leading = tester.getRect( + find.byKey(const ValueKey('schedule-header-leading-actions')), + ); + final trailing = tester.getRect( + find.byKey(const ValueKey('schedule-header-trailing-actions')), + ); + final obstruction = BusyMaxLinuxWindowMetrics.clusterWidth( + testCase.preferences.decorationLayout.left.isNotEmpty + ? testCase.preferences.decorationLayout.left + : testCase.preferences.decorationLayout.right, + ); + if (testCase.onLeft) { + expect( + leading.left, + greaterThanOrEqualTo( + header.left + obstruction + BusyMaxSpacing.headerInset, + ), + ); + } else { + expect( + trailing.right, + lessThanOrEqualTo( + header.right - obstruction - BusyMaxSpacing.headerInset, + ), + ); + } + }); + } + + testWidgets('title and Search retain centered opacity-only geometry', ( + tester, + ) async { + final harnessKey = GlobalKey<_ToolbarHarnessState>(); + await _pumpToolbarHarness(tester, harnessKey: harnessKey, width: 1100); + + final normalSlot = tester.getRect(_scheduleCenterPresentation()); + final normalTitle = _scheduleCenterTitle(); + expect( + tester.getRect(normalTitle).center.dx, + closeTo(normalSlot.center.dx, .01), + ); + + harnessKey.currentState!.setSearchActive(true); + await tester.pump(); + final searchSlot = tester.getRect(_scheduleCenterPresentation()); + _expectTransitionGeometry(tester, searchSlot); + + await tester.pump(const Duration(milliseconds: 80)); + _expectTransitionGeometry(tester, searchSlot); + + await tester.pump(const Duration(milliseconds: 80)); + _expectTransitionGeometry(tester, searchSlot); + }); + + testWidgets('title and Search geometry remains stable through reversal', ( + tester, + ) async { + final harnessKey = GlobalKey<_ToolbarHarnessState>(); + await _pumpToolbarHarness(tester, harnessKey: harnessKey, width: 1100); + final normalSlot = tester.getRect(_scheduleCenterPresentation()); + + harnessKey.currentState!.setSearchActive(true); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 80)); + final searchSlot = tester.getRect(_scheduleCenterPresentation()); + _expectTransitionGeometry(tester, searchSlot); + + harnessKey.currentState!.setSearchActive(false); + await tester.pump(); + _expectRectCloseTo( + tester.getRect(_scheduleCenterPresentation()), + normalSlot, + ); + _expectTransitionGeometry( + tester, + normalSlot, + expectSearchHitTestable: false, + ); + + await tester.pump(const Duration(milliseconds: 80)); + _expectRectCloseTo( + tester.getRect(_scheduleCenterPresentation()), + normalSlot, + ); + expect( + tester.getRect(_scheduleCenterTitle()).center.dx, + closeTo(normalSlot.center.dx, .01), + ); + }); + + testWidgets('reduced motion uses full interval without a stale frame', ( + tester, + ) async { + tester.binding.platformDispatcher.accessibilityFeaturesTestValue = + const FakeAccessibilityFeatures(disableAnimations: true); + addTearDown( + tester.binding.platformDispatcher.clearAccessibilityFeaturesTestValue, + ); + final harnessKey = GlobalKey<_ToolbarHarnessState>(); + await _pumpToolbarHarness(tester, harnessKey: harnessKey, width: 1100); + + harnessKey.currentState!.setSearchActive(true); + await tester.pump(); + _expectSearchFillsPhysicalInterval(tester, TextDirection.ltr); + expect(_scheduleCenterTitle().hitTestable(), findsNothing); + + harnessKey.currentState!.setSearchActive(false); + await tester.pump(); + expect( + find.byType(BusyMaxLinuxHeaderSearchField).hitTestable(), + findsNothing, + ); + final slot = tester.getRect(_scheduleCenterPresentation()); + expect( + tester.getRect(_scheduleCenterTitle()).center.dx, + closeTo(slot.center.dx, .01), + ); + }); + + testWidgets('Schedule publishes only the settled non-empty Search query', ( + tester, + ) async { + final repository = _RecordingScheduleRepository(); + await _pumpWorkspace(tester, width: 1100, repository: repository); + await _openSearch(tester); + repository.queries.clear(); + final field = _searchTextField(); + + await tester.enterText(field, 'p'); + await tester.pump(const Duration(milliseconds: 25)); + await tester.enterText(field, 'pl'); + await tester.pump(const Duration(milliseconds: 25)); + await tester.enterText(field, 'pla'); + await tester.pump(const Duration(milliseconds: 25)); + await tester.enterText(field, 'plan'); + expect(repository.queries, isEmpty); + await tester.pump(const Duration(milliseconds: 149)); + expect(repository.queries, isEmpty); + await tester.pump(const Duration(milliseconds: 1)); + expect(repository.queries, ['plan']); + }); + + testWidgets('Schedule applies Clear immediately and drops pending text', ( + tester, + ) async { + final repository = _RecordingScheduleRepository(); + await _pumpWorkspace(tester, width: 1100, repository: repository); + await _openSearch(tester); + repository.queries.clear(); + + await tester.enterText(_searchTextField(), 'planning'); + await tester.pump(const Duration(milliseconds: 50)); + await tester.tap(find.byKey(BusyMaxLinuxHeaderSearchField.clearKey)); + await tester.pump(); + expect(tester.widget(_searchTextField()).controller!.text, ''); + expect(repository.queries, isNotEmpty); + expect(repository.queries.last, ''); + expect(repository.queries, isNot(contains('planning'))); + await tester.pump(const Duration(milliseconds: 151)); + expect(repository.queries, isNot(contains('planning'))); + }); + + testWidgets('Search inputs and controls never initiate a titlebar drag', ( + tester, + ) async { + await _pumpWorkspace(tester, width: 1100); + await tester.sendKeyEvent(LogicalKeyboardKey.f9); + await tester.pumpAndSettle(); + await _openSearch(tester); + + final field = _searchTextField(); + await tester.enterText(field, 'selection survives'); + final fieldDrag = await tester.startGesture( + tester.getCenter(field), + kind: PointerDeviceKind.mouse, + ); + await fieldDrag.moveBy(const Offset(24, 0)); + await tester.pump(); + await fieldDrag.up(); + expect(_dragCalls(windowCalls), 0); + + await tester.tap(field); + await tester.pump(const Duration(milliseconds: 40)); + await tester.tap(field); + await tester.pump(); + expect(_dragCalls(windowCalls), 0); + + await _dragControl(tester, 'schedule-search-filter-button'); + await _dragControl(tester, 'schedule-search-button'); + await _dragControl(tester, 'busymax-main-menu-button'); + expect(_dragCalls(windowCalls), 0); + + await tester.tap(find.byTooltip('Filters')); + await tester.pumpAndSettle(); + expect(_dragCalls(windowCalls), 0); + Navigator.of(tester.element(find.byType(BusyMaxDialogShell))).pop(); + await tester.pumpAndSettle(); + + final clearIcon = find.byWidgetPredicate( + (widget) => + widget is BusyMaxGtkHeaderIcon && + widget.icon == BusyMaxLinuxHeaderIcon.searchClear, + ); + expect(tester.getSize(clearIcon), const Size.square(16)); + expect( + find.ancestor( + of: clearIcon, + matching: find.byType(BusyMaxHeaderIconButton), + ), + findsNothing, + ); + expect( + find.ancestor(of: clearIcon, matching: find.byType(IconButton)), + findsNothing, + ); + await _dragControl( + tester, + BusyMaxLinuxHeaderSearchField.secondaryIconKey.value, + ); + expect(_dragCalls(windowCalls), 0); + await tester.tap(find.byKey(BusyMaxLinuxHeaderSearchField.clearKey)); + await tester.pump(); + expect(_dragCalls(windowCalls), 0); + expect(tester.widget(field).controller!.text, isEmpty); + final searchIcon = find.descendant( + of: find.byType(BusyMaxLinuxHeaderSearchField), + matching: find.byWidgetPredicate( + (widget) => + widget is BusyMaxGtkHeaderIcon && + widget.icon == BusyMaxLinuxHeaderIcon.searchEntryFind, + ), + ); + expect(searchIcon, findsOneWidget); + expect(clearIcon, findsNothing); + await _dragControl( + tester, + BusyMaxLinuxHeaderSearchField.primaryIconKey.value, + ); + expect(_dragCalls(windowCalls), 0); + + await tester.tap(find.byTooltip('Main Menu')); + await tester.pump(); + expect(_dragCalls(windowCalls), 0); + + await tester.tap(find.byTooltip('Search (Ctrl+F)')); + await tester.pumpAndSettle(); + expect(_dragCalls(windowCalls), 0); + expect( + find.byType(BusyMaxLinuxHeaderSearchField).hitTestable(), + findsNothing, + ); + }); + + testWidgets('search text selection and focus survive an unrelated rebuild', ( + tester, + ) async { + final harnessKey = GlobalKey<_WorkspaceHarnessState>(); + await _pumpWorkspace(tester, width: 1100, harnessKey: harnessKey); + await _openSearch(tester); + + final field = _searchTextField(); + await tester.enterText(field, 'planning review'); + final editable = tester.widget( + find.descendant(of: field, matching: find.byType(EditableText)), + ); + final controller = editable.controller; + controller.selection = const TextSelection(baseOffset: 0, extentOffset: 8); + editable.focusNode.requestFocus(); + await tester.pump(); + expect(editable.focusNode.hasFocus, isTrue); + + harnessKey.currentState!.rebuildParent(); + await tester.pump(); + + final rebuilt = tester.widget( + find.descendant( + of: _searchTextField(), + matching: find.byType(EditableText), + ), + ); + expect(rebuilt.controller, same(controller)); + expect(rebuilt.controller.text, 'planning review'); + expect( + rebuilt.controller.selection, + const TextSelection(baseOffset: 0, extentOffset: 8), + ); + expect(rebuilt.focusNode, same(editable.focusNode)); + expect(rebuilt.focusNode.hasFocus, isTrue); + expect(_dragCalls(windowCalls), 0); + + rebuilt.controller.selection = const TextSelection( + baseOffset: 3, + extentOffset: 6, + ); + await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft); + await tester.sendKeyEvent(LogicalKeyboardKey.keyF); + await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft); + await tester.pump(); + expect( + rebuilt.controller.selection, + TextSelection( + baseOffset: 0, + extentOffset: rebuilt.controller.text.length, + ), + ); + expect( + find.byType(BusyMaxLinuxHeaderSearchField).hitTestable(), + findsOneWidget, + ); + }); +} + +Future _dragControl(WidgetTester tester, String key) async { + final gesture = await tester.startGesture( + tester.getCenter(find.byKey(ValueKey(key))), + kind: PointerDeviceKind.mouse, + ); + await gesture.moveBy(const Offset(30, 0)); + await tester.pump(); + await gesture.up(); +} + +Finder _searchTextField() => find.descendant( + of: find.byType(BusyMaxLinuxHeaderSearchField), + matching: find.byType(TextField), +); + +Finder _scheduleCenterPresentation() => find.descendant( + of: find.byType(ScheduleToolbar), + matching: find.byType(BusyMaxBinaryPresentation), +); + +Finder _scheduleCenterTitle() => find.descendant( + of: _scheduleCenterPresentation(), + matching: find.byType(Text), +); + +void _expectSearchFillsPhysicalInterval( + WidgetTester tester, + TextDirection direction, { + double tolerance = .01, +}) { + final field = tester.getRect(find.byType(BusyMaxLinuxHeaderSearchField)); + final leading = tester.getRect( + find.byKey(const ValueKey('schedule-header-leading-actions')), + ); + final trailing = tester.getRect( + find.byKey(const ValueKey('schedule-header-trailing-actions')), + ); + final physicalLeftEdge = direction == TextDirection.ltr + ? leading.right + : trailing.right; + final physicalRightEdge = direction == TextDirection.ltr + ? trailing.left + : leading.left; + + expect( + field.left, + closeTo(physicalLeftEdge + BusyMaxSpacing.headerInset, tolerance), + ); + expect( + field.right, + closeTo(physicalRightEdge - BusyMaxSpacing.headerInset, tolerance), + ); + expect( + field.width, + closeTo( + physicalRightEdge - physicalLeftEdge - 2 * BusyMaxSpacing.headerInset, + tolerance, + ), + ); + expect(field.overlaps(leading), isFalse); + expect(field.overlaps(trailing), isFalse); +} + +void _expectTransitionGeometry( + WidgetTester tester, + Rect expectedSlot, { + bool expectSearchHitTestable = true, +}) { + _expectRectCloseTo( + tester.getRect(_scheduleCenterPresentation()), + expectedSlot, + ); + final field = find.byType(BusyMaxLinuxHeaderSearchField); + final fieldRect = tester.getRect(field); + expect(fieldRect.left, closeTo(expectedSlot.left, .01)); + expect(fieldRect.right, closeTo(expectedSlot.right, .01)); + expect( + field.hitTestable(), + expectSearchHitTestable ? findsOneWidget : findsNothing, + ); + final title = _scheduleCenterTitle(); + if (title.evaluate().isNotEmpty) { + expect( + tester.getRect(title).center.dx, + closeTo(expectedSlot.center.dx, .01), + ); + expect(tester.getRect(title).left, greaterThan(expectedSlot.left)); + } +} + +void _expectRectCloseTo(Rect actual, Rect expected) { + expect(actual.left, closeTo(expected.left, .01)); + expect(actual.top, closeTo(expected.top, .01)); + expect(actual.right, closeTo(expected.right, .01)); + expect(actual.bottom, closeTo(expected.bottom, .01)); +} + +int _dragCalls(List calls) => + calls.where((call) => call.method == 'drag').length; + +Future _openSearch(WidgetTester tester) async { + await _toggleSearchShortcut(tester); + await tester.pumpAndSettle(); + expect( + find.byType(BusyMaxLinuxHeaderSearchField).hitTestable(), + findsOneWidget, + ); +} + +Future _toggleSearchShortcut(WidgetTester tester) async { + await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft); + await tester.sendKeyEvent(LogicalKeyboardKey.keyF); + await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft); +} + +Future _pumpWorkspace( + WidgetTester tester, { + required double width, + GlobalKey<_WorkspaceHarnessState>? harnessKey, + TextDirection direction = TextDirection.ltr, + ScheduleRepository? repository, + GtkWindowPreferences preferences = _testWindowPreferences, +}) async { + tester.view + ..devicePixelRatio = 1 + ..physicalSize = Size(width, 720); + addTearDown(tester.view.reset); + final database = AppDatabase.memoryForTests(); + addTearDown(database.close); + + await tester.pumpWidget( + ProviderScope( + overrides: [ + databaseProvider.overrideWithValue(database), + accountsStreamProvider.overrideWith( + (ref) => Stream.value(const []), + ), + localTimeZoneProvider.overrideWithValue('UTC'), + localSettingsStoreProvider.overrideWithValue(_MemorySettingsStore()), + if (repository != null) + scheduleRepositoryProvider.overrideWithValue(repository), + gtkWindowPreferencesProvider.overrideWith( + (ref) => Stream.value(preferences), + ), + ], + child: localizedTestApp( + child: Directionality( + textDirection: direction, + child: _WorkspaceHarness(key: harnessKey), + ), + ), + ), + ); + await tester.pumpAndSettle(); +} + +Future _pumpToolbarHarness( + WidgetTester tester, { + required GlobalKey<_ToolbarHarnessState> harnessKey, + required double width, +}) async { + tester.view + ..devicePixelRatio = 1 + ..physicalSize = Size(width, 240); + addTearDown(tester.view.reset); + await tester.pumpWidget( + localizedTestApp( + child: Scaffold( + body: Align( + alignment: Alignment.topCenter, + child: SizedBox( + width: width, + child: _ToolbarHarness(key: harnessKey), + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); +} + +const _testWindowPreferences = GtkWindowPreferences( + decorationLayout: GtkDecorationLayout(left: [], right: []), + doubleClick: GtkTitlebarAction.toggleMaximize, + middleClick: GtkTitlebarAction.none, + rightClick: GtkTitlebarAction.menu, +); + +const _leftSystemControls = GtkWindowPreferences( + decorationLayout: GtkDecorationLayout( + left: [ + GtkWindowDecorationElement.minimize, + GtkWindowDecorationElement.maximize, + GtkWindowDecorationElement.close, + ], + right: [], + ), + doubleClick: GtkTitlebarAction.toggleMaximize, + middleClick: GtkTitlebarAction.none, + rightClick: GtkTitlebarAction.menu, +); + +const _rightSystemControls = GtkWindowPreferences( + decorationLayout: GtkDecorationLayout( + left: [], + right: [ + GtkWindowDecorationElement.minimize, + GtkWindowDecorationElement.maximize, + GtkWindowDecorationElement.close, + ], + ), + doubleClick: GtkTitlebarAction.toggleMaximize, + middleClick: GtkTitlebarAction.none, + rightClick: GtkTitlebarAction.menu, +); + +class _SystemControlCase { + const _SystemControlCase(this.label, this.preferences, this.onLeft); + + final String label; + final GtkWindowPreferences preferences; + final bool onLeft; +} + +class _WorkspaceHarness extends StatefulWidget { + const _WorkspaceHarness({super.key}); + + @override + State<_WorkspaceHarness> createState() => _WorkspaceHarnessState(); +} + +class _WorkspaceHarnessState extends State<_WorkspaceHarness> { + var rebuilds = 0; + + void rebuildParent() => setState(() => rebuilds += 1); + + @override + Widget build(BuildContext context) { + return Theme( + data: Theme.of(context).copyWith( + splashColor: rebuilds.isEven + ? Colors.transparent + : Colors.black.withValues(alpha: .01), + ), + child: LinuxWindowHost(child: const ScheduleWorkspace()), + ); + } +} + +class _ToolbarHarness extends StatefulWidget { + const _ToolbarHarness({super.key}); + + @override + State<_ToolbarHarness> createState() => _ToolbarHarnessState(); +} + +class _ToolbarHarnessState extends State<_ToolbarHarness> { + final searchController = TextEditingController(); + var searchActive = false; + + void setSearchActive(bool value) => setState(() => searchActive = value); + + @override + void dispose() { + searchController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return ScheduleToolbar( + mode: ScheduleViewMode.week, + range: ScheduleRange.week( + DateTime(2026, 7, 22), + firstWeekday: DateTime.monday, + ), + selectedDate: DateTime(2026, 7, 22), + onToday: () {}, + onPrevious: () {}, + onNext: () {}, + onModeChanged: (_) {}, + canCreateEvent: true, + canCreateTask: true, + onCreateEvent: () {}, + onCreateTask: () {}, + onRefresh: () {}, + canShowSidebar: true, + sidebarVisible: true, + onToggleSidebar: () {}, + onSearch: () => setSearchActive(!searchActive), + searchActive: searchActive, + searchController: searchController, + onSearchChanged: (_) {}, + onClearSearch: searchController.clear, + onMenuSelected: (_) {}, + ); + } +} + +class _MemorySettingsStore implements LocalSettingsStore { + @override + Future> load() async => {}; + + @override + Future save(Map json) async {} +} + +class _RecordingScheduleRepository implements ScheduleRepository { + final queries = []; + + @override + Future> listItems({ + required ScheduleRange range, + ScheduleFilters filters = const ScheduleFilters(), + }) async { + queries.add(filters.query); + return const []; + } + + @override + Stream watchChanges() => const Stream.empty(); + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} diff --git a/test/features/schedule/presentation/schedule_sidebar_collection_actions_test.dart b/test/features/schedule/presentation/schedule_sidebar_collection_actions_test.dart index 73ed8a60..1d735d7e 100644 --- a/test/features/schedule/presentation/schedule_sidebar_collection_actions_test.dart +++ b/test/features/schedule/presentation/schedule_sidebar_collection_actions_test.dart @@ -6,10 +6,14 @@ import 'package:busymax/src/schedule/schedule_sidebar_sources.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/auth/data/auth_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/sync/account_sync_operations.dart'; +import 'package:busymax/src/core/auth/oauth_models.dart'; +import 'package:busymax/src/core/http/request_dispatch_exception.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'; @@ -83,6 +87,134 @@ void main() { }, ); + testWidgets('eligible calendar refresh dispatches synchronization', ( + tester, + ) async { + final account = _account(BusyProvider.google, id: 'account'); + final accounts = _MutableAccountsRepository(account); + final sync = _RecordingAccountSyncOperations(); + addTearDown(accounts.dispose); + + await _pumpSidebar( + tester, + [account], + accountSnapshots: accounts.watchAccounts(), + accountsRepository: accounts, + authRepository: _ReconnectRecordingAuthRepository(accounts), + syncOperations: sync, + calendarSources: [_calendar(account.id, 'calendar')], + ); + + final menu = tester.widget>( + find.byKey(const ValueKey(('calendar-options', 'calendar'))), + ); + expect( + menu.entries.singleWhere((entry) => entry.value == 'refresh').enabled, + isTrue, + ); + menu.onSelected('refresh'); + await tester.pumpAndSettle(); + + expect(sync.calendarCalls, 1); + }); + + testWidgets( + 'calendar refresh invalid_grant requires reconnect and blocks retry', + (tester) async { + final account = _account(BusyProvider.google, id: 'account'); + final accounts = _MutableAccountsRepository(account); + final auth = _ReconnectRecordingAuthRepository(accounts); + final sync = _RecordingAccountSyncOperations( + calendarError: _wrappedInvalidGrant, + ); + addTearDown(accounts.dispose); + + await _pumpSidebar( + tester, + [account], + accountSnapshots: accounts.watchAccounts(), + accountsRepository: accounts, + authRepository: auth, + syncOperations: sync, + calendarSources: [_calendar(account.id, 'calendar')], + ); + + var menu = tester.widget>( + find.byKey(const ValueKey(('calendar-options', 'calendar'))), + ); + menu.onSelected('refresh'); + await tester.pumpAndSettle(); + + expect(sync.calendarCalls, 1); + expect(auth.markedAccountIds, ['account']); + expect(accounts.account.needsReconnect, isTrue); + expect( + find.text('Refresh failed: This account needs to be reconnected.'), + findsOneWidget, + ); + menu = tester.widget>( + find.byKey(const ValueKey(('calendar-options', 'calendar'))), + ); + final refresh = menu.entries.singleWhere( + (entry) => entry.value == 'refresh', + ); + expect(refresh.enabled, isFalse); + expect(refresh.tooltip, 'This account needs to be reconnected.'); + + menu.onSelected('refresh'); + await tester.pumpAndSettle(); + expect(sync.calendarCalls, 1); + }, + ); + + testWidgets( + 'task-list refresh invalid_grant requires reconnect and blocks retry', + (tester) async { + final account = _account(BusyProvider.google, id: 'account'); + final accounts = _MutableAccountsRepository(account); + final auth = _ReconnectRecordingAuthRepository(accounts); + final sync = _RecordingAccountSyncOperations( + taskError: _wrappedInvalidGrant, + ); + addTearDown(accounts.dispose); + + await _pumpSidebar( + tester, + [account], + accountSnapshots: accounts.watchAccounts(), + accountsRepository: accounts, + authRepository: auth, + syncOperations: sync, + taskLists: [_list(account.id, 'tasks')], + ); + + var menu = tester.widget>( + find.byKey(const ValueKey(('task-list-options', 'account', 'tasks'))), + ); + menu.onSelected('refresh'); + await tester.pumpAndSettle(); + + expect(sync.taskCalls, 1); + expect(auth.markedAccountIds, ['account']); + expect(accounts.account.needsReconnect, isTrue); + expect( + find.text('Refresh failed: This account needs to be reconnected.'), + findsOneWidget, + ); + menu = tester.widget>( + find.byKey(const ValueKey(('task-list-options', 'account', 'tasks'))), + ); + expect( + menu.entries.singleWhere((entry) => entry.value == 'refresh').enabled, + isFalse, + ); + + menu.onSelected('refresh'); + await tester.pumpAndSettle(); + expect(sync.taskCalls, 1); + }, + ); + testWidgets( 'sidebar moves adjacent siblings locally and restores order after restart', (tester) async { @@ -169,7 +301,9 @@ void main() { ('schedule-account', 'a'), ]); expect( - find.byKey(const ValueKey(('schedule-calendar', 'a', 'c2'))), + find + .byKey(const ValueKey(('schedule-calendar', 'a', 'c2'))) + .hitTestable(), findsNothing, ); expect( @@ -553,13 +687,13 @@ void main() { find.byKey(const ValueKey(('account-collapse', accountId))), ); await tester.pumpAndSettle(); - expect(find.text('No calendars synced yet.'), findsNothing); + expect(find.text('No calendars synced yet.').hitTestable(), 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); + expect(find.text('No calendars synced yet.').hitTestable(), findsNothing); }); testWidgets('creation actions stay in the header for empty accounts', ( @@ -819,6 +953,10 @@ Future _pumpSidebar( MemorySettingsStore? settingsStore, Stream>? calendarSnapshots, Stream>? taskSnapshots, + Stream>? accountSnapshots, + AccountsRepository? accountsRepository, + AuthRepository? authRepository, + AccountSyncOperations? syncOperations, }) async { tester.view.devicePixelRatio = 1; tester.view.physicalSize = const Size(900, 1200); @@ -830,7 +968,15 @@ Future _pumpSidebar( localSettingsStoreProvider.overrideWithValue( settingsStore ?? MemorySettingsStore(), ), - accountsStreamProvider.overrideWith((ref) => Stream.value(accounts)), + accountsStreamProvider.overrideWith( + (ref) => accountSnapshots ?? Stream.value(accounts), + ), + if (accountsRepository != null) + accountsRepositoryProvider.overrideWithValue(accountsRepository), + if (authRepository != null) + authRepositoryProvider.overrideWithValue(authRepository), + if (syncOperations != null) + accountSyncOperationsProvider.overrideWithValue(syncOperations), if (networkAvailability != null) networkAvailabilityProvider.overrideWith( (ref) => Stream.value(networkAvailability), @@ -866,19 +1012,21 @@ Future _pumpSidebar( child: localizedTestApp( locale: locale, theme: theme, - child: Align( - alignment: Alignment.topLeft, - child: SizedBox( - width: width, - height: height, - child: ScheduleSidebar( - selectedDate: DateTime(2026, 8, 29), - firstWeekday: DateTime.monday, - items: const [], - onDateSelected: (_) {}, - onMonthSelected: (_) {}, - onYearSelected: (_) {}, - onWeekSelected: (_) {}, + child: Scaffold( + body: Align( + alignment: Alignment.topLeft, + child: SizedBox( + width: width, + height: height, + child: ScheduleSidebar( + selectedDate: DateTime(2026, 8, 29), + firstWeekday: DateTime.monday, + items: const [], + onDateSelected: (_) {}, + onMonthSelected: (_) {}, + onYearSelected: (_) {}, + onWeekSelected: (_) {}, + ), ), ), ), @@ -984,6 +1132,7 @@ AccountEntity _account( String? displayName, String? email, String authority = 'https://example.test', + String authState = accountAuthStateSignedIn, }) => AccountEntity( id: id ?? '${provider.storageValue}-account', provider: provider, @@ -995,7 +1144,93 @@ AccountEntity _account( ? 'WebCal account' : provider.displayName), email: email, - authState: accountAuthStateSignedIn, + authState: authState, calendarsEnabled: calendarsEnabled, tasksEnabled: tasksEnabled, ); + +const _wrappedInvalidGrant = KnownUnsentRequestException( + kind: RequestPreDispatchFailureKind.authentication, + cause: OAuthRefreshException( + 'OAuthRefreshFailed', + 'Provider refresh failed.', + statusCode: 400, + oauthError: 'invalid_grant', + ), +); + +final class _RecordingAccountSyncOperations implements AccountSyncOperations { + _RecordingAccountSyncOperations({this.calendarError, this.taskError}); + + final Object? calendarError; + final Object? taskError; + int calendarCalls = 0; + int taskCalls = 0; + + @override + Future syncAccount(String accountId, {required bool full}) async {} + + @override + Future syncCalendar(String accountId, {required bool full}) async { + calendarCalls += 1; + if (calendarError case final error?) throw error; + } + + @override + Future syncTasks(String accountId, {required bool full}) async { + taskCalls += 1; + if (taskError case final error?) throw error; + } +} + +final class _MutableAccountsRepository implements AccountsRepository { + _MutableAccountsRepository(this.account); + + AccountEntity account; + final _changes = StreamController>.broadcast(); + + @override + Stream> watchAccounts() async* { + yield [account]; + yield* _changes.stream; + } + + void requireReconnect() { + account = _account( + account.provider, + id: account.id, + displayName: account.displayName, + email: account.email, + authority: account.authority, + calendarsEnabled: account.calendarsEnabled, + tasksEnabled: account.tasksEnabled, + authState: accountAuthStateReauthRequired, + ); + _changes.add([account]); + } + + Future dispose() => _changes.close(); + + @override + Future accountById(String accountId) async => + account.id == accountId ? account : null; + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +final class _ReconnectRecordingAuthRepository implements AuthRepository { + _ReconnectRecordingAuthRepository(this.accounts); + + final _MutableAccountsRepository accounts; + final markedAccountIds = []; + + @override + Future markReconnectRequired(String accountId) async { + markedAccountIds.add(accountId); + accounts.requireReconnect(); + } + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} diff --git a/test/features/schedule/presentation/schedule_toolbar_test.dart b/test/features/schedule/presentation/schedule_toolbar_test.dart index 2adf6f94..99a8441a 100644 --- a/test/features/schedule/presentation/schedule_toolbar_test.dart +++ b/test/features/schedule/presentation/schedule_toolbar_test.dart @@ -1,7 +1,10 @@ import 'dart:async'; +import 'dart:convert'; import 'package:busymax/src/app/busymax_design.dart'; +import 'package:busymax/src/app/linux/linux_header_style.dart'; import 'package:busymax/src/features/schedule/presentation/schedule_toolbar.dart'; +import 'package:busymax/src/platform/gtk_header_icon_service.dart'; import 'package:busymax/src/platform/native_menu_service.dart'; import 'package:busymax/src/schedule/schedule_range.dart'; import 'package:busymax/src/schedule/schedule_view_mode.dart'; @@ -14,6 +17,9 @@ import '../../../test_localized_app.dart'; const _nativeMenuChannel = MethodChannel(nativeMenuChannelName); +String _viewLabel(int index) => + const ['Day', 'Week', 'Month', 'Year', 'Agenda'][index]; + void main() { TestWidgetsFlutterBinding.ensureInitialized(); @@ -33,16 +39,25 @@ void main() { testWidgets('fallback toolbar uses the semantic header title style', ( tester, ) async { - const inheritedTitleStyle = TextStyle( + const inheritedBodyStyle = TextStyle( color: Color(0xFF123456), - fontSize: 17, + fontFamily: 'Body family', + fontSize: 13, fontWeight: FontWeight.normal, ); + const unrelatedTitleStyle = TextStyle( + color: Color(0xFFABCDEF), + fontFamily: 'Title family', + fontSize: 27, + ); await tester.pumpWidget( localizedTestApp( theme: ThemeData( - textTheme: const TextTheme(titleMedium: inheritedTitleStyle), + textTheme: const TextTheme( + bodyMedium: inheritedBodyStyle, + titleMedium: unrelatedTitleStyle, + ), ), child: Scaffold( body: SizedBox( @@ -74,8 +89,10 @@ void main() { ); expect(titleFinder, findsOneWidget); final title = tester.widget(titleFinder); - expect(title.style?.color, inheritedTitleStyle.color); - expect(title.style?.fontSize, inheritedTitleStyle.fontSize); + expect(title.style?.color, inheritedBodyStyle.color); + expect(title.style?.fontFamily, inheritedBodyStyle.fontFamily); + expect(title.style?.fontSize, inheritedBodyStyle.fontSize); + expect(title.style?.fontSize, isNot(unrelatedTitleStyle.fontSize)); expect(title.style?.fontWeight, FontWeight.bold); }); @@ -147,7 +164,14 @@ void main() { ); expect(find.text('Today'), findsNothing); - expect(find.byIcon(Icons.today_outlined), findsOneWidget); + expect( + find.byWidgetPredicate( + (widget) => + widget is BusyMaxGtkHeaderIcon && + widget.icon == BusyMaxLinuxHeaderIcon.today, + ), + findsOneWidget, + ); expect(find.byTooltip('Today (Shift+T)'), findsOneWidget); expect(find.bySemanticsLabel('Today'), findsOneWidget); @@ -156,6 +180,344 @@ void main() { semantics.dispose(); }); + testWidgets( + 'View trigger and all native rows share one resolved icon presentation', + (tester) async { + const semanticIcons = { + ScheduleViewMode.day: BusyMaxLinuxHeaderIcon.viewDay, + ScheduleViewMode.week: BusyMaxLinuxHeaderIcon.viewWeek, + ScheduleViewMode.month: BusyMaxLinuxHeaderIcon.viewMonth, + ScheduleViewMode.year: BusyMaxLinuxHeaderIcon.viewYear, + ScheduleViewMode.agenda: BusyMaxLinuxHeaderIcon.viewAgenda, + }; + const resolvedNames = { + BusyMaxLinuxHeaderIcon.viewDay: 'resolved-view-continuous-symbolic', + BusyMaxLinuxHeaderIcon.viewWeek: 'resolved-calendar-week-symbolic', + BusyMaxLinuxHeaderIcon.viewMonth: 'resolved-calendar-month-symbolic', + BusyMaxLinuxHeaderIcon.viewYear: 'resolved-view-app-grid-symbolic', + BusyMaxLinuxHeaderIcon.viewAgenda: 'resolved-view-list-symbolic', + }; + final png = base64Decode( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVQIHWP4z8DwHwAFgAI/ScL1WQAAAABJRU5ErkJggg==', + ); + final service = GtkHeaderIconService.testing( + initialCatalog: GtkHeaderIconCatalog({ + for (final entry in resolvedNames.entries) + '${entry.key.name}.ltr': GtkHeaderIconAsset( + bytes: png, + resolvedName: entry.value, + scale: 1, + pixelWidth: 16, + pixelHeight: 16, + ), + }, revision: 1), + ); + addTearDown(service.dispose); + MethodCall? nativeCall; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(_nativeMenuChannel, (call) async { + nativeCall = call; + return null; + }); + + for (final mode in ScheduleViewMode.values) { + await tester.pumpWidget( + GtkHeaderIconScope( + service: service, + child: localizedTestApp( + child: Scaffold( + body: SizedBox( + width: 1200, + child: ScheduleToolbar( + mode: mode, + range: ScheduleRange.week( + DateTime(2026, 7, 22), + firstWeekday: DateTime.monday, + ), + selectedDate: DateTime(2026, 7, 22), + onToday: () {}, + onPrevious: () {}, + onNext: () {}, + onModeChanged: (_) {}, + canCreateEvent: true, + canCreateTask: true, + onCreateEvent: () {}, + onCreateTask: () {}, + onRefresh: () {}, + ), + ), + ), + ), + ), + ); + + final trigger = find.byKey(const ValueKey('schedule-view-button')); + final triggerIcons = tester.widgetList( + find.descendant( + of: trigger, + matching: find.byType(BusyMaxGtkHeaderIcon), + ), + ); + expect(triggerIcons.first.icon, semanticIcons[mode]); + expect(find.byTooltip('View'), findsOneWidget); + for (var index = 0; index < ScheduleViewMode.values.length; index++) { + expect( + find.byTooltip('${_viewLabel(index)} (${index + 1})'), + findsNothing, + ); + } + + nativeCall = null; + await tester.tap(trigger); + await tester.pumpAndSettle(); + expect(nativeCall?.method, 'show'); + final entries = + (nativeCall!.arguments as Map)['entries']! + as List; + expect(entries, hasLength(ScheduleViewMode.values.length)); + for (var index = 0; index < entries.length; index++) { + final entry = entries[index]! as Map; + final semantic = semanticIcons[ScheduleViewMode.values[index]]!; + expect(entry['icon'], resolvedNames[semantic]); + expect(entry['shortcut'], '${index + 1}'); + } + } + }, + ); + + testWidgets('Linux application controls use native header geometry', ( + tester, + ) async { + tester.view + ..devicePixelRatio = 1 + ..physicalSize = const Size(1200, 200); + addTearDown(tester.view.reset); + await tester.pumpWidget( + localizedTestApp( + child: Scaffold( + body: SizedBox( + width: 1200, + child: ScheduleToolbar( + mode: ScheduleViewMode.week, + range: ScheduleRange.week( + DateTime(2026, 7, 22), + firstWeekday: DateTime.monday, + ), + selectedDate: DateTime(2026, 7, 22), + onToday: () {}, + onPrevious: () {}, + onNext: () {}, + onModeChanged: (_) {}, + canCreateEvent: true, + canCreateTask: true, + onCreateEvent: () {}, + onCreateTask: () {}, + onRefresh: () {}, + canShowSidebar: true, + sidebarVisible: true, + onToggleSidebar: () {}, + onSearch: () {}, + onMenuSelected: (_) {}, + ), + ), + ), + ), + ); + + expect( + tester.getSize(find.byType(ScheduleToolbar)).height, + BusyMaxSizes.toolbarHeight, + ); + const squareControlKeys = [ + 'schedule-sidebar-button', + 'schedule-today-button', + 'schedule-previous-button', + 'schedule-next-button', + 'schedule-create-button', + 'schedule-refresh-button', + 'schedule-search-button', + 'busymax-main-menu-button', + ]; + for (final key in squareControlKeys) { + expect( + tester.getSize(find.byKey(ValueKey(key))), + const Size.square(BusyMaxSizes.headerIconButton), + ); + } + final view = find.byKey(const ValueKey('schedule-view-button')); + expect(tester.getSize(view).height, BusyMaxSizes.headerIconButton); + expect( + tester.getSize(view).width, + greaterThan(BusyMaxSizes.headerIconButton), + ); + final viewIcons = find.descendant( + of: view, + matching: find.byType(BusyMaxGtkHeaderIcon), + ); + expect(viewIcons, findsNWidgets(2)); + final viewIconWidgets = tester + .widgetList(viewIcons) + .toList(); + expect(viewIconWidgets.first.icon, BusyMaxLinuxHeaderIcon.viewWeek); + expect(viewIconWidgets.last.icon, BusyMaxLinuxHeaderIcon.viewMenuArrow); + for (final icon in tester.widgetList( + find.byType(BusyMaxGtkHeaderIcon), + )) { + expect( + tester.getSize(find.byWidget(icon)), + const Size.square(BusyMaxLinuxHeaderStyle.symbolicIconSize), + ); + } + final firstViewIcon = tester.getRect(viewIcons.at(0)); + final secondViewIcon = tester.getRect(viewIcons.at(1)); + expect( + secondViewIcon.left - firstViewIcon.right, + BusyMaxLinuxHeaderStyle.viewIconGap, + ); + + Rect rect(String key) => tester.getRect(find.byKey(ValueKey(key))); + expect(rect('schedule-sidebar-button').left, BusyMaxSpacing.headerInset); + expect( + rect('schedule-today-button').left - + rect('schedule-sidebar-button').right, + BusyMaxSpacing.headerInset, + ); + expect( + rect('schedule-previous-button').left - + rect('schedule-today-button').right, + BusyMaxSpacing.headerInset, + ); + expect( + rect('schedule-next-button').left - + rect('schedule-previous-button').right, + BusyMaxSpacing.headerInset, + ); + expect( + 1200 - rect('busymax-main-menu-button').right, + BusyMaxSpacing.headerInset, + ); + expect( + rect('schedule-create-button').left - rect('schedule-view-button').right, + BusyMaxSpacing.headerInset, + ); + + void expectHeaderIcon(String key, BusyMaxLinuxHeaderIcon icon) { + final icons = find.descendant( + of: find.byKey(ValueKey(key)), + matching: find.byType(BusyMaxGtkHeaderIcon), + ); + expect(tester.widget(icons.first).icon, icon); + } + + expectHeaderIcon('schedule-sidebar-button', BusyMaxLinuxHeaderIcon.sidebar); + expectHeaderIcon('schedule-today-button', BusyMaxLinuxHeaderIcon.today); + expectHeaderIcon( + 'schedule-previous-button', + BusyMaxLinuxHeaderIcon.previous, + ); + expectHeaderIcon('schedule-next-button', BusyMaxLinuxHeaderIcon.next); + expectHeaderIcon('schedule-create-button', BusyMaxLinuxHeaderIcon.create); + expectHeaderIcon('schedule-refresh-button', BusyMaxLinuxHeaderIcon.refresh); + expectHeaderIcon('schedule-search-button', BusyMaxLinuxHeaderIcon.search); + expectHeaderIcon( + 'busymax-main-menu-button', + BusyMaxLinuxHeaderIcon.mainMenu, + ); + }); + + testWidgets('open header menus retain neutral checked foreground', ( + tester, + ) async { + Completer? pendingSelection; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(_nativeMenuChannel, (call) async { + if (call.method == 'show') { + pendingSelection = Completer(); + return pendingSelection!.future; + } + if (call.method == 'dismiss') { + final selection = pendingSelection; + if (selection != null && !selection.isCompleted) { + selection.complete(); + } + return true; + } + throw MissingPluginException(); + }); + + await tester.pumpWidget( + localizedTestApp( + child: Scaffold( + body: SizedBox( + width: 1200, + child: ScheduleToolbar( + mode: ScheduleViewMode.week, + range: ScheduleRange.week( + DateTime(2026, 7, 22), + firstWeekday: DateTime.monday, + ), + selectedDate: DateTime(2026, 7, 22), + onToday: () {}, + onPrevious: () {}, + onNext: () {}, + onModeChanged: (_) {}, + canCreateEvent: true, + canCreateTask: true, + onCreateEvent: () {}, + onCreateTask: () {}, + onRefresh: () {}, + onMenuSelected: (_) {}, + ), + ), + ), + ), + ); + + for (final key in const [ + 'schedule-create-button', + 'busymax-main-menu-button', + ]) { + final menu = find.byKey(ValueKey(key)); + await tester.tap(menu); + await tester.pump(); + + final triggerFinder = find.descendant( + of: menu, + matching: find.byType(BusyMaxLinuxHeaderIconButton), + ); + final trigger = tester.widget( + triggerFinder, + ); + final button = tester.widget( + find.descendant(of: triggerFinder, matching: find.byType(IconButton)), + ); + final triggerContext = tester.element(triggerFinder); + final foreground = button.style!.foregroundColor!.resolve(const { + WidgetState.selected, + }); + expect(trigger.selected, isTrue); + expect(foreground, busyMaxLinuxHeaderForeground(triggerContext)); + expect(foreground, isNot(Theme.of(triggerContext).colorScheme.primary)); + + pendingSelection!.complete(); + await tester.pumpAndSettle(); + pendingSelection = null; + } + + final viewMenu = find.byKey(const ValueKey('schedule-view-button')); + await tester.tap(viewMenu); + await tester.pump(); + final viewButton = tester.widget( + find.descendant(of: viewMenu, matching: find.byType(TextButton)), + ); + final viewContext = tester.element(viewMenu); + final foreground = viewButton.style!.foregroundColor!.resolve(const {}); + expect(foreground, busyMaxLinuxHeaderForeground(viewContext)); + expect(foreground, isNot(Theme.of(viewContext).colorScheme.primary)); + pendingSelection!.complete(); + await tester.pumpAndSettle(); + }); + testWidgets('toolbar delegates create selection to the native menu host', ( tester, ) async { @@ -225,6 +587,45 @@ void main() { ); }); + testWidgets('main header menu uses the pre-migration GTK icon names', ( + tester, + ) async { + MethodCall? nativeCall; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(_nativeMenuChannel, (call) async { + nativeCall = call; + return null; + }); + + await tester.pumpWidget( + localizedTestApp( + child: Scaffold( + body: BusyMaxMainMenuButton( + includeRefresh: true, + canRefresh: true, + onSelected: (_) {}, + ), + ), + ), + ); + await tester.tap(find.byTooltip('Main Menu')); + await tester.pumpAndSettle(); + + final entries = + (nativeCall!.arguments as Map)['entries']! + as List; + expect( + [for (final entry in entries) (entry! as Map)['icon']], + const [ + 'view-refresh-symbolic', + 'preferences-system-symbolic', + 'input-keyboard-symbolic', + 'dialog-warning-symbolic', + 'help-about-symbolic', + ], + ); + }); + testWidgets('fallback toolbar hides a range title that does not fit', ( tester, ) async { @@ -347,7 +748,7 @@ void main() { expect(events, 1); expect(tasks, 0); - await tester.tap(find.byTooltip('Week (2)')); + await tester.tap(find.byTooltip('View')); await tester.pumpAndSettle(); expect( find.byWidgetPredicate((widget) => widget is PopupMenuItem), @@ -358,6 +759,9 @@ void main() { findsNWidgets(ScheduleViewMode.values.length), ); expect(find.text('Compact'), findsNothing); + for (var shortcut = 1; shortcut <= 5; shortcut++) { + expect(find.text('$shortcut'), findsOneWidget); + } await tester.tap( find.ancestor( of: find.text('Month'), @@ -548,10 +952,10 @@ void main() { expect(find.text('Event'), findsOneWidget); expect(find.text('Task'), findsOneWidget); - final trigger = tester.widget( + final trigger = tester.widget( find.ancestor( of: find.byTooltip('Create'), - matching: find.byType(YaruIconButton), + matching: find.byType(BusyMaxLinuxHeaderIconButton), ), ); expect(trigger.focusNode, isNotNull); @@ -748,10 +1152,10 @@ void main() { ), ); - final trigger = tester.widget( + final trigger = tester.widget( find.ancestor( of: find.byTooltip('Create'), - matching: find.byType(YaruIconButton), + matching: find.byType(BusyMaxLinuxHeaderIconButton), ), ); expect(trigger.onPressed, isNull); diff --git a/test/features/schedule/presentation/schedule_views_test.dart b/test/features/schedule/presentation/schedule_views_test.dart index c62e0e78..68205b4a 100644 --- a/test/features/schedule/presentation/schedule_views_test.dart +++ b/test/features/schedule/presentation/schedule_views_test.dart @@ -931,8 +931,10 @@ void main() { matching: find.byType(InkWell), ); expect( - tester.widget(taskInkWell).mouseCursor, - SystemMouseCursors.click, + tester + .widgetList(taskInkWell) + .where((inkWell) => inkWell.mouseCursor == SystemMouseCursors.click), + hasLength(1), ); }); @@ -1061,7 +1063,7 @@ void main() { 'final fullDayBarHeight = showFullDayBar ? _fullDayBarHeight : 0.0;', ), ); - expect(source, contains('fullDayEventsBarVisibility: showFullDayBar')); + expect(source, contains('fullDayEventsBarVisibility: true')); expect(source, contains('fullDayEventsBarHeight: fullDayBarHeight')); expect(source, contains('fullDayEventHeight: showFullDayBar ? 24 : 0')); expect(source, contains('fullDayEventsBuilder: (events, width)')); @@ -3584,55 +3586,56 @@ void main() { final source = File( 'lib/src/features/schedule/presentation/schedule_workspace.dart', ).readAsStringSync(); - expect(source, contains('onToday: _goToToday')); - expect(source, contains('BusyMaxHeaderBarAction.today')); + expect(source, contains('BusyMaxHeaderAction.today')); expect( source, isNot(contains('onToday: () => _selectScope(ScheduleScope.today)')), ); }); - test('schedule workspace uses Flutter toolbar only as native fallback', () { + test('schedule workspace uses the canonical Flutter page header', () { final source = File( 'lib/src/features/schedule/presentation/schedule_workspace.dart', ).readAsStringSync(); - expect(source, contains('linuxHeaderBarServiceProvider')); - expect(source, contains('final showFallbackHeader')); - expect(source, contains('if (showFallbackHeader)')); - expect(source, contains('final headerBarState = BusyMaxHeaderBarState(')); - expect(source, contains('.claimSession()')); - expect(source, contains('_headerBarSession.updateState(headerBarState)')); - expect(source, contains('onMenuSelected: _handleFallbackToolbarMenu')); + expect(source, contains('LinuxPageFrame(')); + expect(source, contains('ScheduleToolbar(')); + expect(source, contains('onMenuSelected: _handleToolbarMenu')); + expect(source, isNot(contains('linuxHeaderBarServiceProvider'))); + expect(source, isNot(contains('showFallbackHeader'))); + expect(source, isNot(contains('BusyMaxHeaderBarState'))); + expect(source, isNot(contains('_headerBarSession'))); }); - test('native headerbar actions are wired to schedule commands', () { + test('Flutter header actions are wired directly to schedule commands', () { final source = File( 'lib/src/features/schedule/presentation/schedule_workspace.dart', ).readAsStringSync(); - - expect(source, contains('BusyMaxHeaderBarAction.previous')); - expect(source, contains('BusyMaxHeaderBarAction.next')); - expect(source, contains('BusyMaxHeaderBarAction.viewModeDay')); - expect(source, contains('BusyMaxHeaderBarAction.viewModeWeek')); - expect(source, contains('BusyMaxHeaderBarAction.viewModeMonth')); - expect(source, contains('BusyMaxHeaderBarAction.viewModeYear')); - expect(source, contains('BusyMaxHeaderBarAction.viewModeAgenda')); - expect(source, isNot(contains('BusyMaxHeaderBarAction.viewModeCompact'))); - expect(source, contains('BusyMaxHeaderBarAction.refresh')); + final toolbar = File( + 'lib/src/features/schedule/presentation/schedule_toolbar.dart', + ).readAsStringSync(); + expect(source, contains('BusyMaxHeaderAction.previous')); + expect(source, contains('BusyMaxHeaderAction.next')); + expect(source, contains('BusyMaxHeaderAction.viewModeDay')); + expect(source, contains('BusyMaxHeaderAction.viewModeWeek')); + expect(source, contains('BusyMaxHeaderAction.viewModeMonth')); + expect(source, contains('BusyMaxHeaderAction.viewModeYear')); + expect(source, contains('BusyMaxHeaderAction.viewModeAgenda')); + expect(source, isNot(contains('BusyMaxHeaderAction.viewModeCompact'))); + expect(source, contains('BusyMaxHeaderAction.refresh')); expect(source, contains('allAccountsSyncRunnerProvider')); expect(source, contains('context.l10n.allTasksRefreshed')); expect(source, contains('setScheduleViewMode(mode)')); expect(source, contains('settings.scheduleViewMode')); expect( - source, + toolbar, contains('localizedScheduleHeading('), - reason: 'native header state shares the Flutter heading formatter', + reason: 'the Flutter header uses the shared heading formatter', ); expect(source, contains('ScheduleEmptyState')); - expect(source, isNot(contains('BusyMaxHeaderBarAction.newItem'))); - expect(source, isNot(contains('BusyMaxHeaderBarAction.openMenu'))); + expect(source, isNot(contains('BusyMaxHeaderAction.newItem'))); + expect(source, isNot(contains('BusyMaxHeaderAction.openMenu'))); }); test('schedule workspace wires navigation and view keyboard shortcuts', () { @@ -3823,10 +3826,17 @@ void main() { 'linux/runner/my_application.cc', ).readAsStringSync(); - expect(nativeRunner, contains('header_sidebar_brand_box')); - expect(nativeRunner, contains('gtk_label_new(kApplicationDisplayName)')); - expect(nativeRunner, isNot(contains('busymax-sidebar-header'))); - expect(nativeRunner, isNot(contains('GtkWidget* brand_box'))); + final pageFrame = File( + 'lib/src/app/linux/linux_page_frame.dart', + ).readAsStringSync(); + + expect(pageFrame, contains('class BusyMaxLinuxBrandHeader')); + expect(pageFrame, contains("'BusyMax'")); + expect(nativeRunner, isNot(contains('header_sidebar_brand_box'))); + expect( + nativeRunner, + isNot(contains('gtk_label_new(kApplicationDisplayName)')), + ); expect(sidebar, isNot(contains('title: account.provider.displayName'))); expect(sidebar, isNot(contains('YaruIcons.globe'))); expect(sidebar, isNot(contains('Icons.account_circle_outlined'))); @@ -3846,9 +3856,14 @@ void main() { ); expect(sidebar, contains("'schedule-calendar'")); expect(sidebar, contains("'schedule-task-list'")); - expect(sidebar, contains('AnimatedRotation')); + expect(sidebar, contains('AnimationController _expansion')); + expect(sidebar, contains('BusyMaxMotion.accountDisclosure * distance')); + expect(sidebar, contains('heightFactor: _expansion.value')); + expect(sidebar, contains('Transform.rotate')); + expect(sidebar, contains('ExcludeFocus(')); + expect(sidebar, contains('ExcludeSemantics(')); expect(sidebar, contains('BusyMaxGlyphs.collapsedFor')); - expect(sidebar, contains('if (_expanded)')); + expect(sidebar, isNot(contains('if (_expanded)'))); expect(sidebar, contains('MiniCalendar(')); expect(sidebar, isNot(contains('BusyMaxGroupedList('))); expect(sidebar, isNot(contains('hoverColor: Colors.transparent'))); @@ -4502,7 +4517,6 @@ void main() { final workspace = File( 'lib/src/features/schedule/presentation/schedule_workspace.dart', ).readAsStringSync(); - expect(sidebar, contains('required this.firstWeekday')); expect(sidebar, contains('firstWeekday: firstWeekday')); expect(workspace, contains('firstWeekday: _firstWeekday(context)')); @@ -4585,69 +4599,44 @@ void main() { 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', - ).readAsStringSync(); - final headerBar = File('linux/runner/my_application.cc').readAsStringSync(); - final headerService = File( - 'lib/src/platform/linux_header_bar_service.dart', - ).readAsStringSync(); - final sidebar = File( - 'lib/src/features/schedule/presentation/schedule_sidebar.dart', - ).readAsStringSync(); - final toolbar = File( - 'lib/src/features/schedule/presentation/schedule_toolbar.dart', - ).readAsStringSync(); + test( + 'schedule Create uses the shared Flutter header menu before refresh', + () { + final workspace = File( + 'lib/src/features/schedule/presentation/schedule_workspace.dart', + ).readAsStringSync(); + final nativeRunner = File( + 'linux/runner/my_application.cc', + ).readAsStringSync(); + final headerActions = File( + 'lib/src/app/busymax_header_actions.dart', + ).readAsStringSync(); + final sidebar = File( + 'lib/src/features/schedule/presentation/schedule_sidebar.dart', + ).readAsStringSync(); + final toolbar = File( + 'lib/src/features/schedule/presentation/schedule_toolbar.dart', + ).readAsStringSync(); - expect(workspace, isNot(contains('floatingActionButtonLocation'))); - expect(workspace, isNot(contains('FloatingActionButton('))); - expect(workspace, contains('BusyMaxHeaderBarAction.createEvent')); - expect(workspace, contains('BusyMaxHeaderBarAction.createTask')); - expect(workspace, isNot(contains('void _openCreateAtSelectedDate()'))); - expect( - headerService, - isNot(contains("'create' => BusyMaxHeaderBarAction.create")), - ); - expect( - headerBar, - contains('gtk_image_new_from_icon_name("list-add-symbolic"'), - ); - expect( - headerBar, - contains( - 'append_header_action_item(menu, self->header_create_event_label,', - ), - ); - expect( - headerBar, - contains( - 'append_header_action_item(menu, self->header_create_task_label,', - ), - ); - expect(headerBar, contains('gtk_menu_button_set_menu_model')); - expect(headerBar, contains('g_simple_action_set_enabled')); - expect(headerBar, contains('show_header_create_menu')); - expect(headerService, contains("'showCreateMenu'")); - expect( - headerBar.indexOf( - 'gtk_box_pack_start(GTK_BOX(end_box), self->create_button', - ), - lessThan( - headerBar.indexOf( - 'gtk_box_pack_start(GTK_BOX(end_box), self->refresh_button', - ), - ), - ); - 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')); - expect(toolbar, contains('icon: const Icon(YaruIcons.plus)')); - expect(toolbar, contains('tooltip: context.l10n.refreshAll')); - }); + expect(workspace, isNot(contains('floatingActionButtonLocation'))); + expect(workspace, isNot(contains('FloatingActionButton('))); + expect(workspace, contains('BusyMaxHeaderAction.createEvent')); + expect(workspace, contains('BusyMaxHeaderAction.createTask')); + expect(workspace, isNot(contains('void _openCreateAtSelectedDate()'))); + expect(headerActions, contains('enum BusyMaxHeaderAction')); + expect(headerActions, isNot(contains('MethodChannel'))); + expect(nativeRunner, isNot(contains('header_create_event_label'))); + expect(nativeRunner, isNot(contains('show_header_create_menu'))); + 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')); + expect(toolbar, contains('icon: BusyMaxLinuxHeaderIcon.create')); + expect(toolbar, contains('tooltip: context.l10n.refreshAll')); + }, + ); test('day and week today tint stays subtle', () { final source = File( @@ -4843,16 +4832,13 @@ void main() { expect(source, contains('.setScheduleViewMode(ScheduleViewMode.day)')); }); - test('agenda removes page controls from toolbar and native headerbar', () { + test('agenda removes page controls from the Flutter header', () { final workspace = File( 'lib/src/features/schedule/presentation/schedule_workspace.dart', ).readAsStringSync(); final toolbar = File( 'lib/src/features/schedule/presentation/schedule_toolbar.dart', ).readAsStringSync(); - final headerService = File( - 'lib/src/platform/linux_header_bar_service.dart', - ).readAsStringSync(); final nativeRunner = File( 'linux/runner/my_application.cc', ).readAsStringSync(); @@ -4861,24 +4847,13 @@ void main() { toolbar, contains('final showPaging = mode != ScheduleViewMode.agenda'), ); - expect(toolbar, contains('if (showPaging)')); + expect(toolbar, contains('if (!searchActive && showPaging)')); expect(toolbar, contains('agendaLabel: context.l10n.viewAgenda')); - expect( - workspace, - contains( - 'navigationVisible: !_searchActive && _mode != ScheduleViewMode.agenda', - ), - ); - expect( - workspace, - contains('_headerBarSession.updateState(headerBarState)'), - ); - expect(headerService, contains('class BusyMaxHeaderBarState')); - expect(headerService, contains('class LinuxHeaderBarSession')); - expect(headerService, contains('Future updateState(')); - expect(nativeRunner, contains('set_header_bar_state')); - expect(nativeRunner, contains('set_header_navigation_visible')); - expect(nativeRunner, contains('setNavigationVisible')); + expect(workspace, contains('_mode != ScheduleViewMode.agenda')); + expect(workspace, contains('ScheduleToolbar(')); + expect(workspace, isNot(contains('_headerBarSession'))); + expect(nativeRunner, isNot(contains('set_header_bar_state'))); + expect(nativeRunner, isNot(contains('set_header_navigation_visible'))); }); test('agenda queries bounded buckets separately from dated items', () { @@ -4965,6 +4940,9 @@ void main() { final workspace = File( 'lib/src/features/schedule/presentation/schedule_workspace.dart', ).readAsStringSync(); + final toolbar = File( + 'lib/src/features/schedule/presentation/schedule_toolbar.dart', + ).readAsStringSync(); final yearView = File( 'lib/src/features/schedule/presentation/schedule_year_view.dart', ).readAsStringSync(); @@ -4973,7 +4951,7 @@ void main() { expect(range, contains('factory ScheduleRange.year(DateTime day)')); expect(workspace, contains('ScheduleRange.year(_selectedDate)')); expect( - workspace, + toolbar, contains('localizedScheduleHeading('), reason: 'schedule headers use the centralized localized heading helper', ); diff --git a/test/features/schedule/presentation/schedule_workspace_navigation_test.dart b/test/features/schedule/presentation/schedule_workspace_navigation_test.dart index f5ac25f6..add45582 100644 --- a/test/features/schedule/presentation/schedule_workspace_navigation_test.dart +++ b/test/features/schedule/presentation/schedule_workspace_navigation_test.dart @@ -1,15 +1,15 @@ import 'dart:io'; import 'package:busymax/src/app/app_bootstrap.dart'; +import 'package:busymax/src/calendar_providers/calendar_sync_dto.dart'; import 'package:busymax/src/db/app_database.dart'; import 'package:busymax/src/features/accounts/data/accounts_repository.dart'; +import 'package:busymax/src/features/calendar/data/calendar_repository.dart'; import 'package:busymax/src/features/schedule/presentation/schedule_day_week_view.dart'; import 'package:busymax/src/features/schedule/presentation/schedule_month_view.dart'; import 'package:busymax/src/features/schedule/presentation/schedule_workspace.dart'; import 'package:busymax/src/features/task_lists/data/task_lists_repository.dart'; import 'package:busymax/src/features/tasks/data/tasks_repository.dart'; -import 'package:busymax/src/platform/linux_header_bar_service.dart'; -import 'package:busymax/src/platform/linux_header_bar_provider.dart'; import 'package:busymax/src/providers/busy_provider.dart'; import 'package:busymax/src/schedule/schedule_scope.dart'; import 'package:drift/drift.dart'; @@ -22,6 +22,94 @@ import '../../../support/process_time_zone.dart'; import '../../../test_localized_app.dart'; void main() { + testWidgets('month view shows recurring instances when sync writes them', ( + tester, + ) async { + final database = await _pumpWorkspace( + tester, + DateTime(2026, 9, 22), + includeRecurringGoogleEvent: true, + ); + addTearDown(database.close); + await tester.sendKeyEvent(LogicalKeyboardKey.digit3); + await tester.pumpAndSettle(); + + List occurrenceDates() => tester + .widget(find.byType(ScheduleMonthView)) + .items + .where((item) => item.title == 'Every two days') + .map((item) => item.start) + .toList(); + + expect(occurrenceDates(), hasLength(1)); + + final calendar = CalendarRepository(database: database); + for (final day in [22, 24, 26]) { + final date = '2026-09-${day.toString().padLeft(2, '0')}'; + final nextDate = '2026-09-${(day + 1).toString().padLeft(2, '0')}'; + await calendar.upsertEvent( + accountId: 'account', + event: CalendarEventDto( + provider: BusyProvider.google, + providerCalendarId: 'personal', + providerEventId: 'series-$day', + providerRecurringEventId: 'series', + title: 'Every two days', + allDay: true, + startDate: date, + endDate: nextDate, + ), + ); + } + await (database.update(database.calendarEvents) + ..where((row) => row.providerEventId.equals('series'))) + .write(const CalendarEventsCompanion(isDeleted: Value(true))); + await tester.pumpAndSettle(); + + expect(occurrenceDates(), [ + DateTime(2026, 9, 22), + DateTime(2026, 9, 24), + DateTime(2026, 9, 26), + ]); + }); + + testWidgets( + 'previous and next day navigation render the destination all-day event', + (tester) async { + for (final scenario in [ + (start: DateTime(2026, 9, 13), next: true), + (start: DateTime(2026, 9, 19), next: false), + ]) { + final harness = await _pumpWorkspace( + tester, + scenario.start, + includeSeptemberBirthday: true, + ); + await tester.sendKeyEvent(LogicalKeyboardKey.digit1); + await tester.pumpAndSettle(); + + for (var index = 0; index < 3; index++) { + await _navigate(tester, next: scenario.next); + await tester.pumpAndSettle(); + } + + final view = tester.widget( + find.byKey(const ValueKey('schedule-day-planner')), + ); + expect(view.selectedDate, DateTime(2026, 9, 16)); + expect( + view.items.map((item) => item.title), + contains("Ildar's Birthday"), + ); + expect(find.text("Ildar's Birthday"), findsOneWidget); + + await tester.pumpWidget(const SizedBox.shrink()); + await tester.pump(const Duration(milliseconds: 1)); + await harness.close(); + } + }, + ); + testWidgets( 'workspace month commands always select the adjacent month first', (tester) async { @@ -39,15 +127,18 @@ void main() { await tester.sendKeyEvent(LogicalKeyboardKey.digit3); await tester.pump(); await _navigate(tester, next: scenario.next); - final view = tester.widget( - find.byType(ScheduleMonthView), + final destination = find.byWidgetPredicate( + (widget) => + widget is ScheduleMonthView && + widget.selectedDate == scenario.expected, ); + expect(destination, findsOneWidget); + final view = tester.widget(destination); expect(view.selectedDate, scenario.expected); expect(view.selectedDate.hour, 0); await tester.pumpWidget(const SizedBox.shrink()); await tester.pump(const Duration(milliseconds: 1)); - harness.headerBar.dispose(); - await harness.database.close(); + await harness.close(); } }, ); @@ -85,7 +176,13 @@ void main() { await tester.pump(); await _navigate(tester, next: scenario.next); final view = tester.widget( - find.byType(ScheduleDayWeekView), + find.byKey( + ValueKey( + scenario.days == 1 + ? 'schedule-day-planner' + : 'schedule-week-planner', + ), + ), ); final direction = scenario.next ? 1 : -1; final expected = DateTime( @@ -97,8 +194,7 @@ void main() { expect(view.selectedDate.hour, 0); await tester.pumpWidget(const SizedBox.shrink()); await tester.pump(const Duration(milliseconds: 1)); - harness.headerBar.dispose(); - await harness.database.close(); + await harness.close(); }, ); } @@ -115,8 +211,12 @@ Future _navigate(WidgetTester tester, {required bool next}) async { await tester.pump(); } -Future<({AppDatabase database, LinuxHeaderBarService headerBar})> -_pumpWorkspace(WidgetTester tester, DateTime initialDate) async { +Future _pumpWorkspace( + WidgetTester tester, + DateTime initialDate, { + bool includeSeptemberBirthday = false, + bool includeRecurringGoogleEvent = false, +}) async { final database = AppDatabase.memoryForTests(); await database .into(database.accounts) @@ -142,6 +242,53 @@ _pumpWorkspace(WidgetTester tester, DateTime initialDate) async { updatedLocalAtUtc: _now, ), ); + if (includeSeptemberBirthday) { + final calendar = CalendarRepository(database: database); + await calendar.upsertSource( + accountId: 'account', + source: const CalendarSourceDto( + provider: BusyProvider.google, + providerCalendarId: 'birthdays', + summary: 'Birthdays', + ), + ); + await calendar.upsertEvent( + accountId: 'account', + event: const CalendarEventDto( + provider: BusyProvider.google, + providerCalendarId: 'birthdays', + providerEventId: 'birthday-2026', + title: "Ildar's Birthday", + allDay: true, + startDate: '2026-09-16', + endDate: '2026-09-17', + ), + ); + } + if (includeRecurringGoogleEvent) { + final calendar = CalendarRepository(database: database); + await calendar.upsertSource( + accountId: 'account', + source: const CalendarSourceDto( + provider: BusyProvider.google, + providerCalendarId: 'personal', + summary: 'Personal Calendar', + ), + ); + await calendar.upsertEvent( + accountId: 'account', + event: const CalendarEventDto( + provider: BusyProvider.google, + providerCalendarId: 'personal', + providerEventId: 'series', + title: 'Every two days', + allDay: true, + startDate: '2026-09-22', + endDate: '2026-09-23', + recurrenceJson: ['RRULE:FREQ=DAILY;INTERVAL=2;UNTIL=20260927'], + ), + ); + } const account = AccountEntity( id: 'account', provider: BusyProvider.google, @@ -149,7 +296,6 @@ _pumpWorkspace(WidgetTester tester, DateTime initialDate) async { providerAccountId: 'account', authState: accountAuthStateSignedIn, ); - final headerBar = LinuxHeaderBarService(isLinux: false); await tester.pumpWidget( ProviderScope( overrides: [ @@ -158,7 +304,6 @@ _pumpWorkspace(WidgetTester tester, DateTime initialDate) async { activeAccountProvider.overrideWithValue('account'), localTimeZoneProvider.overrideWithValue('America/Vancouver'), localSettingsStoreProvider.overrideWithValue(_MemorySettingsStore()), - linuxHeaderBarServiceProvider.overrideWithValue(headerBar), taskListsRepositoryForAccountProvider.overrideWith( (ref, id) => TaskListsRepository(database: database, accountId: id), ), @@ -175,7 +320,7 @@ _pumpWorkspace(WidgetTester tester, DateTime initialDate) async { ), ); await tester.pumpAndSettle(); - return (database: database, headerBar: headerBar); + return database; } class _MemorySettingsStore implements LocalSettingsStore { diff --git a/test/features/schedule/presentation/schedule_workspace_states_test.dart b/test/features/schedule/presentation/schedule_workspace_states_test.dart index a3c52f8b..b5156b98 100644 --- a/test/features/schedule/presentation/schedule_workspace_states_test.dart +++ b/test/features/schedule/presentation/schedule_workspace_states_test.dart @@ -1,17 +1,14 @@ import 'dart:async'; -import 'dart:io'; import 'package:busymax/src/app/app_bootstrap.dart'; import 'package:busymax/src/app/busymax_design.dart'; +import 'package:busymax/src/app/linux/linux_header_style.dart'; import 'package:busymax/src/app/busymax_surface_colors.dart'; import 'package:busymax/src/db/app_database.dart'; import 'package:busymax/src/features/accounts/data/accounts_repository.dart'; import 'package:busymax/src/features/schedule/presentation/schedule_empty_states.dart'; import 'package:busymax/src/features/schedule/presentation/schedule_sidebar.dart'; -import 'package:busymax/src/features/schedule/presentation/schedule_search_filters.dart'; import 'package:busymax/src/features/schedule/presentation/schedule_workspace.dart'; -import 'package:busymax/src/platform/linux_header_bar_service.dart'; -import 'package:busymax/src/platform/linux_header_bar_provider.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter/services.dart'; @@ -176,8 +173,8 @@ void main() { await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft); await tester.pumpAndSettle(); - expect(find.byType(BusyMaxSearchField), findsOneWidget); - expect(find.byType(YaruSearchField), findsOneWidget); + expect(find.byType(BusyMaxLinuxHeaderSearchField), findsOneWidget); + expect(find.byType(YaruSearchField), findsNothing); expect(_searchFieldHasPrimaryFocus(tester), isTrue); FocusManager.instance.primaryFocus?.unfocus(); @@ -190,21 +187,21 @@ void main() { await tester.enterText( find.descendant( - of: find.byType(BusyMaxSearchField), + of: find.byType(BusyMaxLinuxHeaderSearchField), matching: find.byType(TextField), ), 'planning', ); await tester.pump(); - await tester.tap(find.byIcon(YaruIcons.edit_clear)); + await tester.tap(find.byKey(BusyMaxLinuxHeaderSearchField.clearKey)); await tester.pump(); - expect(find.byType(BusyMaxSearchField), findsOneWidget); + expect(find.byType(BusyMaxLinuxHeaderSearchField), findsOneWidget); expect( tester .widget( find.descendant( - of: find.byType(BusyMaxSearchField), + of: find.byType(BusyMaxLinuxHeaderSearchField), matching: find.byType(TextField), ), ) @@ -216,7 +213,10 @@ void main() { await tester.sendKeyEvent(LogicalKeyboardKey.escape); await tester.pump(); - expect(find.byType(BusyMaxSearchField), findsNothing); + expect( + find.byType(BusyMaxLinuxHeaderSearchField).hitTestable(), + findsNothing, + ); }); testWidgets('F9 hides and shows the schedule sidebar', (tester) async { @@ -226,132 +226,64 @@ void main() { ); await tester.pumpAndSettle(); - expect(find.byType(ScheduleSidebar), findsOneWidget); + expect(find.byType(ScheduleSidebar).hitTestable(), findsOneWidget); + expect( + tester + .widgetList(find.byType(BusyMaxSidebarSurface)) + .every((surface) => !surface.showEndBorder), + isTrue, + ); await tester.sendKeyEvent(LogicalKeyboardKey.f9); await tester.pumpAndSettle(); - expect(find.byType(ScheduleSidebar), findsNothing); + expect(find.byType(ScheduleSidebar).hitTestable(), findsNothing); await tester.sendKeyEvent(LogicalKeyboardKey.f9); await tester.pumpAndSettle(); - expect(find.byType(ScheduleSidebar), findsOneWidget); + expect(find.byType(ScheduleSidebar).hitTestable(), findsOneWidget); }); testWidgets( - 'native search owns Linux entry state without a Flutter duplicate', + 'Flutter search and main menu remain reachable with sidebar collapsed', (tester) async { - const channel = MethodChannel('busymax_test/schedule_native_search'); - final calls = []; - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(channel, (call) async { - calls.add(call); - return call.method == 'initialize' ? true : null; - }); - addTearDown(() { - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(channel, null); - }); - final headerBarService = LinuxHeaderBarService( - channel: channel, - isLinux: true, - ); - await _pumpWorkspace( tester, accountsFactory: () => Stream.value(const []), - headerBarService: headerBarService, ); await tester.pumpAndSettle(); - await headerBarService.handleNativeMethodCall(const MethodCall('search')); - await tester.pumpAndSettle(); - - expect(find.byType(BusyMaxSearchField), findsNothing); - expect( - calls.where((call) => call.method == 'setState').last.arguments, - containsPair('searchActive', true), - ); - expect(find.byType(ScheduleSearchFilters), findsOneWidget); - - await tester.sendKeyEvent(LogicalKeyboardKey.f9); - await tester.pumpAndSettle(); - expect(find.byType(ScheduleSearchFilters), findsNothing); await tester.sendKeyEvent(LogicalKeyboardKey.f9); await tester.pumpAndSettle(); - expect(find.byType(ScheduleSearchFilters), findsOneWidget); - - await headerBarService.handleNativeMethodCall( - const MethodCall('searchQueryChanged', 'planning'), - ); - await tester.pumpAndSettle(); - expect( - calls.where((call) => call.method == 'setState').last.arguments, - containsPair('searchQuery', 'planning'), - ); - - await tester.tap(find.byType(TextField).first); - await tester.pumpAndSettle(); - expect(calls.where((call) => call.method == 'focusContent'), isNotEmpty); - await tester.enterText(find.byType(TextField).first, 'Taylor'); - await tester.pumpAndSettle(); - expect( - tester - .widget(find.byType(ScheduleSearchFilters)) - .value - .person, - 'Taylor', - ); - expect( - calls.where((call) => call.method == 'setState').last.arguments, - containsPair('searchQuery', 'planning'), - ); + expect(find.byType(ScheduleSidebar).hitTestable(), findsNothing); + expect(find.byTooltip('Main Menu'), findsOneWidget); - await headerBarService.handleNativeMethodCall( - const MethodCall('searchFocusChanged', true), - ); + await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft); + await tester.sendKeyEvent(LogicalKeyboardKey.keyF); + await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft); await tester.pumpAndSettle(); expect( - FocusManager.instance.primaryFocus?.context?.widget, - isNot(isA()), + find.byType(BusyMaxLinuxHeaderSearchField).hitTestable(), + findsOneWidget, ); + expect(_searchFieldHasPrimaryFocus(tester), isTrue); + expect(find.byTooltip('Filters'), findsOneWidget); + expect(find.byTooltip('Main Menu'), findsOneWidget); - await headerBarService.handleNativeMethodCall( - const MethodCall('searchCleared'), - ); - await tester.pumpAndSettle(); - final clearedState = calls - .where((call) => call.method == 'setState') - .last - .arguments; - expect(clearedState, containsPair('searchActive', true)); - expect(clearedState, containsPair('searchQuery', '')); - - await headerBarService.handleNativeMethodCall( - const MethodCall('searchEscapePressed'), - ); + await tester.sendKeyEvent(LogicalKeyboardKey.escape); await tester.pumpAndSettle(); expect( - calls.where((call) => call.method == 'setState').last.arguments, - containsPair('searchActive', false), + find.byType(BusyMaxLinuxHeaderSearchField).hitTestable(), + findsNothing, ); - - // Native entry focus must not strand Flutter focus outside the - // workspace's shortcuts after the search session closes. - await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft); - await tester.sendKeyEvent(LogicalKeyboardKey.keyF); - await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft); - await tester.pumpAndSettle(); - expect(find.byType(ScheduleSearchFilters), findsOneWidget); - await tester.sendKeyEvent(LogicalKeyboardKey.escape); - await tester.pumpAndSettle(); - expect(find.byType(ScheduleSidebar), findsOneWidget); + expect(find.byType(ScheduleSidebar).hitTestable(), findsNothing); }, - skip: !Platform.isLinux, ); } bool _searchFieldHasPrimaryFocus(WidgetTester tester) { - final searchElement = tester.element(find.byType(BusyMaxSearchField)); + final searchElement = tester.element( + find.byType(BusyMaxLinuxHeaderSearchField), + ); final focusContext = FocusManager.instance.primaryFocus?.context; if (identical(focusContext, searchElement)) { return true; @@ -369,13 +301,9 @@ bool _searchFieldHasPrimaryFocus(WidgetTester tester) { Future _pumpWorkspace( WidgetTester tester, { required Stream> Function() accountsFactory, - LinuxHeaderBarService? headerBarService, }) async { final database = AppDatabase.memoryForTests(); addTearDown(database.close); - final resolvedHeaderBarService = - headerBarService ?? LinuxHeaderBarService(isLinux: false); - addTearDown(resolvedHeaderBarService.dispose); await tester.pumpWidget( ProviderScope( @@ -384,9 +312,6 @@ Future _pumpWorkspace( accountsStreamProvider.overrideWith((ref) => accountsFactory()), localTimeZoneProvider.overrideWithValue('UTC'), localSettingsStoreProvider.overrideWithValue(_MemorySettingsStore()), - linuxHeaderBarServiceProvider.overrideWithValue( - resolvedHeaderBarService, - ), ], child: localizedTestApp(child: const ScheduleWorkspace()), ), 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 e3be0678..6b5570b8 100644 --- a/test/features/schedule/presentation/schedule_workspace_task_mutations_test.dart +++ b/test/features/schedule/presentation/schedule_workspace_task_mutations_test.dart @@ -1,12 +1,11 @@ import 'package:busymax/src/app/app_bootstrap.dart'; import 'package:busymax/src/app/busymax_design.dart'; +import 'package:busymax/src/app/busymax_window_close.dart'; import 'package:busymax/src/db/app_database.dart'; import 'package:busymax/src/features/accounts/data/accounts_repository.dart'; import 'package:busymax/src/features/schedule/presentation/schedule_workspace.dart'; import 'package:busymax/src/features/task_lists/data/task_lists_repository.dart'; import 'package:busymax/src/features/tasks/data/tasks_repository.dart'; -import 'package:busymax/src/platform/linux_header_bar_service.dart'; -import 'package:busymax/src/platform/linux_header_bar_provider.dart'; import 'package:busymax/src/platform/native_dialog_service.dart'; import 'package:busymax/src/platform/native_menu_service.dart'; import 'package:busymax/src/schedule/schedule_scope.dart'; @@ -164,22 +163,17 @@ void main() { testWidgets('dirty deep-linked task confirms before Escape closes it', ( tester, ) async { - final headerBarService = _RecordingHeaderBarService(); - addTearDown(headerBarService.dispose); await _pumpScheduleWorkspace( tester, taskTitle: 'Opened from route', initialTaskAccountId: _accountId, initialTaskListId: _taskListId, initialTaskId: 'task-1', - headerBarService: headerBarService, ); expect(find.text('Edit Task'), findsOneWidget); expect(find.text('Opened from route'), findsWidgets); - expect(headerBarService.modalBarrierStates, [ - (visible: true, shadeDepth: 1), - ]); + expect(find.byType(ModalBarrier), findsWidgets); await tester.enterText(find.byType(TextField).first, 'Unsaved route edit'); await tester.pump(); @@ -195,9 +189,6 @@ void main() { expect(modalBarrierColors.where((color) => color != null && color.a != 0), [ busyMaxModalBarrierColor(tester.element(find.byType(ModalBarrier).first)), ]); - expect(headerBarService.modalBarrierStates, [ - (visible: true, shadeDepth: 1), - ], reason: 'the nested confirmation must not repaint the native headerbar'); await tester.tap(find.text('Cancel').last); await tester.pumpAndSettle(); expect(find.text('Edit Task'), findsOneWidget); @@ -209,10 +200,42 @@ void main() { expect(find.text('Edit Task'), findsNothing); expect(find.text('Opened from route'), findsOneWidget); - expect(headerBarService.modalBarrierStates, [ - (visible: true, shadeDepth: 1), - (visible: false, shadeDepth: 0), - ]); + final visibleBarriers = tester + .widgetList(find.byType(ModalBarrier)) + .where((barrier) => (barrier.color?.a ?? 0) > 0); + expect(visibleBarriers, isEmpty); + }); + + testWidgets('dirty task resolves a destructive window close request', ( + tester, + ) async { + final harness = await _pumpScheduleWorkspace( + tester, + taskTitle: 'Protected draft', + initialTaskAccountId: _accountId, + initialTaskListId: _taskListId, + initialTaskId: 'task-1', + ); + await tester.enterText(find.byType(TextField).first, 'Unsaved window edit'); + await tester.pump(); + + final cancelledClose = harness.closeCoordinator.requestClose(); + await tester.pumpAndSettle(); + expect(find.text('Discard changes?'), findsOneWidget); + await tester.tap(find.text('Cancel').last); + await tester.pumpAndSettle(); + + expect(await cancelledClose, isFalse); + expect(find.text('Edit Task'), findsOneWidget); + expect(find.text('Unsaved window edit'), findsOneWidget); + + final confirmedClose = harness.closeCoordinator.requestClose(); + await tester.pumpAndSettle(); + await tester.tap(find.text('Discard')); + await tester.pumpAndSettle(); + + expect(await confirmedClose, isTrue); + expect(find.text('Edit Task'), findsOneWidget); }); } @@ -222,7 +245,6 @@ Future<_ScheduleHarness> _pumpScheduleWorkspace( String? initialTaskAccountId, String? initialTaskListId, String? initialTaskId, - LinuxHeaderBarService? headerBarService, }) async { final database = AppDatabase.memoryForTests(); addTearDown(database.close); @@ -286,11 +308,7 @@ Future<_ScheduleHarness> _pumpScheduleWorkspace( displayName: 'Schedule test', email: 'schedule@example.test', ); - final effectiveHeaderBarService = - headerBarService ?? LinuxHeaderBarService(isLinux: false); - if (headerBarService == null) { - addTearDown(effectiveHeaderBarService.dispose); - } + final closeCoordinator = BusyMaxWindowCloseCoordinator(); await tester.pumpWidget( ProviderScope( overrides: [ @@ -299,9 +317,6 @@ Future<_ScheduleHarness> _pumpScheduleWorkspace( activeAccountProvider.overrideWithValue(_accountId), localTimeZoneProvider.overrideWithValue('UTC'), localSettingsStoreProvider.overrideWithValue(_MemorySettingsStore()), - linuxHeaderBarServiceProvider.overrideWithValue( - effectiveHeaderBarService, - ), taskListsRepositoryForAccountProvider.overrideWith((ref, accountId) { return TaskListsRepository(database: database, accountId: accountId); }), @@ -310,17 +325,20 @@ Future<_ScheduleHarness> _pumpScheduleWorkspace( }), ], child: localizedTestApp( - child: ScheduleWorkspace( - initialScope: ScheduleScope.tasks, - initialTaskAccountId: initialTaskAccountId, - initialTaskListId: initialTaskListId, - initialTaskId: initialTaskId, + child: BusyMaxWindowCloseScope( + coordinator: closeCoordinator, + child: ScheduleWorkspace( + initialScope: ScheduleScope.tasks, + initialTaskAccountId: initialTaskAccountId, + initialTaskListId: initialTaskListId, + initialTaskId: initialTaskId, + ), ), ), ), ); await tester.pumpAndSettle(); - return _ScheduleHarness(database); + return _ScheduleHarness(database, closeCoordinator); } String _todayUtc() { @@ -329,9 +347,10 @@ String _todayUtc() { } class _ScheduleHarness { - const _ScheduleHarness(this.database); + const _ScheduleHarness(this.database, this.closeCoordinator); final AppDatabase database; + final BusyMaxWindowCloseCoordinator closeCoordinator; } class _MemorySettingsStore implements LocalSettingsStore { @@ -342,20 +361,6 @@ class _MemorySettingsStore implements LocalSettingsStore { Future save(Map json) async {} } -class _RecordingHeaderBarService extends LinuxHeaderBarService { - _RecordingHeaderBarService() : super(isLinux: false); - - final modalBarrierStates = <({bool visible, int shadeDepth})>[]; - - @override - Future setModalBarrierState({ - required bool visible, - required int shadeDepth, - }) async { - modalBarrierStates.add((visible: visible, shadeDepth: shadeDepth)); - } -} - const _accountId = 'google:schedule-test'; const _taskListId = 'inbox'; const _projectListId = 'projects'; diff --git a/test/features/schedule/schedule_search_test.dart b/test/features/schedule/schedule_search_test.dart index 43ee3d81..ee4af73e 100644 --- a/test/features/schedule/schedule_search_test.dart +++ b/test/features/schedule/schedule_search_test.dart @@ -793,11 +793,17 @@ void main() { providerCalendarId: 'calendar', providerEventId: 'event', providerRecurringEventId: 'series-master', + providerOriginalStartKey: '2026-06-11T04:20:00.0000000', title: 'Planning', startDateTime: '2026-06-11T04:20:00.0000000', startTimeZone: 'Pacific Standard Time', endDateTime: '2026-06-11T04:50:00.0000000', endTimeZone: 'Pacific Standard Time', + rawJson: { + 'id': 'event', + 'seriesMasterId': 'series-master', + 'originalStart': '2026-06-11T04:20:00.0000000', + }, ), ); diff --git a/test/features/settings/presentation/settings_screen_test.dart b/test/features/settings/presentation/settings_screen_test.dart index b2b6e55b..647bcdfd 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:convert'; import 'dart:io'; import 'dart:ui' as ui; @@ -11,6 +12,7 @@ import 'package:yaru/yaru.dart'; import 'package:busymax/src/app/app_bootstrap.dart'; import 'package:busymax/src/app/busymax_design.dart'; import 'package:busymax/src/app/busymax_yaru_theme.dart'; +import 'package:busymax/src/app/linux/linux_header_style.dart'; import 'package:busymax/src/config/build_config.dart'; import 'package:busymax/src/core/secrets/secret_store.dart'; import 'package:busymax/src/dav/auth/dav_account_onboarding_service.dart'; @@ -23,14 +25,16 @@ import 'package:busymax/src/features/accounts/domain/account_connection_state.da import 'package:busymax/src/features/auth/data/auth_repository.dart'; import 'package:busymax/src/features/calendar/data/calendar_repository.dart'; import 'package:busymax/src/features/settings/presentation/settings_screen.dart'; +import 'package:busymax/src/features/task_lists/data/task_lists_repository.dart'; import 'package:busymax/src/features/sync/sync_auth_error.dart'; 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/gtk_header_icon_service.dart'; import 'package:busymax/src/platform/native_menu_service.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'; +import 'package:busymax/src/webcal/webcal_http_client.dart'; +import 'package:busymax/src/webcal/webcal_subscription_service.dart'; import 'package:busymax/l10n/generated/app_localizations.dart'; import 'package:drift/native.dart'; import 'package:http/http.dart' as http; @@ -394,21 +398,23 @@ void main() { ), ); - final pageHeading = tester.widget( - find.byKey(const ValueKey('settings-page-heading')), - ); final accountHeading = tester.widget( find.byKey(const ValueKey('settings-account-heading-google:g')), ); final calendarHeading = tester.widget(find.text('Calendars')); - expect( - pageHeading.style!.fontSize, - greaterThan(accountHeading.style!.fontSize!), - ); expect( accountHeading.style!.fontSize, greaterThan(calendarHeading.style!.fontSize!), ); + final foreground = Theme.of( + tester.element( + find.byKey(const ValueKey('settings-account-heading-google:g')), + ), + ).colorScheme.onSurface; + expect(accountHeading.style?.fontWeight, FontWeight.bold); + expect(accountHeading.style?.color, foreground); + expect(calendarHeading.style?.fontWeight, FontWeight.bold); + expect(calendarHeading.style?.color, foreground); expect( tester.getTopLeft(find.text('Calendars')).dx, closeTo( @@ -422,7 +428,7 @@ void main() { ); }); - testWidgets('Every Settings page uses a primary page heading', ( + testWidgets('Every Settings page uses only its headerbar title', ( tester, ) async { final container = _container( @@ -447,11 +453,14 @@ void main() { find.byKey(ValueKey('settings-navigation-${entry.key.name}')), ); await tester.pumpAndSettle(); - final heading = tester.widget( - find.byKey(const ValueKey('settings-page-heading')), + expect(find.byKey(const ValueKey('settings-page-heading')), findsNothing); + expect( + find.descendant( + of: find.byKey(const ValueKey('settings-header-title')), + matching: find.text(entry.value), + ), + findsOneWidget, ); - expect(heading.data, entry.value); - expect(heading.style?.fontWeight, FontWeight.w600); } await tester.pumpWidget(const SizedBox()); @@ -459,23 +468,159 @@ void main() { await tester.pump(const Duration(milliseconds: 1)); }); - testWidgets('Settings fallback header uses the semantic title style', ( + testWidgets('Settings section headings use the foreground and bold weight', ( + tester, + ) async { + final container = _container( + selectedAccountId: 'google:g', + authRepository: _FakeAuthRepository(), + accounts: const [_googleAccount], + ); + addTearDown(container.dispose); + + await _pumpSettings(tester, container, logicalSize: const Size(1000, 900)); + final foreground = Theme.of( + tester.element(find.text('Calendar import')), + ).colorScheme.onSurface; + for (final label in [ + 'Google', + 'Calendar import', + 'Calendar subscriptions', + ]) { + final heading = tester.widget(find.text(label)); + expect(heading.style?.fontWeight, FontWeight.bold); + expect(heading.style?.color, foreground); + } + + await tester.tap( + find.byKey(const ValueKey('settings-navigation-schedule')), + ); + await tester.pumpAndSettle(); + final scheduleHeading = tester.widget(find.text('Schedule display')); + expect(scheduleHeading.style?.fontWeight, FontWeight.bold); + expect(scheduleHeading.style?.color, foreground); + }); + + testWidgets('Schedule lets users choose a default or Last used destination', ( + tester, + ) async { + const calendar = CalendarSourceEntity( + id: 'calendar-1', + accountId: 'google:g', + provider: BusyProvider.google, + providerCalendarId: 'calendar-1', + summary: 'Work calendar', + selected: true, + hidden: false, + readOnly: false, + isDeleted: false, + ); + const list = TaskListEntity( + accountId: 'google:g', + id: 'tasks-1', + title: 'Work tasks', + localDirty: false, + pendingDelete: false, + rawJson: '{}', + ); + final container = _container( + selectedAccountId: 'google:g', + authRepository: _FakeAuthRepository(), + accounts: const [_googleAccount], + calendarSources: const [calendar], + taskLists: const [list], + ); + addTearDown(container.dispose); + await _pumpSettings( + tester, + container, + initialPage: SettingsPage.schedule, + logicalSize: const Size(1000, 900), + ); + expect(find.text('New events and tasks'), findsOneWidget); + final rows = tester + .widgetList>( + find.byType(BusyMaxComboRow), + ) + .toList(); + expect(rows, hasLength(2)); + expect(rows.map((row) => row.title), [ + 'Default calendar', + 'Default task list', + ]); + expect(rows.map((row) => row.selected), [null, null]); + rows[0].onSelected( + const CreationDestination(accountId: 'google:g', id: 'calendar-1'), + ); + rows[1].onSelected( + const CreationDestination(accountId: 'google:g', id: 'tasks-1'), + ); + await tester.pumpAndSettle(); + final settings = container.read(appSettingsControllerProvider); + expect(settings.defaultCalendar?.id, 'calendar-1'); + expect(settings.defaultTaskList?.id, 'tasks-1'); + final updatedRows = tester + .widgetList>( + find.byType(BusyMaxComboRow), + ) + .toList(); + updatedRows[0].onSelected(null); + updatedRows[1].onSelected(null); + await tester.pumpAndSettle(); + expect( + container.read(appSettingsControllerProvider).defaultCalendar, + isNull, + ); + expect( + container.read(appSettingsControllerProvider).defaultTaskList, + isNull, + ); + }); + + testWidgets('Settings Flutter header uses native geometry and title style', ( tester, ) async { final container = _container( selectedAccountId: 'google:g', authRepository: _FakeAuthRepository(), accounts: const [_googleAccount], - useFlutterHeader: true, ); addTearDown(container.dispose); await _pumpSettings(tester, container); - final emphasizedAccountTitles = tester - .widgetList(find.text('Accounts')) - .where((text) => text.style?.fontWeight == FontWeight.bold); - expect(emphasizedAccountTitles, hasLength(1)); + final titleRoot = find.byKey(const ValueKey('settings-header-title')); + final title = tester.widget( + find.descendant(of: titleRoot, matching: find.text('Accounts')), + ); + final context = tester.element(titleRoot); + final bodyStyle = Theme.of(context).textTheme.bodyMedium; + expect(title.style?.fontSize, bodyStyle?.fontSize); + expect(title.style?.fontFamily, bodyStyle?.fontFamily); + expect(title.style?.fontWeight, FontWeight.bold); + expect( + tester.getSize(find.byKey(const ValueKey('settings-header-back-button'))), + const Size.square(BusyMaxSizes.headerIconButton), + ); + expect( + tester.getSize(find.byKey(const ValueKey('busymax-main-menu-button'))), + const Size.square(BusyMaxSizes.headerIconButton), + ); + final header = tester.getRect(find.byType(BusyMaxLinuxHeaderLayout)); + expect(tester.getRect(titleRoot).center.dx, closeTo(header.center.dx, .01)); + BusyMaxLinuxHeaderIcon iconFor(String key) => tester + .widget( + find.descendant( + of: find.byKey(ValueKey(key)), + matching: find.byType(BusyMaxGtkHeaderIcon), + ), + ) + .icon; + expect(iconFor('settings-header-back-button'), BusyMaxLinuxHeaderIcon.back); + expect( + iconFor('busymax-main-menu-button'), + BusyMaxLinuxHeaderIcon.mainMenu, + ); }); testWidgets('Settings removes the selected Microsoft account', ( @@ -627,7 +772,153 @@ void main() { expect(container.read(selectedAccountIdProvider), 'google:g'); }); - testWidgets('Settings exposes one clear account-removal action', ( + testWidgets('subscription dialog groups, validates, and submits its form', ( + tester, + ) async { + final database = AppDatabase(NativeDatabase.memory()); + addTearDown(database.close); + final transport = _SettingsWebCalTransport(); + final service = WebCalSubscriptionService( + database: database, + secretStore: InMemorySecretStore(), + httpTransport: transport, + idFactory: () => 'settings-subscription', + nowUtc: () => DateTime.utc(2026, 9, 19), + onNotificationScheduleChanged: () async {}, + ); + late BuildContext hostContext; + late WidgetRef widgetRef; + await tester.pumpWidget( + ProviderScope( + overrides: [ + webCalSubscriptionServiceProvider.overrideWithValue(service), + ], + child: localizedTestApp( + theme: BusyMaxYaruTheme.build( + brightness: Brightness.dark, + accentColor: YaruColors.orange, + ), + child: Consumer( + builder: (context, ref, child) { + hostContext = context; + widgetRef = ref; + return const Scaffold(body: SizedBox.expand()); + }, + ), + ), + ), + ); + + final flow = showAddCalendarSubscriptionFlow( + hostContext, + widgetRef, + initialUrl: 'webcal://calendar.example.test/feed', + ); + await tester.pumpAndSettle(); + + expect(find.byType(BusyMaxDialogShell), findsOneWidget); + expect(find.byType(BusyMaxGroupedList), findsOneWidget); + final fields = find.descendant( + of: find.byType(BusyMaxDialogShell), + matching: find.byType(TextField), + ); + expect(fields, findsNWidgets(3)); + expect( + tester.widget(fields.first).decoration?.border, + InputBorder.none, + ); + expect( + tester.widget(fields.first).controller!.text, + 'webcal://calendar.example.test/feed', + ); + expect( + find.textContaining('https://calendar.example.test'), + findsOneWidget, + ); + expect( + find.byWidgetPredicate( + (widget) => widget is Text && (widget.data ?? '').contains('/feed'), + ), + findsNothing, + ); + + await tester.enterText(fields.first, 'not a subscription URL'); + await tester.pump(); + expect( + tester + .widget( + find.byKey(const ValueKey('confirm-add-subscription')), + ) + .onPressed, + isNull, + ); + + await tester.enterText(fields.first, 'webcal://calendar.example.test/feed'); + await tester.enterText(fields.at(1), 'Team schedule'); + await tester.enterText(fields.at(2), '#336699'); + await tester.pump(); + await tester.tap(find.byKey(const ValueKey('confirm-add-subscription'))); + await tester.pumpAndSettle(); + await flow; + + expect(transport.requests, [ + Uri.parse('https://calendar.example.test/feed'), + ]); + final subscription = await database + .select(database.webCalSubscriptions) + .getSingle(); + final source = await database.select(database.calendarSources).getSingle(); + expect(subscription.safeOrigin, 'https://calendar.example.test'); + expect(subscription.refreshMode, WebCalRefreshMode.automatic.storageValue); + expect(source.summary, 'Team schedule'); + expect(source.backgroundColor, '#336699'); + }); + + testWidgets('subscription dialog cancellation performs no request', ( + tester, + ) async { + final database = AppDatabase(NativeDatabase.memory()); + addTearDown(database.close); + final transport = _SettingsWebCalTransport(); + final service = WebCalSubscriptionService( + database: database, + secretStore: InMemorySecretStore(), + httpTransport: transport, + ); + late BuildContext hostContext; + late WidgetRef widgetRef; + await tester.pumpWidget( + ProviderScope( + overrides: [ + webCalSubscriptionServiceProvider.overrideWithValue(service), + ], + child: localizedTestApp( + child: Consumer( + builder: (context, ref, child) { + hostContext = context; + widgetRef = ref; + return const Scaffold(body: SizedBox.expand()); + }, + ), + ), + ), + ); + + final flow = showAddCalendarSubscriptionFlow( + hostContext, + widgetRef, + initialUrl: 'https://calendar.example.test/feed', + ); + await tester.pumpAndSettle(); + await tester.tap(find.text('Cancel')); + await tester.pumpAndSettle(); + await flow; + + expect(transport.requests, isEmpty); + expect(await database.select(database.webCalSubscriptions).get(), isEmpty); + }); + + testWidgets('Settings exposes one readable dark account-removal action', ( tester, ) async { final container = _container( @@ -637,7 +928,20 @@ void main() { ); addTearDown(container.dispose); - await _pumpSettings(tester, container); + final theme = BusyMaxYaruTheme.build( + brightness: Brightness.dark, + accentColor: YaruColors.orange, + ); + await tester.pumpWidget( + UncontrolledProviderScope( + container: container, + child: localizedTestApp( + theme: theme, + child: const SettingsScreen(initialPage: SettingsPage.accounts), + ), + ), + ); + await tester.pumpAndSettle(); expect(find.text('Remove account…'), findsOneWidget); expect( @@ -649,6 +953,36 @@ void main() { expect(find.text('Sign out this account'), findsNothing); expect(find.text('Disconnect this account'), findsNothing); expect(find.text('Delete local data for this account'), findsNothing); + + final removeRowFinder = find.ancestor( + of: find.text('Remove account…'), + matching: find.byType(BusyMaxActionRow), + ); + final removeRow = tester.widget(removeRowFinder); + final removeTitle = tester.widget(find.text('Remove account…')); + final trash = tester.widget( + find.descendant( + of: removeRowFinder, + matching: find.byIcon(YaruIcons.trash), + ), + ); + expect(removeRow.destructive, isFalse); + expect(removeTitle.style?.color, isNot(theme.colorScheme.error)); + expect(trash.color, theme.colorScheme.error); + + await tester.tap(find.text('Remove account…')); + await tester.pumpAndSettle(); + final confirm = tester.widget( + find.byKey(const Key('confirm-account-removal')), + ); + expect( + confirm.style?.backgroundColor?.resolve(const {}), + theme.colorScheme.error, + ); + expect( + confirm.style?.foregroundColor?.resolve(const {}), + theme.colorScheme.onError, + ); }); testWidgets('Settings reports a local account-removal failure in place', ( @@ -690,10 +1024,19 @@ void main() { await _pumpSettings(tester, container); await _openAccountRemovalDialog(tester); await tester.tap(find.byKey(const Key('confirm-account-removal'))); - await tester.pump(); + // Account mutation starts only after the dialog route has completed its + // exit transition and released its modal protection. + await tester.pumpAndSettle(); expect(auth.removalCalls, hasLength(1)); expect(find.text('Removing account…'), findsOneWidget); + final removingRow = tester.widget( + find.ancestor( + of: find.text('Removing account…'), + matching: find.byType(BusyMaxActionRow), + ), + ); + expect(removingRow.enabled, isFalse); await tester.tap(find.text('Removing account…'), warnIfMissed: false); await tester.pump(); expect(auth.removalCalls, hasLength(1)); @@ -851,6 +1194,79 @@ void main() { }, ); + testWidgets('eligible DAV account can refresh collections', (tester) async { + final calls = []; + final container = _container( + selectedAccountId: _nextcloudAccount.id, + authRepository: _FakeAuthRepository(), + accounts: const [_nextcloudAccount], + signedInSyncRunner: (accountId, full) async => calls.add(accountId), + ); + addTearDown(container.dispose); + + await _pumpSettings(tester, container, logicalSize: const Size(1000, 900)); + await tester.ensureVisible(find.text('Refresh calendars and task lists')); + await tester.tap(find.text('Refresh calendars and task lists')); + await tester.pumpAndSettle(); + + expect(calls, [_nextcloudAccount.id]); + }); + + testWidgets('reconnect-required DAV account cannot refresh collections', ( + tester, + ) async { + final calls = []; + final container = _container( + selectedAccountId: _reconnectRequiredNextcloudAccount.id, + authRepository: _FakeAuthRepository(), + accounts: const [_reconnectRequiredNextcloudAccount], + signedInSyncRunner: (accountId, full) async => calls.add(accountId), + ); + addTearDown(container.dispose); + + await _pumpSettings(tester, container, logicalSize: const Size(1000, 900)); + + expect(find.text('Refresh calendars and task lists'), findsNothing); + expect(find.text(accountReconnectRequiredActionLabel), findsOneWidget); + expect(calls, isEmpty); + }); + + testWidgets('DAV collection refresh re-reads eligibility before dispatch', ( + tester, + ) async { + final calls = []; + final repository = _MutableEligibilityAccountsRepository([ + _nextcloudAccount, + ]); + final cachedCollection = _davCollection( + id: 'cached-calendar', + name: 'Cached calendar', + supportsEvents: true, + ); + final container = _container( + selectedAccountId: _nextcloudAccount.id, + authRepository: _FakeAuthRepository(), + accounts: const [_nextcloudAccount], + accountsRepository: repository, + davCollections: [cachedCollection], + signedInSyncRunner: (accountId, full) async => calls.add(accountId), + ); + addTearDown(container.dispose); + + await _pumpSettings(tester, container, logicalSize: const Size(1000, 900)); + repository.canonicalAccounts = const [_reconnectRequiredNextcloudAccount]; + await tester.ensureVisible(find.text('Refresh calendars and task lists')); + await tester.tap(find.text('Refresh calendars and task lists')); + await tester.pumpAndSettle(); + + expect(calls, isEmpty); + expect( + find.text('Sync failed: This account needs to be reconnected.'), + findsOneWidget, + ); + expect(find.text('Cached calendar'), findsOneWidget); + }); + testWidgets('Settings nests controls for combined DAV content', ( tester, ) async { @@ -1021,6 +1437,12 @@ void main() { ); expect(find.byType(YaruNavigationRail), findsNothing); expect(find.byType(BusyMaxSidebarSurface), findsOneWidget); + expect( + tester + .widget(find.byType(BusyMaxSidebarSurface)) + .showEndBorder, + isFalse, + ); final navigationTiles = tester .widgetList( find.byType(BusyMaxSidebarNavigationTile), @@ -1122,6 +1544,121 @@ void main() { await tester.pump(const Duration(milliseconds: 1)); }); + testWidgets('Force full resync runs for a connected account', (tester) async { + final calls = []; + final container = _container( + selectedAccountId: _googleAccount.id, + authRepository: _FakeAuthRepository(), + accounts: const [_googleAccount], + signedInSyncRunner: (accountId, full) async => calls.add(accountId), + ); + addTearDown(container.dispose); + + await _pumpSettings( + tester, + container, + logicalSize: const Size(1000, 700), + initialPage: SettingsPage.diagnostics, + ); + await tester.tap(find.text('Force full resync')); + await tester.pumpAndSettle(); + + expect(calls, [_googleAccount.id]); + await _disposeDiagnosticsWidget(tester); + }); + + testWidgets('Force full resync skips reconnect-required accounts', ( + tester, + ) async { + final calls = []; + const reconnect = AccountEntity( + id: 'google:reauth', + provider: BusyProvider.google, + authority: 'https://accounts.google.com', + providerAccountId: 'reauth', + authState: accountAuthStateReauthRequired, + ); + final container = _container( + selectedAccountId: _googleAccount.id, + authRepository: _FakeAuthRepository(), + accounts: const [_googleAccount, reconnect], + signedInSyncRunner: (accountId, full) async => calls.add(accountId), + ); + addTearDown(container.dispose); + + await _pumpSettings( + tester, + container, + logicalSize: const Size(1000, 700), + initialPage: SettingsPage.diagnostics, + ); + await tester.tap(find.text('Force full resync')); + await tester.pumpAndSettle(); + + expect(calls, [_googleAccount.id]); + await _disposeDiagnosticsWidget(tester); + }); + + testWidgets('Force full resync is disabled without eligible accounts', ( + tester, + ) async { + final calls = []; + final container = _container( + selectedAccountId: _reconnectRequiredGoogleAccount.id, + authRepository: _FakeAuthRepository(), + accounts: const [_reconnectRequiredGoogleAccount], + signedInSyncRunner: (accountId, full) async => calls.add(accountId), + ); + addTearDown(container.dispose); + + await _pumpSettings( + tester, + container, + logicalSize: const Size(1000, 700), + initialPage: SettingsPage.diagnostics, + ); + + final row = tester.widget( + find.ancestor( + of: find.text('Force full resync'), + matching: find.byType(BusyMaxActionRow), + ), + ); + expect(row.enabled, isFalse); + expect(row.onTap, isNull); + expect(calls, isEmpty); + await _disposeDiagnosticsWidget(tester); + }); + + testWidgets('Force full resync re-reads eligibility before dispatch', ( + tester, + ) async { + final calls = []; + final repository = _MutableEligibilityAccountsRepository([_googleAccount]); + final container = _container( + selectedAccountId: _googleAccount.id, + authRepository: _FakeAuthRepository(), + accounts: const [_googleAccount], + accountsRepository: repository, + signedInSyncRunner: (accountId, full) async => calls.add(accountId), + ); + addTearDown(container.dispose); + + await _pumpSettings( + tester, + container, + logicalSize: const Size(1000, 700), + initialPage: SettingsPage.diagnostics, + ); + repository.canonicalAccounts = const [_reconnectRequiredGoogleAccount]; + await tester.tap(find.text('Force full resync')); + await tester.pumpAndSettle(); + + expect(calls, isEmpty); + expect(tester.takeException(), isNull); + await _disposeDiagnosticsWidget(tester); + }); + testWidgets('Settings uses single-pane navigation at narrow widths', ( tester, ) async { @@ -1137,7 +1674,7 @@ void main() { await _pumpSettings(tester, container, logicalSize: const Size(640, 700)); expect(find.text('Accounts'), findsWidgets); - expect(find.text('Schedule'), findsNothing); + expect(find.text('Schedule'), findsOneWidget); await tester.tap(find.byKey(const ValueKey('settings-page-selector'))); await tester.pumpAndSettle(); @@ -1317,14 +1854,26 @@ void main() { await _pumpRoutedSettings(tester, container); - await container - .read(linuxHeaderBarServiceProvider) - .handleNativeMethodCall(const MethodCall('back')); + await tester.tap(find.byTooltip('Back')); await tester.pumpAndSettle(); expect(find.text('schedule route'), findsOneWidget); }); + testWidgets('Alt+Left uses Settings schedule fallback', (tester) async { + final container = _container( + selectedAccountId: 'google:g', + authRepository: _FakeAuthRepository(), + accounts: const [_googleAccount], + ); + addTearDown(container.dispose); + + await _pumpRoutedSettings(tester, container); + await _sendAltLeft(tester); + + expect(find.text('schedule route'), findsOneWidget); + }); + testWidgets('Settings back returns to the route that opened it', ( tester, ) async { @@ -1349,14 +1898,35 @@ void main() { await tester.pumpAndSettle(); expect(router.state.uri.queryParameters['page'], 'notifications'); - await container - .read(linuxHeaderBarServiceProvider) - .handleNativeMethodCall(const MethodCall('back')); + await tester.tap(find.byTooltip('Back')); await tester.pumpAndSettle(); expect(find.text('tasks route'), findsOneWidget); }); + testWidgets('Alt+Left pops Settings to the route that opened it', ( + tester, + ) async { + final container = _container( + selectedAccountId: 'google:g', + authRepository: _FakeAuthRepository(), + accounts: const [_googleAccount], + ); + addTearDown(container.dispose); + + final router = await _pumpRoutedSettings( + tester, + container, + initialLocation: '/tasks', + ); + unawaited(router.push('/settings')); + await tester.pumpAndSettle(); + + await _sendAltLeft(tester); + + expect(find.text('tasks route'), findsOneWidget); + }); + testWidgets('Removing last account routes to sign in cleanly', ( tester, ) async { @@ -1398,18 +1968,28 @@ Future _openAccountRemovalDialog(WidgetTester tester) async { expect(find.textContaining('from BusyMax?'), findsOneWidget); } +Future _sendAltLeft(WidgetTester tester) async { + await tester.sendKeyDownEvent(LogicalKeyboardKey.altLeft); + await tester.sendKeyDownEvent(LogicalKeyboardKey.arrowLeft); + await tester.sendKeyUpEvent(LogicalKeyboardKey.arrowLeft); + await tester.sendKeyUpEvent(LogicalKeyboardKey.altLeft); + await tester.pumpAndSettle(); +} + ProviderContainer _container({ required String selectedAccountId, required _FakeAuthRepository authRepository, required List accounts, BuildConfig buildConfig = _emptyBuildConfig, String? activeAccountIdOverride = _useDefaultActiveAccountId, - bool useFlutterHeader = false, DavAccountOnboardingService? davOnboardingService, List davCollections = const [], List calendarSources = const [], + List taskLists = const [], DesktopAutostartService? autostartService, LocalSettingsStore? settingsStore, + AccountsRepository? accountsRepository, + SignedInSyncRunner? signedInSyncRunner, }) { return ProviderContainer( overrides: [ @@ -1421,8 +2001,10 @@ ProviderContainer _container({ davOnboardingService, ), accountsRepositoryProvider.overrideWithValue( - _FakeAccountsRepository(accounts), + accountsRepository ?? _FakeAccountsRepository(accounts), ), + if (signedInSyncRunner != null) + signedInSyncRunnerProvider.overrideWithValue(signedInSyncRunner), accountsStreamProvider.overrideWith((ref) => Stream.value(accounts)), accountManagementStreamProvider.overrideWith( (ref) => Stream.value(accounts), @@ -1433,6 +2015,7 @@ ProviderContainer _container({ calendarSourcesStreamProvider.overrideWith( (ref) => Stream.value(calendarSources), ), + scheduleTaskListsProvider.overrideWith((ref) async => taskLists), davConflictsStreamProvider.overrideWith((ref) => Stream.value(const [])), webCalSubscriptionsProvider.overrideWith((ref) => Stream.value(const [])), selectedAccountIdProvider.overrideWith((ref) => selectedAccountId), @@ -1442,12 +2025,6 @@ ProviderContainer _container({ settingsStore ?? _MemorySettingsStore(), ), buildConfigProvider.overrideWithValue(buildConfig), - if (useFlutterHeader) - linuxHeaderBarServiceProvider.overrideWith((ref) { - final service = LinuxHeaderBarService(isLinux: false); - ref.onDispose(service.dispose); - return service; - }), ], ); } @@ -1506,6 +2083,12 @@ Future _pumpSettings( await tester.pumpAndSettle(); } +Future _disposeDiagnosticsWidget(WidgetTester tester) async { + await tester.pumpWidget(const SizedBox()); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 1)); +} + Future _pumpDefaultSettings( WidgetTester tester, ProviderContainer container, { @@ -1627,10 +2210,78 @@ class _FakeAccountsRepository implements AccountsRepository { @override Future> listSignedInAccounts() async => accounts; + @override + Future> listSyncEligibleAccounts() async => + accounts.where((account) => account.isSyncEligible).toList(); + + @override + Future accountById(String accountId) async { + for (final account in accounts) { + if (account.id == accountId) return account; + } + return null; + } + @override dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); } +final class _MutableEligibilityAccountsRepository + implements AccountsRepository { + _MutableEligibilityAccountsRepository(this.canonicalAccounts); + + List canonicalAccounts; + + @override + Future> listSyncEligibleAccounts() async => + canonicalAccounts.where((account) => account.isSyncEligible).toList(); + + @override + Future accountById(String accountId) async { + for (final account in canonicalAccounts) { + if (account.id == accountId) return account; + } + return null; + } + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +final class _SettingsWebCalTransport implements WebCalHttpTransport { + final requests = []; + + @override + Future get( + Uri uri, { + WebCalHttpValidators validators = const WebCalHttpValidators(), + Uri? validatorTarget, + }) async { + requests.add(uri); + return WebCalHttpResponse( + statusCode: 200, + finalUri: uri, + body: Uint8List.fromList( + utf8.encode('''BEGIN:VCALENDAR\r +VERSION:2.0\r +PRODID:-//BusyMax Test//EN\r +BEGIN:VEVENT\r +UID:settings-subscription-event\r +DTSTART:20260920T160000Z\r +DTEND:20260920T170000Z\r +SUMMARY:Team event\r +END:VEVENT\r +END:VCALENDAR\r +'''), + ), + etag: '"settings-test"', + lastModified: null, + contentType: 'text/calendar; charset=utf-8', + conditionalRequestSent: false, + ); + } +} + class _MemorySettingsStore implements LocalSettingsStore { Map value = {}; @@ -1673,6 +2324,16 @@ const _nextcloudAccount = AccountEntity( authState: accountAuthStateSignedIn, ); +const _reconnectRequiredNextcloudAccount = AccountEntity( + id: 'nextcloud:n', + provider: BusyProvider.nextcloud, + authority: 'https://cloud.example.test', + providerAccountId: 'alex', + credentialKind: CredentialKind.nextcloudAppPassword, + displayName: 'Nextcloud User', + authState: accountAuthStateReauthRequired, +); + DavCollectionSettingsEntity _davCollection({ required String id, required String name, diff --git a/test/features/sync/calendar_pending_ops_replayer_test.dart b/test/features/sync/calendar_pending_ops_replayer_test.dart index 8eb6b2a3..dde8bb86 100644 --- a/test/features/sync/calendar_pending_ops_replayer_test.dart +++ b/test/features/sync/calendar_pending_ops_replayer_test.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'dart:convert'; import 'dart:io'; +import 'dart:math'; import 'package:busymax/src/calendar_providers/calendar_colors.dart'; import 'package:busymax/src/app/app_settings.dart'; @@ -10,6 +11,7 @@ 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'; import 'package:busymax/src/core/time/provider_date_time.dart'; +import 'package:busymax/src/core/http/request_dispatch_exception.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'; @@ -304,6 +306,292 @@ void main() { expect(await database.pendingOpsDao.getOp(operationId), equals(null)); }); + test('event edit waits for a retrying creation and then completes', () async { + final repository = CalendarRepository( + database: database, + now: () => DateTime.utc(2026, 6, 8), + ); + 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: 'Draft'), + ); + final local = await database.select(database.calendarEvents).getSingle(); + final detail = (await repository.loadEventDetail(local.id))!; + await repository.updateLocalEvent( + EventEditorDraft.fromEventDetail(detail).copyWith(title: 'Revised'), + ); + final queued = await database.select(database.pendingOps).get(); + final create = queued.singleWhere( + (operation) => operation.operationType == 'event.create', + ); + final edit = queued.singleWhere( + (operation) => operation.operationType == 'event.patch', + ); + expect(edit.dependsOnOpId, create.id); + client.createEventResponseError = StateError('response lost'); + + expect( + await CalendarPendingOpsReplayer( + database: database, + client: client, + accountId: 'account', + random: Random(0), + nowUtc: () => DateTime.utc(2026, 6, 8), + ).replayDueOps(), + 0, + ); + expect( + await CalendarPendingOpsReplayer( + database: database, + client: client, + accountId: 'account', + nowUtc: () => DateTime.utc(2026, 6, 9), + ).replayDueOps(), + 2, + ); + expect(await database.select(database.pendingOps).get(), isEmpty); + expect(client.updatedMutations.single.title, 'Revised'); + }); + + test( + 'creation acknowledgement preserves a newer event edit and rebases it', + () async { + final repository = CalendarRepository( + database: database, + now: () => DateTime.utc(2026, 6, 8), + ); + final createId = 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: 'Draft'), + ); + final temporary = await database + .select(database.calendarEvents) + .getSingle(); + final detail = (await repository.loadEventDetail(temporary.id))!; + await repository.updateLocalEvent( + EventEditorDraft.fromEventDetail(detail).copyWith(title: 'Revised'), + ); + client.createEventOverride = (calendarId, mutation) => client._event( + mutation.providerEventId!, + title: mutation.title!, + providerCalendarId: calendarId, + etagOrChangeKey: '"created"', + ); + client.transientUpdateFailures = 1; + + expect( + await CalendarPendingOpsReplayer( + database: database, + client: client, + accountId: 'account', + random: Random(0), + nowUtc: () => DateTime.utc(2026, 6, 8), + ).replayDueOps(), + 1, + ); + + final local = await database.select(database.calendarEvents).getSingle(); + expect(local.providerEventId, googleCalendarCreateEventId(createId)); + expect(local.title, 'Revised'); + expect(local.syncStatus, 'pending'); + final pending = await database.select(database.pendingOps).getSingle(); + expect(pending.operationType, 'event.patch'); + expect(pending.baselineUpdatedUtc, '2026-06-08T00:00:00.000Z'); + expect( + jsonDecode(pending.baselineRawJson!)['id'], + googleCalendarCreateEventId(createId), + ); + expect(client.ifMatches, ['"created"']); + }, + ); + + test( + 'creation acknowledgement versions every queued edit descendant', + () async { + final repository = CalendarRepository( + database: database, + now: () => DateTime.utc(2026, 6, 8), + ); + final createId = 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: 'Draft'), + ); + var local = await database.select(database.calendarEvents).getSingle(); + var detail = (await repository.loadEventDetail(local.id))!; + await repository.updateLocalEvent( + EventEditorDraft.fromEventDetail(detail).copyWith(title: 'Revised'), + ); + detail = (await repository.loadEventDetail(local.id))!; + await repository.updateLocalEvent( + EventEditorDraft.fromEventDetail( + detail, + ).copyWith(location: 'Local room'), + ); + final queued = await database.select(database.pendingOps).get(); + final create = queued.singleWhere( + (operation) => operation.operationType == 'event.create', + ); + final edits = queued + .where((operation) => operation.operationType == 'event.patch') + .toList(); + expect(edits, hasLength(2)); + expect(edits.first.dependsOnOpId, create.id); + expect(edits.last.dependsOnOpId, edits.first.id); + + client.createEventOverride = (calendarId, mutation) => client._event( + mutation.providerEventId!, + title: mutation.title!, + providerCalendarId: calendarId, + etagOrChangeKey: '"created"', + ); + client + ..persistEventUpdates = true + ..transientUpdateFailureCall = 2; + + expect( + await CalendarPendingOpsReplayer( + database: database, + client: client, + accountId: 'account', + random: Random(0), + nowUtc: () => DateTime.utc(2026, 6, 8), + ).replayDueOps(), + 2, + ); + final pending = await database.select(database.pendingOps).getSingle(); + expect(pending.operationType, 'event.patch'); + expect(pending.baselineUpdatedUtc, isNotNull); + expect( + jsonDecode(pending.baselineRawJson!)[calendarEventSemanticBaselineKey], + containsPair('title', 'Revised'), + ); + local = await database.select(database.calendarEvents).getSingle(); + expect(local.title, 'Revised'); + expect(local.location, 'Local room'); + + final providerEventId = googleCalendarCreateEventId(createId); + client._createdEventsByIdentity[providerEventId] = client._event( + providerEventId, + title: 'Revised', + location: 'Remote room', + updatedAtServer: '2026-06-08T00:10:00.000Z', + etagOrChangeKey: '"remote"', + ); + expect( + await CalendarPendingOpsReplayer( + database: database, + client: client, + accountId: 'account', + nowUtc: () => DateTime.utc(2026, 6, 9), + ).replayDueOps(), + 0, + ); + final blocked = await database.select(database.pendingOps).getSingle(); + expect(blocked.lastErrorCode, 'conflict'); + expect(client.updatedMutations, hasLength(2)); + }, + ); + + test( + 'acknowledged event fields propagate through every later edit', + () async { + final repository = CalendarRepository( + database: database, + now: () => DateTime.utc(2026, 6, 8), + ); + final createId = 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: 'Draft'), + ); + final local = await database.select(database.calendarEvents).getSingle(); + var detail = (await repository.loadEventDetail(local.id))!; + await repository.updateLocalEvent( + EventEditorDraft.fromEventDetail(detail).copyWith(title: 'Revised'), + ); + detail = (await repository.loadEventDetail(local.id))!; + await repository.updateLocalEvent( + EventEditorDraft.fromEventDetail(detail).copyWith(location: 'Room A'), + ); + detail = (await repository.loadEventDetail(local.id))!; + await repository.updateLocalEvent( + EventEditorDraft.fromEventDetail(detail).copyWith(title: 'Final'), + ); + + final queued = await database.pendingOpsDao.pendingOpsForReplay( + 'account', + _later, + ); + expect(queued, hasLength(4)); + expect(queued.map((operation) => operation.operationType), [ + 'event.create', + 'event.patch', + 'event.patch', + 'event.patch', + ]); + expect( + jsonDecode(queued[2].requestJson), + containsPair('location', 'Room A'), + ); + expect(jsonDecode(queued[2].requestJson), isNot(contains('title'))); + expect(jsonDecode(queued[3].requestJson), containsPair('title', 'Final')); + expect(jsonDecode(queued[3].requestJson), isNot(contains('location'))); + + client.createEventOverride = (calendarId, mutation) => client._event( + mutation.providerEventId!, + title: mutation.title!, + providerCalendarId: calendarId, + etagOrChangeKey: '"created"', + ); + client.persistEventUpdates = true; + + expect( + await CalendarPendingOpsReplayer( + database: database, + client: client, + accountId: 'account', + nowUtc: () => DateTime.utc(2026, 6, 9), + ).replayDueOps(), + 4, + ); + + expect(client.updatedMutations.map((mutation) => mutation.title), [ + 'Revised', + null, + 'Final', + ]); + expect(client.updatedMutations.map((mutation) => mutation.location), [ + null, + 'Room A', + null, + ]); + final providerEventId = googleCalendarCreateEventId(createId); + final remote = client._createdEventsByIdentity[providerEventId]!; + expect(remote.title, 'Final'); + expect(remote.location, 'Room A'); + expect(await database.select(database.pendingOps).get(), isEmpty); + }, + ); + test('Microsoft create retry reuses its transaction ID', () async { final repository = CalendarRepository(database: database); await repository.upsertSource( @@ -516,6 +804,68 @@ void main() { }, ); + test( + 'end-only edit preserves a foreign-zone start in the host DST gap', + () async { + final hostZone = ProcessTimeZone(); + hostZone.set('America/Vancouver'); + addTearDown(hostZone.restore); + expect(DateTime(2026, 3, 8, 2, 30).hour, isNot(2)); + final repository = CalendarRepository(database: database); + await repository.upsertEvent( + accountId: 'account', + event: const CalendarEventDto( + provider: BusyProvider.google, + providerCalendarId: 'cal-1', + providerEventId: 'tokyo-event', + title: 'Tokyo event', + organizerJson: {'self': true}, + startDateTime: '2026-03-08T02:30:00', + startTimeZone: 'Asia/Tokyo', + endDateTime: '2026-03-08T03:30:00', + endTimeZone: 'Asia/Tokyo', + updatedAtServer: '2026-03-01T00:00:00.000Z', + rawJson: { + 'id': 'tokyo-event', + 'summary': 'Tokyo event', + 'start': { + 'dateTime': '2026-03-08T02:30:00', + 'timeZone': 'Asia/Tokyo', + }, + 'end': { + 'dateTime': '2026-03-08T03:30:00', + 'timeZone': 'Asia/Tokyo', + }, + 'updated': '2026-03-01T00:00:00.000Z', + }, + ), + ); + final eventId = CalendarRepository.eventId( + accountId: 'account', + provider: BusyProvider.google, + providerCalendarId: 'cal-1', + providerEventId: 'tokyo-event', + ); + final detail = (await repository.loadEventDetail(eventId))!; + final draft = EventEditorDraft.fromEventDetail(detail); + expect(draft.start!.hour, 2); + + await repository.updateLocalEvent( + draft.copyWith(end: providerCivilDateTime(DateTime.utc(2026, 3, 8, 4))), + ); + + final request = + jsonDecode( + (await database.select(database.pendingOps).getSingle()) + .requestJson, + ) + as Map; + expect(request['start'], '2026-03-08T02:30:00.000'); + expect(request['end'], '2026-03-08T04:00:00.000'); + }, + skip: !(Platform.isLinux || Platform.isMacOS), + ); + test('same-account Google move uses the native provider operation', () async { final repository = CalendarRepository(database: database); await repository.upsertSource( @@ -567,6 +917,7 @@ void main() { expect(client.calls, [ 'getEvent:cal-1:provider-event', 'moveEvent:cal-1:provider-event:cal-2', + 'getEvent:cal-2:provider-event', 'updateEvent:cal-2:provider-event:Moved', ]); expect( @@ -937,6 +1288,81 @@ void main() { }, ); + test( + 'cross-account copy persists civil wall times without a fake offset', + () async { + await _insertMicrosoftAccountAndSource(database); + final repository = CalendarRepository(database: database); + const original = CalendarEventDto( + provider: BusyProvider.google, + providerCalendarId: 'cal-1', + providerEventId: 'tokyo-event', + title: 'Tokyo meeting', + organizerJson: {'self': true}, + startDateTime: '2026-03-08T02:30:00.000', + startTimeZone: 'Asia/Tokyo', + endDateTime: '2026-03-08T03:30:00.000', + endTimeZone: 'Asia/Tokyo', + updatedAtServer: '2026-03-01T00:00:00.000Z', + rawJson: { + 'id': 'tokyo-event', + 'summary': 'Tokyo meeting', + 'start': { + 'dateTime': '2026-03-08T02:30:00.000', + 'timeZone': 'Asia/Tokyo', + }, + 'end': { + 'dateTime': '2026-03-08T03:30:00.000', + 'timeZone': 'Asia/Tokyo', + }, + }, + ); + await repository.upsertEvent(accountId: 'account', event: original); + final originalId = CalendarRepository.eventId( + accountId: 'account', + provider: BusyProvider.google, + providerCalendarId: 'cal-1', + providerEventId: 'tokyo-event', + ); + final detail = (await repository.loadEventDetail(originalId))!; + + await repository.updateLocalEvent( + EventEditorDraft.fromEventDetail(detail).copyWith( + accountId: 'microsoft-account', + sourceId: 'microsoft-account|microsoft|ms-cal-1', + providerCalendarId: 'ms-cal-1', + ), + ); + + final copied = + await (database.select(database.calendarEvents)..where( + (row) => + row.accountId.equals('microsoft-account') & + row.providerEventId.like('local:%'), + )) + .getSingle(); + expect(copied.startDateTime, '2026-03-08T02:30:00.000'); + expect(copied.endDateTime, '2026-03-08T03:30:00.000'); + expect( + providerDateTimeAsUtcInstant( + copied.startDateTime, + copied.startTimeZone, + ), + providerDateTimeAsUtcInstant( + original.startDateTime, + original.startTimeZone, + ), + ); + expect( + providerDateTimeAsUtcInstant(copied.endDateTime, copied.endTimeZone), + providerDateTimeAsUtcInstant( + original.endDateTime, + original.endTimeZone, + ), + ); + }, + ); + test( 'copying a locally resolved Google event preserves its Microsoft pin', () async { @@ -1970,17 +2396,48 @@ END:VEVENT }, ); - test('zoned guarded series edit replays the same civil delta', () async { - final repository = CalendarRepository(database: database); - final id = await _insertGoogleOccurrence(repository, day: 8); - await database - .update(database.calendarEvents) - .write( - const CalendarEventsCompanion( - startTimeZone: Value('America/Vancouver'), - endTimeZone: Value('America/Vancouver'), - ), - ); + test('Google event patch binds the write to the checked ETag', () async { + final eventId = await _insertEvent( + database, + providerEventId: 'provider-event', + ); + await _enqueueEventOp( + database, + id: 'op-etag', + operation: 'patch', + operationType: 'event.patch', + eventId: eventId, + request: {'title': 'Patched'}, + ); + client.remoteEvent = client._event( + 'provider-event', + title: 'Base', + etagOrChangeKey: '"checked"', + ); + + expect( + await CalendarPendingOpsReplayer( + database: database, + client: client, + accountId: 'account', + nowUtc: () => DateTime.utc(2026, 6, 8), + ).replayDueOps(), + 1, + ); + expect(client.ifMatches, ['"checked"']); + }); + + test('zoned guarded series edit replays the same civil delta', () async { + final repository = CalendarRepository(database: database); + final id = await _insertGoogleOccurrence(repository, day: 8); + await database + .update(database.calendarEvents) + .write( + const CalendarEventsCompanion( + startTimeZone: Value('America/Vancouver'), + endTimeZone: Value('America/Vancouver'), + ), + ); final detail = (await repository.loadEventDetail(id))!; await repository.updateLocalEvent( EventEditorDraft.fromEventDetail(detail).copyWith( @@ -2148,6 +2605,38 @@ END:VEVENT expect(rows.map((row) => row.syncStatus), everyElement('synced')); }); + test( + 'entire-series edit conflicts with an overlapping master edit', + () async { + final repository = CalendarRepository(database: database); + final occurrenceId = await _insertGoogleOccurrence(repository, day: 8); + final detail = (await repository.loadEventDetail(occurrenceId))!; + await repository.updateLocalEvent( + EventEditorDraft.fromEventDetail(detail).copyWith( + title: 'Local title', + recurringMutationScope: RecurringEventMutationScope.entireSeries, + ), + ); + client.remoteEvent = _googleSeriesMaster( + title: 'Remote title', + updatedAtServer: '2026-06-01T00:00:00.000Z', + ); + + expect( + await CalendarPendingOpsReplayer( + database: database, + client: client, + accountId: 'account', + nowUtc: () => DateTime.utc(2026, 6, 8), + ).replayDueOps(), + 0, + ); + final operation = await database.select(database.pendingOps).getSingle(); + expect(operation.lastErrorCode, 'conflict'); + expect(client.updatedMutations, isEmpty); + }, + ); + test( 'entire-series retry does not apply the occurrence delta twice', () async { @@ -2194,7 +2683,7 @@ END:VEVENT ); expect( client.calls.where((call) => call.startsWith('getEvent:')), - hasLength(1), + hasLength(2), ); }, ); @@ -2748,7 +3237,7 @@ END:VEVENT (row) => OrderingTerm.asc(row.providerOriginalStartKey), ])) .get(); - expect(rows.map((row) => row.isDeleted), [false, true, true]); + expect(rows.map((row) => row.isDeleted), [false, false, true, true]); expect(rows.map((row) => row.syncStatus), everyElement('synced')); expect(await database.select(database.pendingOps).get(), isEmpty); }); @@ -3159,6 +3648,250 @@ END:VEVENT }, ); + test('Microsoft location rename followed by deletion succeeds', () async { + await _insertMicrosoftAccountAndSource(database); + final microsoftClient = _FakeMicrosoftCalendarClient() + ..persistEventUpdates = true; + final repository = CalendarRepository(database: database); + final initialLocation = {'displayName': 'Room A'}; + final baseline = microsoftClient.microsoftEvent( + 'ms-rename-delete', + location: initialLocation, + locations: [initialLocation], + ); + microsoftClient.remoteEvent = baseline; + await repository.upsertEvent( + accountId: 'microsoft-account', + event: baseline, + ); + final eventId = CalendarRepository.eventId( + accountId: 'microsoft-account', + provider: BusyProvider.microsoft, + providerCalendarId: 'ms-cal-1', + providerEventId: 'ms-rename-delete', + ); + final detail = (await repository.loadEventDetail(eventId))!; + await repository.updateLocalEvent( + EventEditorDraft.fromEventDetail(detail).copyWith( + location: 'Room B', + locationChange: LocationChange.replace( + LocationResult( + label: 'Room B', + point: GeographicPoint(latitude: 1, longitude: 2), + ), + ), + ), + ); + await repository.deleteLocalEvent(eventId); + final queued = await database.select(database.pendingOps).get(); + final patch = queued.singleWhere( + (operation) => operation.operationType == 'event.patch', + ); + final delete = queued.singleWhere( + (operation) => operation.operationType == 'event.delete', + ); + expect(delete.dependsOnOpId, patch.id); + + expect( + await CalendarPendingOpsReplayer( + database: database, + client: microsoftClient, + accountId: 'microsoft-account', + nowUtc: () => DateTime.utc(2026, 6, 9), + ).replayDueOps(), + 2, + ); + + expect(microsoftClient.updatedMutations, hasLength(1)); + expect( + microsoftClient.calls.where( + (call) => call == 'deleteEvent:ms-cal-1:ms-rename-delete', + ), + hasLength(1), + ); + expect(await database.select(database.pendingOps).get(), isEmpty); + }); + + test('Microsoft location clear followed by deletion succeeds', () async { + await _insertMicrosoftAccountAndSource(database); + final microsoftClient = _FakeMicrosoftCalendarClient() + ..persistEventUpdates = true; + final repository = CalendarRepository(database: database); + final initialLocation = {'displayName': 'Room A'}; + final baseline = microsoftClient.microsoftEvent( + 'ms-clear-delete', + location: initialLocation, + locations: [initialLocation], + ); + microsoftClient.remoteEvent = baseline; + await repository.upsertEvent( + accountId: 'microsoft-account', + event: baseline, + ); + final eventId = CalendarRepository.eventId( + accountId: 'microsoft-account', + provider: BusyProvider.microsoft, + providerCalendarId: 'ms-cal-1', + providerEventId: 'ms-clear-delete', + ); + final detail = (await repository.loadEventDetail(eventId))!; + await repository.updateLocalEvent( + EventEditorDraft.fromEventDetail(detail).copyWith(clearLocation: true), + ); + await repository.deleteLocalEvent(eventId); + + expect( + await CalendarPendingOpsReplayer( + database: database, + client: microsoftClient, + accountId: 'microsoft-account', + nowUtc: () => DateTime.utc(2026, 6, 9), + ).replayDueOps(), + 2, + ); + + expect(microsoftClient.updatedMutations, hasLength(1)); + expect( + microsoftClient.calls.where( + (call) => call == 'deleteEvent:ms-cal-1:ms-clear-delete', + ), + hasLength(1), + ); + expect(await database.select(database.pendingOps).get(), isEmpty); + }); + + test( + 'Microsoft consecutive location edits followed by deletion succeed', + () async { + await _insertMicrosoftAccountAndSource(database); + final microsoftClient = _FakeMicrosoftCalendarClient() + ..persistEventUpdates = true; + final repository = CalendarRepository(database: database); + final initialLocation = {'displayName': 'Room A'}; + final baseline = microsoftClient.microsoftEvent( + 'ms-consecutive-location-delete', + location: initialLocation, + locations: [initialLocation], + ); + microsoftClient.remoteEvent = baseline; + await repository.upsertEvent( + accountId: 'microsoft-account', + event: baseline, + ); + final eventId = CalendarRepository.eventId( + accountId: 'microsoft-account', + provider: BusyProvider.microsoft, + providerCalendarId: 'ms-cal-1', + providerEventId: 'ms-consecutive-location-delete', + ); + var detail = (await repository.loadEventDetail(eventId))!; + await repository.updateLocalEvent( + EventEditorDraft.fromEventDetail(detail).copyWith( + location: 'Room B', + locationChange: LocationChange.replace( + LocationResult( + label: 'Room B', + point: GeographicPoint(latitude: 1, longitude: 2), + ), + ), + ), + ); + detail = (await repository.loadEventDetail(eventId))!; + await repository.updateLocalEvent( + EventEditorDraft.fromEventDetail(detail).copyWith( + location: 'Room C', + locationChange: LocationChange.replace( + LocationResult( + label: 'Room C', + point: GeographicPoint(latitude: 3, longitude: 4), + ), + ), + ), + ); + await repository.deleteLocalEvent(eventId); + + expect( + await CalendarPendingOpsReplayer( + database: database, + client: microsoftClient, + accountId: 'microsoft-account', + nowUtc: () => DateTime.utc(2026, 6, 9), + ).replayDueOps(), + 3, + ); + + expect(microsoftClient.updatedMutations, hasLength(2)); + expect( + microsoftClient.calls.where( + (call) => + call == 'deleteEvent:ms-cal-1:ms-consecutive-location-delete', + ), + hasLength(1), + ); + expect(await database.select(database.pendingOps).get(), isEmpty); + }, + ); + + test( + 'Microsoft unrelated remote location change still blocks deletion', + () async { + await _insertMicrosoftAccountAndSource(database); + final microsoftClient = _FakeMicrosoftCalendarClient() + ..persistEventUpdates = true; + final repository = CalendarRepository(database: database); + final initialLocation = {'displayName': 'Room A'}; + final baseline = microsoftClient.microsoftEvent( + 'ms-title-location-conflict-delete', + location: initialLocation, + locations: [initialLocation], + ); + await repository.upsertEvent( + accountId: 'microsoft-account', + event: baseline, + ); + final eventId = CalendarRepository.eventId( + accountId: 'microsoft-account', + provider: BusyProvider.microsoft, + providerCalendarId: 'ms-cal-1', + providerEventId: 'ms-title-location-conflict-delete', + ); + final detail = (await repository.loadEventDetail(eventId))!; + await repository.updateLocalEvent( + EventEditorDraft.fromEventDetail(detail).copyWith(title: 'Edited'), + ); + await repository.deleteLocalEvent(eventId); + final remoteLocation = {'displayName': 'Remote room'}; + microsoftClient.remoteEvent = microsoftClient.microsoftEvent( + 'ms-title-location-conflict-delete', + location: remoteLocation, + locations: [remoteLocation], + updatedAtServer: '2026-06-08T00:05:00.000Z', + ); + + expect( + await CalendarPendingOpsReplayer( + database: database, + client: microsoftClient, + accountId: 'microsoft-account', + nowUtc: () => DateTime.utc(2026, 6, 9), + ).replayDueOps(), + 1, + ); + + expect(microsoftClient.updatedMutations, hasLength(1)); + final remaining = await database.select(database.pendingOps).getSingle(); + expect(remaining.operationType, 'event.delete'); + expect(remaining.lastErrorCode, 'conflict'); + expect(remaining.lastErrorMessage, contains('location')); + expect( + microsoftClient.calls, + isNot( + contains('deleteEvent:ms-cal-1:ms-title-location-conflict-delete'), + ), + ); + }, + ); + test( 'discarding a rebased blocked edit restores the complete remote event', () async { @@ -3323,15 +4056,324 @@ END:VEVENT nowUtc: () => DateTime.utc(2026, 6, 8), ).replayDueOps(); - final row = await (database.select( - database.calendarEvents, - )..where((table) => table.id.equals(eventId))).getSingle(); - expect(applied, 1); - expect(client.calls, [ - 'getEvent:cal-1:provider-event', - 'deleteEvent:cal-1:provider-event', - ]); - expect(row.isDeleted, isTrue); + final row = await (database.select( + database.calendarEvents, + )..where((table) => table.id.equals(eventId))).getSingle(); + expect(applied, 1); + expect(client.calls, [ + 'getEvent:cal-1:provider-event', + 'deleteEvent:cal-1:provider-event', + ]); + expect(row.isDeleted, isTrue); + }); + + test( + 'offline event edit followed by delete does not self-conflict', + () async { + final repository = CalendarRepository(database: database); + await repository.upsertEvent( + accountId: 'account', + event: client._event( + 'provider-event', + title: 'Base', + organizerJson: const {'self': true}, + ), + ); + final eventId = CalendarRepository.eventId( + accountId: 'account', + provider: BusyProvider.google, + providerCalendarId: 'cal-1', + providerEventId: 'provider-event', + ); + final detail = (await repository.loadEventDetail(eventId))!; + await repository.updateLocalEvent( + EventEditorDraft.fromEventDetail(detail).copyWith(title: 'Edited'), + ); + await repository.deleteLocalEvent(eventId); + final queued = await database.select(database.pendingOps).get(); + final patch = queued.singleWhere( + (operation) => operation.operationType == 'event.patch', + ); + final delete = queued.singleWhere( + (operation) => operation.operationType == 'event.delete', + ); + expect(delete.dependsOnOpId, patch.id); + client + ..remoteEvent = client._event('provider-event', title: 'Base') + ..persistEventUpdates = true; + + expect( + await CalendarPendingOpsReplayer( + database: database, + client: client, + accountId: 'account', + nowUtc: () => DateTime.utc(2026, 6, 8), + ).replayDueOps(), + 2, + ); + expect(await database.select(database.pendingOps).get(), isEmpty); + expect(client.calls.last, 'deleteEvent:cal-1:provider-event'); + }, + ); + + test( + 'event edit followed by delete preserves an unrelated remote conflict', + () async { + final repository = CalendarRepository(database: database); + await repository.upsertEvent( + accountId: 'account', + event: client._event( + 'provider-event', + title: 'Base', + description: 'Base description', + organizerJson: const {'self': true}, + ), + ); + final eventId = CalendarRepository.eventId( + accountId: 'account', + provider: BusyProvider.google, + providerCalendarId: 'cal-1', + providerEventId: 'provider-event', + ); + final detail = (await repository.loadEventDetail(eventId))!; + await repository.updateLocalEvent( + EventEditorDraft.fromEventDetail(detail).copyWith(title: 'Edited'), + ); + await repository.deleteLocalEvent(eventId); + client + ..remoteEvent = client._event( + 'provider-event', + title: 'Base', + description: 'Remote description', + updatedAtServer: '2026-06-08T00:05:00.000Z', + ) + ..persistEventUpdates = true; + + expect( + await CalendarPendingOpsReplayer( + database: database, + client: client, + accountId: 'account', + nowUtc: () => DateTime.utc(2026, 6, 8, 1), + ).replayDueOps(), + 1, + ); + + final remaining = await database.select(database.pendingOps).getSingle(); + expect(remaining.operationType, 'event.delete'); + expect(remaining.lastErrorCode, 'conflict'); + expect(remaining.lastErrorMessage, contains('description')); + expect(client.remoteEvent!.title, 'Edited'); + expect(client.remoteEvent!.description, 'Remote description'); + expect(client.calls, isNot(contains('deleteEvent:cal-1:provider-event'))); + }, + ); + + test( + 'event edit followed by delete preserves a remote attachment addition', + () async { + final repository = CalendarRepository(database: database); + await repository.upsertEvent( + accountId: 'account', + event: client._event( + 'provider-event', + title: 'Base', + organizerJson: const {'self': true}, + ), + ); + final eventId = CalendarRepository.eventId( + accountId: 'account', + provider: BusyProvider.google, + providerCalendarId: 'cal-1', + providerEventId: 'provider-event', + ); + final detail = (await repository.loadEventDetail(eventId))!; + await repository.updateLocalEvent( + EventEditorDraft.fromEventDetail(detail).copyWith(title: 'Edited'), + ); + await repository.deleteLocalEvent(eventId); + final queued = await database.select(database.pendingOps).get(); + final patch = queued.singleWhere( + (operation) => operation.operationType == 'event.patch', + ); + final delete = queued.singleWhere( + (operation) => operation.operationType == 'event.delete', + ); + expect(delete.dependsOnOpId, patch.id); + const remoteAttachments = [ + { + 'fileId': 'file-a', + 'fileUrl': 'https://example.test/files/a', + 'title': 'Agenda', + 'mimeType': 'application/pdf', + }, + ]; + client + ..remoteEvent = client._event( + 'provider-event', + title: 'Base', + attachmentsJson: remoteAttachments, + updatedAtServer: '2026-06-08T00:05:00.000Z', + ) + ..persistEventUpdates = true; + + expect( + await CalendarPendingOpsReplayer( + database: database, + client: client, + accountId: 'account', + nowUtc: () => DateTime.utc(2026, 6, 8, 1), + ).replayDueOps(), + 1, + ); + + final remaining = await database.select(database.pendingOps).getSingle(); + expect(remaining.operationType, 'event.delete'); + expect(remaining.lastErrorCode, 'conflict'); + expect(remaining.lastErrorMessage, contains('attachmentsJson')); + expect(client.remoteEvent!.title, 'Edited'); + expect(client.remoteEvent!.attachmentsJson, remoteAttachments); + expect(client.remoteEvent!.rawJson['attachments'], remoteAttachments); + expect(client.calls, isNot(contains('deleteEvent:cal-1:provider-event'))); + }, + ); + + for (final attachmentChange in ['removal', 'replacement']) { + test( + 'event edit followed by delete preserves a remote attachment $attachmentChange', + () async { + final repository = CalendarRepository(database: database); + const attachmentA = [ + { + 'fileId': 'file-a', + 'fileUrl': 'https://example.test/files/a', + 'title': 'Agenda A', + }, + ]; + await repository.upsertEvent( + accountId: 'account', + event: client._event( + 'provider-event', + title: 'Base', + attachmentsJson: attachmentA, + organizerJson: const {'self': true}, + ), + ); + final eventId = CalendarRepository.eventId( + accountId: 'account', + provider: BusyProvider.google, + providerCalendarId: 'cal-1', + providerEventId: 'provider-event', + ); + final detail = (await repository.loadEventDetail(eventId))!; + await repository.updateLocalEvent( + EventEditorDraft.fromEventDetail(detail).copyWith(title: 'Edited'), + ); + await repository.deleteLocalEvent(eventId); + const attachmentB = [ + { + 'fileId': 'file-b', + 'fileUrl': 'https://example.test/files/b', + 'title': 'Agenda B', + }, + ]; + final remoteAttachments = attachmentChange == 'removal' + ? null + : attachmentB; + client + ..remoteEvent = client._event( + 'provider-event', + title: 'Base', + attachmentsJson: remoteAttachments, + updatedAtServer: '2026-06-08T00:05:00.000Z', + ) + ..persistEventUpdates = true; + + expect( + await CalendarPendingOpsReplayer( + database: database, + client: client, + accountId: 'account', + nowUtc: () => DateTime.utc(2026, 6, 8, 1), + ).replayDueOps(), + 1, + ); + + final remaining = await database + .select(database.pendingOps) + .getSingle(); + expect(remaining.operationType, 'event.delete'); + expect(remaining.lastErrorCode, 'conflict'); + expect(remaining.lastErrorMessage, contains('attachmentsJson')); + expect(client.remoteEvent!.title, 'Edited'); + expect(client.remoteEvent!.attachmentsJson, remoteAttachments); + expect( + client.calls, + isNot(contains('deleteEvent:cal-1:provider-event')), + ); + }, + ); + } + + test('event edit followed by delete accepts unchanged attachments', () async { + final repository = CalendarRepository(database: database); + final baselineAttachments = [ + { + 'fileId': 'file-a', + 'fileUrl': 'https://example.test/files/a', + 'title': 'Agenda', + }, + ]; + await repository.upsertEvent( + accountId: 'account', + event: client._event( + 'provider-event', + title: 'Base', + attachmentsJson: baselineAttachments, + organizerJson: const {'self': true}, + ), + ); + final eventId = CalendarRepository.eventId( + accountId: 'account', + provider: BusyProvider.google, + providerCalendarId: 'cal-1', + providerEventId: 'provider-event', + ); + final detail = (await repository.loadEventDetail(eventId))!; + await repository.updateLocalEvent( + EventEditorDraft.fromEventDetail(detail).copyWith(title: 'Edited'), + ); + await repository.deleteLocalEvent(eventId); + final remoteAttachments = [ + { + 'fileId': 'file-a', + 'fileUrl': 'https://example.test/files/a', + 'title': 'Agenda', + }, + ]; + client + ..remoteEvent = client._event( + 'provider-event', + title: 'Base', + attachmentsJson: remoteAttachments, + ) + ..persistEventUpdates = true; + + expect( + await CalendarPendingOpsReplayer( + database: database, + client: client, + accountId: 'account', + nowUtc: () => DateTime.utc(2026, 6, 8, 1), + ).replayDueOps(), + 2, + ); + + expect( + client.calls.where((call) => call == 'deleteEvent:cal-1:provider-event'), + hasLength(1), + ); + expect(await database.select(database.pendingOps).get(), isEmpty); }); test('entire-series delete marks the master and every occurrence', () async { @@ -3347,6 +4389,7 @@ END:VEVENT occurrenceId, recurringScope: RecurringEventMutationScope.entireSeries, ); + client.remoteEvent = _googleSeriesMaster(); await CalendarPendingOpsReplayer( database: database, client: client, @@ -3354,13 +4397,99 @@ END:VEVENT nowUtc: () => DateTime.utc(2026, 6, 8), ).replayDueOps(); - expect(client.calls, ['deleteEvent:cal-1:series-master']); + expect(client.calls, [ + 'getEvent:cal-1:series-master', + '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('entire-series delete conflicts with a changed master', () async { + final repository = CalendarRepository(database: database); + final occurrenceId = await _insertGoogleOccurrence(repository, day: 8); + await repository.deleteLocalEvent( + occurrenceId, + recurringScope: RecurringEventMutationScope.entireSeries, + ); + client.remoteEvent = _googleSeriesMaster( + title: 'Remote title', + updatedAtServer: '2026-06-01T00:00:00.000Z', + ); + + expect( + await CalendarPendingOpsReplayer( + database: database, + client: client, + accountId: 'account', + nowUtc: () => DateTime.utc(2026, 6, 8), + ).replayDueOps(), + 0, + ); + final operation = await database.select(database.pendingOps).getSingle(); + expect(operation.lastErrorCode, 'conflict'); + expect(client.calls, ['getEvent:cal-1:series-master']); + }); + + test( + 'occurrence acknowledgement cannot replace a series deletion baseline', + () async { + final repository = CalendarRepository(database: database); + final occurrenceId = await _insertGoogleOccurrence(repository, day: 8); + final detail = (await repository.loadEventDetail(occurrenceId))!; + await repository.updateLocalEvent( + EventEditorDraft.fromEventDetail(detail).copyWith( + location: 'Local room', + recurringMutationScope: RecurringEventMutationScope.singleOccurrence, + ), + ); + await repository.deleteLocalEvent( + occurrenceId, + recurringScope: RecurringEventMutationScope.entireSeries, + ); + final queued = await database.select(database.pendingOps).get(); + final patch = queued.singleWhere( + (operation) => operation.operationType == 'event.patch', + ); + final delete = queued.singleWhere( + (operation) => operation.operationType == 'event.delete', + ); + expect(delete.dependsOnOpId, patch.id); + expect(jsonDecode(delete.baselineRawJson!)['id'], 'series-master'); + + client + ..persistEventUpdates = true + ..remoteEventsById['occurrence-08'] = client._event( + 'occurrence-08', + title: 'Base', + updatedAtServer: '2026-05-30T00:00:00.000Z', + ) + ..remoteEventsById['series-master'] = _googleSeriesMaster( + title: 'Remote title', + updatedAtServer: '2026-06-01T00:00:00.000Z', + ); + + expect( + await CalendarPendingOpsReplayer( + database: database, + client: client, + accountId: 'account', + nowUtc: () => DateTime.utc(2026, 6, 8), + ).replayDueOps(), + 1, + ); + final remaining = await database.select(database.pendingOps).getSingle(); + expect(remaining.operationType, 'event.delete'); + expect(remaining.lastErrorCode, 'conflict'); + expect(remaining.baselineUpdatedUtc, '2026-05-30T00:00:00.000Z'); + expect(jsonDecode(remaining.baselineRawJson!)['id'], 'series-master'); + expect(client.calls, contains('getEvent:cal-1:series-master')); + expect(client.calls, isNot(contains('deleteEvent:cal-1:series-master'))); + }, + ); + test('provider missing delete is treated as success', () async { client.deleteError = const GoogleCalendarApiError( statusCode: 404, @@ -3613,6 +4742,246 @@ END:VEVENT }, ); + test('unknown calendar creation outcome is not posted again', () async { + final repository = CalendarRepository( + database: database, + now: () => DateTime.utc(2026, 6, 8), + ); + await repository.createLocalSource( + accountId: 'account', + summary: 'Project', + ); + client.calendarCreateError = StateError('response lost'); + final replayer = CalendarPendingOpsReplayer( + database: database, + client: client, + accountId: 'account', + nowUtc: () => DateTime.utc(2026, 6, 8), + ); + + expect(await replayer.replayDueOps(), 0); + expect(await replayer.replayDueOps(), 0); + + final operation = await database.select(database.pendingOps).getSingle(); + expect(operation.state, 'recovery_required'); + expect(operation.lastErrorCode, 'calendar_creation_outcome_unknown'); + expect(client.calls, ['createCalendar:Project']); + }); + + test( + 'definitely rejected calendar creation can be manually retried', + () async { + final repository = CalendarRepository( + database: database, + now: () => DateTime.utc(2026, 6, 8), + ); + await repository.createLocalSource( + accountId: 'account', + summary: 'Project', + ); + final operation = await database.select(database.pendingOps).getSingle(); + client.calendarCreateError = const GoogleCalendarApiError( + statusCode: 403, + code: 'PERMISSION_DENIED', + message: 'Calendar creation is not permitted.', + ); + + expect( + await CalendarPendingOpsReplayer( + database: database, + client: client, + accountId: 'account', + nowUtc: () => DateTime.utc(2026, 6, 8), + ).replayDueOps(), + 0, + ); + final rejected = await database.pendingOpsDao.getOp(operation.id); + expect(rejected!.state, 'failed'); + expect(rejected.lastErrorCode, 'PERMISSION_DENIED'); + + client.calendarCreateError = null; + var taskSyncCalls = 0; + var calendarSyncCalls = 0; + await PendingOpResolutionService( + database: database, + accountId: 'account', + syncTasks: () async => taskSyncCalls += 1, + syncCalendar: () async { + calendarSyncCalls += 1; + await CalendarPendingOpsReplayer( + database: database, + client: client, + accountId: 'account', + nowUtc: () => DateTime.utc(2026, 6, 9), + ).replayDueOps(); + }, + nowUtc: () => DateTime.utc(2026, 6, 9), + ).retryNow(operation.id); + + expect(calendarSyncCalls, 1); + expect(taskSyncCalls, 0); + expect( + client.calls.where((call) => call == 'createCalendar:Project'), + hasLength(2), + ); + expect(await database.pendingOpsDao.getOp(operation.id), equals(null)); + }, + ); + + test( + 'calendar color retry is separate from acknowledged calendar creation', + () async { + final repository = CalendarRepository( + database: database, + now: () => DateTime.utc(2026, 6, 8), + ); + await repository.createLocalSource( + accountId: 'account', + summary: 'Project', + ); + final create = await database.select(database.pendingOps).getSingle(); + await (database.update( + database.pendingOps, + )..where((row) => row.id.equals(create.id))).write( + PendingOpsCompanion( + requestJson: Value( + jsonEncode({ + 'summary': 'Project', + 'backgroundColor': '#3584e4', + 'foregroundColor': '#ffffff', + }), + ), + ), + ); + client.calendarListPatchFailures = 1; + + expect( + await CalendarPendingOpsReplayer( + database: database, + client: client, + accountId: 'account', + nowUtc: () => DateTime.utc(2026, 6, 8), + ).replayDueOps(), + 1, + ); + var pending = await database.select(database.pendingOps).getSingle(); + expect(pending.operationType, 'calendar.patch'); + expect(pending.providerCalendarId, 'cal-created'); + + expect( + await CalendarPendingOpsReplayer( + database: database, + client: client, + accountId: 'account', + random: Random(0), + nowUtc: () => DateTime.utc(2026, 6, 8), + ).replayDueOps(), + 0, + ); + pending = await database.select(database.pendingOps).getSingle(); + expect(pending.attemptCount, 1); + expect( + DateTime.parse( + pending.nextAttemptAtUtc!, + ).isAfter(DateTime.utc(2026, 6, 8)), + isTrue, + ); + expect( + await CalendarPendingOpsReplayer( + database: database, + client: client, + accountId: 'account', + nowUtc: () => DateTime.utc(2026, 6, 9), + ).replayDueOps(), + 1, + ); + expect( + client.calls.where((call) => call == 'createCalendar:Project'), + hasLength(1), + ); + expect( + client.calls.where( + (call) => call == 'updateCalendarListEntry:cal-created:null', + ), + hasLength(2), + ); + expect(await database.select(database.pendingOps).get(), isEmpty); + }, + ); + + test( + 'event created during calendar backoff waits for that creation', + () async { + final repository = CalendarRepository( + database: database, + now: () => DateTime.utc(2026, 6, 8), + ); + final sourceId = await repository.createLocalSource( + accountId: 'account', + summary: 'Project', + ); + client.calendarCreateError = const KnownUnsentRequestException( + kind: RequestPreDispatchFailureKind.connectivity, + ); + expect( + await CalendarPendingOpsReplayer( + database: database, + client: client, + accountId: 'account', + random: Random(0), + nowUtc: () => DateTime.utc(2026, 6, 8), + ).replayDueOps(), + 0, + ); + final create = await database.select(database.pendingOps).getSingle(); + expect(create.state, 'retry'); + final source = await (database.select( + database.calendarSources, + )..where((row) => row.id.equals(sourceId))).getSingle(); + await repository.createLocalEvent( + EventEditorDraft.newEvent( + accountId: 'account', + sourceId: sourceId, + providerCalendarId: source.providerCalendarId, + start: DateTime.utc(2026, 6, 8, 9), + end: DateTime.utc(2026, 6, 8, 10), + ).copyWith(title: 'Planning'), + ); + final eventCreate = (await database.select(database.pendingOps).get()) + .singleWhere( + (operation) => operation.operationType == 'event.create', + ); + expect(eventCreate.dependsOnOpId, create.id); + + client.calendarCreateError = null; + expect( + await CalendarPendingOpsReplayer( + database: database, + client: client, + accountId: 'account', + nowUtc: () => DateTime.utc(2026, 6, 8), + ).replayDueOps(), + 0, + ); + expect(client.calls, ['createCalendar:Project']); + expect( + await CalendarPendingOpsReplayer( + database: database, + client: client, + accountId: 'account', + nowUtc: () => DateTime.utc(2026, 6, 9), + ).replayDueOps(), + 2, + ); + expect(client.calls, [ + 'createCalendar:Project', + 'createCalendar:Project', + 'createEvent:cal-created:Planning', + ]); + expect(await database.select(database.pendingOps).get(), isEmpty); + }, + ); + test('calendar delete pending op calls provider deleteCalendar', () async { await database.pendingOpsDao.enqueue( PendingOpsCompanion.insert( @@ -4118,6 +5487,10 @@ Future _insertEvent( 'id': providerEventId, 'summary': 'Base', 'updated': '2026-06-08T00:00:00.000Z', + if (providerRecurringEventId != null) ...{ + 'recurringEventId': providerRecurringEventId, + 'originalStartTime': {'dateTime': '2026-06-08T09:00:00.000Z'}, + }, }, ); await CalendarRepository( @@ -4143,6 +5516,18 @@ Future _insertGoogleOccurrence( start ??= '2026-06-${date}T09:00:00.000Z'; end ??= '2026-06-${date}T10:00:00.000Z'; final providerEventId = 'occurrence-$date'; + final masterId = CalendarRepository.eventId( + accountId: 'account', + provider: BusyProvider.google, + providerCalendarId: 'cal-1', + providerEventId: 'series-master', + ); + if (await repository.loadEventDetail(masterId) == null) { + await repository.upsertEvent( + accountId: 'account', + event: _googleSeriesMaster(timeZone: timeZone, location: location), + ); + } await repository.upsertEvent( accountId: 'account', event: CalendarEventDto( @@ -4201,6 +5586,8 @@ CalendarEventDto _googleSeriesMaster({ String timeZone = 'UTC', String start = '2026-06-01T09:00:00.000Z', String end = '2026-06-01T10:00:00.000Z', + String title = 'Base', + String updatedAtServer = '2026-05-30T00:00:00.000Z', String? location, }) { const recurrence = ['RRULE:FREQ=WEEKLY;INTERVAL=1;BYDAY=MO;COUNT=5']; @@ -4208,7 +5595,7 @@ CalendarEventDto _googleSeriesMaster({ provider: BusyProvider.google, providerCalendarId: 'cal-1', providerEventId: 'series-master', - title: 'Base', + title: title, location: location, organizerJson: {'self': true}, startDateTime: start, @@ -4216,15 +5603,15 @@ CalendarEventDto _googleSeriesMaster({ endDateTime: end, endTimeZone: timeZone, recurrenceJson: recurrence, - updatedAtServer: '2026-05-30T00:00:00.000Z', + updatedAtServer: updatedAtServer, rawJson: { 'id': 'series-master', - 'summary': 'Base', + 'summary': title, if (location != null) 'location': location, 'start': {'dateTime': start, 'timeZone': timeZone}, 'end': {'dateTime': end, 'timeZone': timeZone}, 'recurrence': recurrence, - 'updated': '2026-05-30T00:00:00.000Z', + 'updated': updatedAtServer, }, ); } @@ -4341,17 +5728,23 @@ class _FakeCalendarClient final updatedMutations = []; final calendarMutations = []; final guestUpdatePolicies = []; + final ifMatches = []; final invitationResponses = []; final Map _createdEventsByIdentity = {}; int _createdCount = 0; int transientUpdateFailures = 0; + int? transientUpdateFailureCall; + int _updateEventCallCount = 0; CalendarEventDto? syncEvent; CalendarEventDto? remoteEvent; + final Map remoteEventsById = {}; List eventInstances = const []; bool persistEventUpdates = false; int _eventUpdateRevision = 0; GoogleCalendarApiError? deleteError; Object? calendarDeleteError; + Object? calendarCreateError; + int calendarListPatchFailures = 0; GoogleCalendarApiError? calendarListDeleteError; Completer? createEventGate; Completer? calendarPatchGate; @@ -4421,6 +5814,7 @@ class _FakeCalendarClient location: mutation.location, startTimeZone: mutation.startTimeZone, endTimeZone: mutation.endTimeZone, + recurrenceJson: mutation.recurrence, ); if (identity != null) { _createdEventsByIdentity[identity] = event; @@ -4438,19 +5832,32 @@ class _FakeCalendarClient required CalendarEventMutation mutation, CalendarGuestUpdatePolicy guestUpdatePolicy = CalendarGuestUpdatePolicy.send, + String? ifMatch, }) async { + _updateEventCallCount += 1; calls.add('updateEvent:$calendarId:$eventId:${mutation.title}'); updatedMutations.add(mutation); guestUpdatePolicies.add(guestUpdatePolicy); - if (transientUpdateFailures > 0) { - transientUpdateFailures -= 1; + ifMatches.add(ifMatch); + if (transientUpdateFailures > 0 || + transientUpdateFailureCall == _updateEventCallCount) { + if (transientUpdateFailures > 0) transientUpdateFailures -= 1; throw const GoogleCalendarApiError( statusCode: 500, code: 'backendError', message: 'Temporary provider failure', ); } - final current = remoteEvent; + String? createdIdentity; + CalendarEventDto? createdEvent; + for (final entry in _createdEventsByIdentity.entries) { + if (entry.value.providerEventId == eventId) { + createdIdentity = entry.key; + createdEvent = entry.value; + break; + } + } + final current = createdEvent ?? remoteEventsById[eventId] ?? remoteEvent; final event = _event( eventId, title: persistEventUpdates @@ -4465,7 +5872,9 @@ class _FakeCalendarClient remindersJson: persistEventUpdates ? mutation.reminders ?? current?.remindersJson : mutation.reminders, + attachmentsJson: persistEventUpdates ? current?.attachmentsJson : null, organizerJson: persistEventUpdates ? current?.organizerJson : null, + etagOrChangeKey: current?.etagOrChangeKey, updatedAtServer: persistEventUpdates ? DateTime.utc( 2026, @@ -4482,7 +5891,13 @@ class _FakeCalendarClient endDateTime: mutation.endDateTime ?? current?.endDateTime, ); if (persistEventUpdates) { - remoteEvent = event; + if (createdIdentity != null) { + _createdEventsByIdentity[createdIdentity] = event; + } else if (remoteEventsById.containsKey(eventId)) { + remoteEventsById[eventId] = event; + } else { + remoteEvent = event; + } } return event; } @@ -4498,6 +5913,8 @@ class _FakeCalendarClient for (final created in _createdEventsByIdentity.values) { if (created.providerEventId == eventId) return created; } + final mapped = remoteEventsById[eventId]; + if (mapped != null) return mapped; final remote = remoteEvent; if (remote != null) return remote; return _event(eventId, title: 'Base'); @@ -4509,9 +5926,11 @@ class _FakeCalendarClient required String eventId, CalendarGuestUpdatePolicy guestUpdatePolicy = CalendarGuestUpdatePolicy.send, + String? ifMatch, }) async { calls.add('deleteEvent:$calendarId:$eventId'); guestUpdatePolicies.add(guestUpdatePolicy); + ifMatches.add(ifMatch); final error = deleteError; if (error != null) { throw error; @@ -4582,6 +6001,8 @@ class _FakeCalendarClient String? endTimeZone, String? startDateTime, String? endDateTime, + Object? recurrenceJson, + Object? attachmentsJson, }) { return CalendarEventDto( provider: provider, @@ -4595,7 +6016,9 @@ class _FakeCalendarClient startTimeZone: startTimeZone ?? 'UTC', endDateTime: endDateTime ?? '2026-06-08T10:00:00.000Z', endTimeZone: endTimeZone ?? 'UTC', + recurrenceJson: recurrenceJson, remindersJson: remindersJson, + attachmentsJson: attachmentsJson, organizerJson: organizerJson, updatedAtServer: updatedAtServer, rawJson: { @@ -4612,7 +6035,9 @@ class _FakeCalendarClient 'dateTime': endDateTime ?? '2026-06-08T10:00:00.000Z', 'timeZone': endTimeZone ?? 'UTC', }, + if (recurrenceJson != null) 'recurrence': recurrenceJson, if (remindersJson != null) 'reminders': remindersJson, + if (attachmentsJson != null) 'attachments': attachmentsJson, if (organizerJson != null) 'organizer': organizerJson, 'updated': updatedAtServer, }, @@ -4622,6 +6047,8 @@ class _FakeCalendarClient @override Future createCalendar(CalendarMutation mutation) async { calls.add('createCalendar:${mutation.summary}'); + final error = calendarCreateError; + if (error != null) throw error; return CalendarSourceDto( provider: BusyProvider.google, providerCalendarId: 'cal-created', @@ -4719,6 +6146,14 @@ class _FakeCalendarClient calls.add('updateCalendarListEntry:$calendarId:${mutation.summary}'); calendarMutations.add(mutation); await calendarPatchGate?.future; + if (calendarListPatchFailures > 0) { + calendarListPatchFailures -= 1; + throw const GoogleCalendarApiError( + statusCode: 429, + code: 'rateLimitExceeded', + message: 'Rate limited', + ); + } return CalendarSourceDto( provider: BusyProvider.google, providerCalendarId: calendarId, @@ -4775,6 +6210,7 @@ class _FakeMicrosoftCalendarClient extends _FakeCalendarClient { required CalendarEventMutation mutation, CalendarGuestUpdatePolicy guestUpdatePolicy = CalendarGuestUpdatePolicy.send, + String? ifMatch, }) async { calls.add('updateEvent:$calendarId:$eventId:${mutation.title}'); updatedMutations.add(mutation); diff --git a/test/features/sync/calendar_sync_engine_test.dart b/test/features/sync/calendar_sync_engine_test.dart index 2d948026..bd0ed250 100644 --- a/test/features/sync/calendar_sync_engine_test.dart +++ b/test/features/sync/calendar_sync_engine_test.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:convert'; import 'package:busymax/src/app/app_settings.dart'; import 'package:busymax/src/features/notifications/desktop_notification_service.dart'; import 'package:busymax/src/features/notifications/notification_scheduler.dart'; @@ -9,6 +10,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/calendar/presentation/event_editor_draft.dart'; import 'package:busymax/src/features/notifications/notification_schedule_service.dart'; import 'package:busymax/src/features/sync/calendar_sync_engine.dart'; import 'package:busymax/src/providers/busy_provider.dart'; @@ -540,7 +542,6 @@ void main() { 'recurrence': ['RRULE:FREQ=WEEKLY'], }, ); - await repository.upsertEvent(accountId: 'account', event: master); await repository.saveSyncState( accountId: 'account', provider: BusyProvider.google, @@ -576,6 +577,7 @@ void main() { final client = _FakeCalendarClient( provider: BusyProvider.google, calendars: const [source], + eventsById: const {'series-1': master}, pages: const [ CalendarSyncPageDto( events: [instance], @@ -611,6 +613,132 @@ void main() { expect(masterRow.isDeleted, isTrue); expect(instanceRow.isDeleted, isFalse); expect(instanceRow.providerRecurringEventId, master.providerEventId); + + await repository.deleteLocalEvent( + instanceId, + recurringScope: RecurringEventMutationScope.entireSeries, + ); + final operation = await database.select(database.pendingOps).getSingle(); + expect(operation.baselineUpdatedUtc, master.updatedAtServer); + expect(operation.baselineRawJson, contains('"id":"series-1"')); + }, + ); + + test( + 'Microsoft calendar-view occurrences hydrate their master for whole-series mutations', + () async { + const source = CalendarSourceDto( + provider: BusyProvider.microsoft, + providerCalendarId: 'cal-1', + summary: 'Work', + ); + await _insertAccount(database, provider: BusyProvider.microsoft); + const master = CalendarEventDto( + provider: BusyProvider.microsoft, + providerCalendarId: 'cal-1', + providerEventId: 'series-1', + title: 'Weekly planning', + startDateTime: '2026-07-14T09:00:00.000Z', + endDateTime: '2026-07-14T10:00:00.000Z', + recurrenceJson: { + 'pattern': {'type': 'weekly', 'interval': 1}, + 'range': {'type': 'noEnd', 'startDate': '2026-07-14'}, + }, + eventType: 'seriesMaster', + updatedAtServer: '2026-07-01T00:00:00.000Z', + rawJson: { + 'id': 'series-1', + 'subject': 'Weekly planning', + 'type': 'seriesMaster', + 'lastModifiedDateTime': '2026-07-01T00:00:00.000Z', + }, + ); + const occurrence = CalendarEventDto( + provider: BusyProvider.microsoft, + providerCalendarId: 'cal-1', + providerEventId: 'occurrence-1', + providerRecurringEventId: 'series-1', + providerOriginalStartKey: '2026-07-14T09:00:00.000Z', + title: 'Weekly planning', + startDateTime: '2026-07-14T09:00:00.000Z', + endDateTime: '2026-07-14T10:00:00.000Z', + eventType: 'occurrence', + updatedAtServer: '2026-07-10T00:00:00.000Z', + rawJson: { + 'id': 'occurrence-1', + 'seriesMasterId': 'series-1', + 'originalStart': '2026-07-14T09:00:00.000Z', + 'type': 'occurrence', + 'lastModifiedDateTime': '2026-07-10T00:00:00.000Z', + 'isOrganizer': true, + }, + ); + final client = _FakeCalendarClient( + provider: BusyProvider.microsoft, + calendars: const [source], + eventsById: const {'series-1': master}, + pages: const [ + CalendarSyncPageDto(events: [occurrence]), + ], + ); + + await CalendarSyncEngine( + database: database, + client: client, + accountId: 'account', + nowUtc: () => DateTime.utc(2026, 7, 10), + ).fullSync(); + + final occurrenceId = CalendarRepository.eventId( + accountId: 'account', + provider: BusyProvider.microsoft, + providerCalendarId: source.providerCalendarId, + providerEventId: occurrence.providerEventId, + providerOriginalStartKey: occurrence.providerOriginalStartKey, + ); + final masterId = CalendarRepository.eventId( + accountId: 'account', + provider: BusyProvider.microsoft, + providerCalendarId: source.providerCalendarId, + providerEventId: master.providerEventId, + ); + final storedMaster = await (database.select( + database.calendarEvents, + )..where((row) => row.id.equals(masterId))).getSingle(); + expect(storedMaster.isDeleted, isTrue); + expect(storedMaster.updatedAtServer, master.updatedAtServer); + expect((jsonDecode(storedMaster.rawJson!) as Map)['id'], 'series-1'); + final repository = CalendarRepository(database: database); + final detail = await repository.loadEventDetail(occurrenceId); + await repository.updateLocalEvent( + EventEditorDraft.fromEventDetail(detail!).copyWith( + title: 'Renamed series', + recurringMutationScope: RecurringEventMutationScope.entireSeries, + ), + ); + await repository.deleteLocalEvent( + occurrenceId, + recurringScope: RecurringEventMutationScope.entireSeries, + ); + + final operations = await (database.select( + database.pendingOps, + )..orderBy([(row) => OrderingTerm.asc(row.createdAtUtc)])).get(); + expect(operations, hasLength(2)); + expect(operations.map((operation) => operation.operationType), [ + 'event.patch', + 'event.delete', + ]); + expect( + operations.map((operation) => operation.baselineUpdatedUtc), + everyElement(master.updatedAtServer), + ); + expect( + operations.map( + (operation) => (jsonDecode(operation.baselineRawJson!) as Map)['id'], + ), + everyElement(master.providerEventId), + ); }, ); @@ -1079,11 +1207,13 @@ class _FakeCalendarClient implements CloudCalendarClient { required this.provider, required this.calendars, required List pages, + this.eventsById = const {}, }) : _pages = List.of(pages); @override final BusyProvider provider; final List calendars; + final Map eventsById; final List _pages; final List<_SyncCall> syncCalls = []; Completer? secondPageStarted; @@ -1159,6 +1289,7 @@ class _FakeCalendarClient implements CloudCalendarClient { required String eventId, CalendarGuestUpdatePolicy guestUpdatePolicy = CalendarGuestUpdatePolicy.send, + String? ifMatch, }) { throw UnimplementedError(); } @@ -1178,8 +1309,12 @@ class _FakeCalendarClient implements CloudCalendarClient { Future getEvent({ required String calendarId, required String eventId, - }) { - throw UnimplementedError(); + }) async { + final event = eventsById[eventId]; + if (event == null) { + throw StateError('No fake event exists for $eventId.'); + } + return event; } @override @@ -1217,6 +1352,7 @@ class _FakeCalendarClient implements CloudCalendarClient { required CalendarEventMutation mutation, CalendarGuestUpdatePolicy guestUpdatePolicy = CalendarGuestUpdatePolicy.send, + String? ifMatch, }) { throw UnimplementedError(); } diff --git a/test/features/sync/pending_op_resolution_service_test.dart b/test/features/sync/pending_op_resolution_service_test.dart index 03b4f6bf..9a91f9ac 100644 --- a/test/features/sync/pending_op_resolution_service_test.dart +++ b/test/features/sync/pending_op_resolution_service_test.dart @@ -7,10 +7,12 @@ 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/accounts/data/accounts_repository.dart'; import 'package:busymax/src/features/calendar/data/calendar_repository.dart'; import 'package:busymax/src/features/calendar/presentation/event_editor_draft.dart'; import 'package:busymax/src/features/sync/pending_op_resolution_service.dart'; import 'package:busymax/src/features/sync/pending_ops_replayer.dart'; +import 'package:busymax/src/features/sync/sync_auth_error.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'; @@ -46,6 +48,72 @@ void main() { await database.close(); }); + test( + 'retry leaves the operation blocked when account needs reconnect', + () async { + await _enqueueBlockedOp( + database, + operation: 'patch_task', + taskListId: 'list-1', + taskId: 'task-1', + ); + final before = await database.pendingOpsDao.getOp('op-1'); + await AccountsRepository( + database: database, + ).markReconnectRequired('account'); + + await expectLater( + service.retryNow('op-1'), + throwsA( + isA().having( + (error) => error.needsReconnect, + 'needsReconnect', + isTrue, + ), + ), + ); + + final after = await database.pendingOpsDao.getOp('op-1'); + expect(after?.state, before?.state); + expect(after?.retryClassification, before?.retryClassification); + expect(after?.nextAttemptAtUtc, before?.nextAttemptAtUtc); + expect(taskSyncCalls, 0); + expect(calendarSyncCalls, 0); + }, + ); + + test( + 'provider-dependent discard does not read provider state when account needs reconnect', + () async { + await database.tasksDao.upsertTask( + _localTask('task-1', localDirty: true), + ); + await _enqueueBlockedOp( + database, + operation: 'patch_task', + taskListId: 'list-1', + taskId: 'task-1', + ); + await AccountsRepository( + database: database, + ).markReconnectRequired('account'); + + await expectLater( + service.discard('op-1'), + throwsA(isA()), + ); + + expect(apiClient.getTaskIds, isEmpty); + expect(await database.pendingOpsDao.getOp('op-1'), isNot(equals(null))); + expect( + await database.tasksDao.listTasks('account', 'list-1'), + hasLength(1), + ); + expect(taskSyncCalls, 0); + expect(calendarSyncCalls, 0); + }, + ); + test( 'discard blocked task patch refreshes task and clears dirty flags', () async { @@ -288,6 +356,84 @@ void main() { }, ); + test('discarding a cross-list move discards its dependent edits', () async { + apiClient.remoteTask = _taskDto('task-1', title: 'Remote source task'); + await database.taskListsDao.upsertTaskList(_localTaskList('list-2')); + await database.tasksDao.upsertTask( + _localTask( + 'task-1', + taskListId: 'list-2', + localDirty: true, + pendingMove: true, + ), + ); + await _enqueueBlockedOp( + database, + operation: 'move_task', + taskListId: 'list-1', + taskId: 'task-1', + request: {'destinationTasklist': 'list-2'}, + ); + await _enqueueBlockedOp( + database, + id: 'dependent-edit', + operation: 'patch_task', + taskListId: 'list-2', + taskId: 'task-1', + dependsOnOpId: 'op-1', + request: {'title': 'Destination edit'}, + ); + + await service.discard('op-1'); + + expect(await database.select(database.pendingOps).get(), isEmpty); + expect( + (await database.tasksDao.listTasks('account', 'list-1')).single.title, + 'Remote source task', + ); + expect(await database.tasksDao.listTasks('account', 'list-2'), isEmpty); + }); + + test('discarding A to B to C removes every destination projection', () async { + apiClient.remoteTask = _taskDto('task-1', title: 'Remote source task'); + await database.taskListsDao.upsertTaskList(_localTaskList('list-2')); + await database.taskListsDao.upsertTaskList(_localTaskList('list-3')); + await database.tasksDao.upsertTask( + _localTask( + 'task-1', + taskListId: 'list-3', + localDirty: true, + pendingMove: true, + ), + ); + await _enqueueBlockedOp( + database, + operation: 'move_task', + taskListId: 'list-1', + taskId: 'task-1', + request: {'destinationTasklist': 'list-2'}, + ); + await _enqueueBlockedOp( + database, + id: 'move-to-c', + operation: 'move_task', + taskListId: 'list-2', + taskId: 'task-1', + dependsOnOpId: 'op-1', + request: {'destinationTasklist': 'list-3'}, + ); + + await service.discard('op-1'); + + final source = await database.tasksDao.listTasks('account', 'list-1'); + expect(source, hasLength(1)); + expect(source.single.title, 'Remote source task'); + expect(source.single.localDirty, isFalse); + expect(await database.tasksDao.listTasks('account', 'list-2'), isEmpty); + expect(await database.tasksDao.listTasks('account', 'list-3'), isEmpty); + expect(await database.select(database.pendingOps).get(), isEmpty); + }); + test( 'discard blocked cross-list move with remote 404 removes both rows', () async { @@ -612,6 +758,463 @@ void main() { expect(taskSyncCalls, 0); }); + test( + 'discard uncertain task create removes dependent edit before replay', + () async { + const temporaryTaskId = 'local-task-1'; + await database.tasksDao.upsertTask( + _localTask(temporaryTaskId, localDirty: true), + ); + await _enqueueBlockedOp( + database, + id: 'create-task', + operation: 'create_task', + taskListId: 'list-1', + taskId: temporaryTaskId, + localTempId: temporaryTaskId, + state: 'recovery_required', + request: const { + 'body': {'title': 'Offline task'}, + }, + ); + await _enqueueBlockedOp( + database, + id: 'edit-task', + operation: 'patch_task', + taskListId: 'list-1', + taskId: temporaryTaskId, + dependsOnOpId: 'create-task', + nextAttemptAtUtc: null, + request: const {'title': 'Edited offline task'}, + ); + + await service.discard('create-task'); + + expect(await database.select(database.pendingOps).get(), isEmpty); + expect(await database.tasksDao.listTasks('account', 'list-1'), isEmpty); + expect(apiClient.getTaskIds, isEmpty); + expect(taskSyncCalls, 1); + + final replayed = await PendingOpsReplayer( + database: database, + apiClient: apiClient, + accountId: 'account', + nowUtc: () => DateTime.utc(2026, 6, 4), + ).replayDueOps(); + + expect(replayed, 0); + expect(apiClient.patchedTaskIds, isEmpty); + expect(apiClient.getTaskIds, isEmpty); + }, + ); + + test( + 'discard uncertain task-list create removes its local chain before replay', + () async { + const temporaryTaskListId = 'local-tasklist-1'; + await database.taskListsDao.upsertTaskList( + _localTaskList( + temporaryTaskListId, + title: 'Offline list', + localDirty: true, + ), + ); + await database.tasksDao.upsertTask( + _localTask( + 'local-child-task', + taskListId: temporaryTaskListId, + localDirty: true, + ), + ); + await _enqueueBlockedOp( + database, + id: 'create-list', + entityType: 'task_list', + operation: 'create_task_list', + taskListId: temporaryTaskListId, + localTempId: temporaryTaskListId, + state: 'recovery_required', + request: const {'title': 'Offline list'}, + ); + await _enqueueBlockedOp( + database, + id: 'edit-list', + entityType: 'task_list', + operation: 'patch_task_list', + taskListId: temporaryTaskListId, + dependsOnOpId: 'create-list', + nextAttemptAtUtc: null, + request: const {'title': 'Edited offline list'}, + ); + await _enqueueBlockedOp( + database, + id: 'create-child-task', + operation: 'create_task', + taskListId: temporaryTaskListId, + taskId: 'local-child-task', + localTempId: 'local-child-task', + nextAttemptAtUtc: null, + request: const { + 'body': {'title': 'Child task'}, + }, + ); + + await service.discard('create-list'); + + expect(await database.select(database.pendingOps).get(), isEmpty); + expect( + (await database.taskListsDao.listTaskLists( + 'account', + )).map((taskList) => taskList.id), + isNot(contains(temporaryTaskListId)), + ); + expect( + await database.tasksDao.listTasks('account', temporaryTaskListId), + isEmpty, + ); + expect(apiClient.getTaskListIds, isEmpty); + expect(taskSyncCalls, 1); + + final replayed = await PendingOpsReplayer( + database: database, + apiClient: apiClient, + accountId: 'account', + nowUtc: () => DateTime.utc(2026, 6, 4), + ).replayDueOps(); + + expect(replayed, 0); + expect(apiClient.patchedTaskListIds, isEmpty); + expect(apiClient.createdTaskTaskListIds, isEmpty); + expect(apiClient.getTaskListIds, isEmpty); + }, + ); + + test( + 'discard uncertain task-list create restores an old moved server task', + () async { + const temporaryTaskListId = 'local-tasklist-1'; + apiClient.remoteTask = TaskDto.fromJson(const { + 'id': 'existing-task', + 'title': 'Existing remote task', + 'updated': '2025-01-01T00:00:00.000Z', + }); + await database.taskListsDao.upsertTaskList( + _localTaskList( + temporaryTaskListId, + title: 'Offline list', + localDirty: true, + ), + ); + await database.tasksDao.upsertTask( + _localTask( + 'existing-task', + taskListId: temporaryTaskListId, + localDirty: true, + pendingMove: true, + ), + ); + await _enqueueBlockedOp( + database, + id: 'create-list', + entityType: 'task_list', + operation: 'create_task_list', + taskListId: temporaryTaskListId, + localTempId: temporaryTaskListId, + state: 'recovery_required', + request: const {'title': 'Offline list'}, + ); + await _enqueueBlockedOp( + database, + id: 'move-existing-task', + operation: 'move_task', + taskListId: 'list-1', + taskId: 'existing-task', + request: const {'destinationTasklist': temporaryTaskListId}, + ); + + await service.discard('create-list'); + + final sourceTasks = await database.tasksDao.listTasks( + 'account', + 'list-1', + ); + expect(sourceTasks, hasLength(1)); + expect(sourceTasks.single.title, 'Existing remote task'); + expect(sourceTasks.single.pendingMove, isFalse); + expect(sourceTasks.single.localDirty, isFalse); + expect( + await database.tasksDao.listTasks('account', temporaryTaskListId), + isEmpty, + ); + expect(await database.select(database.pendingOps).get(), isEmpty); + expect(apiClient.getTaskIds, ['existing-task']); + expect(taskSyncCalls, 1); + }, + ); + + test( + 'discard uncertain list restores repeated move chain from remote origin', + () async { + const temporaryTaskListId = 'list-b'; + apiClient.remoteTasksByList['list-a'] = TaskDto.fromJson(const { + 'id': 'existing-task', + 'title': 'Existing remote task', + 'updated': '2025-01-01T00:00:00.000Z', + }); + await database.taskListsDao.upsertTaskList( + _localTaskList( + temporaryTaskListId, + title: 'Offline list', + localDirty: true, + ), + ); + await database.taskListsDao.upsertTaskList( + _localTaskList('list-a', title: 'Source list'), + ); + await database.taskListsDao.upsertTaskList( + _localTaskList('list-c', title: 'Intermediate list'), + ); + await database.tasksDao.upsertTask( + _localTask( + 'existing-task', + taskListId: temporaryTaskListId, + localDirty: true, + pendingMove: true, + ), + ); + await _enqueueBlockedOp( + database, + id: 'create-list', + entityType: 'task_list', + operation: 'create_task_list', + taskListId: temporaryTaskListId, + localTempId: temporaryTaskListId, + state: 'recovery_required', + request: const {'title': 'Offline list'}, + ); + await _enqueueBlockedOp( + database, + id: 'move-a-b', + operation: 'move_task', + taskListId: 'list-a', + taskId: 'existing-task', + request: const {'destinationTasklist': temporaryTaskListId}, + ); + await _enqueueBlockedOp( + database, + id: 'move-b-c', + operation: 'move_task', + taskListId: temporaryTaskListId, + taskId: 'existing-task', + dependsOnOpId: 'move-a-b', + request: const {'destinationTasklist': 'list-c'}, + ); + await _enqueueBlockedOp( + database, + id: 'move-c-b', + operation: 'move_task', + taskListId: 'list-c', + taskId: 'existing-task', + dependsOnOpId: 'move-b-c', + request: const {'destinationTasklist': temporaryTaskListId}, + ); + + await service.discard('create-list'); + + final restored = await database.select(database.tasks).get(); + expect(restored, hasLength(1)); + expect(restored.single.id, 'existing-task'); + expect(restored.single.taskListId, 'list-a'); + expect(restored.single.title, 'Existing remote task'); + expect(restored.single.pendingMove, isFalse); + expect(restored.single.localDirty, isFalse); + expect(await database.select(database.pendingOps).get(), isEmpty); + expect(apiClient.getTaskTaskListIds, ['list-a']); + expect(taskSyncCalls, 1); + }, + ); + + test( + 'discard checklist patch restores provider item and preserves sibling edit', + () async { + await database.tasksDao.upsertTask( + _localTask( + 'task-1', + checklistItemsJson: encodeTaskChecklistItems([ + TaskChecklistItemEntity.fromJson(const { + 'id': 'target-step', + 'displayName': 'Rejected local title', + 'isChecked': true, + }), + TaskChecklistItemEntity.fromJson(const { + 'id': 'sibling-step', + 'displayName': 'Pending sibling title', + 'isChecked': false, + }), + ]), + ), + ); + apiClient.checklistPages = { + null: const TaskChecklistItemsPageDto( + items: [ + TaskChecklistItemDto( + id: 'target-step', + title: 'Provider title', + completed: false, + rawJson: {}, + ), + TaskChecklistItemDto( + id: 'sibling-step', + title: 'Provider sibling title', + completed: false, + rawJson: {}, + ), + ], + rawJson: {}, + ), + }; + await _enqueueBlockedOp( + database, + id: 'target-patch', + entityType: 'task_checklist_item', + operation: 'patch_task_checklist_item', + taskListId: 'list-1', + taskId: 'task-1', + request: const { + 'checklistItemId': 'target-step', + 'body': {'displayName': 'Rejected local title', 'isChecked': true}, + }, + ); + await _enqueueBlockedOp( + database, + id: 'sibling-patch', + entityType: 'task_checklist_item', + operation: 'patch_task_checklist_item', + taskListId: 'list-1', + taskId: 'task-1', + request: const { + 'checklistItemId': 'sibling-step', + 'body': {'displayName': 'Pending sibling title'}, + }, + ); + + await service.discard('target-patch'); + + final items = decodeTaskChecklistItems( + (await database.tasksDao.listTasks( + 'account', + 'list-1', + )).single.microsoftChecklistItemsJson, + ); + final target = items.singleWhere((item) => item.id == 'target-step'); + final sibling = items.singleWhere((item) => item.id == 'sibling-step'); + expect(target.title, 'Provider title'); + expect(target.completed, isFalse); + expect(sibling.title, 'Pending sibling title'); + expect(await database.pendingOpsDao.getOp('target-patch'), equals(null)); + expect( + await database.pendingOpsDao.getOp('sibling-patch'), + isNot(equals(null)), + ); + expect(taskSyncCalls, 1); + }, + ); + + test('discard checklist deletion restores the provider item', () async { + await database.tasksDao.upsertTask( + _localTask( + 'task-1', + checklistItemsJson: encodeTaskChecklistItems(const []), + ), + ); + apiClient.checklistPages = { + null: const TaskChecklistItemsPageDto( + items: [ + TaskChecklistItemDto( + id: 'target-step', + title: 'Provider title', + completed: true, + rawJson: {}, + ), + ], + rawJson: {}, + ), + }; + await _enqueueBlockedOp( + database, + id: 'target-delete', + entityType: 'task_checklist_item', + operation: 'delete_task_checklist_item', + taskListId: 'list-1', + taskId: 'task-1', + request: const {'checklistItemId': 'target-step'}, + ); + + await service.discard('target-delete'); + + final item = decodeTaskChecklistItems( + (await database.tasksDao.listTasks( + 'account', + 'list-1', + )).single.microsoftChecklistItemsJson, + ).single; + expect(item.id, 'target-step'); + expect(item.title, 'Provider title'); + expect(item.completed, isTrue); + expect(await database.pendingOpsDao.getOp('target-delete'), equals(null)); + expect(taskSyncCalls, 1); + }); + + test( + 'failed checklist patch reconciliation keeps operation and projection', + () async { + final projection = encodeTaskChecklistItems([ + TaskChecklistItemEntity.fromJson(const { + 'id': 'target-step', + 'displayName': 'Rejected local title', + 'isChecked': true, + }), + ]); + await database.tasksDao.upsertTask( + _localTask('task-1', checklistItemsJson: projection), + ); + apiClient.checklistError = const GoogleTasksApiError( + statusCode: 503, + message: 'Unavailable', + ); + await _enqueueBlockedOp( + database, + id: 'target-patch', + entityType: 'task_checklist_item', + operation: 'patch_task_checklist_item', + taskListId: 'list-1', + taskId: 'task-1', + request: const { + 'checklistItemId': 'target-step', + 'body': {'displayName': 'Rejected local title', 'isChecked': true}, + }, + ); + + await expectLater( + service.discard('target-patch'), + throwsA(isA()), + ); + + expect( + await database.pendingOpsDao.getOp('target-patch'), + isNot(equals(null)), + ); + expect( + (await database.tasksDao.listTasks( + 'account', + 'list-1', + )).single.microsoftChecklistItemsJson, + projection, + ); + expect(taskSyncCalls, 0); + }, + ); + test( 'discard uncertain checklist create removes its chain and merges all pages', () async { @@ -954,12 +1557,19 @@ void main() { class _FakeTaskRemoteClient implements TaskRemoteClient, TaskChecklistRemoteClient { TaskDto? remoteTask; + final remoteTasksByList = {}; TaskListDto? remoteTaskList; GoogleTasksApiError? getTaskError; GoogleTasksApiError? getTaskListError; GoogleTasksApiError? checklistError; Map checklistPages = const {}; final checklistPageTokens = []; + final getTaskIds = []; + final getTaskTaskListIds = []; + final getTaskListIds = []; + final patchedTaskIds = []; + final patchedTaskListIds = []; + final createdTaskTaskListIds = []; Completer? checklistReadStarted; Completer? checklistReadGate; @@ -985,15 +1595,23 @@ class _FakeTaskRemoteClient required String taskListId, required String taskId, }) async { + getTaskIds.add(taskId); + getTaskTaskListIds.add(taskListId); final error = getTaskError; if (error != null) { throw error; } + if (remoteTasksByList.isNotEmpty) { + final task = remoteTasksByList[taskListId]; + if (task != null) return task; + throw const GoogleTasksApiError(statusCode: 404, message: 'Not found'); + } return remoteTask ?? _taskDto(taskId); } @override Future getTaskList(String taskListId) async { + getTaskListIds.add(taskListId); final error = getTaskListError; if (error != null) { throw error; @@ -1001,6 +1619,42 @@ class _FakeTaskRemoteClient return remoteTaskList ?? _taskListDto(taskListId); } + @override + Future patchTask({ + required String taskListId, + required String taskId, + required TaskPatch patch, + }) async { + patchedTaskIds.add(taskId); + return _taskDto(taskId, title: patch.fields['title']?.toString() ?? 'Task'); + } + + @override + Future patchTaskList( + String taskListId, + TaskListPatch patch, + ) async { + patchedTaskListIds.add(taskListId); + return _taskListDto( + taskListId, + title: patch.fields['title']?.toString() ?? 'List', + ); + } + + @override + Future createTask({ + required String taskListId, + String? parentTaskId, + String? previousSiblingTaskId, + required TaskCreate create, + }) async { + createdTaskTaskListIds.add(taskListId); + return _taskDto( + 'created-task', + title: create.fields['title']?.toString() ?? 'Task', + ); + } + @override dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); } @@ -1019,6 +1673,7 @@ Future _insertAccount( providerAccountId: 'google-$accountId', credentialKind: 'oauth', email: Value('google-$accountId@example.com'), + authState: const Value(accountAuthStateSignedIn), createdAtUtc: _now, updatedAtUtc: _now, ), @@ -1098,6 +1753,7 @@ Future _enqueueBlockedOp( String state = 'pending', String? localTempId, String? dependsOnOpId, + String? nextAttemptAtUtc = '9999-12-31T00:00:00.000Z', Map request = const {}, }) { return database.pendingOpsDao.enqueue( @@ -1114,7 +1770,7 @@ Future _enqueueBlockedOp( calendarSourceId: Value(calendarSourceId), requestJson: jsonEncode(request), state: Value(state), - nextAttemptAtUtc: const Value('9999-12-31T00:00:00.000Z'), + nextAttemptAtUtc: Value(nextAttemptAtUtc), lastErrorCode: const Value('conflict'), createdAtUtc: _now, updatedAtUtc: _now, diff --git a/test/features/sync/pending_ops_replayer_test.dart b/test/features/sync/pending_ops_replayer_test.dart index 5e600b1c..f982ede3 100644 --- a/test/features/sync/pending_ops_replayer_test.dart +++ b/test/features/sync/pending_ops_replayer_test.dart @@ -1,5 +1,6 @@ import 'dart:async'; import 'dart:convert'; +import 'dart:io'; import 'dart:math'; import 'package:busymax/src/features/notifications/notification_schedule_service.dart'; @@ -474,6 +475,359 @@ void main() { expect(operations, isEmpty); }); + test('task creation acknowledgment preserves a newer queued edit', () async { + final repository = TasksRepository( + database: database, + accountId: 'account', + nowUtc: () => DateTime.utc(2026, 6, 4), + ); + await repository.createTask( + 'list-1', + const TaskCreateInput(title: 'Draft'), + ); + final temporary = (await database.tasksDao.listTasks( + 'account', + 'list-1', + )).single; + await repository.patchTask( + 'list-1', + temporary.id, + const TaskPatchInput({'title': 'Revised'}), + ); + apiClient.patchTaskError = const GoogleTasksApiError( + statusCode: 503, + message: 'Temporary failure', + ); + + expect( + await PendingOpsReplayer( + database: database, + apiClient: apiClient, + accountId: 'account', + random: Random(0), + nowUtc: () => DateTime.utc(2026, 6, 4, 1), + ).replayDueOps(), + 1, + ); + + final visible = (await database.tasksDao.listTasks( + 'account', + 'list-1', + )).single; + expect(visible.id, 'task-server'); + expect(visible.title, 'Revised'); + expect(visible.localDirty, isTrue); + expect( + (await database.select(database.pendingOps).get()).single.operation, + 'patch_task', + ); + }); + + test( + 'remote edit after creation conflicts with a delayed dependent patch', + () async { + final createdUpdated = DateTime.utc(2026, 6, 4, 0, 5); + final createdTask = _taskDto( + 'task-server', + title: 'Draft', + updated: createdUpdated, + ); + apiClient + ..createdTask = createdTask + ..remoteTask = createdTask + ..patchTaskError = const GoogleTasksApiError( + statusCode: 503, + message: 'Temporary failure', + ); + final repository = TasksRepository( + database: database, + accountId: 'account', + nowUtc: () => DateTime.utc(2026, 6, 4), + ); + await repository.createTask( + 'list-1', + const TaskCreateInput(title: 'Draft'), + ); + final temporary = (await database.tasksDao.listTasks( + 'account', + 'list-1', + )).single; + await repository.patchTask( + 'list-1', + temporary.id, + const TaskPatchInput({'title': 'Local title'}), + ); + + expect( + await PendingOpsReplayer( + database: database, + apiClient: apiClient, + accountId: 'account', + random: Random(0), + nowUtc: () => DateTime.utc(2026, 6, 4, 1), + ).replayDueOps(), + 1, + ); + + var pending = await database.select(database.pendingOps).getSingle(); + expect(pending.operation, 'patch_task'); + expect(pending.state, 'pending'); + expect(pending.attemptCount, 1); + expect(pending.nextAttemptAtUtc, isNot(equals(null))); + expect(pending.lastErrorCode, '503'); + expect(pending.baselineUpdatedUtc, createdUpdated.toIso8601String()); + expect(jsonDecode(pending.baselineRawJson!)['title'], 'Draft'); + + apiClient + ..patchTaskError = null + ..remoteTask = _taskDto( + 'task-server', + title: 'Competing remote title', + updated: DateTime.utc(2026, 6, 4, 0, 10), + ); + + expect( + await PendingOpsReplayer( + database: database, + apiClient: apiClient, + accountId: 'account', + random: Random(0), + nowUtc: () => DateTime.utc(2026, 6, 5), + ).replayDueOps(), + 0, + ); + + pending = await database.select(database.pendingOps).getSingle(); + expect(pending.lastErrorCode, 'conflict'); + expect(pending.lastErrorMessage, contains('title')); + expect(apiClient.taskPatchFields, isEmpty); + expect(apiClient.remoteTask!.title, 'Competing remote title'); + }, + ); + + for (final entity in ['task', 'task list']) { + test( + '$entity creation restart after identity persistence stays acknowledged', + () async { + final directory = await Directory.systemTemp.createTemp( + 'busymax-$entity-create-ack-', + ); + final databaseFile = File('${directory.path}/busymax.sqlite'); + final previousDatabaseWarningSetting = + driftRuntimeOptions.dontWarnAboutMultipleDatabases; + driftRuntimeOptions.dontWarnAboutMultipleDatabases = true; + final commitInterruption = _CommitInterruption(); + final interruptedDatabase = AppDatabase( + NativeDatabase(databaseFile).interceptWith(commitInterruption), + ); + AppDatabase? restartedDatabase; + Future? interruptedReplay; + addTearDown(() async { + commitInterruption.resume(); + await interruptedReplay; + await restartedDatabase?.close(); + await interruptedDatabase.close(); + await directory.delete(recursive: true); + driftRuntimeOptions.dontWarnAboutMultipleDatabases = + previousDatabaseWarningSetting; + }); + + await _insertAccount(interruptedDatabase); + await interruptedDatabase.taskListsDao.upsertTaskList( + _taskList('list-1'), + ); + if (entity == 'task') { + await interruptedDatabase.tasksDao.upsertTask( + _task('list-1', 'local-task-1', title: 'Draft'), + ); + await _enqueue( + interruptedDatabase, + id: 'create', + operation: 'create_task', + taskListId: 'list-1', + taskId: 'local-task-1', + localTempId: 'local-task-1', + request: { + 'body': {'title': 'Draft'}, + }, + ); + } else { + await interruptedDatabase.taskListsDao.upsertTaskList( + _taskList('local-list', title: 'Draft list'), + ); + await _enqueue( + interruptedDatabase, + id: 'create', + operation: 'create_task_list', + entityType: 'task_list', + taskListId: 'local-list', + localTempId: 'local-list', + request: {'title': 'Draft list'}, + ); + } + + final interruptedClient = _FakeTaskRemoteClient(); + commitInterruption.arm(); + interruptedReplay = PendingOpsReplayer( + database: interruptedDatabase, + apiClient: interruptedClient, + accountId: 'account', + nowUtc: () => DateTime.utc(2026, 6, 4), + ).replayDueOps(); + await commitInterruption.commitReached.future; + + restartedDatabase = AppDatabase(NativeDatabase(databaseFile)); + final restartedClient = _FakeTaskRemoteClient(); + expect( + await PendingOpsReplayer( + database: restartedDatabase, + apiClient: restartedClient, + accountId: 'account', + nowUtc: () => DateTime.utc(2026, 6, 4, 1), + ).replayDueOps(), + 0, + ); + + expect( + await restartedDatabase.pendingOpsDao.getOp('create'), + equals(null), + ); + expect(restartedClient.calls, isEmpty); + if (entity == 'task') { + final tasks = await restartedDatabase.tasksDao.listTasks( + 'account', + 'list-1', + ); + expect(tasks.map((task) => task.id), ['task-server']); + expect(interruptedClient.calls, ['create_task:list-1']); + } else { + final lists = await restartedDatabase.taskListsDao.listTaskLists( + 'account', + ); + expect(lists.map((list) => list.id), contains('list-server')); + expect(lists.map((list) => list.id), isNot(contains('local-list'))); + expect(interruptedClient.calls, ['create_task_list:Draft list']); + } + + commitInterruption.resume(); + expect(await interruptedReplay, 1); + }, + ); + } + + test( + 'checklist creation restart after identity persistence stays acknowledged', + () async { + final directory = await Directory.systemTemp.createTemp( + 'busymax-checklist-create-ack-', + ); + final databaseFile = File('${directory.path}/busymax.sqlite'); + final previousDatabaseWarningSetting = + driftRuntimeOptions.dontWarnAboutMultipleDatabases; + driftRuntimeOptions.dontWarnAboutMultipleDatabases = true; + final commitInterruption = _CommitInterruption(); + final interruptedDatabase = AppDatabase( + NativeDatabase(databaseFile).interceptWith(commitInterruption), + ); + AppDatabase? restartedDatabase; + Future? interruptedReplay; + addTearDown(() async { + commitInterruption.resume(); + await interruptedReplay; + await restartedDatabase?.close(); + await interruptedDatabase.close(); + await directory.delete(recursive: true); + driftRuntimeOptions.dontWarnAboutMultipleDatabases = + previousDatabaseWarningSetting; + }); + + await _insertAccount(interruptedDatabase); + await interruptedDatabase.taskListsDao.upsertTaskList( + _taskList('list-1'), + ); + await interruptedDatabase.tasksDao.upsertTask( + _task( + 'list-1', + 'task-1', + checklistItemsJson: jsonEncode([ + {'id': 'local-step', 'displayName': 'Step', 'isChecked': false}, + ]), + ), + ); + await _enqueue( + interruptedDatabase, + id: 'create', + operation: 'create_task_checklist_item', + entityType: 'task_checklist_item', + taskListId: 'list-1', + taskId: 'task-1', + localTempId: 'local-step', + request: { + 'checklistItemId': 'local-step', + 'body': {'displayName': 'Step', 'isChecked': false}, + }, + ); + await _enqueue( + interruptedDatabase, + id: 'patch', + operation: 'patch_task_checklist_item', + entityType: 'task_checklist_item', + taskListId: 'list-1', + taskId: 'task-1', + dependsOnOpId: 'create', + request: { + 'checklistItemId': 'local-step', + 'body': {'isChecked': true}, + }, + ); + + final interruptedClient = _ChecklistTaskRemoteClient(); + commitInterruption.arm(); + interruptedReplay = PendingOpsReplayer( + database: interruptedDatabase, + apiClient: interruptedClient, + accountId: 'account', + nowUtc: () => DateTime.utc(2026, 6, 4), + ).replayDueOps(); + await commitInterruption.commitReached.future; + + restartedDatabase = AppDatabase(NativeDatabase(databaseFile)); + final restartedClient = _ChecklistTaskRemoteClient(); + expect( + await PendingOpsReplayer( + database: restartedDatabase, + apiClient: restartedClient, + accountId: 'account', + nowUtc: () => DateTime.utc(2026, 6, 4, 1), + ).replayDueOps(), + 1, + ); + + expect( + await restartedDatabase.pendingOpsDao.getOp('create'), + equals(null), + ); + expect(restartedClient.checklistCalls, ['update:server-step:true']); + final task = (await restartedDatabase.tasksDao.listTasks( + 'account', + 'list-1', + )).single; + final item = decodeTaskChecklistItems( + task.microsoftChecklistItemsJson, + ).single; + expect(item.id, 'server-step'); + expect(item.completed, isTrue); + expect( + await restartedDatabase.select(restartedDatabase.pendingOps).get(), + isEmpty, + ); + + commitInterruption.resume(); + expect(await interruptedReplay, 1); + expect(interruptedClient.checklistCalls, ['create:Step']); + }, + ); + test('unknown task creation outcome is not submitted again', () async { await database.tasksDao.upsertTask( _task('list-1', 'local-task-1', title: 'Draft'), @@ -730,86 +1084,67 @@ void main() { }); test( - 'cross-list move orders an equal-timestamp edit and preserves projection', + 'new task moved and edited before creation sync has one destination row', () async { await database.taskListsDao.upsertTaskList(_taskList('list-2')); - await database.tasksDao.upsertTask( - _task('list-1', 'task-1', title: 'Original'), - ); final repository = TasksRepository( database: database, accountId: 'account', nowUtc: () => DateTime.utc(2026, 6, 4), ); + await repository.createTask( + 'list-1', + const TaskCreateInput(title: 'Draft'), + ); + final localTask = (await database.tasksDao.listTasks( + 'account', + 'list-1', + )).single; await repository.moveTask( - const TaskMoveInput( + TaskMoveInput( sourceTaskListId: 'list-1', - taskId: 'task-1', + taskId: localTask.id, destinationTaskListId: 'list-2', ), ); await repository.patchTask( 'list-2', - 'task-1', + localTask.id, const TaskPatchInput({'title': 'Edited after move'}), ); - final queued = await database.select(database.pendingOps).get(); - final move = queued.singleWhere((op) => op.operation == 'move_task'); - final edit = queued.singleWhere((op) => op.operation == 'patch_task'); - expect(edit.dependsOnOpId, move.id); - apiClient.moveTaskError = const GoogleTasksApiError( - statusCode: 429, - message: 'Rate limited', - ); expect( await PendingOpsReplayer( database: database, apiClient: apiClient, accountId: 'account', - random: Random(0), nowUtc: () => DateTime.utc(2026, 6, 4, 1), ).replayDueOps(), - 0, - ); - expect(apiClient.calls, ['move_task:task-1']); - expect( - (await database.tasksDao.listTasks('account', 'list-2')).single.title, - 'Edited after move', + 3, ); - apiClient.moveTaskError = null; - expect( - await PendingOpsReplayer( - database: database, - apiClient: apiClient, - accountId: 'account', - random: Random(0), - nowUtc: () => DateTime.utc(2026, 6, 4, 2), - ).replayDueOps(), - 2, - ); expect(apiClient.calls, [ - 'move_task:task-1', - 'move_task:task-1', - 'patch_task:task-1', + 'create_task:list-1', + 'move_task:task-server', + 'patch_task:task-server', ]); expect(await database.tasksDao.listTasks('account', 'list-1'), isEmpty); - final finalTask = (await database.tasksDao.listTasks( - 'account', - 'list-2', - )).single; - expect(finalTask.title, 'Edited after move'); - expect(finalTask.localDirty, isFalse); + final tasks = await database.select(database.tasks).get(); + expect(tasks, hasLength(1)); + expect(tasks.single.taskListId, 'list-2'); + expect(tasks.single.id, 'task-server'); + expect(tasks.single.title, 'Edited after move'); + expect(tasks.single.localDirty, isFalse); }, ); test( - 'three cross-list moves form one dependency chain before an edit', + 'move replay repairs a source projection before a dependent edit', () async { await database.taskListsDao.upsertTaskList(_taskList('list-2')); - await database.taskListsDao.upsertTaskList(_taskList('list-3')); - await database.tasksDao.upsertTask(_task('list-1', 'task-1')); + await database.tasksDao.upsertTask( + _task('list-1', 'task-1', title: 'Original'), + ); final repository = TasksRepository( database: database, accountId: 'account', @@ -822,10 +1157,135 @@ void main() { destinationTaskListId: 'list-2', ), ); - await repository.moveTask( - const TaskMoveInput( - sourceTaskListId: 'list-2', - taskId: 'task-1', + await repository.patchTask( + 'list-2', + 'task-1', + const TaskPatchInput({'title': 'Edited after move'}), + ); + await (database.update(database.tasks)..where( + (row) => + row.accountId.equals('account') & + row.taskListId.equals('list-2') & + row.id.equals('task-1'), + )) + .write(const TasksCompanion(taskListId: Value('list-1'))); + + expect( + await PendingOpsReplayer( + database: database, + apiClient: apiClient, + accountId: 'account', + nowUtc: () => DateTime.utc(2026, 6, 4, 1), + ).replayDueOps(), + 2, + ); + + expect(await database.tasksDao.listTasks('account', 'list-1'), isEmpty); + final tasks = await database.select(database.tasks).get(); + expect(tasks, hasLength(1)); + expect(tasks.single.taskListId, 'list-2'); + expect(tasks.single.title, 'Edited after move'); + expect(tasks.single.localDirty, isFalse); + }, + ); + + test( + 'cross-list move orders an equal-timestamp edit and preserves projection', + () async { + await database.taskListsDao.upsertTaskList(_taskList('list-2')); + await database.tasksDao.upsertTask( + _task('list-1', 'task-1', title: 'Original'), + ); + final repository = TasksRepository( + database: database, + accountId: 'account', + nowUtc: () => DateTime.utc(2026, 6, 4), + ); + await repository.moveTask( + const TaskMoveInput( + sourceTaskListId: 'list-1', + taskId: 'task-1', + destinationTaskListId: 'list-2', + ), + ); + await repository.patchTask( + 'list-2', + 'task-1', + const TaskPatchInput({'title': 'Edited after move'}), + ); + final queued = await database.select(database.pendingOps).get(); + final move = queued.singleWhere((op) => op.operation == 'move_task'); + final edit = queued.singleWhere((op) => op.operation == 'patch_task'); + expect(edit.dependsOnOpId, move.id); + + apiClient.moveTaskError = const GoogleTasksApiError( + statusCode: 429, + message: 'Rate limited', + ); + expect( + await PendingOpsReplayer( + database: database, + apiClient: apiClient, + accountId: 'account', + random: Random(0), + nowUtc: () => DateTime.utc(2026, 6, 4, 1), + ).replayDueOps(), + 0, + ); + expect(apiClient.calls, ['move_task:task-1']); + expect( + (await database.tasksDao.listTasks('account', 'list-2')).single.title, + 'Edited after move', + ); + + apiClient.moveTaskError = null; + expect( + await PendingOpsReplayer( + database: database, + apiClient: apiClient, + accountId: 'account', + random: Random(0), + nowUtc: () => DateTime.utc(2026, 6, 4, 2), + ).replayDueOps(), + 2, + ); + expect(apiClient.calls, [ + 'move_task:task-1', + 'move_task:task-1', + 'patch_task:task-1', + ]); + expect(await database.tasksDao.listTasks('account', 'list-1'), isEmpty); + final finalTask = (await database.tasksDao.listTasks( + 'account', + 'list-2', + )).single; + expect(finalTask.title, 'Edited after move'); + expect(finalTask.localDirty, isFalse); + }, + ); + + test( + 'three cross-list moves form one dependency chain before an edit', + () async { + await database.taskListsDao.upsertTaskList(_taskList('list-2')); + await database.taskListsDao.upsertTaskList(_taskList('list-3')); + await database.tasksDao.upsertTask(_task('list-1', 'task-1')); + final repository = TasksRepository( + database: database, + accountId: 'account', + nowUtc: () => DateTime.utc(2026, 6, 4), + ); + await repository.moveTask( + const TaskMoveInput( + sourceTaskListId: 'list-1', + taskId: 'task-1', + destinationTaskListId: 'list-2', + ), + ); + await repository.moveTask( + const TaskMoveInput( + sourceTaskListId: 'list-2', + taskId: 'task-1', destinationTaskListId: 'list-3', ), ); @@ -1405,6 +1865,78 @@ void main() { ); } + test( + 'parent identity dependency does not leave the parent locally dirty', + () async { + final repository = TasksRepository( + database: database, + accountId: 'account', + apiClient: apiClient, + nowUtc: () => DateTime.utc(2026, 6, 4), + ); + await repository.createTask( + 'list-1', + const TaskCreateInput(title: 'Parent'), + ); + final temporaryParent = (await database.tasksDao.listTasks( + 'account', + 'list-1', + )).single; + await repository.createSubtask( + taskListId: 'list-1', + parentTaskId: temporaryParent.id, + title: 'Child', + ); + apiClient.createdTasks.addAll([ + _taskDto('parent-server', title: 'Parent'), + _taskDto('child-server', title: 'Child'), + ]); + + expect( + await PendingOpsReplayer( + database: database, + apiClient: apiClient, + accountId: 'account', + nowUtc: () => DateTime.utc(2026, 6, 4, 1), + ).replayDueOps(), + 3, + ); + var tasks = await database.tasksDao.listTasks('account', 'list-1'); + expect(tasks, hasLength(2)); + expect(tasks.map((task) => task.localDirty), everyElement(isFalse)); + expect(await database.select(database.pendingOps).get(), isEmpty); + expect(apiClient.createParentTaskIds, [null, 'parent-server']); + + apiClient + ..remoteTaskListsPage = TaskListsPageDto( + items: [_taskListDto('list-1')], + rawJson: const {}, + ) + ..remoteTasksPage = TasksPageDto( + items: [ + _taskDto( + 'parent-server', + title: 'Parent changed remotely', + updated: DateTime.utc(2026, 6, 4, 2), + ), + _taskDto('child-server', title: 'Child', parent: 'parent-server'), + ], + rawJson: const {}, + ); + await SyncEngine( + database: database, + apiClient: apiClient, + accountId: 'account', + nowUtc: () => DateTime.utc(2026, 6, 4, 3), + ).fullSync(); + tasks = await database.tasksDao.listTasks('account', 'list-1'); + expect( + tasks.singleWhere((task) => task.id == 'parent-server').title, + 'Parent changed remotely', + ); + }, + ); + test( 'Google subtask is moved under its parent after a root insert', () async { @@ -1835,6 +2367,53 @@ void main() { ); } + test('Microsoft plain-text angle-bracket note changes conflict', () async { + final baselineRaw = { + 'id': 'task-1', + 'title': 'Task', + 'lastModifiedDateTime': '2026-06-04T00:00:00.000Z', + 'body': {'content': 'Contact ', 'contentType': 'text'}, + }; + final remoteRaw = { + 'id': 'task-1', + 'title': 'Task', + 'lastModifiedDateTime': '2026-06-04T00:10:00.000Z', + 'body': {'content': 'Contact ', 'contentType': 'text'}, + }; + await database.tasksDao.upsertTask( + _task( + 'list-1', + 'task-1', + updatedUtc: '2026-06-04T00:00:00.000Z', + rawJson: jsonEncode(baselineRaw), + ), + ); + await _enqueue( + database, + id: '01', + operation: 'patch_task', + taskListId: 'list-1', + taskId: 'task-1', + request: const {'notes': 'Local notes'}, + baselineUpdatedUtc: '2026-06-04T00:00:00.000Z', + baselineRawJson: jsonEncode(baselineRaw), + ); + final graphClient = _ConflictMicrosoftTodoApiClient(remoteRaw); + + final applied = await PendingOpsReplayer( + database: database, + apiClient: _microsoftAdapter(graphClient), + accountId: 'account', + nowUtc: () => DateTime.utc(2026, 6, 4, 1), + ).replayDueOps(); + + final pending = await database.pendingOpsDao.getOp('01'); + expect(applied, 0); + expect(graphClient.updateTaskCalls, 0); + expect(pending!.lastErrorCode, 'conflict'); + expect(pending.lastErrorMessage, contains('notes')); + }); + test( 'back-to-back local task patches replay in order without self-conflict', () async { @@ -1872,128 +2451,1052 @@ void main() { 'task-1', const TaskPatchInput({'title': 'First local title'}), ); - await repository.patchTask( + await repository.patchTask( + 'list-1', + 'task-1', + const TaskPatchInput({'title': 'Second local title'}), + ); + + final queued = await database.pendingOpsDao.pendingOpsForReplay( + 'account', + _later, + ); + expect(queued, hasLength(2)); + expect(queued.first.dependsOnOpId, equals(null)); + expect(queued.last.dependsOnOpId, queued.first.id); + expect( + queued.map((op) => op.baselineRawJson), + everyElement(baselineRawJson), + ); + expect( + queued.map((op) => op.baselineUpdatedUtc), + everyElement(baselineUpdatedUtc), + ); + + 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(apiClient.taskPatchFields.map((fields) => fields['title']), [ + 'First local title', + 'Second local title', + ]); + expect(apiClient.remoteTask!.title, 'Second local title'); + expect(await database.select(database.pendingOps).get(), isEmpty); + final local = await database.tasksDao.listTasks('account', 'list-1'); + expect(local.single.title, 'Second local title'); + expect(local.single.localDirty, isFalse); + }, + ); + + test('title notes title task patches replay without self-conflict', () async { + const baselineUpdatedUtc = '2026-06-04T00:00:00.000Z'; + final baselineRawJson = jsonEncode({ + 'id': 'task-1', + 'title': 'A', + 'notes': 'Base notes', + 'updated': baselineUpdatedUtc, + }); + apiClient + ..persistTaskPatches = true + ..remoteTask = _taskDto( + 'task-1', + title: 'A', + notes: 'Base notes', + updated: DateTime.parse(baselineUpdatedUtc), + ); + await database.tasksDao.upsertTask( + _task( + 'list-1', + 'task-1', + title: 'A', + 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': 'B'}), + ); + await repository.patchTask( + 'list-1', + 'task-1', + const TaskPatchInput({'notes': 'Local notes'}), + ); + await repository.patchTask( + 'list-1', + 'task-1', + const TaskPatchInput({'title': 'C'}), + ); + + final applied = await PendingOpsReplayer( + database: database, + apiClient: apiClient, + accountId: 'account', + random: Random(0), + nowUtc: () => DateTime.utc(2026, 6, 4, 1), + ).replayDueOps(); + + expect(applied, 3); + expect(apiClient.taskPatchFields, [ + {'title': 'B'}, + {'notes': 'Local notes'}, + {'title': 'C'}, + ]); + expect(apiClient.remoteTask!.title, 'C'); + expect(await database.select(database.pendingOps).get(), isEmpty); + }); + + test( + 'title notes title task patches preserve a genuine remote title conflict', + () async { + const baselineUpdatedUtc = '2026-06-04T00:00:00.000Z'; + final baselineRawJson = jsonEncode({ + 'id': 'task-1', + 'title': 'A', + 'notes': 'Base notes', + 'updated': baselineUpdatedUtc, + }); + apiClient + ..persistTaskPatches = true + ..remoteTask = _taskDto( + 'task-1', + title: 'A', + notes: 'Base notes', + updated: DateTime.parse(baselineUpdatedUtc), + ) + ..remoteTaskAfterSecondPatch = _taskDto( + 'task-1', + title: 'External title', + notes: 'Local notes', + updated: DateTime.utc(2026, 6, 4, 0, 8), + ); + await database.tasksDao.upsertTask( + _task( + 'list-1', + 'task-1', + title: 'A', + 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': 'B'}), + ); + await repository.patchTask( + 'list-1', + 'task-1', + const TaskPatchInput({'notes': 'Local notes'}), + ); + await repository.patchTask( + 'list-1', + 'task-1', + const TaskPatchInput({'title': 'C'}), + ); + + 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(apiClient.taskPatchFields, [ + {'title': 'B'}, + {'notes': 'Local notes'}, + ]); + expect(apiClient.remoteTask!.title, 'External title'); + final pending = await database.select(database.pendingOps).getSingle(); + expect(pending.lastErrorCode, 'conflict'); + expect(pending.lastErrorMessage, contains('title')); + }, + ); + + 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( + 'task edit does not hide an unrelated remote change from deletion', + () async { + const baselineUpdatedUtc = '2026-06-04T00:00:00.000Z'; + final baselineRawJson = jsonEncode({ + 'id': 'task-1', + 'title': 'A', + 'notes': 'N', + 'updated': baselineUpdatedUtc, + }); + apiClient + ..persistTaskPatches = true + ..remoteTask = _taskDto( + 'task-1', + title: 'A', + notes: 'Remote notes', + updated: DateTime.utc(2026, 6, 4, 0, 5), + ); + await database.tasksDao.upsertTask( + _task( + 'list-1', + 'task-1', + title: 'A', + 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': 'B'}), + ); + await repository.deleteTask('list-1', 'task-1'); + + final applied = await PendingOpsReplayer( + database: database, + apiClient: apiClient, + accountId: 'account', + nowUtc: () => DateTime.utc(2026, 6, 4, 1), + ).replayDueOps(); + + expect(applied, 1); + expect(apiClient.calls, ['patch_task:task-1']); + final pending = await database.select(database.pendingOps).getSingle(); + expect(pending.operation, 'delete_task'); + expect(pending.lastErrorCode, 'conflict'); + expect(pending.lastErrorMessage, contains('Remote task changed')); + }, + ); + + for (final editCase in [ + (name: 'completion', fields: {'status': 'completed'}), + ( + name: 'due date', + fields: {'due': DateTime.utc(2026, 6, 10)}, + ), + ]) { + test( + 'Google ${editCase.name} followed by deletion does not self-conflict', + () async { + const baselineUpdatedUtc = '2026-06-04T00:00:00.000Z'; + final baselineRaw = { + 'kind': 'tasks#task', + 'id': 'task-1', + 'title': 'Task', + 'notes': 'Base notes', + 'status': 'needsAction', + 'updated': baselineUpdatedUtc, + }; + apiClient + ..persistTaskPatches = true + ..remoteTask = TaskDto.fromJson(baselineRaw); + await database.tasksDao.upsertTask( + _task( + 'list-1', + 'task-1', + updatedUtc: baselineUpdatedUtc, + rawJson: jsonEncode(baselineRaw), + ), + ); + var edit = 0; + final repository = TasksRepository( + database: database, + accountId: 'account', + nowUtc: () => DateTime.utc(2026, 6, 4, 0, 0, ++edit), + ); + await repository.patchTask( + 'list-1', + 'task-1', + TaskPatchInput(editCase.fields), + ); + await repository.deleteTask('list-1', 'task-1'); + + final applied = await PendingOpsReplayer( + database: database, + apiClient: apiClient, + accountId: 'account', + 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', 'delete_task:task-1']); + }, + ); + + test( + 'Google ${editCase.name} does not hide an unrelated deletion conflict', + () async { + const baselineUpdatedUtc = '2026-06-04T00:00:00.000Z'; + final baselineRaw = { + 'kind': 'tasks#task', + 'id': 'task-1', + 'title': 'Task', + 'notes': 'Base notes', + 'status': 'needsAction', + 'updated': baselineUpdatedUtc, + }; + apiClient + ..persistTaskPatches = true + ..remoteTask = TaskDto.fromJson({ + ...baselineRaw, + 'notes': 'Remote notes', + 'updated': '2026-06-04T00:03:00.000Z', + }); + await database.tasksDao.upsertTask( + _task( + 'list-1', + 'task-1', + updatedUtc: baselineUpdatedUtc, + rawJson: jsonEncode(baselineRaw), + ), + ); + var edit = 0; + final repository = TasksRepository( + database: database, + accountId: 'account', + nowUtc: () => DateTime.utc(2026, 6, 4, 0, 0, ++edit), + ); + await repository.patchTask( + 'list-1', + 'task-1', + TaskPatchInput(editCase.fields), + ); + await repository.deleteTask('list-1', 'task-1'); + + final applied = await PendingOpsReplayer( + database: database, + apiClient: apiClient, + accountId: 'account', + nowUtc: () => DateTime.utc(2026, 6, 4, 1), + ).replayDueOps(); + + expect(applied, 1); + expect(apiClient.calls, ['patch_task:task-1']); + final pending = await database.select(database.pendingOps).getSingle(); + expect(pending.operation, 'delete_task'); + expect(pending.lastErrorCode, 'conflict'); + }, + ); + } + + test('queued move followed by deletion does not self-conflict', () async { + const baselineUpdatedUtc = '2026-06-04T00:00:00.000Z'; + final baselineRaw = { + 'kind': 'tasks#task', + 'id': 'task-1', + 'title': 'Task', + 'position': 'old-position', + 'updated': baselineUpdatedUtc, + }; + apiClient.remoteTask = TaskDto.fromJson(baselineRaw); + await database.tasksDao.upsertTask( + _task( + 'list-1', + 'task-1', + updatedUtc: baselineUpdatedUtc, + rawJson: jsonEncode(baselineRaw), + ), + ); + var edit = 0; + final repository = TasksRepository( + database: database, + accountId: 'account', + nowUtc: () => DateTime.utc(2026, 6, 4, 0, 0, ++edit), + ); + await repository.moveTask( + const TaskMoveInput( + sourceTaskListId: 'list-1', + taskId: 'task-1', + previousSiblingTaskId: 'task-0', + ), + ); + await repository.deleteTask('list-1', 'task-1'); + + final applied = await PendingOpsReplayer( + database: database, + apiClient: apiClient, + accountId: 'account', + nowUtc: () => DateTime.utc(2026, 6, 4, 1), + ).replayDueOps(); + + expect(applied, 2); + expect(apiClient.calls, ['move_task:task-1', 'delete_task:task-1']); + expect(await database.select(database.pendingOps).get(), isEmpty); + }); + + test( + 'queued move followed by deletion preserves a remote notes conflict', + () async { + const baselineUpdatedUtc = '2026-06-04T00:00:00.000Z'; + final baselineRaw = { + 'kind': 'tasks#task', + 'id': 'task-1', + 'title': 'Task', + 'notes': 'Base notes', + 'position': 'old-position', + 'updated': baselineUpdatedUtc, + }; + apiClient + ..remoteTask = TaskDto.fromJson(baselineRaw) + ..remoteTaskAfterMove = TaskDto.fromJson({ + ...baselineRaw, + 'notes': 'Remote notes', + 'position': 'moved-position', + 'updated': '2026-06-04T00:10:00.000Z', + }); + await database.tasksDao.upsertTask( + _task( + 'list-1', + 'task-1', + updatedUtc: baselineUpdatedUtc, + rawJson: jsonEncode(baselineRaw), + ), + ); + var edit = 0; + final repository = TasksRepository( + database: database, + accountId: 'account', + nowUtc: () => DateTime.utc(2026, 6, 4, 0, 0, ++edit), + ); + await repository.moveTask( + const TaskMoveInput( + sourceTaskListId: 'list-1', + taskId: 'task-1', + previousSiblingTaskId: 'task-0', + ), + ); + await repository.deleteTask('list-1', 'task-1'); + + final applied = await PendingOpsReplayer( + database: database, + apiClient: apiClient, + accountId: 'account', + nowUtc: () => DateTime.utc(2026, 6, 4, 1), + ).replayDueOps(); + + expect(applied, 1); + expect(apiClient.calls, ['move_task:task-1']); + final pending = await database.select(database.pendingOps).getSingle(); + expect(pending.operation, 'delete_task'); + expect(pending.lastErrorCode, 'conflict'); + }, + ); + + for (final remoteTitle in ['Task', 'Remote task']) { + final conflicts = remoteTitle != 'Task'; + test( + 'Microsoft collection-to-GET annotation ${conflicts ? 'preserves a real conflict' : 'does not create a conflict'}', + () async { + const baselineUpdatedUtc = '2026-06-04T00:00:00.000Z'; + final baselineRaw = { + 'id': 'task-1', + 'title': 'Task', + 'status': 'notStarted', + 'lastModifiedDateTime': baselineUpdatedUtc, + 'body': {'content': 'Notes', 'contentType': 'text'}, + }; + await database.tasksDao.upsertTask( + _task( + 'list-1', + 'task-1', + updatedUtc: baselineUpdatedUtc, + rawJson: jsonEncode(baselineRaw), + ), + ); + await _enqueue( + database, + id: '01', + operation: 'delete_task', + taskListId: 'list-1', + taskId: 'task-1', + request: const {}, + baselineUpdatedUtc: baselineUpdatedUtc, + baselineRawJson: jsonEncode(baselineRaw), + ); + final graphClient = _ConflictMicrosoftTodoApiClient({ + ...baselineRaw, + '@odata.context': + r'https://graph.microsoft.com/v1.0/$metadata#tasks/$entity', + 'title': remoteTitle, + }); + + final applied = await PendingOpsReplayer( + database: database, + apiClient: _microsoftAdapter(graphClient), + accountId: 'account', + nowUtc: () => DateTime.utc(2026, 6, 4, 1), + ).replayDueOps(); + + expect(applied, conflicts ? 0 : 1); + expect(graphClient.deleteTaskCalls, conflicts ? 0 : 1); + if (conflicts) { + expect( + (await database.pendingOpsDao.getOp('01'))!.lastErrorCode, + 'conflict', + ); + } else { + expect(await database.pendingOpsDao.getOp('01'), equals(null)); + } + }, + ); + } + + test( + 'Microsoft list rename without updated timestamp detects conflict', + () async { + await database.taskListsDao.upsertTaskList( + _taskList( + 'list-1', + title: 'Base list', + rawJson: jsonEncode(const { + 'id': 'list-1', + 'displayName': 'Base list', + }), + ), + ); + final repository = TaskListsRepository( + database: database, + accountId: 'account', + nowUtc: () => DateTime.utc(2026, 6, 4), + ); + await repository.renameTaskList('list-1', 'Local list'); + final graphClient = _ConflictMicrosoftTaskListApiClient( + list: const {'id': 'list-1', 'displayName': 'Remote list'}, + ); + + final applied = await PendingOpsReplayer( + database: database, + apiClient: _microsoftAdapter(graphClient), + accountId: 'account', + nowUtc: () => DateTime.utc(2026, 6, 4, 1), + ).replayDueOps(); + + expect(applied, 0); + expect(graphClient.updateTaskListCalls, 0); + final pending = await database.select(database.pendingOps).getSingle(); + expect(pending.lastErrorCode, 'conflict'); + expect(pending.lastErrorMessage, contains('title')); + }, + ); + + test( + 'Microsoft list deletion detects child change without list timestamp', + () async { + const childBaseline = '2026-06-04T00:00:00.000Z'; + final baselineTaskRaw = { + 'id': 'task-1', + 'title': 'Base task', + 'lastModifiedDateTime': childBaseline, + 'body': {'content': 'Base notes', 'contentType': 'text'}, + }; + await database.taskListsDao.upsertTaskList( + _taskList( + 'list-1', + title: 'Base list', + rawJson: jsonEncode(const { + 'id': 'list-1', + 'displayName': 'Base list', + }), + ), + ); + await database.tasksDao.upsertTask( + _task( + 'list-1', + 'task-1', + title: 'Base task', + updatedUtc: childBaseline, + rawJson: jsonEncode(baselineTaskRaw), + ), + ); + final repository = TaskListsRepository( + database: database, + accountId: 'account', + nowUtc: () => DateTime.utc(2026, 6, 4), + ); + await repository.deleteTaskList('list-1'); + final graphClient = _ConflictMicrosoftTaskListApiClient( + list: const {'id': 'list-1', 'displayName': 'Base list'}, + tasks: const [ + { + 'id': 'task-1', + 'title': 'Base task', + 'lastModifiedDateTime': '2026-06-04T00:10:00.000Z', + 'body': {'content': 'Remote notes', 'contentType': 'text'}, + }, + ], + ); + + final applied = await PendingOpsReplayer( + database: database, + apiClient: _microsoftAdapter(graphClient), + accountId: 'account', + nowUtc: () => DateTime.utc(2026, 6, 4, 1), + ).replayDueOps(); + + expect(applied, 0); + expect(graphClient.deleteTaskListCalls, 0); + final pending = await database.select(database.pendingOps).getSingle(); + expect(pending.lastErrorCode, 'conflict'); + expect(pending.lastErrorMessage, contains('Remote task in list')); + }, + ); + + test( + 'Microsoft list deletion detects an independently added remote child', + () async { + await database.taskListsDao.upsertTaskList( + _taskList( + 'list-1', + rawJson: jsonEncode(const { + 'id': 'list-1', + 'displayName': 'Base list', + }), + ), + ); + final repository = TaskListsRepository( + database: database, + accountId: 'account', + nowUtc: () => DateTime.utc(2026, 6, 4), + ); + await repository.deleteTaskList('list-1'); + final graphClient = _ConflictMicrosoftTaskListApiClient( + list: const {'id': 'list-1', 'displayName': 'Base list'}, + tasks: const [ + { + 'id': 'remote-task', + 'title': 'Remote task', + 'lastModifiedDateTime': '2026-06-04T00:10:00.000Z', + }, + ], + ); + + final applied = await PendingOpsReplayer( + database: database, + apiClient: _microsoftAdapter(graphClient), + accountId: 'account', + nowUtc: () => DateTime.utc(2026, 6, 4, 1), + ).replayDueOps(); + + expect(applied, 0); + expect(graphClient.deleteTaskListCalls, 0); + final pending = await database.select(database.pendingOps).getSingle(); + expect(pending.lastErrorCode, 'conflict'); + }, + ); + + for (final renameBeforeDelete in [false, true]) { + test( + 'Microsoft create${renameBeforeDelete ? ' rename' : ''} delete list uses the server identity baseline', + () async { + await (database.update(database.accounts) + ..where((row) => row.id.equals('account'))) + .write(const AccountsCompanion(provider: Value('microsoft'))); + var mutation = 0; + final repository = TaskListsRepository( + database: database, + accountId: 'account', + nowUtc: () => DateTime.utc(2026, 6, 4, 0, 0, ++mutation), + ); + await repository.createTaskList('Draft list'); + final localList = (await database.taskListsDao.listTaskLists( + 'account', + )).singleWhere((list) => list.id.startsWith('local-tasklist-')); + if (renameBeforeDelete) { + await repository.renameTaskList(localList.id, 'Renamed list'); + } + await repository.deleteTaskList(localList.id); + final graphClient = _ConflictMicrosoftTaskListApiClient( + list: const {'id': 'list-server', 'displayName': 'Draft list'}, + ); + + final applied = await PendingOpsReplayer( + database: database, + apiClient: _microsoftAdapter(graphClient), + accountId: 'account', + nowUtc: () => DateTime.utc(2026, 6, 4, 1), + ).replayDueOps(); + + expect(applied, renameBeforeDelete ? 3 : 2); + expect(graphClient.createTaskListCalls, 1); + expect(graphClient.updateTaskListCalls, renameBeforeDelete ? 1 : 0); + expect(graphClient.deleteTaskListCalls, 1); + expect(await database.select(database.pendingOps).get(), isEmpty); + }, + ); + } + + test( + 'Microsoft create delete list still detects a post-create remote rename', + () async { + await (database.update(database.accounts) + ..where((row) => row.id.equals('account'))) + .write(const AccountsCompanion(provider: Value('microsoft'))); + var mutation = 0; + final repository = TaskListsRepository( + database: database, + accountId: 'account', + nowUtc: () => DateTime.utc(2026, 6, 4, 0, 0, ++mutation), + ); + await repository.createTaskList('Draft list'); + final localList = (await database.taskListsDao.listTaskLists( + 'account', + )).singleWhere((list) => list.id.startsWith('local-tasklist-')); + await repository.deleteTaskList(localList.id); + final graphClient = _ConflictMicrosoftTaskListApiClient( + list: const {'id': 'list-server', 'displayName': 'Draft list'}, + listAfterCreate: const {'displayName': 'Remote list'}, + ); + + final applied = await PendingOpsReplayer( + database: database, + apiClient: _microsoftAdapter(graphClient), + accountId: 'account', + nowUtc: () => DateTime.utc(2026, 6, 4, 1), + ).replayDueOps(); + + expect(applied, 1); + expect(graphClient.deleteTaskListCalls, 0); + final pending = await database.select(database.pendingOps).getSingle(); + expect(pending.operation, 'delete_task_list'); + expect(pending.lastErrorCode, 'conflict'); + }, + ); + + test( + 'Microsoft child edit followed by list deletion does not self-conflict', + () async { + const baselineUpdatedUtc = '2026-06-04T00:00:00.000Z'; + final taskRaw = { + 'id': 'task-1', + 'title': 'A', + 'status': 'notStarted', + 'lastModifiedDateTime': baselineUpdatedUtc, + 'body': {'content': 'Notes', 'contentType': 'text'}, + }; + await database.taskListsDao.upsertTaskList( + _taskList( + 'list-1', + rawJson: jsonEncode(const {'id': 'list-1', 'displayName': 'List'}), + ), + ); + await database.tasksDao.upsertTask( + _task( + 'list-1', + 'task-1', + title: 'A', + updatedUtc: baselineUpdatedUtc, + rawJson: jsonEncode(taskRaw), + ), + ); + var mutation = 0; + final tasks = TasksRepository( + database: database, + accountId: 'account', + nowUtc: () => DateTime.utc(2026, 6, 4, 0, 0, ++mutation), + ); + final lists = TaskListsRepository( + database: database, + accountId: 'account', + nowUtc: () => DateTime.utc(2026, 6, 4, 0, 0, ++mutation), + ); + await tasks.patchTask( + 'list-1', + 'task-1', + const TaskPatchInput({'title': 'B'}), + ); + await lists.deleteTaskList('list-1'); + final graphClient = _ConflictMicrosoftTaskListApiClient( + list: const {'id': 'list-1', 'displayName': 'List'}, + tasks: [taskRaw], + ); + + final applied = await PendingOpsReplayer( + database: database, + apiClient: _microsoftAdapter(graphClient), + accountId: 'account', + nowUtc: () => DateTime.utc(2026, 6, 4, 1), + ).replayDueOps(); + + expect(applied, 2); + expect(graphClient.updateTaskCalls, 1); + expect(graphClient.deleteTaskListCalls, 1); + expect(await database.select(database.pendingOps).get(), isEmpty); + }, + ); + + test( + 'Microsoft child creation followed by list deletion rebases membership', + () async { + await database.taskListsDao.upsertTaskList( + _taskList( + 'list-1', + rawJson: jsonEncode(const {'id': 'list-1', 'displayName': 'List'}), + ), + ); + var mutation = 0; + final tasks = TasksRepository( + database: database, + accountId: 'account', + nowUtc: () => DateTime.utc(2026, 6, 4, 0, 0, ++mutation), + ); + final lists = TaskListsRepository( + database: database, + accountId: 'account', + nowUtc: () => DateTime.utc(2026, 6, 4, 0, 0, ++mutation), + ); + await tasks.createTask( + 'list-1', + const TaskCreateInput(title: 'New task'), + ); + await lists.deleteTaskList('list-1'); + final graphClient = _ConflictMicrosoftTaskListApiClient( + list: const {'id': 'list-1', 'displayName': 'List'}, + ); + + final applied = await PendingOpsReplayer( + database: database, + apiClient: _microsoftAdapter(graphClient), + accountId: 'account', + nowUtc: () => DateTime.utc(2026, 6, 4, 1), + ).replayDueOps(); + + expect(applied, 2); + expect(graphClient.createTaskCalls, 1); + expect(graphClient.deleteTaskListCalls, 1); + expect(await database.select(database.pendingOps).get(), isEmpty); + }, + ); + + test( + 'Microsoft child deletion followed by list deletion rebases membership', + () async { + const baselineUpdatedUtc = '2026-06-04T00:00:00.000Z'; + final taskRaw = { + 'id': 'task-1', + 'title': 'Task', + 'status': 'notStarted', + 'lastModifiedDateTime': baselineUpdatedUtc, + }; + await database.taskListsDao.upsertTaskList( + _taskList( + 'list-1', + rawJson: jsonEncode(const {'id': 'list-1', 'displayName': 'List'}), + ), + ); + await database.tasksDao.upsertTask( + _task( + 'list-1', + 'task-1', + updatedUtc: baselineUpdatedUtc, + rawJson: jsonEncode(taskRaw), + ), + ); + var mutation = 0; + final tasks = TasksRepository( + database: database, + accountId: 'account', + nowUtc: () => DateTime.utc(2026, 6, 4, 0, 0, ++mutation), + ); + final lists = TaskListsRepository( + database: database, + accountId: 'account', + nowUtc: () => DateTime.utc(2026, 6, 4, 0, 0, ++mutation), + ); + await tasks.deleteTask('list-1', 'task-1'); + await lists.deleteTaskList('list-1'); + final graphClient = _ConflictMicrosoftTaskListApiClient( + list: const {'id': 'list-1', 'displayName': 'List'}, + tasks: [taskRaw], + ); + + final applied = await PendingOpsReplayer( + database: database, + apiClient: _microsoftAdapter(graphClient), + accountId: 'account', + nowUtc: () => DateTime.utc(2026, 6, 4, 1), + ).replayDueOps(); + + expect(applied, 2); + expect(graphClient.deleteTaskCalls, 1); + expect(graphClient.deleteTaskListCalls, 1); + expect(await database.select(database.pendingOps).get(), isEmpty); + }, + ); + + test( + 'Microsoft acknowledged child edit preserves a remote notes conflict', + () async { + const baselineUpdatedUtc = '2026-06-04T00:00:00.000Z'; + final baselineRaw = { + 'id': 'task-1', + 'title': 'A', + 'status': 'notStarted', + 'lastModifiedDateTime': baselineUpdatedUtc, + 'body': {'content': 'Base notes', 'contentType': 'text'}, + }; + await database.taskListsDao.upsertTaskList( + _taskList( + 'list-1', + rawJson: jsonEncode(const {'id': 'list-1', 'displayName': 'List'}), + ), + ); + await database.tasksDao.upsertTask( + _task( + 'list-1', + 'task-1', + title: 'A', + updatedUtc: baselineUpdatedUtc, + rawJson: jsonEncode(baselineRaw), + ), + ); + var mutation = 0; + final tasks = TasksRepository( + database: database, + accountId: 'account', + nowUtc: () => DateTime.utc(2026, 6, 4, 0, 0, ++mutation), + ); + final lists = TaskListsRepository( + database: database, + accountId: 'account', + nowUtc: () => DateTime.utc(2026, 6, 4, 0, 0, ++mutation), + ); + await tasks.patchTask( 'list-1', 'task-1', - const TaskPatchInput({'title': 'Second local title'}), - ); - - final queued = await database.pendingOpsDao.pendingOpsForReplay( - 'account', - _later, - ); - expect(queued, hasLength(2)); - expect(queued.first.dependsOnOpId, equals(null)); - expect(queued.last.dependsOnOpId, queued.first.id); - expect( - queued.map((op) => op.baselineRawJson), - everyElement(baselineRawJson), + const TaskPatchInput({'title': 'B'}), ); - expect( - queued.map((op) => op.baselineUpdatedUtc), - everyElement(baselineUpdatedUtc), + await lists.deleteTaskList('list-1'); + final graphClient = _ConflictMicrosoftTaskListApiClient( + list: const {'id': 'list-1', 'displayName': 'List'}, + tasks: [ + { + ...baselineRaw, + 'lastModifiedDateTime': '2026-06-04T00:03:00.000Z', + 'body': {'content': 'Remote notes', 'contentType': 'text'}, + }, + ], ); final applied = await PendingOpsReplayer( database: database, - apiClient: apiClient, + apiClient: _microsoftAdapter(graphClient), accountId: 'account', - random: Random(0), nowUtc: () => DateTime.utc(2026, 6, 4, 1), ).replayDueOps(); - expect(applied, 2); - expect(apiClient.taskPatchFields.map((fields) => fields['title']), [ - 'First local title', - 'Second local title', - ]); - expect(apiClient.remoteTask!.title, 'Second local title'); - expect(await database.select(database.pendingOps).get(), isEmpty); - final local = await database.tasksDao.listTasks('account', 'list-1'); - expect(local.single.title, 'Second local title'); - expect(local.single.localDirty, isFalse); + expect(applied, 1); + expect(graphClient.updateTaskCalls, 1); + expect(graphClient.deleteTaskListCalls, 0); + final pending = await database.select(database.pendingOps).getSingle(); + expect(pending.operation, 'delete_task_list'); + expect(pending.lastErrorCode, 'conflict'); }, ); - 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 { @@ -2453,82 +3956,392 @@ void main() { request: const {}, ); - final applied = await PendingOpsReplayer( - database: database, - apiClient: apiClient, - accountId: 'account', - random: Random(0), - nowUtc: () => DateTime.utc(2026, 6, 4), - ).replayDueOps(); + final applied = await PendingOpsReplayer( + database: database, + apiClient: apiClient, + accountId: 'account', + random: Random(0), + nowUtc: () => DateTime.utc(2026, 6, 4), + ).replayDueOps(); + + final op = await database.select(database.pendingOps).getSingle(); + expect(applied, 0); + expect(op.lastErrorCode, '400'); + expect(op.lastErrorMessage, contains('Clear completed')); + expect(op.nextAttemptAtUtc, startsWith('9999-12-31')); + }); + + test( + 'replays checklist create and dependent patch against the server id', + () async { + final checklistClient = _ChecklistTaskRemoteClient(); + await database.tasksDao.upsertTask( + _task( + 'list-1', + 'task-1', + checklistItemsJson: jsonEncode([ + {'id': 'local-step', 'displayName': 'Step', 'isChecked': false}, + ]), + ), + ); + await _enqueue( + database, + id: '01', + operation: 'create_task_checklist_item', + entityType: 'task_checklist_item', + taskListId: 'list-1', + taskId: 'task-1', + localTempId: 'local-step', + request: { + 'checklistItemId': 'local-step', + 'body': {'displayName': 'Step', 'isChecked': false}, + }, + ); + await _enqueue( + database, + id: '02', + operation: 'patch_task_checklist_item', + entityType: 'task_checklist_item', + taskListId: 'list-1', + taskId: 'task-1', + request: { + 'checklistItemId': 'local-step', + 'body': {'isChecked': true}, + }, + ); + + final applied = await PendingOpsReplayer( + database: database, + apiClient: checklistClient, + accountId: 'account', + random: Random(0), + nowUtc: () => DateTime.utc(2026, 6, 4), + ).replayDueOps(); + + final task = (await database.tasksDao.listTasks( + 'account', + 'list-1', + )).single; + final item = decodeTaskChecklistItems( + task.microsoftChecklistItemsJson, + ).single; + expect(applied, 2); + expect(checklistClient.checklistCalls, [ + 'create:Step', + 'update:server-step:true', + ]); + expect(item.id, 'server-step'); + expect(item.completed, isTrue); + expect(await database.select(database.pendingOps).get(), isEmpty); + }, + ); + + test( + 'deleting an in-flight checklist create queues a server delete', + () async { + final checklistClient = _ChecklistTaskRemoteClient(); + final repository = TasksRepository( + database: database, + accountId: 'account', + apiClient: checklistClient, + nowUtc: () => DateTime.utc(2026, 6, 4), + ); + await database.tasksDao.upsertTask(_task('list-1', 'task-1')); + await repository.createSubtask( + taskListId: 'list-1', + parentTaskId: 'task-1', + title: 'Step', + ); + final localItem = decodeTaskChecklistItems( + (await database.tasksDao.listTasks( + 'account', + 'list-1', + )).single.microsoftChecklistItemsJson, + ).single; + final gate = Completer(); + checklistClient.createChecklistGate = gate; + final replay = PendingOpsReplayer( + database: database, + apiClient: checklistClient, + accountId: 'account', + nowUtc: () => DateTime.utc(2026, 6, 4, 1), + ).replayDueOps(); + await _waitFor(() => checklistClient.checklistCalls.isNotEmpty); + + await repository.deleteChecklistSubtask( + taskListId: 'list-1', + parentTaskId: 'task-1', + checklistItemId: localItem.id, + ); + final queued = await database.select(database.pendingOps).get(); + final create = queued.singleWhere( + (operation) => operation.operation == 'create_task_checklist_item', + ); + final delete = queued.singleWhere( + (operation) => operation.operation == 'delete_task_checklist_item', + ); + expect(create.state, 'in_progress'); + expect(delete.dependsOnOpId, create.id); + + gate.complete(); + expect(await replay, 1); + final afterCreate = decodeTaskChecklistItems( + (await database.tasksDao.listTasks( + 'account', + 'list-1', + )).single.microsoftChecklistItemsJson, + ); + expect(afterCreate, isEmpty); + expect( + await PendingOpsReplayer( + database: database, + apiClient: checklistClient, + accountId: 'account', + nowUtc: () => DateTime.utc(2026, 6, 4, 2), + ).replayDueOps(), + 1, + ); + expect(checklistClient.checklistCalls, [ + 'create:Step', + 'delete:server-step', + ]); + }, + ); + + test( + 'checklist claim invalidates a cancellation snapshot before its transaction', + () async { + final checklistClient = _ChecklistTaskRemoteClient(); + final deleteReached = Completer(); + final releaseDelete = Completer(); + final repository = TasksRepository( + database: database, + accountId: 'account', + apiClient: checklistClient, + nowUtc: () => DateTime.utc(2026, 6, 4), + beforeChecklistDeleteTransaction: () async { + deleteReached.complete(); + await releaseDelete.future; + }, + ); + await database.tasksDao.upsertTask(_task('list-1', 'task-1')); + await repository.createSubtask( + taskListId: 'list-1', + parentTaskId: 'task-1', + title: 'Step', + ); + final localItem = decodeTaskChecklistItems( + (await database.tasksDao.listTasks( + 'account', + 'list-1', + )).single.microsoftChecklistItemsJson, + ).single; + + final deletion = repository.deleteChecklistSubtask( + taskListId: 'list-1', + parentTaskId: 'task-1', + checklistItemId: localItem.id, + ); + await deleteReached.future; + final createGate = Completer(); + checklistClient.createChecklistGate = createGate; + final replay = PendingOpsReplayer( + database: database, + apiClient: checklistClient, + accountId: 'account', + nowUtc: () => DateTime.utc(2026, 6, 4, 1), + ).replayDueOps(); + await _waitFor(() => checklistClient.checklistCalls.isNotEmpty); + releaseDelete.complete(); + await deletion; - final op = await database.select(database.pendingOps).getSingle(); - expect(applied, 0); - expect(op.lastErrorCode, '400'); - expect(op.lastErrorMessage, contains('Clear completed')); - expect(op.nextAttemptAtUtc, startsWith('9999-12-31')); - }); + final queued = await database.select(database.pendingOps).get(); + final create = queued.singleWhere( + (operation) => operation.operation == 'create_task_checklist_item', + ); + final delete = queued.singleWhere( + (operation) => operation.operation == 'delete_task_checklist_item', + ); + expect(create.state, 'in_progress'); + expect(delete.dependsOnOpId, create.id); + + createGate.complete(); + expect(await replay, 1); + expect( + await PendingOpsReplayer( + database: database, + apiClient: checklistClient, + accountId: 'account', + nowUtc: () => DateTime.utc(2026, 6, 4, 2), + ).replayDueOps(), + 1, + ); + expect(checklistClient.checklistCalls, [ + 'create:Step', + 'delete:server-step', + ]); + }, + ); test( - 'replays checklist create and dependent patch against the server id', + 'checklist deletion resolves a creation acknowledged before its transaction', () async { final checklistClient = _ChecklistTaskRemoteClient(); - await database.tasksDao.upsertTask( - _task( - 'list-1', - 'task-1', - checklistItemsJson: jsonEncode([ - {'id': 'local-step', 'displayName': 'Step', 'isChecked': false}, - ]), - ), + final deleteReached = Completer(); + final releaseDelete = Completer(); + final repository = TasksRepository( + database: database, + accountId: 'account', + apiClient: checklistClient, + nowUtc: () => DateTime.utc(2026, 6, 4), + beforeChecklistDeleteTransaction: () async { + deleteReached.complete(); + await releaseDelete.future; + }, ); - await _enqueue( - database, - id: '01', - operation: 'create_task_checklist_item', - entityType: 'task_checklist_item', + await database.tasksDao.upsertTask(_task('list-1', 'task-1')); + await repository.createSubtask( taskListId: 'list-1', - taskId: 'task-1', - localTempId: 'local-step', - request: { - 'checklistItemId': 'local-step', - 'body': {'displayName': 'Step', 'isChecked': false}, - }, + parentTaskId: 'task-1', + title: 'Step', ); - await _enqueue( - database, - id: '02', - operation: 'patch_task_checklist_item', - entityType: 'task_checklist_item', + final localItem = decodeTaskChecklistItems( + (await database.tasksDao.listTasks( + 'account', + 'list-1', + )).single.microsoftChecklistItemsJson, + ).single; + + final deletion = repository.deleteChecklistSubtask( taskListId: 'list-1', - taskId: 'task-1', - request: { - 'checklistItemId': 'local-step', - 'body': {'isChecked': true}, - }, + parentTaskId: 'task-1', + checklistItemId: localItem.id, + ); + await deleteReached.future; + expect( + await PendingOpsReplayer( + database: database, + apiClient: checklistClient, + accountId: 'account', + nowUtc: () => DateTime.utc(2026, 6, 4, 1), + ).replayDueOps(), + 1, + ); + expect(await database.select(database.pendingOps).get(), isEmpty); + expect( + decodeTaskChecklistItems( + (await database.tasksDao.listTasks( + 'account', + 'list-1', + )).single.microsoftChecklistItemsJson, + ).single.id, + 'server-step', ); - final applied = await PendingOpsReplayer( + releaseDelete.complete(); + await deletion; + final pendingDelete = await database + .select(database.pendingOps) + .getSingle(); + expect(pendingDelete.operation, 'delete_task_checklist_item'); + expect( + jsonDecode(pendingDelete.requestJson)['checklistItemId'], + 'server-step', + ); + expect( + decodeTaskChecklistItems( + (await database.tasksDao.listTasks( + 'account', + 'list-1', + )).single.microsoftChecklistItemsJson, + ), + isEmpty, + ); + expect( + await PendingOpsReplayer( + database: database, + apiClient: checklistClient, + accountId: 'account', + nowUtc: () => DateTime.utc(2026, 6, 4, 2), + ).replayDueOps(), + 1, + ); + expect(checklistClient.checklistCalls, [ + 'create:Step', + 'delete:server-step', + ]); + expect(await database.select(database.pendingOps).get(), isEmpty); + }, + ); + + test( + 'checklist deletion accepts a temporary ID after creation is acknowledged', + () async { + final checklistClient = _ChecklistTaskRemoteClient(); + final repository = TasksRepository( database: database, - apiClient: checklistClient, accountId: 'account', - random: Random(0), + apiClient: checklistClient, nowUtc: () => DateTime.utc(2026, 6, 4), - ).replayDueOps(); + ); + await database.tasksDao.upsertTask(_task('list-1', 'task-1')); + await repository.createSubtask( + taskListId: 'list-1', + parentTaskId: 'task-1', + title: 'Step', + ); + final temporaryId = decodeTaskChecklistItems( + (await database.tasksDao.listTasks( + 'account', + 'list-1', + )).single.microsoftChecklistItemsJson, + ).single.id; - final task = (await database.tasksDao.listTasks( - 'account', - 'list-1', - )).single; - final item = decodeTaskChecklistItems( - task.microsoftChecklistItemsJson, + expect( + await PendingOpsReplayer( + database: database, + apiClient: checklistClient, + accountId: 'account', + nowUtc: () => DateTime.utc(2026, 6, 4, 1), + ).replayDueOps(), + 1, + ); + final acknowledged = decodeTaskChecklistItems( + (await database.tasksDao.listTasks( + 'account', + 'list-1', + )).single.microsoftChecklistItemsJson, ).single; - expect(applied, 2); + expect(acknowledged.id, 'server-step'); + expect(acknowledged.matchesIdentity(temporaryId), isTrue); + + await repository.deleteChecklistSubtask( + taskListId: 'list-1', + parentTaskId: 'task-1', + checklistItemId: temporaryId, + ); + + final pendingDelete = await database + .select(database.pendingOps) + .getSingle(); + expect(pendingDelete.operation, 'delete_task_checklist_item'); + expect( + jsonDecode(pendingDelete.requestJson)['checklistItemId'], + 'server-step', + ); + expect( + await PendingOpsReplayer( + database: database, + apiClient: checklistClient, + accountId: 'account', + nowUtc: () => DateTime.utc(2026, 6, 4, 2), + ).replayDueOps(), + 1, + ); expect(checklistClient.checklistCalls, [ 'create:Step', - 'update:server-step:true', + 'delete:server-step', ]); - expect(item.id, 'server-step'); - expect(item.completed, isTrue); expect(await database.select(database.pendingOps).get(), isEmpty); }, ); @@ -2618,6 +4431,7 @@ class _ConflictMicrosoftTodoApiClient implements MicrosoftTodoApiClient { final MicrosoftTodoTaskDto remoteTask; int updateTaskCalls = 0; + int deleteTaskCalls = 0; @override Future getTask({ @@ -2635,12 +4449,165 @@ class _ConflictMicrosoftTodoApiClient implements MicrosoftTodoApiClient { return MicrosoftTodoTaskDto.fromJson({...remoteTask.rawJson, ...patch}); } + @override + Future deleteTask({ + required String taskListId, + required String taskId, + }) async { + deleteTaskCalls += 1; + } + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +class _ConflictMicrosoftTaskListApiClient implements MicrosoftTodoApiClient { + _ConflictMicrosoftTaskListApiClient({ + required Map list, + List> tasks = const [], + this.listAfterCreate, + }) : _list = Map.from(list), + _tasks = tasks.map(Map.from).toList(); + + Map _list; + final List> _tasks; + final Map? listAfterCreate; + int createTaskListCalls = 0; + int updateTaskListCalls = 0; + int deleteTaskListCalls = 0; + int createTaskCalls = 0; + int updateTaskCalls = 0; + int deleteTaskCalls = 0; + + @override + Future createTaskList({ + required String displayName, + }) async { + createTaskListCalls += 1; + final created = { + ..._list, + 'id': _list['id'] ?? 'list-server', + 'displayName': displayName, + }; + _list = listAfterCreate == null + ? Map.from(created) + : {...created, ...listAfterCreate!}; + return MicrosoftTodoTaskListDto.fromJson(created); + } + + @override + Future listTaskListsPage({ + String? nextLink, + }) async { + return MicrosoftTodoTaskListsPageDto.fromJson({ + 'value': [_list], + }); + } + + @override + Future updateTaskList({ + required String taskListId, + required Map patch, + }) async { + updateTaskListCalls += 1; + _list = {..._list, ...patch}; + return MicrosoftTodoTaskListDto.fromJson(_list); + } + + @override + Future deleteTaskList(String taskListId) async { + deleteTaskListCalls += 1; + } + + @override + Future listTasksPage({ + required String taskListId, + String? nextLink, + }) async { + return MicrosoftTodoTasksPageDto.fromJson({'value': _tasks}); + } + + @override + Future createTask({ + required String taskListId, + required Map body, + }) async { + createTaskCalls += 1; + final created = { + 'id': 'task-server', + 'title': body['title'] ?? '', + 'status': body['status'] ?? 'notStarted', + 'lastModifiedDateTime': '2026-06-04T00:05:00.000Z', + ...body, + }; + _tasks.add(created); + return MicrosoftTodoTaskDto.fromJson(created); + } + + @override + Future getTask({ + required String taskListId, + required String taskId, + }) async { + return MicrosoftTodoTaskDto.fromJson( + _tasks.singleWhere((task) => task['id'] == taskId), + ); + } + + @override + Future updateTask({ + required String taskListId, + required String taskId, + required Map patch, + }) async { + updateTaskCalls += 1; + final index = _tasks.indexWhere((task) => task['id'] == taskId); + final updated = { + ..._tasks[index], + ...patch, + 'lastModifiedDateTime': '2026-06-04T00:05:00.000Z', + }; + _tasks[index] = updated; + return MicrosoftTodoTaskDto.fromJson(updated); + } + + @override + Future deleteTask({ + required String taskListId, + required String taskId, + }) async { + deleteTaskCalls += 1; + _tasks.removeWhere((task) => task['id'] == taskId); + } + @override dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); } +class _CommitInterruption extends QueryInterceptor { + final commitReached = Completer(); + final _resume = Completer(); + var _armed = false; + + void arm() => _armed = true; + + void resume() { + if (!_resume.isCompleted) _resume.complete(); + } + + @override + Future commitTransaction(TransactionExecutor inner) async { + await super.commitTransaction(inner); + if (!_armed) return; + _armed = false; + commitReached.complete(); + await _resume.future; + } +} + class _FakeTaskRemoteClient implements TaskRemoteClient { TaskDto? createdTask; + final List createdTasks = []; final calls = []; final taskPatchFields = >[]; final createParentTaskIds = []; @@ -2649,11 +4616,13 @@ class _FakeTaskRemoteClient implements TaskRemoteClient { GoogleTasksApiError? patchTaskListError; Object? createTaskListError; Object? createTaskError; + Object? patchTaskError; GoogleTasksApiError? deleteTaskError; GoogleTasksApiError? clearCompletedError; GoogleTasksApiError? moveTaskError; TaskListDto? remoteTaskList; TaskDto? remoteTask; + TaskDto? remoteTaskAfterSecondPatch; TaskDto? remoteTaskAfterMove; DateTime? taskListPatchResultUpdated; final taskListPageUpdatedMins = []; @@ -2666,6 +4635,10 @@ class _FakeTaskRemoteClient implements TaskRemoteClient { bool persistTaskListPatches = false; int _taskPatchRevision = 0; TasksPageDto remoteTasksPage = const TasksPageDto(items: [], rawJson: {}); + TaskListsPageDto remoteTaskListsPage = const TaskListsPageDto( + items: [], + rawJson: {}, + ); @override Future createTaskList({required String title}) async { @@ -2733,7 +4706,7 @@ class _FakeTaskRemoteClient implements TaskRemoteClient { await createTaskGate?.future; final error = createTaskError; if (error != null) throw error; - return createdTask ?? + return (createdTasks.isNotEmpty ? createdTasks.removeAt(0) : createdTask) ?? _taskDto('task-server', title: create.fields['title'].toString()); } @@ -2743,23 +4716,33 @@ class _FakeTaskRemoteClient implements TaskRemoteClient { required String taskId, required TaskPatch patch, }) async { + final error = patchTaskError; + if (error != null) throw error; taskPatchFields.add(patch.fields); calls.add('patch_task:$taskId'); final current = remoteTask; + final revision = DateTime.utc(2026, 6, 4, 0, ++_taskPatchRevision + 5); + final patchedRaw = { + ...?current?.rawJson, + 'id': taskId, + ...patch.fields, + 'updated': revision.toIso8601String(), + }; + if (patch.fields['status'] == 'completed' && + !patchedRaw.containsKey('completed')) { + patchedRaw['completed'] = revision.toIso8601String(); + } else if (patch.fields.containsKey('status') && + patch.fields['status'] != 'completed') { + patchedRaw.remove('completed'); + } final dto = persistTaskPatches - ? _taskDto( - taskId, - title: patch.fields.containsKey('title') - ? patch.fields['title']?.toString() ?? '' - : current?.title ?? '', - notes: patch.fields.containsKey('notes') - ? patch.fields['notes']?.toString() - : current?.notes, - updated: DateTime.utc(2026, 6, 4, 0, ++_taskPatchRevision + 5), - ) + ? TaskDto.fromJson(patchedRaw) : _taskDto(taskId, title: patch.fields['title'].toString()); if (persistTaskPatches) { remoteTask = dto; + if (taskPatchFields.length == 2 && remoteTaskAfterSecondPatch != null) { + remoteTask = remoteTaskAfterSecondPatch; + } } return dto; } @@ -2798,7 +4781,18 @@ class _FakeTaskRemoteClient implements TaskRemoteClient { moveParentTaskIds.add(parentTaskId); final error = moveTaskError; if (error != null) throw error; - final result = _taskDto(taskId, title: 'Moved', parent: parentTaskId); + final current = remoteTask; + final movedRaw = { + ...?current?.rawJson, + 'id': taskId, + 'title': current?.title ?? 'Task', + 'position': 'moved-position', + 'updated': DateTime.utc(2026, 6, 4, 0, 5).toIso8601String(), + if (parentTaskId != null) 'parent': parentTaskId, + }; + if (parentTaskId == null) movedRaw.remove('parent'); + final result = TaskDto.fromJson(movedRaw); + remoteTask = result; final afterMove = remoteTaskAfterMove; if (afterMove != null) remoteTask = afterMove; return result; @@ -2830,7 +4824,7 @@ class _FakeTaskRemoteClient implements TaskRemoteClient { Future listTaskListsPage({ int maxResults = 1000, String? pageToken, - }) async => const TaskListsPageDto(items: [], rawJson: {}); + }) async => remoteTaskListsPage; @override Future listTasksPage({ @@ -2856,6 +4850,7 @@ class _ChecklistTaskRemoteClient extends _FakeTaskRemoteClient implements TaskChecklistRemoteClient { final checklistCalls = []; Object? createChecklistItemError; + Completer? createChecklistGate; @override Future createChecklistItem({ @@ -2865,6 +4860,7 @@ class _ChecklistTaskRemoteClient extends _FakeTaskRemoteClient bool completed = false, }) async { checklistCalls.add('create:$title'); + await createChecklistGate?.future; final error = createChecklistItemError; if (error != null) throw error; return TaskChecklistItemDto( @@ -2938,13 +4934,14 @@ TaskListsCompanion _taskList( String id, { String title = 'List', String? updatedUtc, + String? rawJson, }) { return TaskListsCompanion.insert( accountId: 'account', id: id, title: title, updatedUtc: Value(updatedUtc), - rawJson: jsonEncode({'id': id, 'title': title}), + rawJson: rawJson ?? jsonEncode({'id': id, 'title': title}), localDirty: Value(id.startsWith('local-')), createdLocalAtUtc: _now, updatedLocalAtUtc: _now, @@ -2984,6 +4981,7 @@ Future _enqueue( String? taskListId, String? taskId, String? localTempId, + String? dependsOnOpId, String? baselineUpdatedUtc, String? baselineRawJson, }) { @@ -2997,6 +4995,7 @@ Future _enqueue( taskListId: Value(taskListId), taskId: Value(taskId), localTempId: Value(localTempId), + dependsOnOpId: Value(dependsOnOpId), baselineUpdatedUtc: Value(baselineUpdatedUtc), baselineRawJson: Value(baselineRawJson), requestJson: jsonEncode(request), diff --git a/test/features/sync/sync_failure_notification_policy_test.dart b/test/features/sync/sync_failure_notification_policy_test.dart index 352b0453..52e95e1e 100644 --- a/test/features/sync/sync_failure_notification_policy_test.dart +++ b/test/features/sync/sync_failure_notification_policy_test.dart @@ -2,9 +2,11 @@ import 'dart:async'; import 'dart:io'; import 'package:busymax/src/core/auth/oauth_models.dart'; +import 'package:busymax/src/core/http/request_dispatch_exception.dart'; import 'package:busymax/src/dav/dav_errors.dart'; import 'package:busymax/src/dav/sync/dav_account_sync_engine.dart'; import 'package:busymax/src/features/sync/sync_failure_notification_policy.dart'; +import 'package:busymax/src/features/sync/sync_auth_error.dart'; import 'package:busymax/src/features/connectivity/network_connectivity_service.dart'; import 'package:busymax/src/features/tasks/domain/task_remote_error.dart'; import 'package:busymax/src/google_calendar/google_calendar_errors.dart'; @@ -62,6 +64,7 @@ void main() { 'OAuthRefreshFailed', 'Unavailable', statusCode: 503, + oauthError: 'temporarily_unavailable', ), ]; @@ -108,6 +111,7 @@ void main() { 'OAuthRefreshFailed', 'Invalid grant', statusCode: 400, + oauthError: 'invalid_grant', ), expected: SyncFailureNotificationDisposition.reconnectRequired, ), @@ -172,5 +176,179 @@ void main() { SyncFailureNotificationDisposition.temporarilyUnavailable, ); }); + + test('unwraps known-unsent authentication failures', () { + const invalidGrant = OAuthRefreshException( + 'OAuthRefreshFailed', + 'Provider refresh failed.', + statusCode: 400, + oauthError: 'invalid_grant', + ); + const missingToken = OAuthException( + 'OAuthMissingToken', + 'No OAuth token is available.', + ); + + for (final failure in [ + invalidGrant, + const KnownUnsentRequestException( + kind: RequestPreDispatchFailureKind.authentication, + cause: invalidGrant, + ), + const KnownUnsentRequestException( + kind: RequestPreDispatchFailureKind.authentication, + cause: missingToken, + ), + const KnownUnsentRequestException( + kind: RequestPreDispatchFailureKind.authentication, + cause: KnownUnsentRequestException( + kind: RequestPreDispatchFailureKind.authentication, + cause: invalidGrant, + ), + ), + ]) { + expect( + syncFailureNotificationDisposition(failure), + SyncFailureNotificationDisposition.reconnectRequired, + ); + } + }); + + test('does not infer invalid credentials from refresh HTTP 400', () { + const failure = OAuthRefreshException( + 'OAuthRefreshFailed', + 'Provider refresh failed.', + statusCode: 400, + oauthError: 'invalid_request', + ); + + expect( + syncFailureNotificationDisposition(failure), + SyncFailureNotificationDisposition.temporarilyUnavailable, + ); + }); + + test('wrapped connectivity failures retain transient classification', () { + const failure = KnownUnsentRequestException( + kind: RequestPreDispatchFailureKind.connectivity, + cause: NetworkUnavailableException(), + ); + + expect( + syncFailureNotificationDisposition(failure), + SyncFailureNotificationDisposition.suppressed, + ); + }); + + test('cause-less and cyclic wrappers remain bounded and controlled', () { + const withoutCause = KnownUnsentRequestException( + kind: RequestPreDispatchFailureKind.authentication, + ); + final first = _MutableRequestNotDispatchedException(); + final second = _MutableRequestNotDispatchedException(); + first.cause = second; + second.cause = first; + + for (final failure in [withoutCause, first]) { + expect( + syncFailureNotificationDisposition(failure), + SyncFailureNotificationDisposition.temporarilyUnavailable, + ); + } + expect( + syncFailureMessage(withoutCause), + syncTemporarilyUnavailableMessage, + ); + expect( + syncFailureMessage(withoutCause), + isNot(contains('KnownUnsentRequestException')), + ); + }); }); + + group('sync failure messages', () { + test('current-state eligibility failures use controlled messages', () { + const reconnect = AccountNotSyncEligibleException(needsReconnect: true); + const unavailable = AccountNotSyncEligibleException( + needsReconnect: false, + ); + + expect( + syncFailureNotificationDisposition(reconnect), + SyncFailureNotificationDisposition.reconnectRequired, + ); + expect( + syncFailureMessage(reconnect), + accountReconnectRequiredSyncMessage, + ); + expect( + syncFailureNotificationDisposition(unavailable), + SyncFailureNotificationDisposition.temporarilyUnavailable, + ); + expect( + syncFailureMessage(unavailable), + syncTemporarilyUnavailableMessage, + ); + }); + + test('wrapped authentication failures use the reconnect message', () { + const invalidGrant = KnownUnsentRequestException( + kind: RequestPreDispatchFailureKind.authentication, + cause: OAuthRefreshException( + 'OAuthRefreshFailed', + 'invalid_grant raw provider response refresh_token=secret-token', + statusCode: 400, + oauthError: 'invalid_grant', + oauthErrorDescription: 'refresh_token=[REDACTED]', + ), + ); + const missingToken = KnownUnsentRequestException( + kind: RequestPreDispatchFailureKind.authentication, + cause: OAuthException( + 'OAuthMissingToken', + 'No OAuth token is available.', + ), + ); + + for (final failure in [invalidGrant, missingToken]) { + final message = syncFailureMessage(failure); + expect(message, accountReconnectRequiredSyncMessage); + expect(message, isNot(contains('KnownUnsentRequestException'))); + expect(message, isNot(contains('invalid_grant'))); + expect(message, isNot(contains('secret-token'))); + } + }); + + test('direct and wrapped Google OAuth failures behave identically', () { + const direct = OAuthRefreshException( + 'OAuthRefreshFailed', + 'Provider refresh failed.', + statusCode: 400, + oauthError: 'invalid_grant', + ); + const wrapped = KnownUnsentRequestException( + kind: RequestPreDispatchFailureKind.authentication, + cause: direct, + ); + + expect( + syncFailureNotificationDisposition(direct), + syncFailureNotificationDisposition(wrapped), + ); + expect(syncFailureMessage(direct), syncFailureMessage(wrapped)); + }); + }); +} + +final class _MutableRequestNotDispatchedException + implements RequestNotDispatchedException { + @override + Object? cause; + + @override + String get code => 'test_cycle'; + + @override + RequestPreDispatchFailureKind get kind => + RequestPreDispatchFailureKind.authentication; } diff --git a/test/features/sync/sync_scheduler_test.dart b/test/features/sync/sync_scheduler_test.dart index 5ab8aa0f..52376fe9 100644 --- a/test/features/sync/sync_scheduler_test.dart +++ b/test/features/sync/sync_scheduler_test.dart @@ -101,6 +101,31 @@ void main() { expect(failures.single, same(failure)); }); + test( + 'interactive all-account sync reports failure after syncing others', + () async { + final synced = []; + final failure = StateError('Google sync failed'); + final failures = []; + + await expectLater( + runAllSyncEligibleAccountSync( + listSyncEligibleAccounts: () async => [_account('a'), _account('b')], + syncAccount: (accountId) async { + synced.add(accountId); + if (accountId == 'a') throw failure; + }, + onSyncFailure: (error) async => failures.add(error), + rethrowFirstFailure: true, + ), + throwsA(same(failure)), + ); + + expect(synced, ['a', 'b']); + expect(failures, [same(failure)]); + }, + ); + test('missing OAuth token is preserved for both failure callbacks', () async { final synced = []; final authFailures = []; diff --git a/test/features/tasks/data/tasks_repository_test.dart b/test/features/tasks/data/tasks_repository_test.dart index bb97e930..f1e9592e 100644 --- a/test/features/tasks/data/tasks_repository_test.dart +++ b/test/features/tasks/data/tasks_repository_test.dart @@ -188,6 +188,38 @@ void main() { expect(jsonDecode(move.requestJson), {'parent': 'parent'}); }); + test('cancelling an unsent parent also cancels its child creation', () async { + await repository.createTask( + 'list-1', + const TaskCreateInput(title: 'Parent'), + ); + final parent = (await database.tasksDao.listTasks( + 'account', + 'list-1', + )).single; + await repository.createSubtask( + taskListId: 'list-1', + parentTaskId: parent.id, + title: 'Child', + ); + + final operations = await database.select(database.pendingOps).get(); + final parentCreate = operations.firstWhere( + (operation) => + operation.operation == 'create_task' && operation.taskId == parent.id, + ); + final childCreate = operations.firstWhere( + (operation) => + operation.operation == 'create_task' && operation.taskId != parent.id, + ); + expect(childCreate.dependsOnOpId, parentCreate.id); + + await repository.deleteTask('list-1', parent.id); + + expect(await database.select(database.pendingOps).get(), isEmpty); + expect(await database.tasksDao.listTasks('account', 'list-1'), isEmpty); + }); + test('createTask writes and queues extended task fields', () async { await repository.createTask( 'list-1', diff --git a/test/features/tasks/presentation/task_details_pane_test.dart b/test/features/tasks/presentation/task_details_pane_test.dart index 20c6ebbc..21eb3eeb 100644 --- a/test/features/tasks/presentation/task_details_pane_test.dart +++ b/test/features/tasks/presentation/task_details_pane_test.dart @@ -13,8 +13,11 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:busymax/src/app/app_bootstrap.dart'; import 'package:busymax/src/app/busymax_design.dart'; import 'package:busymax/src/app/busymax_yaru_theme.dart'; +import 'package:busymax/src/core/auth/oauth_models.dart'; +import 'package:busymax/src/core/http/request_dispatch_exception.dart'; import 'package:busymax/src/core/time/time_zone_catalog.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/task_lists/data/task_lists_repository.dart'; import 'package:busymax/src/features/tasks/data/tasks_repository.dart'; import 'package:busymax/src/features/tasks/presentation/ical_task_fields_editor.dart'; @@ -33,6 +36,7 @@ import 'package:url_launcher/url_launcher.dart'; import 'package:busymax/src/platform/native_dialog_service.dart'; import 'package:busymax/src/platform/native_menu_service.dart'; import 'package:busymax/src/providers/busy_provider.dart'; +import 'package:busymax/src/google_tasks/oauth/oauth_service.dart'; import 'package:busymax/src/features/tasks/domain/task_capabilities.dart'; import 'package:busymax/src/features/tasks/domain/task_checklist_item.dart'; import 'package:yaru/yaru.dart'; @@ -59,6 +63,45 @@ String _testAuthority(BusyProvider provider) => switch (provider) { }; void main() { + testWidgets('new task opens with the title field focused', (tester) async { + await tester.pumpWidget( + localizedTestApp( + child: Scaffold( + body: TaskDetailsEditor( + task: _switchTask('new-task', ''), + taskLists: [ + TaskListEntity( + accountId: 'microsoft:m', + id: 'list-1', + title: 'Tasks', + localDirty: false, + pendingDelete: false, + rawJson: '{}', + ), + ], + capabilities: googleTaskCollectionCapabilities, + localTimeZone: 'UTC', + accountLabel: 'Account', + onRefresh: () {}, + onSave: (_, _) async {}, + onCreateSubtask: (_) async {}, + onMoveToTop: () {}, + onDelete: () async {}, + onCancel: () {}, + isCreate: true, + ), + ), + ), + ); + await tester.pumpAndSettle(); + + final title = find.descendant( + of: find.byType(TextField).first, + matching: find.byType(EditableText), + ); + expect(tester.widget(title).focusNode.hasFocus, isTrue); + }); + testWidgets( 'clock changes preserve a timed recurring task draft and its absolute alarm', (tester) async { @@ -148,6 +191,128 @@ void main() { .setMockMethodCallHandler(_nativeMenuChannel, null); }); + testWidgets('task refresh dispatches for an eligible account', ( + tester, + ) async { + final database = AppDatabase(NativeDatabase.memory()); + addTearDown(database.close); + final accounts = AccountsRepository(database: database); + await _seedRefreshAccount(accounts); + final repository = _FakeTasksRepository(accountId: 'google:g'); + + await _pumpDetails( + tester, + googleTaskCollectionCapabilities, + repository: repository, + database: database, + authRepository: AuthRepository( + oAuth: _TaskDetailsOAuthGateway(), + database: database, + ), + ); + + final refresh = tester + .widget(find.byType(TaskDetailsEditor)) + .onRefresh; + refresh(); + await tester.pumpAndSettle(); + + expect(repository.refreshCalls, 1); + }); + + testWidgets('wrapped invalid_grant refresh requires reconnect safely', ( + tester, + ) async { + final database = AppDatabase(NativeDatabase.memory()); + addTearDown(database.close); + final accounts = AccountsRepository(database: database); + await _seedRefreshAccount(accounts); + final repository = _FakeTasksRepository( + accountId: 'google:g', + refreshError: const KnownUnsentRequestException( + kind: RequestPreDispatchFailureKind.authentication, + cause: OAuthRefreshException( + 'OAuthRefreshFailed', + 'raw provider description refresh_token=test-refresh-secret', + statusCode: 400, + oauthError: 'invalid_grant', + oauthErrorDescription: 'provider description [REDACTED]', + ), + ), + ); + + await _pumpDetails( + tester, + googleTaskCollectionCapabilities, + repository: repository, + database: database, + authRepository: AuthRepository( + oAuth: _TaskDetailsOAuthGateway(), + database: database, + ), + ); + + final refresh = tester + .widget(find.byType(TaskDetailsEditor)) + .onRefresh; + refresh(); + await tester.pumpAndSettle(); + + expect(repository.refreshCalls, 1); + expect( + (await accounts.accountById('google:g'))?.authState, + accountAuthStateReauthRequired, + ); + expect( + find.text('Refresh failed: This account needs to be reconnected.'), + findsOneWidget, + ); + expect(find.textContaining('KnownUnsentRequestException'), findsNothing); + expect(find.textContaining('OAuthRefreshException'), findsNothing); + expect(find.textContaining('test-refresh-secret'), findsNothing); + + refresh(); + await tester.pumpAndSettle(); + expect(repository.refreshCalls, 1); + }); + + testWidgets('task refresh guards a reconnect-required account at runtime', ( + tester, + ) async { + final database = AppDatabase(NativeDatabase.memory()); + addTearDown(database.close); + final accounts = AccountsRepository(database: database); + await _seedRefreshAccount(accounts); + final renderedAccount = await accounts.accountById('google:g'); + final repository = _FakeTasksRepository(accountId: 'google:g'); + + await _pumpDetails( + tester, + googleTaskCollectionCapabilities, + repository: repository, + database: database, + authRepository: AuthRepository( + oAuth: _TaskDetailsOAuthGateway(), + database: database, + ), + accountsStream: Stream.value([renderedAccount!]), + ); + final staleRefresh = tester + .widget(find.byType(TaskDetailsEditor)) + .onRefresh; + + await accounts.markReconnectRequired('google:g'); + staleRefresh(); + await tester.pumpAndSettle(); + + expect(repository.refreshCalls, 0); + expect( + find.text('Refresh failed: This account needs to be reconnected.'), + findsOneWidget, + ); + expect(repository.refreshCalls, 0); + }); + testWidgets( 'iCalendar task location is plain text and opens only the saved value', (tester) async { @@ -2000,6 +2165,8 @@ Future _pumpDetails( ThemeData? theme, bool modalEditorSurface = false, BusyProvider? providerOverride, + AppDatabase? database, + AuthRepository? authRepository, }) async { final accountId = accountIdOverride ?? @@ -2027,6 +2194,9 @@ Future _pumpDetails( await tester.pumpWidget( ProviderScope( overrides: [ + if (database != null) databaseProvider.overrideWithValue(database), + if (authRepository != null) + authRepositoryProvider.overrideWithValue(authRepository), selectedAccountProvider.overrideWithValue( AccountEntity( id: accountId, @@ -2301,6 +2471,7 @@ class _FakeTasksRepository implements TasksRepository { this.categorySuggestions = const [], this.recurrenceIdKey, this.hierarchy = const TaskHierarchySnapshot(parent: null, subtasks: []), + this.refreshError, }); final String accountId; @@ -2312,9 +2483,19 @@ class _FakeTasksRepository implements TasksRepository { final List categorySuggestions; final String? recurrenceIdKey; final TaskHierarchySnapshot hierarchy; + final Object? refreshError; final List patches = []; final List moves = []; var deleteCalls = 0; + var refreshCalls = 0; + + @override + Future refreshTask(String taskListId, String taskId) async { + refreshCalls += 1; + if (refreshError case final error?) { + throw error; + } + } @override Stream watchTask(String taskListId, String taskId) { @@ -2384,6 +2565,47 @@ class _FakeTasksRepository implements TasksRepository { dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); } +Future _seedRefreshAccount(AccountsRepository repository) { + return repository.upsertSignedInAccount( + id: 'google:g', + provider: BusyProvider.google, + grantedScopes: 'tasks calendar', + ); +} + +final class _TaskDetailsOAuthGateway implements OAuthGateway { + @override + Future get activeAccountId async => 'google:g'; + + @override + Future cancelSignIn() async {} + + @override + Future clearLocalSession({String? accountId}) async {} + + @override + Future fetchUserInfo(OAuthTokenSet tokenSet) async => null; + + @override + Future readActiveTokenSet() async => null; + + @override + Future refreshActiveToken() { + throw UnimplementedError(); + } + + @override + Future revokeAndSignOutAccount(String accountId) async {} + + @override + Future revokeAuthorization(String accountId) async {} + + @override + Future signIn({String? loginHint}) { + throw UnimplementedError(); + } +} + class _SwitchingTasksRepository implements TasksRepository { final _controllers = >{}; final _latest = {}; diff --git a/test/google_calendar/google_calendar_api_client_test.dart b/test/google_calendar/google_calendar_api_client_test.dart index 72503ab8..d2f59c5d 100644 --- a/test/google_calendar/google_calendar_api_client_test.dart +++ b/test/google_calendar/google_calendar_api_client_test.dart @@ -5,6 +5,7 @@ import 'package:busymax/src/calendar_providers/calendar_sync_dto.dart'; import 'package:busymax/src/db/app_database.dart'; import 'package:busymax/src/features/sync/calendar_sync_engine.dart'; import 'package:busymax/src/google_calendar/google_calendar_api_client.dart'; +import 'package:busymax/src/google_calendar/google_calendar_errors.dart'; import 'package:busymax/src/schedule/schedule_range.dart'; import 'package:busymax/src/schedule/schedule_repository.dart'; import 'package:drift/drift.dart'; @@ -14,6 +15,68 @@ import 'package:http/http.dart' as http; import 'package:http/testing.dart'; void main() { + test( + 'calendar color retry never repeats an acknowledged creation POST', + () async { + final requests = []; + var colorAttempts = 0; + final client = _client((request) { + requests.add(request); + if (request.method == 'POST') { + return _json({'id': 'created@example.com', 'summary': 'Project'}); + } + colorAttempts += 1; + if (colorAttempts == 1) { + return http.Response( + jsonEncode({ + 'error': {'code': 429, 'message': 'Rate limited'}, + }), + 429, + headers: {'Content-Type': 'application/json'}, + ); + } + return _json({ + 'id': 'created@example.com', + 'summary': 'Project', + 'backgroundColor': '#3584e4', + 'foregroundColor': '#ffffff', + 'accessRole': 'owner', + }); + }); + const mutation = CalendarMutation( + summary: 'Project', + backgroundColor: '#3584e4', + foregroundColor: '#ffffff', + ); + + final created = await client.createCalendar(mutation); + expect(created.providerCalendarId, 'created@example.com'); + await expectLater( + client.updateCalendarListEntry(created.providerCalendarId, mutation), + throwsA( + isA().having( + (error) => error.statusCode, + 'statusCode', + 429, + ), + ), + ); + await client.updateCalendarListEntry( + created.providerCalendarId, + mutation, + ); + + expect( + requests.where((request) => request.method == 'POST'), + hasLength(1), + ); + expect( + requests.where((request) => request.method == 'PATCH'), + hasLength(2), + ); + }, + ); + test('calendar RGB color updates the Google CalendarList entry', () async { late http.Request captured; final client = _client((request) { @@ -178,11 +241,13 @@ void main() { eventId: 'event-1', mutation: const CalendarEventMutation(title: 'Updated'), guestUpdatePolicy: CalendarGuestUpdatePolicy.doNotSend, + ifMatch: '"update-version"', ); await client.deleteEvent( calendarId: 'calendar@example.com', eventId: 'event-1', guestUpdatePolicy: CalendarGuestUpdatePolicy.doNotSend, + ifMatch: '"delete-version"', ); expect(requests[0].url.queryParameters['sendUpdates'], 'all'); @@ -191,6 +256,8 @@ void main() { expect(requests[0].url.queryParameters['conferenceDataVersion'], '1'); expect(requests[1].url.queryParameters['conferenceDataVersion'], '1'); expect(jsonDecode(requests[1].body), {'summary': 'Updated'}); + expect(requests[1].headers['if-match'], '"update-version"'); + expect(requests[2].headers['if-match'], '"delete-version"'); }); test('native event move sends destination and guest update policy', () async { @@ -369,6 +436,9 @@ void main() { ], }); } + if (request.url.path.endsWith('/events/series-1')) { + return _json(_recurringMaster()); + } eventsRequest = request; final expandsInstances = request.url.queryParameters['singleEvents'] == 'true'; @@ -389,15 +459,24 @@ void main() { expect(eventsRequest.url.queryParameters['singleEvents'], 'true'); final rows = await database.select(database.calendarEvents).get(); - expect(rows, hasLength(2)); + expect(rows, hasLength(3)); + final instances = rows + .where((row) => row.providerRecurringEventId != null) + .toList(); expect( - rows.map((row) => row.providerRecurringEventId), + instances.map((row) => row.providerRecurringEventId), everyElement('series-1'), ); - expect(rows.map((row) => row.providerOriginalStartKey).toSet(), { + expect(instances.map((row) => row.providerOriginalStartKey).toSet(), { '2026-07-14T09:00:00-07:00', '2026-07-21T09:00:00-07:00', }); + expect( + rows + .singleWhere((row) => row.providerRecurringEventId == null) + .isDeleted, + isTrue, + ); final schedule = await ScheduleRepository(database).listItems( range: ScheduleRange( diff --git a/test/google_tasks/oauth/token_exchange_test.dart b/test/google_tasks/oauth/token_exchange_test.dart index 0a7e69df..c33d2b98 100644 --- a/test/google_tasks/oauth/token_exchange_test.dart +++ b/test/google_tasks/oauth/token_exchange_test.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:convert'; import 'package:flutter_test/flutter_test.dart'; @@ -164,6 +165,8 @@ void main() { ); expect(loopbackFlow.extraAuthorizationParameters, { + 'access_type': 'offline', + 'prompt': 'consent', 'include_granted_scopes': 'true', }); expect(result.tokenSet.scopes, isEmpty); @@ -207,6 +210,129 @@ void main() { }, ); + test('Google sign-in rejects a token without renewable access', () async { + final tokenStore = InMemorySecretStore(); + final idToken = + 'header.${base64UrlEncode(utf8.encode(jsonEncode({'sub': 'subject'})))}.signature'; + final service = OAuthService( + config: _config, + httpClient: MockClient( + (_) async => http.Response( + jsonEncode({ + 'access_token': 'access', + 'id_token': idToken, + 'expires_in': 3600, + }), + 200, + ), + ), + tokenStore: tokenStore, + loopbackFlow: _FakeOAuthLoopbackFlow( + callback: const OAuthCallbackResult(code: 'code', scope: null), + ), + nowUtc: () => DateTime.utc(2026, 6, 4), + ); + + await expectLater( + service.signIn(), + throwsA( + isA().having( + (error) => error.code, + 'code', + 'OAuthMissingRefreshToken', + ), + ), + ); + expect( + await tokenStore.readOAuthTokenSet('google:subject', BusyProvider.google), + isNull, + ); + }); + + test( + 'cached Google access without a refresh token requires reconnect', + () async { + final tokenStore = InMemorySecretStore(); + await tokenStore.saveOAuthTokenSet( + 'google:subject', + BusyProvider.google, + OAuthTokenSet( + accessToken: 'still-valid-access', + expiresAtUtc: DateTime.utc(2026, 6, 4, 1), + tokenType: 'Bearer', + scopes: const {}, + ), + ); + final service = OAuthService( + config: _config, + httpClient: MockClient((_) async => throw StateError('No request due')), + tokenStore: tokenStore, + loopbackFlow: OAuthLoopbackFlow(), + nowUtc: () => DateTime.utc(2026, 6, 4), + ); + + await expectLater( + service.authorizationHeaderForAccount('google:subject'), + throwsA( + isA().having( + (error) => error.code, + 'code', + 'OAuthMissingRefreshToken', + ), + ), + ); + }, + ); + + test('Google reconnect retains an existing refresh token', () async { + final tokenStore = InMemorySecretStore(); + final idToken = + 'header.${base64UrlEncode(utf8.encode(jsonEncode({'sub': 'subject'})))}.signature'; + await tokenStore.saveOAuthTokenSet( + 'google:subject', + BusyProvider.google, + OAuthTokenSet( + accessToken: 'old-access', + refreshToken: 'old-refresh', + idToken: idToken, + expiresAtUtc: DateTime.utc(2026, 6, 4), + tokenType: 'Bearer', + scopes: const {}, + ), + ); + final service = OAuthService( + config: _config, + httpClient: MockClient( + (_) async => http.Response( + jsonEncode({ + 'access_token': 'new-access', + 'id_token': idToken, + 'expires_in': 3600, + }), + 200, + ), + ), + tokenStore: tokenStore, + loopbackFlow: _FakeOAuthLoopbackFlow( + callback: const OAuthCallbackResult(code: 'code', scope: null), + ), + nowUtc: () => DateTime.utc(2026, 6, 4), + ); + + final result = await service.signIn(); + + expect(result.accountId, 'google:subject'); + expect(result.tokenSet.accessToken, 'new-access'); + expect(result.tokenSet.refreshToken, 'old-refresh'); + expect( + (await tokenStore.readOAuthTokenSet( + 'google:subject', + BusyProvider.google, + ))?.refreshToken, + 'old-refresh', + ); + }); + test( 'token exchange with configured client secret sends client_secret', () async { @@ -735,31 +861,94 @@ void main() { }, ); - test('refresh 400 clears only account being refreshed', () async { + test( + 'refresh invalid_grant is structured without clearing credentials', + () async { + final tokenStore = InMemorySecretStore(); + await tokenStore.saveOAuthTokenSet( + 'google-a', + BusyProvider.google, + const OAuthTokenSetFixture().tokenSet, + ); + await tokenStore.saveOAuthTokenSet( + 'google-b', + BusyProvider.google, + const OAuthTokenSetFixture().tokenSet.copyWith(accessToken: 'access-b'), + ); + await tokenStore.saveOAuthTokenSet( + 'microsoft:m', + BusyProvider.microsoft, + const OAuthTokenSetFixture().tokenSet.copyWith( + accessToken: 'ms-access', + ), + ); + await tokenStore.setActiveAccountId('google-b'); + final service = OAuthService( + config: _config, + httpClient: MockClient((request) async { + return http.Response( + jsonEncode({ + 'error': 'invalid_grant', + 'error_description': 'The authorization cannot be refreshed', + }), + 400, + ); + }), + tokenStore: tokenStore, + loopbackFlow: OAuthLoopbackFlow(), + ); + + await expectLater( + service.refreshTokenForAccount('google-a'), + throwsA( + isA() + .having((error) => error.statusCode, 'statusCode', 400) + .having( + (error) => error.oauthError, + 'oauthError', + 'invalid_grant', + ) + .having( + (error) => error.oauthErrorDescription, + 'oauthErrorDescription', + 'The authorization cannot be refreshed', + ), + ), + ); + + expect( + await tokenStore.readOAuthTokenSet('google-a', BusyProvider.google), + isNotNull, + ); + expect( + await tokenStore.readOAuthTokenSet('google-b', BusyProvider.google), + isNotNull, + ); + expect( + await tokenStore.readOAuthTokenSet( + 'microsoft:m', + BusyProvider.microsoft, + ), + isNotNull, + ); + expect(await tokenStore.readActiveAccountId(), 'google-b'); + }, + ); + + test('refresh invalid_request does not clear the credential', () async { final tokenStore = InMemorySecretStore(); await tokenStore.saveOAuthTokenSet( 'google-a', BusyProvider.google, const OAuthTokenSetFixture().tokenSet, ); - await tokenStore.saveOAuthTokenSet( - 'google-b', - BusyProvider.google, - const OAuthTokenSetFixture().tokenSet.copyWith(accessToken: 'access-b'), - ); - await tokenStore.saveOAuthTokenSet( - 'microsoft:m', - BusyProvider.microsoft, - const OAuthTokenSetFixture().tokenSet.copyWith(accessToken: 'ms-access'), - ); - await tokenStore.setActiveAccountId('google-b'); final service = OAuthService( config: _config, httpClient: MockClient((request) async { return http.Response( jsonEncode({ - 'error': 'invalid_grant', - 'error_description': 'Token expired', + 'error': 'invalid_request', + 'error_description': 'A required parameter is missing', }), 400, ); @@ -770,32 +959,124 @@ void main() { await expectLater( service.refreshTokenForAccount('google-a'), - throwsA(isA()), + throwsA( + isA() + .having((error) => error.statusCode, 'statusCode', 400) + .having( + (error) => error.oauthError, + 'oauthError', + 'invalid_request', + ), + ), ); expect( await tokenStore.readOAuthTokenSet('google-a', BusyProvider.google), - isNull, - ); - expect( - await tokenStore.readOAuthTokenSet('google-b', BusyProvider.google), isNotNull, ); + }); + + test('refresh HTTP 503 does not clear the credential', () async { + final tokenStore = InMemorySecretStore(); + await tokenStore.saveOAuthTokenSet( + 'google-a', + BusyProvider.google, + const OAuthTokenSetFixture().tokenSet, + ); + final service = OAuthService( + config: _config, + httpClient: MockClient( + (request) async => http.Response( + jsonEncode({ + 'error': 'temporarily_unavailable', + 'error_description': 'Try again later', + }), + 503, + ), + ), + tokenStore: tokenStore, + loopbackFlow: OAuthLoopbackFlow(), + ); + + await expectLater( + service.refreshTokenForAccount('google-a'), + throwsA( + isA() + .having((error) => error.statusCode, 'statusCode', 503) + .having( + (error) => error.oauthError, + 'oauthError', + 'temporarily_unavailable', + ), + ), + ); expect( - await tokenStore.readOAuthTokenSet('microsoft:m', BusyProvider.microsoft), + await tokenStore.readOAuthTokenSet('google-a', BusyProvider.google), isNotNull, ); - expect(await tokenStore.readActiveAccountId(), 'google-b'); }); - test('refresh endpoint 400 JSON body is surfaced without secrets', () async { + test('refresh completion cannot restore a removed credential', () async { + final tokenStore = InMemorySecretStore(); + final started = Completer(); + final release = Completer(); + final service = OAuthService( + config: _config, + httpClient: MockClient((request) async { + started.complete(); + await release.future; + return http.Response( + jsonEncode({ + 'access_token': 'new-access', + 'refresh_token': 'new-refresh', + 'expires_in': 3600, + 'scope': googleBusyMaxOAuthScopes.join(' '), + 'token_type': 'Bearer', + }), + 200, + ); + }), + tokenStore: tokenStore, + loopbackFlow: OAuthLoopbackFlow(), + nowUtc: () => DateTime.utc(2026, 6, 4), + ); + await tokenStore.saveOAuthTokenSet( + 'google-a', + BusyProvider.google, + const OAuthTokenSetFixture().tokenSet, + ); + + final refresh = service.refreshTokenForAccount('google-a'); + await started.future; + await service.clearLocalSession(accountId: 'google-a'); + release.complete(); + + await expectLater(refresh, throwsA(isA())); + expect(await tokenStore.readCredential('google-a'), isNull); + }); + + test('refresh endpoint fields and logs are redacted', () async { + const refreshTokenSecret = 'refresh-secret-token'; + final records = []; + final previousLevel = Logger.root.level; + Logger.root.level = Level.INFO; + final subscription = Logger.root.onRecord.listen((record) { + if (record.loggerName == 'OAuthService') { + records.add(record); + } + }); + addTearDown(() async { + Logger.root.level = previousLevel; + await subscription.cancel(); + }); final service = OAuthService( config: _config, httpClient: MockClient((request) async { return http.Response( jsonEncode({ 'error': 'invalid_grant', - 'error_description': 'Token expired', + 'error_description': + 'refresh_token=$refreshTokenSecret cannot be used', }), 400, ); @@ -808,29 +1089,28 @@ void main() { await expectLater( service.refreshToken( const OAuthTokenSetFixture().tokenSet.copyWith( - refreshToken: 'refresh-secret-token', + refreshToken: refreshTokenSecret, ), ), throwsA( - isA() + isA() .having((error) => error.code, 'code', 'OAuthRefreshFailed') + .having((error) => error.oauthError, 'oauthError', 'invalid_grant') .having( - (error) => error.message, - 'message', - contains('invalid_grant'), - ) - .having( - (error) => error.message, - 'message', - contains('Token expired'), + (error) => error.oauthErrorDescription, + 'oauthErrorDescription', + contains('refresh_token=[REDACTED]'), ) .having( - (error) => error.message, - 'message', - isNot(contains('refresh-secret-token')), + (error) => error.toString(), + 'toString', + isNot(contains(refreshTokenSecret)), ), ), ); + final logText = records.map((record) => record.message).join('\n'); + expect(logText, contains('has_refresh_token=true')); + expect(logText, isNot(contains(refreshTokenSecret))); }); } diff --git a/test/ical/ical_import_service_test.dart b/test/ical/ical_import_service_test.dart index 08aed6bf..81577f65 100644 --- a/test/ical/ical_import_service_test.dart +++ b/test/ical/ical_import_service_test.dart @@ -235,6 +235,32 @@ END:VEVENT }, ); + test('validates cross-zone duration by represented instants', () async { + final preview = importService.parsePreview( + utf8.encode( + _calendar(''' +BEGIN:VEVENT +UID:cross-zone-order +DTSTART;TZID=Asia/Tokyo:20260830T230000 +DTEND;TZID=America/Vancouver:20260830T080000 +SUMMARY:Cross-zone event +END:VEVENT +'''), + ), + ); + + final report = await importService.importPreview( + preview: preview, + destination: (await importService.writableDestinations()).single, + ); + + expect(report.queued, 1); + expect(report.unsupportedRecurrenceSets, isEmpty); + final event = await database.select(database.calendarEvents).getSingle(); + expect(event.startTimeZone, 'Asia/Tokyo'); + expect(event.endTimeZone, 'America/Vancouver'); + }); + test('writable destinations exclude read-only and WebCal sources', () async { await database .into(database.accounts) diff --git a/test/microsoft_todo/api/microsoft_todo_task_remote_client_test.dart b/test/microsoft_todo/api/microsoft_todo_task_remote_client_test.dart index 425f7a20..0c8c0d97 100644 --- a/test/microsoft_todo/api/microsoft_todo_task_remote_client_test.dart +++ b/test/microsoft_todo/api/microsoft_todo_task_remote_client_test.dart @@ -60,6 +60,50 @@ void main() { expect(list, isNot(same(snapshot))); }); + test('plain-text task bodies preserve angle brackets and entities', () async { + final client = _FakeMicrosoftTodoApiClient() + ..taskToGet = MicrosoftTodoTaskDto.fromJson(const { + 'id': 'task-1', + 'title': 'Contact', + 'body': { + 'content': 'Contact & literal', + 'contentType': 'text', + }, + }); + final adapter = MicrosoftTodoTaskRemoteClient( + client: client, + defaultTimeZone: 'UTC', + ); + + final task = await adapter.getTask(taskListId: 'list-1', taskId: 'task-1'); + + expect(task.notes, 'Contact & literal'); + expect( + adapter.normalizeTaskConflictSnapshot(task.rawJson)['notes'], + 'Contact & literal', + ); + }); + + test('HTML task bodies are still converted to plain text', () async { + final client = _FakeMicrosoftTodoApiClient() + ..taskToGet = MicrosoftTodoTaskDto.fromJson(const { + 'id': 'task-1', + 'title': 'Contact', + 'body': { + 'content': '

Contact <owner@example.com>

', + 'contentType': 'html', + }, + }); + final adapter = MicrosoftTodoTaskRemoteClient( + client: client, + defaultTimeZone: 'UTC', + ); + + final task = await adapter.getTask(taskListId: 'list-1', taskId: 'task-1'); + + expect(task.notes, 'Contact '); + }); + test( 'create task maps neutral mutation fields to Microsoft Graph body', () async { @@ -381,6 +425,7 @@ class _FakeMicrosoftTodoApiClient var createdChecklistBody = {}; var updatedChecklistPatch = {}; String? deletedChecklistItemId; + MicrosoftTodoTaskDto? taskToGet; @override Future listChecklistItemsPage({ @@ -484,7 +529,7 @@ class _FakeMicrosoftTodoApiClient Future getTask({ required String taskListId, required String taskId, - }) => throw UnimplementedError(); + }) async => taskToGet ?? (throw UnimplementedError()); @override Future listTaskListsPage({String? nextLink}) => diff --git a/test/microsoft_todo/oauth/microsoft_oauth_service_test.dart b/test/microsoft_todo/oauth/microsoft_oauth_service_test.dart index 25933ca2..6d6cb0e2 100644 --- a/test/microsoft_todo/oauth/microsoft_oauth_service_test.dart +++ b/test/microsoft_todo/oauth/microsoft_oauth_service_test.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:convert'; import 'package:flutter_test/flutter_test.dart'; @@ -8,6 +9,7 @@ import 'package:busymax/src/google_tasks/oauth/oauth_loopback_flow.dart'; import 'package:busymax/src/core/auth/oauth_models.dart'; import 'package:busymax/src/core/secrets/secret_store.dart'; import 'package:busymax/src/microsoft_todo/oauth/microsoft_oauth_service.dart'; +import 'package:busymax/src/providers/busy_provider.dart'; void main() { test('authorization URL uses Microsoft public desktop OAuth parameters', () { @@ -124,6 +126,43 @@ void main() { ); }); + test('refresh completion cannot restore a removed credential', () async { + final store = InMemorySecretStore(); + final started = Completer(); + final release = Completer(); + final service = MicrosoftOAuthService( + config: _config, + httpClient: MockClient((request) async { + started.complete(); + await release.future; + return _tokenResponse(); + }), + tokenStore: store, + loopbackFlow: OAuthLoopbackFlow(authorizationLauncher: (_) async => true), + nowUtc: () => DateTime.utc(2026, 6, 6), + ); + final original = OAuthTokenSet( + accessToken: 'old-access', + refreshToken: 'old-refresh', + expiresAtUtc: DateTime.utc(2026, 6, 6), + tokenType: 'Bearer', + scopes: const {'User.Read'}, + ); + await store.saveOAuthTokenSet( + 'microsoft:user-1', + BusyProvider.microsoft, + original, + ); + + final refresh = service.refreshTokenForAccount('microsoft:user-1'); + await started.future; + await service.signOutAccount('microsoft:user-1'); + release.complete(); + + await expectLater(refresh, throwsA(isA())); + expect(await store.readCredential('microsoft:user-1'), isNull); + }); + test( 'token endpoint 400 surfaces sanitized Microsoft OAuth exception', () async { @@ -170,16 +209,7 @@ MicrosoftOAuthService _service( Future Function(http.Request request) handler, ) { return MicrosoftOAuthService( - config: const BuildConfig( - googleOAuthClientId: '', - googleOAuthClientSecret: '', - microsoftOAuthClientId: 'microsoft-client-id', - apiBaseUrl: 'https://tasks.googleapis.com', - oauthAuthorizationEndpoint: - 'https://accounts.google.com/o/oauth2/v2/auth', - oauthTokenEndpoint: 'https://oauth2.googleapis.com/token', - oauthRevocationEndpoint: 'https://oauth2.googleapis.com/revoke', - ), + config: _config, httpClient: MockClient(handler), tokenStore: InMemorySecretStore(), loopbackFlow: OAuthLoopbackFlow(authorizationLauncher: (_) async => true), @@ -187,6 +217,16 @@ MicrosoftOAuthService _service( ); } +const _config = BuildConfig( + googleOAuthClientId: '', + googleOAuthClientSecret: '', + microsoftOAuthClientId: 'microsoft-client-id', + apiBaseUrl: 'https://tasks.googleapis.com', + oauthAuthorizationEndpoint: 'https://accounts.google.com/o/oauth2/v2/auth', + oauthTokenEndpoint: 'https://oauth2.googleapis.com/token', + oauthRevocationEndpoint: 'https://oauth2.googleapis.com/revoke', +); + http.Response _tokenResponse() { return http.Response( jsonEncode({ diff --git a/test/platform/android/android_adaptive_app_test.dart b/test/platform/android/android_adaptive_app_test.dart index 4de6d9f3..a79e9551 100644 --- a/test/platform/android/android_adaptive_app_test.dart +++ b/test/platform/android/android_adaptive_app_test.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:convert'; import 'package:busymax/src/android/android_app.dart'; @@ -25,6 +26,7 @@ import 'package:busymax/src/features/tasks/data/tasks_repository.dart'; import 'package:busymax/src/features/tasks/domain/task_capabilities.dart'; import 'package:busymax/src/providers/busy_provider.dart'; import 'package:busymax/src/schedule/schedule_repository.dart'; +import 'package:busymax/src/schedule/schedule_range.dart'; import 'package:busymax/src/schedule/schedule_view_mode.dart'; import 'package:drift/drift.dart' hide isNull; import 'package:flutter/material.dart'; @@ -339,6 +341,110 @@ void main() { expect(tester.takeException(), isNull); }); + testWidgets( + 'period navigation keeps the time grid mounted while destination loads', + (tester) async { + tester.view.physicalSize = const Size(412, 915); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + final destination = Completer(); + DateTime? initialRangeStart; + final harness = await _pumpApp( + tester, + AppSettings.defaults().copyWith( + androidScheduleViewMode: ScheduleViewMode.day, + ), + populated: true, + scheduleProjectionCoverage: (range) async { + initialRangeStart ??= range.start; + if (range.start != initialRangeStart) await destination.future; + }, + ); + addTearDown(harness.dispose); + + final grid = find.byKey(const ValueKey('android-day-time-grid')); + expect(grid, findsOneWidget); + final verticalScrollables = find.descendant( + of: grid, + matching: find.byWidgetPredicate( + (widget) => + widget is Scrollable && + widget.axisDirection == AxisDirection.down, + ), + ); + final originalScrollable = tester + .stateList(verticalScrollables) + .singleWhere((state) => state.position.maxScrollExtent > 0); + originalScrollable.position.jumpTo(240); + await tester.pump(); + final originalOffset = originalScrollable.position.pixels; + expect(originalOffset, greaterThan(0)); + + await tester.tap(find.byIcon(Icons.chevron_right)); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + expect(grid, findsOneWidget); + expect(find.byType(CircularProgressIndicator), findsOneWidget); + expect( + tester + .stateList(verticalScrollables) + .any((state) => identical(state, originalScrollable)), + isTrue, + ); + expect(originalScrollable.position.pixels, closeTo(originalOffset, .01)); + + await tester.pump(const Duration(milliseconds: 180)); + expect(grid, findsOneWidget); + expect(find.byType(CircularProgressIndicator), findsOneWidget); + destination.complete(); + await tester.pumpAndSettle(); + expect(grid, findsOneWidget); + expect(find.byType(CircularProgressIndicator), findsNothing); + expect( + tester + .stateList(verticalScrollables) + .any((state) => identical(state, originalScrollable)), + isTrue, + ); + expect(originalScrollable.position.pixels, closeTo(originalOffset, .01)); + }, + ); + + testWidgets('Agenda loading hides its empty message until data resolves', ( + tester, + ) async { + tester.view.physicalSize = const Size(412, 915); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + final destination = Completer(); + DateTime? initialRangeStart; + final harness = await _pumpApp( + tester, + AppSettings.defaults().copyWith( + androidScheduleViewMode: ScheduleViewMode.agenda, + ), + scheduleProjectionCoverage: (range) async { + initialRangeStart ??= range.start; + if (range.start != initialRangeStart) await destination.future; + }, + ); + addTearDown(harness.dispose); + + expect(find.text('No events or tasks'), findsOneWidget); + await tester.tap(find.byIcon(Icons.chevron_right)); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 220)); + expect(find.byType(CircularProgressIndicator), findsOneWidget); + expect(find.text('No events or tasks'), findsNothing); + + destination.complete(); + await tester.pumpAndSettle(); + expect(find.byType(CircularProgressIndicator), findsNothing); + expect(find.text('No events or tasks'), findsOneWidget); + }); + testWidgets( 'long schedule details remain scrollable across constrained viewports', (tester) async { @@ -621,6 +727,13 @@ void main() { testWidgets('Android Settings exposes diagnostics and feedback routes', ( tester, ) async { + PackageInfo.setMockInitialValues( + appName: 'BusyMax', + packageName: 'io.busystack.busymax', + version: '0.2.3', + buildNumber: '3', + buildSignature: '', + ); final harness = await _pumpApp(tester, AppSettings.defaults()); addTearDown(harness.dispose); harness.container.read(androidSelectedDestinationProvider.notifier).state = @@ -656,6 +769,21 @@ void main() { tester.state(find.byType(Navigator).first).pop(); await tester.pumpAndSettle(); + final about = find.text('About BusyMax'); + await _scrollUntilBuilt(tester, about); + await tester.tap(about); + await tester.pumpAndSettle(); + expect(find.text('0.2.3'), findsOneWidget); + expect( + find.descendant( + of: find.byType(AboutDialog), + matching: find.textContaining('+'), + ), + findsNothing, + ); + await tester.tap(find.widgetWithText(TextButton, 'Close')); + await tester.pumpAndSettle(); + final feedback = find.text('Send feedback'); await _scrollUntilBuilt(tester, feedback); await Scrollable.ensureVisible( @@ -1181,6 +1309,8 @@ void main() { testWidgets('dirty task save-and-move persists edits before moving', ( tester, ) async { + final mutationCallbacks = []; + var editorPresentAtCommit = false; final harness = await _pumpApp(tester, AppSettings.defaults()); addTearDown(harness.dispose); await _seedMovableTask(harness.database); @@ -1189,7 +1319,18 @@ void main() { hasLength(3), ); await tester.pump(); - await _openMovableTaskEditor(tester, _movableTask()); + await _openMovableTaskEditor( + tester, + _movableTask(), + onMutationStarted: (result) => mutationCallbacks.add('started'), + onMutationCommitted: (result) { + mutationCallbacks.add('committed'); + editorPresentAtCommit = find + .byType(AndroidTaskEditor) + .evaluate() + .isNotEmpty; + }, + ); await tester.enterText( find.widgetWithText(TextField, 'Title'), @@ -1214,6 +1355,8 @@ void main() { )..where((task) => task.id.equals('move-task'))).getSingle(); expect(row.title, 'Edited before move'); expect(row.taskListId, 'destination-list'); + expect(mutationCallbacks, ['started', 'committed']); + expect(editorPresentAtCommit, isTrue); final operations = await harness.database .select(harness.database.pendingOps) .get(); @@ -1374,8 +1517,8 @@ void main() { PackageInfo.setMockInitialValues( appName: 'BusyMax', packageName: 'io.busystack.busymax', - version: '0.2.2', - buildNumber: '2', + version: '0.2.3', + buildNumber: '3', buildSignature: '', ); final feedback = _RecordingFeedbackService(); @@ -1444,8 +1587,10 @@ TaskEntity _movableTask({String taskListId = 'source-list'}) => TaskEntity( Future _openMovableTaskEditor( WidgetTester tester, - TaskEntity task, -) async { + TaskEntity task, { + ValueChanged? onMutationStarted, + ValueChanged? onMutationCommitted, +}) async { tester .state(find.byType(Navigator).first) .push( @@ -1458,6 +1603,8 @@ Future _openMovableTaskEditor( listLabel: task.taskListId == 'source-list' ? 'Source list' : 'Missing source', + onMutationStarted: onMutationStarted, + onMutationCommitted: onMutationCommitted, ), ), ); @@ -1500,6 +1647,7 @@ Future<_AndroidAppHarness> _pumpApp( Future Function(String accountId)? onCalendarSync, FeedbackSubmissionService? feedbackService, CloudCalendarClient? calendarClient, + Future Function(ScheduleRange range)? scheduleProjectionCoverage, }) async { final database = AppDatabase.memoryForTests(); if (seedDatabase != null) await seedDatabase(database); @@ -1513,7 +1661,10 @@ Future<_AndroidAppHarness> _pumpApp( buildConfigProvider.overrideWithValue(BuildConfig.forAndroid()), databaseProvider.overrideWithValue(database), scheduleRepositoryProvider.overrideWithValue( - ScheduleRepository(database), + ScheduleRepository( + database, + ensureProjectionCoverage: scheduleProjectionCoverage, + ), ), taskListsRepositoryForAccountProvider.overrideWith( (ref, accountId) => diff --git a/test/platform/android/android_notification_reconciliation_test.dart b/test/platform/android/android_notification_reconciliation_test.dart index bc672ce1..40c2f5e8 100644 --- a/test/platform/android/android_notification_reconciliation_test.dart +++ b/test/platform/android/android_notification_reconciliation_test.dart @@ -361,6 +361,37 @@ void main() { ); }, ); + + test('server-missing due-today task cancels the existing summary', () async { + settings = settings.copyWith(notifyDueToday: true); + final notifications = service(); + await notifications.initialize(timeZoneId: 'America/Vancouver'); + await _insertDueTodayTask(database); + + await notifications.reconcile(); + + const summaryId = 0x425903; + final scheduled = await database + .select(database.androidDailySummarySchedules) + .getSingle(); + expect(scheduled.taskCount, 1); + expect(plugin.pendingIds, contains(summaryId)); + + await database + .update(database.tasks) + .write(const TasksCompanion(serverMissing: Value(true))); + plugin.cancelledIds.clear(); + plugin.scheduled.clear(); + + await notifications.reconcile(); + + expect(plugin.cancelledIds, contains(summaryId)); + expect(plugin.scheduledIds, isEmpty); + expect( + await database.select(database.androidDailySummarySchedules).get(), + isEmpty, + ); + }); } Future _insertReminder( diff --git a/test/platform/android/android_settings_sync_test.dart b/test/platform/android/android_settings_sync_test.dart new file mode 100644 index 00000000..db93b2f7 --- /dev/null +++ b/test/platform/android/android_settings_sync_test.dart @@ -0,0 +1,243 @@ +import 'package:busymax/src/android/presentation/android_settings_screen.dart'; +import 'package:busymax/src/android/android_notifications.dart'; +import 'package:busymax/src/app/app_bootstrap.dart'; +import 'package:busymax/src/config/build_config.dart'; +import 'package:busymax/src/core/auth/oauth_models.dart'; +import 'package:busymax/src/core/http/request_dispatch_exception.dart'; +import 'package:busymax/src/db/app_database.dart'; +import 'package:busymax/src/features/accounts/data/accounts_repository.dart'; +import 'package:busymax/src/features/notifications/notification_reconciler.dart'; +import 'package:busymax/src/providers/busy_provider.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../../support/memory_settings_store.dart'; +import '../../test_localized_app.dart'; + +void main() { + testWidgets('eligible Android account exposes actionable Sync', ( + tester, + ) async { + final calls = []; + final harness = await _pumpAndroidSettings( + tester, + signedInSyncRunner: (accountId, initial) async => calls.add(accountId), + ); + addTearDown(harness.dispose); + + await _openAccountMenu(tester); + expect(_popupValues(tester), contains('sync')); + await tester.tap(_popupEntry('sync')); + await tester.pumpAndSettle(); + + expect(calls, ['google:g']); + }); + + testWidgets('reconnect-required Android account offers reconnect, not Sync', ( + tester, + ) async { + final calls = []; + final harness = await _pumpAndroidSettings( + tester, + reconnectRequired: true, + signedInSyncRunner: (accountId, initial) async => calls.add(accountId), + ); + addTearDown(harness.dispose); + + await _openAccountMenu(tester); + + expect(_popupValues(tester), isNot(contains('sync'))); + expect(_popupValues(tester), contains('reconnect')); + expect(calls, isEmpty); + }); + + testWidgets('Android account Sync re-reads eligibility before dispatch', ( + tester, + ) async { + final calls = []; + final harness = await _pumpAndroidSettings( + tester, + signedInSyncRunner: (accountId, initial) async => calls.add(accountId), + ); + addTearDown(harness.dispose); + + await _openAccountMenu(tester); + expect(_popupEntry('sync'), findsOneWidget); + await harness.accounts.markReconnectRequired('google:g'); + await tester.tap(_popupEntry('sync')); + await tester.pumpAndSettle(); + + expect(calls, isEmpty); + expect( + find.text('Sync failed: This account needs to be reconnected.'), + findsOneWidget, + ); + }); + + testWidgets( + 'Android account Sync hides wrapped OAuth implementation details', + (tester) async { + const failure = KnownUnsentRequestException( + kind: RequestPreDispatchFailureKind.authentication, + cause: OAuthRefreshException( + 'OAuthRefreshFailed', + 'raw provider description refresh_token=android-test-secret', + statusCode: 400, + oauthError: 'invalid_grant', + oauthErrorDescription: 'provider description [REDACTED]', + ), + ); + final harness = await _pumpAndroidSettings( + tester, + signedInSyncRunner: (accountId, initial) async => throw failure, + ); + addTearDown(harness.dispose); + + await _openAccountMenu(tester); + await tester.tap(_popupEntry('sync')); + await tester.pumpAndSettle(); + + expect( + find.text('Sync failed: This account needs to be reconnected.'), + findsOneWidget, + ); + expect(find.textContaining('KnownUnsentRequestException'), findsNothing); + expect(find.textContaining('OAuthRefreshException'), findsNothing); + expect(find.textContaining('provider description'), findsNothing); + expect(find.textContaining('android-test-secret'), findsNothing); + }, + ); + + testWidgets('Android Sync All uses the canonical safe failure message', ( + tester, + ) async { + const failure = KnownUnsentRequestException( + kind: RequestPreDispatchFailureKind.authentication, + cause: OAuthRefreshException( + 'OAuthRefreshFailed', + 'InternalOAuthException sync-all-secret', + statusCode: 400, + oauthError: 'invalid_grant', + ), + ); + final harness = await _pumpAndroidSettings( + tester, + allAccountsSyncRunner: () async => throw failure, + ); + addTearDown(harness.dispose); + final syncAll = find.widgetWithText(ListTile, 'Sync'); + await tester.scrollUntilVisible( + syncAll, + 500, + scrollable: find.byType(Scrollable).first, + ); + await tester.ensureVisible(syncAll); + await tester.pumpAndSettle(); + + await tester.tap(syncAll); + await tester.pumpAndSettle(); + + expect( + find.text('Sync failed: This account needs to be reconnected.'), + findsOneWidget, + ); + expect(find.textContaining('KnownUnsentRequestException'), findsNothing); + expect(find.textContaining('InternalOAuthException'), findsNothing); + expect(find.textContaining('sync-all-secret'), findsNothing); + }); +} + +Future<_AndroidSettingsHarness> _pumpAndroidSettings( + WidgetTester tester, { + bool reconnectRequired = false, + SignedInSyncRunner? signedInSyncRunner, + AllAccountsSyncRunner? allAccountsSyncRunner, +}) async { + tester.view.devicePixelRatio = 1; + tester.view.physicalSize = const Size(1000, 1800); + addTearDown(tester.view.resetDevicePixelRatio); + addTearDown(tester.view.resetPhysicalSize); + final database = AppDatabase.memoryForTests(); + final accounts = AccountsRepository(database: database); + await accounts.upsertSignedInAccount( + id: 'google:g', + provider: BusyProvider.google, + grantedScopes: 'tasks calendar', + displayName: 'Google User', + ); + if (reconnectRequired) { + await accounts.markReconnectRequired('google:g'); + } + final container = ProviderContainer( + overrides: [ + buildConfigProvider.overrideWithValue(BuildConfig.forAndroid()), + databaseProvider.overrideWithValue(database), + initialAppSettingsProvider.overrideWithValue(AppSettings.defaults()), + localSettingsStoreProvider.overrideWithValue(MemorySettingsStore()), + calendarSourcesStreamProvider.overrideWith( + (ref) => Stream.value(const []), + ), + scheduleTaskListsProvider.overrideWith((ref) async => const []), + webCalSubscriptionsProvider.overrideWith((ref) => Stream.value(const [])), + davConflictsStreamProvider.overrideWith((ref) => Stream.value(const [])), + signedInSyncRunnerProvider.overrideWithValue( + signedInSyncRunner ?? (accountId, initial) async {}, + ), + allAccountsSyncRunnerProvider.overrideWithValue( + allAccountsSyncRunner ?? () async {}, + ), + notificationReconcilerProvider.overrideWithValue( + CallbackNotificationReconciler(() async {}), + ), + androidNotificationServiceProvider.overrideWithValue( + AndroidNotificationService( + database: database, + settings: AppSettings.defaults, + ), + ), + ], + ); + await tester.pumpWidget( + UncontrolledProviderScope( + container: container, + child: localizedTestApp(child: const AndroidSettingsScreen()), + ), + ); + await tester.pumpAndSettle(); + return _AndroidSettingsHarness(tester, container, database, accounts); +} + +Future _openAccountMenu(WidgetTester tester) async { + await tester.tap(find.byType(PopupMenuButton).first); + await tester.pumpAndSettle(); +} + +List _popupValues(WidgetTester tester) => tester + .widgetList>(find.byType(PopupMenuItem)) + .map((item) => item.value) + .toList(); + +Finder _popupEntry(String value) => find.byWidgetPredicate( + (widget) => widget is PopupMenuItem && widget.value == value, +); + +final class _AndroidSettingsHarness { + const _AndroidSettingsHarness( + this.tester, + this.container, + this.database, + this.accounts, + ); + + final WidgetTester tester; + final ProviderContainer container; + final AppDatabase database; + final AccountsRepository accounts; + + Future dispose() async { + await tester.pumpWidget(const SizedBox.shrink()); + container.dispose(); + await database.close(); + } +} diff --git a/test/platform/gtk_animation_settings_service_test.dart b/test/platform/gtk_animation_settings_service_test.dart new file mode 100644 index 00000000..c0ded5ba --- /dev/null +++ b/test/platform/gtk_animation_settings_service_test.dart @@ -0,0 +1,99 @@ +import 'dart:io'; + +import 'package:busymax/src/platform/gtk_animation_settings_service.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + test('reads the initial GTK animation setting', () async { + const channel = MethodChannel('busymax_test/gtk_animations'); + final messenger = + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger; + messenger.setMockMethodCallHandler(channel, (call) async { + expect(call.method, 'getGtkAnimationsEnabled'); + return false; + }); + addTearDown(() => messenger.setMockMethodCallHandler(channel, null)); + + expect( + await const GtkAnimationSettingsService( + channel: channel, + ).getAnimationsEnabled(), + isFalse, + ); + }); + + test('animation stream follows GTK setting changes', () async { + const events = EventChannel('busymax_test/gtk_animation_events'); + MockStreamHandlerEventSink? sink; + final messenger = + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger; + messenger.setMockStreamHandler( + events, + MockStreamHandler.inline( + onListen: (_, eventSink) { + sink = eventSink; + eventSink.success(true); + }, + ), + ); + addTearDown(() => messenger.setMockStreamHandler(events, null)); + + final values = const GtkAnimationSettingsService( + events: events, + ).watchAnimationsEnabled().take(2).toList(); + await pumpEventQueue(); + sink!.success(false); + + expect(await values, [isTrue, isFalse]); + }); + + test('provider exposes the preloaded value before native events', () async { + final container = ProviderContainer( + overrides: [initialGtkAnimationsEnabledProvider.overrideWithValue(false)], + ); + addTearDown(container.dispose); + + expect(await container.read(gtkAnimationsEnabledProvider.future), isFalse); + }); + + test('missing GTK integration falls back without disabling motion', () async { + expect( + await const GtkAnimationSettingsService( + channel: MethodChannel('busymax_test/gtk_animations_missing'), + ).getAnimationsEnabled(), + isNull, + ); + }); + + test( + 'native GTK bridge observes and tears down animation preference state', + () { + final source = File('linux/runner/my_application.cc').readAsStringSync(); + final frame = File( + 'lib/src/app/linux/linux_page_frame.dart', + ).readAsStringSync(); + + expect(source, contains('"notify::gtk-enable-animations"')); + expect(source, contains('send_gtk_animation_settings_event(self);')); + expect( + source, + contains('disconnect_gtk_animation_settings_signal(self);'), + ); + expect( + source, + contains( + 'g_clear_object(&self->gtk_animation_settings_event_channel);', + ), + ); + expect(source, isNot(contains('header_sidebar_brand_box'))); + expect(source, isNot(contains('gtk_widget_add_tick_callback('))); + expect(frame, contains('MediaQuery.disableAnimationsOf(context)')); + expect(frame, contains('_controller.value = _targetVisible ? 1 : 0')); + expect(frame, contains('BusyMaxMotion.sidebar * distance')); + }, + ); +} diff --git a/test/platform/gtk_font_service_test.dart b/test/platform/gtk_font_service_test.dart index 68da6405..3ed5d1b1 100644 --- a/test/platform/gtk_font_service_test.dart +++ b/test/platform/gtk_font_service_test.dart @@ -288,6 +288,7 @@ void main() { 'floatingBorder': '#24000000', 'sidebarBorder': '#33000000', 'shade': '#55000000', + 'searchEntry': _searchEntryPayload, }; }); addTearDown(() { @@ -313,6 +314,225 @@ void main() { expect(colors?.divider, const Color(0x1AFFFFFF)); expect(colors?.cardShade, const Color(0x5A101010)); expect(colors?.floatingBorder, const Color(0x24000000)); + expect( + colors?.searchEntry?.normal, + const GtkSearchEntryStateStyle( + background: Color(0xFFFDFDFD), + foreground: Color(0xFF101112), + borderTop: 1, + borderRight: 2, + borderBottom: 3, + borderLeft: 4, + borderColor: Color(0xFF202122), + primaryIconForeground: Color(0xFF303132), + primaryIconForegroundRtl: Color(0xFF404142), + secondaryIconForeground: Color(0xFF505152), + secondaryIconForegroundRtl: Color(0xFF606162), + radius: 13, + hasInnerFocus: false, + innerFocusColor: Color(0x00000000), + innerFocusWidth: 0, + ), + ); + expect(colors?.searchEntry?.focused.radius, 12); + expect(colors?.searchEntry?.focused.hasInnerFocus, isTrue); + expect( + colors?.searchEntry?.focused.innerFocusColor, + const Color(0xFF123456), + ); + expect(colors?.searchEntry?.focused.innerFocusWidth, 1); + expect(colors?.searchEntry?.normal.hasInnerFocus, isFalse); + expect(colors?.searchEntry?.normal.innerFocusWidth, 0); + expect(colors?.searchEntry?.backdrop.radius, 11); + expect(colors?.searchEntry?.backdropFocused.radius, 10); + }); + + test('Search-entry theme models have value equality and stable hashes', () { + const state = GtkSearchEntryStateStyle( + background: Color(0xFFFDFDFD), + foreground: Color(0xFF101112), + borderTop: 1, + borderRight: 2, + borderBottom: 3, + borderLeft: 4, + borderColor: Color(0xFF202122), + primaryIconForeground: Color(0xFF303132), + primaryIconForegroundRtl: Color(0xFF404142), + secondaryIconForeground: Color(0xFF505152), + secondaryIconForegroundRtl: Color(0xFF606162), + radius: 13, + ); + const first = GtkSearchEntryTheme( + normal: state, + focused: state, + backdrop: state, + backdropFocused: state, + ); + const second = GtkSearchEntryTheme( + normal: state, + focused: state, + backdrop: state, + backdropFocused: state, + ); + const firstColors = GtkThemeColors( + brightness: Brightness.light, + searchEntry: first, + ); + const secondColors = GtkThemeColors( + brightness: Brightness.light, + searchEntry: second, + ); + + expect(first, second); + expect(first.hashCode, second.hashCode); + expect(firstColors, secondColors); + expect(firstColors.hashCode, secondColors.hashCode); + expect( + state, + isNot( + const GtkSearchEntryStateStyle( + background: Color(0xFFFDFDFD), + foreground: Color(0xFF101112), + borderTop: 1, + borderRight: 2, + borderBottom: 3, + borderLeft: 4, + borderColor: Color(0xFF202122), + primaryIconForeground: Color(0xFF303132), + primaryIconForegroundRtl: Color(0xFF404142), + secondaryIconForeground: Color(0xFF505152), + secondaryIconForegroundRtl: Color(0xFF606162), + radius: 14, + hasInnerFocus: true, + innerFocusColor: Color(0xFF123456), + innerFocusWidth: 1, + ), + ), + ); + }); + + test('malformed Search data does not invalidate GTK theme colors', () async { + const channel = MethodChannel('busymax_test/gtk_theme_bad_search'); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (_) async { + return { + 'brightness': 'light', + 'window': '#FAFAFA', + 'foreground': '#101010', + 'searchEntry': { + ..._searchEntryPayload, + 'focused': { + ..._searchStatePayload, + 'radius': double.nan, + }, + }, + }; + }); + addTearDown(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, null); + }); + + final colors = await const GtkThemeService( + channel: channel, + ).getGtkThemeColors(); + + expect(colors?.brightness, Brightness.light); + expect(colors?.window, const Color(0xFFFAFAFA)); + expect(colors?.foreground, const Color(0xFF101010)); + expect(colors?.searchEntry, isNull); + }); + + test('incomplete Search state data is discarded independently', () async { + const channel = MethodChannel('busymax_test/gtk_theme_partial_search'); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (_) async { + return { + 'brightness': 'light', + 'window': '#FAFAFA', + 'searchEntry': { + ..._searchEntryPayload, + 'backdrop': { + 'background': '#FDFDFD', + 'foreground': '#101112', + }, + }, + }; + }); + addTearDown(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, null); + }); + + final colors = await const GtkThemeService( + channel: channel, + ).getGtkThemeColors(); + + expect(colors?.window, const Color(0xFFFAFAFA)); + expect(colors?.searchEntry, isNull); + }); + + test( + 'malformed focus-inset fields fall back without dropping Search', + () async { + const channel = MethodChannel('busymax_test/gtk_theme_bad_focus_inset'); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (_) async { + return { + 'brightness': 'light', + 'searchEntry': { + ..._searchEntryPayload, + 'focused': { + ..._searchStatePayload, + 'radius': 12, + 'hasInnerFocus': 'yes', + 'innerFocusColor': 123, + 'innerFocusWidth': -1, + }, + }, + }; + }); + addTearDown(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, null); + }); + + final colors = await const GtkThemeService( + channel: channel, + ).getGtkThemeColors(); + + expect(colors?.searchEntry, isNotNull); + expect(colors?.searchEntry?.focused.radius, 12); + expect(colors?.searchEntry?.focused.hasInnerFocus, isFalse); + expect( + colors?.searchEntry?.focused.innerFocusColor, + const Color(0x00000000), + ); + expect(colors?.searchEntry?.focused.innerFocusWidth, 0); + }, + ); + + test('missing Search data leaves the optional theme absent', () async { + const channel = MethodChannel('busymax_test/gtk_theme_no_search'); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler( + channel, + (_) async => { + 'brightness': 'dark', + 'window': '#202020', + }, + ); + addTearDown(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, null); + }); + + final colors = await const GtkThemeService( + channel: channel, + ).getGtkThemeColors(); + + expect(colors?.brightness, Brightness.dark); + expect(colors?.searchEntry, isNull); }); test('missing native GTK theme color channel falls back to null', () async { @@ -418,3 +638,30 @@ void main() { expect(colors, isNull); }); } + +const _searchStatePayload = { + 'background': '#FDFDFD', + 'foreground': '#101112', + 'borderTop': 1, + 'borderRight': 2, + 'borderBottom': 3, + 'borderLeft': 4, + 'borderColor': '#202122', + 'primaryIconForeground': '#303132', + 'primaryIconForegroundRtl': '#404142', + 'secondaryIconForeground': '#505152', + 'secondaryIconForegroundRtl': '#606162', +}; + +final _searchEntryPayload = { + 'normal': {..._searchStatePayload, 'radius': 13}, + 'focused': { + ..._searchStatePayload, + 'radius': 12, + 'hasInnerFocus': true, + 'innerFocusColor': '#123456', + 'innerFocusWidth': 1, + }, + 'backdrop': {..._searchStatePayload, 'radius': 11}, + 'backdropFocused': {..._searchStatePayload, 'radius': 10}, +}; diff --git a/test/platform/gtk_header_icon_service_test.dart b/test/platform/gtk_header_icon_service_test.dart new file mode 100644 index 00000000..a8d9f777 --- /dev/null +++ b/test/platform/gtk_header_icon_service_test.dart @@ -0,0 +1,261 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:busymax/src/app/linux/linux_header_style.dart'; +import 'package:busymax/src/platform/gtk_header_icon_service.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test('semantic catalog uses the exact native GTK icon names', () { + expect(BusyMaxLinuxHeaderIcon.back.gtkNames, const [ + 'go-previous-symbolic', + ]); + expect(BusyMaxLinuxHeaderIcon.sidebar.gtkNames, const [ + 'sidebar-show-symbolic', + ]); + expect(BusyMaxLinuxHeaderIcon.today.gtkNames, const [ + 'today-symbolic', + 'x-office-calendar-symbolic', + ]); + expect(BusyMaxLinuxHeaderIcon.previous.gtkNames, const [ + 'go-previous-symbolic', + ]); + expect(BusyMaxLinuxHeaderIcon.next.gtkNames, const ['go-next-symbolic']); + expect(BusyMaxLinuxHeaderIcon.search.gtkNames, const [ + 'system-search-symbolic', + ]); + expect(BusyMaxLinuxHeaderIcon.searchEntryFind.gtkNames, const [ + 'edit-find-symbolic', + ]); + expect(BusyMaxLinuxHeaderIcon.searchClear.gtkNames, const [ + 'edit-clear-symbolic', + ]); + expect(BusyMaxLinuxHeaderIcon.mainMenu.gtkNames, const [ + 'open-menu-symbolic', + ]); + expect(BusyMaxLinuxHeaderIcon.create.gtkNames, const ['list-add-symbolic']); + expect(BusyMaxLinuxHeaderIcon.refresh.gtkNames, const [ + 'view-refresh-symbolic', + ]); + expect(BusyMaxLinuxHeaderIcon.viewDay.gtkNames, const [ + 'view-continuous-symbolic', + ]); + expect(BusyMaxLinuxHeaderIcon.viewWeek.gtkNames, const [ + 'calendar-week-symbolic', + 'x-office-calendar-symbolic', + ]); + expect(BusyMaxLinuxHeaderIcon.viewMonth.gtkNames, const [ + 'calendar-month-symbolic', + 'x-office-calendar-symbolic', + ]); + expect(BusyMaxLinuxHeaderIcon.viewYear.gtkNames, const [ + 'view-app-grid-symbolic', + ]); + expect(BusyMaxLinuxHeaderIcon.viewAgenda.gtkNames, const [ + 'view-list-symbolic', + ]); + expect(BusyMaxLinuxHeaderIcon.viewMenuArrow.gtkNames, const [ + 'pan-down-symbolic', + ]); + expect(BusyMaxLinuxHeaderIcon.filter.gtkNames, const [ + 'view-filter-symbolic', + ]); + expect(BusyMaxLinuxHeaderIcon.close.gtkNames, const [ + 'window-close-symbolic', + ]); + }); + + test( + 'initial load batches direction variants and preserves candidate order', + () async { + late List> observedRequests; + final service = GtkHeaderIconService.testing( + loadBatch: (requests) async { + observedRequests = requests; + return _responseFor(requests, _pngA, scale: 1); + }, + ); + addTearDown(service.dispose); + + expect(await service.initialize(), isTrue); + expect(service.reloadCount, 1); + expect(service.catalog.scale, 1); + + for (final role in const { + BusyMaxLinuxHeaderIcon.search: 'system-search-symbolic', + BusyMaxLinuxHeaderIcon.searchEntryFind: 'edit-find-symbolic', + BusyMaxLinuxHeaderIcon.searchClear: 'edit-clear-symbolic', + }.entries) { + final request = observedRequests.singleWhere( + (request) => request['key'] == '${role.key.name}.ltr', + ); + expect(request['names'], [role.value]); + final asset = service.catalog.assetFor(role.key, TextDirection.ltr); + expect(asset?.resolvedName, role.value); + expect(asset?.scale, 1); + expect(asset?.pixelWidth, 16); + expect(asset?.pixelHeight, 16); + } + + final today = observedRequests.singleWhere( + (request) => request['key'] == 'today.ltr', + ); + expect(today['names'], const [ + 'today-symbolic', + 'x-office-calendar-symbolic', + ]); + expect(today['direction'], 'ltr'); + expect(today['allowMissing'], isFalse); + + final filter = observedRequests.singleWhere( + (request) => request['key'] == 'filter.ltr', + ); + expect(filter['allowMissing'], isTrue); + expect( + observedRequests + .where((request) => request['key'] != 'filter.ltr') + .every((request) => request['allowMissing'] == false), + isTrue, + ); + + for (final icon in const [ + BusyMaxLinuxHeaderIcon.back, + BusyMaxLinuxHeaderIcon.sidebar, + BusyMaxLinuxHeaderIcon.previous, + BusyMaxLinuxHeaderIcon.next, + ]) { + expect( + observedRequests.where( + (request) => (request['key'] as String).startsWith('${icon.name}.'), + ), + hasLength(2), + ); + } + expect( + observedRequests.where( + (request) => (request['key'] as String).startsWith('viewWeek.'), + ), + hasLength(1), + ); + }, + ); + + testWidgets( + 'theme and scale invalidation atomically replaces one cached catalog', + (tester) async { + final invalidations = StreamController.broadcast(sync: true); + final replacement = Completer(); + var calls = 0; + final service = GtkHeaderIconService.testing( + changedEvents: invalidations.stream, + loadBatch: (requests) { + calls += 1; + if (calls == 1) { + return Future.value(_responseFor(requests, _pngA, scale: 1)); + } + return replacement.future; + }, + ); + addTearDown(() async { + service.dispose(); + await invalidations.close(); + }); + expect(await service.initialize(), isTrue); + final originalCatalog = service.catalog; + + await tester.pumpWidget( + GtkHeaderIconScope( + service: service, + child: const MaterialApp( + home: Center( + child: BusyMaxGtkHeaderIcon(BusyMaxLinuxHeaderIcon.search), + ), + ), + ), + ); + expect( + tester.getSize(find.byType(BusyMaxGtkHeaderIcon)), + const Size.square(BusyMaxLinuxHeaderStyle.symbolicIconSize), + ); + expect(_renderedBytes(tester), _pngA); + expect(_fallbackPaint(), findsNothing); + + invalidations.add(const {'revision': 1, 'scale': 2}); + invalidations.add(const {'revision': 1, 'scale': 2}); + await tester.pump(); + expect(service.reloadCount, 2); + expect(identical(service.catalog, originalCatalog), isTrue); + expect(_renderedBytes(tester), _pngA); + expect(_fallbackPaint(), findsNothing); + + replacement.complete( + _responseFor(_allRequestsFrom(originalCatalog), _pngB, scale: 2), + ); + await tester.pump(); + await tester.pump(); + + expect(service.reloadCount, 2); + expect(identical(service.catalog, originalCatalog), isFalse); + expect(service.catalog.revision, 1); + expect(service.catalog.scale, 2); + for (final icon in const [ + BusyMaxLinuxHeaderIcon.search, + BusyMaxLinuxHeaderIcon.searchEntryFind, + BusyMaxLinuxHeaderIcon.searchClear, + ]) { + final asset = service.catalog.assetFor(icon, TextDirection.ltr); + expect(asset?.scale, 2); + expect(asset?.pixelWidth, 32); + expect(asset?.pixelHeight, 32); + } + expect(_renderedBytes(tester), _pngB); + expect( + tester.getSize(find.byType(BusyMaxGtkHeaderIcon)), + const Size.square(BusyMaxLinuxHeaderStyle.symbolicIconSize), + ); + expect(_fallbackPaint(), findsNothing); + }, + ); +} + +List> _lastRequests = const []; + +Map _responseFor( + List> requests, + Uint8List bytes, { + required int scale, +}) { + _lastRequests = requests; + return { + for (final request in requests) + request['key']! as String: { + 'bytes': bytes, + 'resolvedName': (request['names']! as List).first, + 'scale': scale, + 'pixelWidth': 16 * scale, + 'pixelHeight': 16 * scale, + }, + }; +} + +List> _allRequestsFrom(GtkHeaderIconCatalog _) => + _lastRequests; + +Uint8List _renderedBytes(WidgetTester tester) { + final image = tester.widget(find.byType(Image)); + return (image.image as MemoryImage).bytes; +} + +Finder _fallbackPaint() => find.descendant( + of: find.byType(BusyMaxGtkHeaderIcon), + matching: find.byType(CustomPaint), +); + +final _pngA = base64Decode( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVQIHWP4z8DwHwAFgAI/ScL1WQAAAABJRU5ErkJggg==', +); +final _pngB = base64Decode( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVQIHWP4////fwAJ+wP+4Xz8WQAAAABJRU5ErkJggg==', +); diff --git a/test/platform/gtk_window_preferences_service_test.dart b/test/platform/gtk_window_preferences_service_test.dart new file mode 100644 index 00000000..4c4a2e06 --- /dev/null +++ b/test/platform/gtk_window_preferences_service_test.dart @@ -0,0 +1,116 @@ +import 'package:busymax/src/platform/gtk_window_preferences_service.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + test('parses every GTK 3 decoration token in physical order', () { + final preferences = GtkWindowPreferences.fromMessage(const { + 'decorationLayout': 'close,icon,menu:minimize,maximize', + 'doubleClick': 'toggle-maximize', + 'middleClick': 'lower', + 'rightClick': 'menu', + }); + + expect(preferences.decorationLayout.left, [ + GtkWindowDecorationElement.close, + GtkWindowDecorationElement.windowIcon, + GtkWindowDecorationElement.fallbackApplicationMenu, + ]); + expect(preferences.decorationLayout.right, [ + GtkWindowDecorationElement.minimize, + GtkWindowDecorationElement.maximize, + ]); + expect(preferences.doubleClick, GtkTitlebarAction.toggleMaximize); + expect(preferences.middleClick, GtkTitlebarAction.lower); + expect(preferences.rightClick, GtkTitlebarAction.menu); + }); + + test('ignores unknown controls and actions safely', () { + final preferences = GtkWindowPreferences.fromMessage(const { + 'decorationLayout': 'unknown,close:bogus,minimize', + 'doubleClick': 'unsupported', + 'middleClick': 'none', + 'rightClick': 'minimize', + }); + + expect(preferences.decorationLayout.left, [ + GtkWindowDecorationElement.close, + ]); + expect(preferences.decorationLayout.right, [ + GtkWindowDecorationElement.minimize, + ]); + expect(preferences.doubleClick, GtkTitlebarAction.none); + expect(preferences.middleClick, GtkTitlebarAction.none); + expect(preferences.rightClick, GtkTitlebarAction.minimize); + }); + + test( + 'loads preferences and delegates the narrowly scoped lower action', + () async { + const channel = MethodChannel('busymax_test/gtk_preferences'); + final calls = []; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + calls.add(call); + if (call.method == 'getGtkWindowPreferences') { + return { + 'decorationLayout': 'menu:close', + 'doubleClick': 'minimize', + 'middleClick': 'none', + 'rightClick': 'menu', + }; + } + return null; + }); + addTearDown( + () => TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, null), + ); + const service = GtkWindowPreferencesService(channel: channel); + + final value = await service.load(); + await service.lowerWindow(); + + expect(value?.decorationLayout.left, [ + GtkWindowDecorationElement.fallbackApplicationMenu, + ]); + expect(value?.decorationLayout.right, [GtkWindowDecorationElement.close]); + expect(value?.doubleClick, GtkTitlebarAction.minimize); + expect(calls.map((call) => call.method), [ + 'getGtkWindowPreferences', + 'lowerWindow', + ]); + }, + ); + + test('window preference values have stable equality', () { + final first = GtkWindowPreferences.fromMessage(const { + 'decorationLayout': ':minimize,maximize,close', + 'doubleClick': 'toggle-maximize', + 'middleClick': 'none', + 'rightClick': 'menu', + }); + final second = GtkWindowPreferences.fromMessage(const { + 'decorationLayout': ':minimize,maximize,close', + 'doubleClick': 'toggle-maximize', + 'middleClick': 'none', + 'rightClick': 'menu', + }); + + expect(first, second); + expect(first.hashCode, second.hashCode); + }); + + test('null decoration layout uses the documented GTK 3 default', () { + final layout = GtkDecorationLayout.parse(null); + + expect(layout.left, [GtkWindowDecorationElement.fallbackApplicationMenu]); + expect(layout.right, [ + GtkWindowDecorationElement.minimize, + GtkWindowDecorationElement.maximize, + GtkWindowDecorationElement.close, + ]); + }); +} diff --git a/test/platform/linux_header_bar_configuration_synchronizer_test.dart b/test/platform/linux_header_bar_configuration_synchronizer_test.dart deleted file mode 100644 index 0e94a977..00000000 --- a/test/platform/linux_header_bar_configuration_synchronizer_test.dart +++ /dev/null @@ -1,151 +0,0 @@ -import 'dart:async'; - -import 'package:busymax/src/platform/linux_header_bar_configuration_synchronizer.dart'; -import 'package:busymax/src/platform/linux_header_bar_service.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; - -void main() { - test( - 'coalesces equal requests and applies only the latest frame value', - () async { - final callbacks = []; - final applied = []; - final synchronizer = BusyMaxHeaderBarConfigurationSynchronizer.forTesting( - apply: (configuration) async => applied.add(configuration), - scheduleAfterFrame: callbacks.add, - ); - addTearDown(synchronizer.dispose); - final first = _configuration(dark: false); - final latest = _configuration(dark: true); - - synchronizer - ..schedule(first) - ..schedule(first) - ..schedule(latest); - - expect(callbacks, hasLength(2)); - for (final callback in callbacks) { - callback(); - } - await synchronizer.settled; - - expect(applied, [latest]); - }, - ); - - test('serializes an in-flight update before the newest value', () async { - final callbacks = []; - final firstRelease = Completer(); - final applied = []; - final synchronizer = BusyMaxHeaderBarConfigurationSynchronizer.forTesting( - apply: (configuration) async { - applied.add(configuration); - if (applied.length == 1) { - await firstRelease.future; - } - }, - scheduleAfterFrame: callbacks.add, - ); - addTearDown(synchronizer.dispose); - final first = _configuration(dark: false); - final latest = _configuration(dark: true); - - synchronizer.schedule(first); - callbacks.removeAt(0)(); - await Future.delayed(Duration.zero); - - synchronizer.schedule(latest); - callbacks.removeAt(0)(); - firstRelease.complete(); - await synchronizer.settled; - - expect(applied, [first, latest]); - }); - - test( - 'recovers after a failed native apply and permits an equal retry', - () async { - final callbacks = []; - final applied = []; - final errors = []; - var shouldFail = true; - final synchronizer = BusyMaxHeaderBarConfigurationSynchronizer.forTesting( - apply: (configuration) async { - applied.add(configuration); - if (shouldFail) { - shouldFail = false; - throw StateError('native header unavailable'); - } - }, - scheduleAfterFrame: callbacks.add, - reportError: (error, _) => errors.add(error), - ); - addTearDown(synchronizer.dispose); - final configuration = _configuration(dark: false); - - synchronizer.schedule(configuration); - callbacks.removeAt(0)(); - await synchronizer.settled; - - synchronizer.schedule(configuration); - callbacks.removeAt(0)(); - await synchronizer.settled; - - expect(applied, [configuration, configuration]); - expect(errors, [isA()]); - }, - ); -} - -BusyMaxHeaderBarConfiguration _configuration({required bool dark}) { - return BusyMaxHeaderBarConfiguration( - labels: const BusyMaxHeaderBarLabels( - today: 'Today', - day: 'Day', - week: 'Week', - month: 'Month', - year: 'Year', - agenda: 'Agenda', - search: 'Search', - create: 'Create', - createEvent: 'Event', - createTask: 'Task', - refresh: 'Refresh', - menu: 'Menu', - previous: 'Previous', - next: 'Next', - showSidebarPanel: 'Show sidebar panel', - hideSidebarPanel: 'Hide sidebar panel', - back: 'Back', - settings: 'Settings', - keyboardShortcuts: 'Keyboard shortcuts', - reportIssue: 'Report an issue', - aboutBusyMax: 'About BusyMax', - ), - sidebarWidth: 300, - textDirection: TextDirection.ltr, - theme: BusyMaxHeaderBarTheme( - preferDark: dark, - highContrast: false, - windowBackgroundColor: dark ? Colors.black : Colors.white, - backgroundColor: dark ? Colors.black : Colors.white, - sidebarBackgroundColor: dark ? Colors.black : Colors.white, - foregroundColor: dark ? Colors.white : Colors.black, - sidebarBorderColor: Colors.grey, - dialogBackgroundColor: dark ? Colors.black : Colors.white, - dialogOutlineColor: dark ? Colors.white : Colors.white10, - modalBarrierColor: Colors.black54, - tooltip: const BusyMaxHeaderBarTooltipTheme( - backgroundColor: Color.fromRGBO(0, 0, 0, 0.8), - foregroundColor: Colors.white, - borderColor: Color.fromRGBO(255, 255, 255, 0.1), - borderRadius: 8, - fontSize: 14, - horizontalPadding: 10, - verticalPadding: 6, - minimumHeight: 30, - ), - ), - ); -} diff --git a/test/platform/linux_header_bar_service_test.dart b/test/platform/linux_header_bar_service_test.dart deleted file mode 100644 index f94d85a6..00000000 --- a/test/platform/linux_header_bar_service_test.dart +++ /dev/null @@ -1,1002 +0,0 @@ -import 'dart:async'; -import 'dart:io'; - -import 'package:busymax/src/platform/linux_header_bar_service.dart'; -import 'package:busymax/src/schedule/schedule_view_mode.dart'; -import 'package:flutter/services.dart'; -import 'package:flutter_test/flutter_test.dart'; - -const _scheduleHeaderState = BusyMaxHeaderBarState( - title: 'July 2026', - viewMode: ScheduleViewMode.month, - canRefresh: true, - canCreateEvent: true, - canCreateTask: true, - searchActive: false, - searchQuery: '', - canShowSidebar: true, - sidebarVisible: true, - navigationVisible: true, - scheduleControlsVisible: true, - backVisible: false, -); - -const _settingsHeaderState = BusyMaxHeaderBarState( - title: 'Settings', - viewMode: ScheduleViewMode.month, - canRefresh: false, - canCreateEvent: false, - canCreateTask: false, - searchActive: false, - searchQuery: '', - canShowSidebar: true, - sidebarVisible: true, - navigationVisible: false, - scheduleControlsVisible: false, - backVisible: true, -); - -void main() { - TestWidgetsFlutterBinding.ensureInitialized(); - - test('sends application-wide state to the native headerbar', () async { - const channel = MethodChannel('busymax_test/headerbar_updates'); - final calls = []; - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(channel, (call) async { - calls.add(call); - if (call.method == 'initialize') { - return true; - } - return null; - }); - addTearDown(() { - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(channel, null); - }); - - final service = LinuxHeaderBarService(channel: channel, isLinux: true); - addTearDown(service.dispose); - final session = service.claimSession(); - addTearDown(session.dispose); - - await service.initialize(); - await service.setLocalizedLabels( - const BusyMaxHeaderBarLabels( - today: 'Today', - day: 'Day', - week: 'Week', - month: 'Month', - year: 'Year', - agenda: 'Agenda', - search: 'Search', - create: 'Create', - createEvent: 'Event', - createTask: 'Task', - refresh: 'Refresh', - menu: 'Menu', - previous: 'Previous', - next: 'Next', - showSidebarPanel: 'Show sidebar panel', - hideSidebarPanel: 'Hide sidebar panel', - back: 'Back', - settings: 'Settings', - keyboardShortcuts: 'Keyboard Shortcuts', - reportIssue: 'Report an issue', - aboutBusyMax: 'About BusyMax', - todayShortcut: 'Shift+T', - dayShortcut: '1', - sidebarShortcut: 'F9', - createEventShortcut: 'E', - settingsShortcut: 'Ctrl+Alt+S', - keyboardShortcutsShortcut: 'Ctrl+Alt+K', - ), - ); - await service.setSidebarWidth(300); - await service.setTextDirection(TextDirection.rtl); - await session.setOnboardingControls( - visible: true, - canGoBack: false, - canContinue: true, - backLabel: 'Back', - continueLabel: 'Continue', - ); - await service.setModalBarrierDepth(2); - await service.setTheme( - const BusyMaxHeaderBarTheme( - preferDark: true, - highContrast: false, - windowBackgroundColor: Color(0xFF18181B), - backgroundColor: Color(0xFF1D1D20), - sidebarBackgroundColor: Color(0xFF2E2E32), - foregroundColor: Color(0xFFFFFFFF), - sidebarBorderColor: Color.fromRGBO(0, 0, 6, 0.75), - dialogBackgroundColor: Color(0xFF36363A), - dialogOutlineColor: Color.fromRGBO(255, 255, 255, 0.07), - modalBarrierColor: Color.fromRGBO(0, 0, 0, 0.32), - tooltip: BusyMaxHeaderBarTooltipTheme( - backgroundColor: Color.fromRGBO(0, 0, 0, 0.8), - foregroundColor: Color(0xFFFFFFFF), - borderColor: Color.fromRGBO(255, 255, 255, 0.1), - borderRadius: 8, - fontSize: 14, - horizontalPadding: 10, - verticalPadding: 6, - minimumHeight: 30, - ), - ), - ); - - expect(service.isAvailable, isTrue); - expect( - calls.map((call) => call.method), - containsAllInOrder([ - 'initialize', - 'setLocalizedLabels', - 'setSidebarWidth', - 'setTextDirection', - 'setOnboardingControls', - 'setModalBarrierState', - 'setTheme', - ]), - ); - expect( - calls - .singleWhere((call) => call.method == 'setModalBarrierState') - .arguments, - {'visible': true, 'shadeDepth': 2}, - ); - expect(calls[1].arguments, containsPair('today', 'Today')); - expect(calls[1].arguments, containsPair('year', 'Year')); - expect(calls[1].arguments, containsPair('create', 'Create')); - expect(calls[1].arguments, containsPair('createEvent', 'Event')); - expect(calls[1].arguments, containsPair('createTask', 'Task')); - expect(calls[1].arguments, containsPair('menu', 'Menu')); - expect( - calls[1].arguments, - containsPair('showSidebarPanel', 'Show sidebar panel'), - ); - expect( - calls[1].arguments, - containsPair('hideSidebarPanel', 'Hide sidebar panel'), - ); - expect(calls[1].arguments, containsPair('back', 'Back')); - expect(calls[1].arguments, containsPair('settings', 'Settings')); - expect( - calls[1].arguments, - containsPair('keyboardShortcuts', 'Keyboard Shortcuts'), - ); - expect(calls[1].arguments, containsPair('reportIssue', 'Report an issue')); - expect(calls[1].arguments, containsPair('aboutBusyMax', 'About BusyMax')); - expect(calls[1].arguments, containsPair('todayShortcut', 'Shift+T')); - expect(calls[1].arguments, containsPair('dayShortcut', '1')); - expect(calls[1].arguments, containsPair('sidebarShortcut', 'F9')); - expect(calls[1].arguments, containsPair('createEventShortcut', 'E')); - expect(calls[1].arguments, containsPair('settingsShortcut', 'Ctrl+Alt+S')); - expect( - calls[1].arguments, - containsPair('keyboardShortcutsShortcut', 'Ctrl+Alt+K'), - ); - expect(calls[2].arguments, 300); - expect(calls[3].arguments, 'rtl'); - expect(calls[4].arguments, containsPair('visible', true)); - expect(calls[4].arguments, containsPair('canContinue', true)); - expect(calls[4].arguments, containsPair('continueLabel', 'Continue')); - expect( - calls[4].arguments, - containsPair('contentWidth', busyMaxOnboardingContentMaxWidth), - ); - expect(calls[5].arguments, {'visible': true, 'shadeDepth': 2}); - expect( - calls.last.arguments, - equals({ - 'preferDark': true, - 'highContrast': false, - 'windowBackgroundColor': '#18181B', - 'backgroundColor': '#1D1D20', - 'sidebarBackgroundColor': '#2E2E32', - 'foregroundColor': '#FFFFFF', - 'sidebarBorderColor': 'rgba(0,0,6,0.75)', - 'dialogBackgroundColor': '#36363A', - 'dialogOutlineColor': 'rgba(255,255,255,0.07)', - 'modalBarrierColor': 'rgba(0,0,0,0.32)', - 'tooltip': { - 'backgroundColor': 'rgba(0,0,0,0.80)', - 'foregroundColor': '#FFFFFF', - 'borderColor': 'rgba(255,255,255,0.10)', - 'borderRadius': 8.0, - 'fontSize': 14.0, - 'horizontalPadding': 10.0, - 'verticalPadding': 6.0, - 'minimumHeight': 30.0, - }, - }), - ); - }); - - test('serializes CSS colors for native headerbar', () { - expect(busyMaxCssColor(const Color(0xFF1D1D20)), '#1D1D20'); - expect( - busyMaxCssColor(const Color.fromRGBO(255, 255, 255, 0.08)), - 'rgba(255,255,255,0.08)', - ); - expect( - busyMaxCssColor(const Color.fromRGBO(0, 0, 6, 0.38)), - 'rgba(0,0,6,0.38)', - ); - }); - - test('native bridge failures degrade without blocking Flutter UI', () async { - const channel = MethodChannel('busymax_test/headerbar_platform_failure'); - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(channel, (call) async { - if (call.method == 'initialize') { - return true; - } - throw PlatformException(code: 'native_failure'); - }); - addTearDown(() { - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(channel, null); - }); - final service = LinuxHeaderBarService(channel: channel, isLinux: true); - addTearDown(service.dispose); - - await service.initialize(); - expect(service.isAvailable, isTrue); - - await service.setModalBarrierVisible(true); - expect(service.isAvailable, isFalse); - }); - - test('modal depth compatibility preserves visual shade depth', () async { - const channel = MethodChannel('busymax_test/headerbar_modal_visibility'); - final calls = []; - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(channel, (call) async { - calls.add(call); - return call.method == 'initialize' ? true : null; - }); - addTearDown(() { - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(channel, null); - }); - final service = LinuxHeaderBarService(channel: channel, isLinux: true); - addTearDown(service.dispose); - - await service.initialize(); - await service.setModalBarrierDepth(1); - await service.setModalBarrierDepth(2); - await service.setModalBarrierDepth(1); - await service.setModalBarrierDepth(0); - - expect( - calls - .where((call) => call.method == 'setModalBarrierState') - .map((call) => call.arguments), - [ - {'visible': true, 'shadeDepth': 1}, - {'visible': true, 'shadeDepth': 2}, - {'visible': true, 'shadeDepth': 1}, - {'visible': false, 'shadeDepth': 0}, - ], - ); - }); - - test( - 'sends complete header state atomically and diffs equal state', - () async { - const channel = MethodChannel('busymax_test/headerbar_atomic_state'); - final calls = []; - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(channel, (call) async { - calls.add(call); - return call.method == 'initialize' ? true : null; - }); - addTearDown(() { - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(channel, null); - }); - - final service = LinuxHeaderBarService(channel: channel, isLinux: true); - addTearDown(service.dispose); - final session = service.claimSession(); - addTearDown(session.dispose); - const state = BusyMaxHeaderBarState( - title: 'July 2026', - viewMode: ScheduleViewMode.month, - canRefresh: true, - canCreateEvent: false, - canCreateTask: true, - searchActive: true, - searchQuery: 'planning', - canShowSidebar: false, - sidebarVisible: false, - navigationVisible: true, - scheduleControlsVisible: true, - backVisible: false, - ); - - await service.initialize(); - await session.updateState(state); - await session.updateState(state.copyWith()); - await session.updateState(state.copyWith(title: 'August 2026')); - - expect(calls.map((call) => call.method), [ - 'initialize', - 'setState', - 'setState', - ]); - expect(calls[1].arguments, { - 'schemaVersion': BusyMaxHeaderBarState.schemaVersion, - 'title': 'July 2026', - 'viewMode': 'month', - 'canRefresh': true, - 'canCreateEvent': false, - 'canCreateTask': true, - 'searchActive': true, - 'searchQuery': 'planning', - 'canShowSidebar': false, - 'sidebarVisible': false, - 'navigationVisible': true, - 'scheduleControlsVisible': true, - 'backVisible': false, - }); - expect(calls[2].arguments, containsPair('title', 'August 2026')); - expect(state.canCreate, isTrue); - expect(BusyMaxHeaderBarState.schemaVersion, 3); - }, - ); - - test( - 'only the active route session can open the native Create menu', - () async { - const channel = MethodChannel('busymax_test/headerbar_create_menu'); - final calls = []; - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(channel, (call) async { - calls.add(call); - if (call.method == 'initialize' || - call.method == 'showCreateMenu') { - return true; - } - return null; - }); - addTearDown(() { - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(channel, null); - }); - - final service = LinuxHeaderBarService(channel: channel, isLinux: true); - addTearDown(service.dispose); - final scheduleSession = service.claimSession(); - addTearDown(scheduleSession.dispose); - final coveredSession = service.claimSession(); - addTearDown(coveredSession.dispose); - - expect(await scheduleSession.showCreateMenu(), isFalse); - expect(await coveredSession.showCreateMenu(), isTrue); - expect( - calls.where((call) => call.method == 'showCreateMenu'), - hasLength(1), - ); - }, - ); - - test('shares in-flight initialization before applying state', () async { - const channel = MethodChannel('busymax_test/headerbar_shared_initialize'); - final calls = []; - final initialization = Completer(); - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(channel, (call) async { - calls.add(call); - if (call.method == 'initialize') { - return initialization.future; - } - return null; - }); - addTearDown(() { - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(channel, null); - }); - - final service = LinuxHeaderBarService(channel: channel, isLinux: true); - addTearDown(service.dispose); - final session = service.claimSession(); - addTearDown(session.dispose); - - final firstInitialization = service.initialize(); - final stateUpdate = session.updateState(_settingsHeaderState); - await pumpEventQueue(times: 1); - - expect(calls.where((call) => call.method == 'initialize'), hasLength(1)); - expect(calls.where((call) => call.method == 'setState'), isEmpty); - - initialization.complete(true); - await Future.wait([firstInitialization, stateUpdate]); - - expect(calls.where((call) => call.method == 'setState'), hasLength(1)); - expect(service.isAvailable, isTrue); - }); - - test('only the active route session can publish header state', () async { - const channel = MethodChannel('busymax_test/headerbar_route_ownership'); - final calls = []; - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(channel, (call) async { - calls.add(call); - return call.method == 'initialize' ? true : null; - }); - addTearDown(() { - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(channel, null); - }); - - final service = LinuxHeaderBarService(channel: channel, isLinux: true); - addTearDown(service.dispose); - final scheduleSession = service.claimSession(); - addTearDown(scheduleSession.dispose); - await scheduleSession.updateState(_scheduleHeaderState); - - final settingsSession = service.claimSession(); - addTearDown(settingsSession.dispose); - await settingsSession.updateState(_settingsHeaderState); - - await scheduleSession.updateState( - _scheduleHeaderState.copyWith(title: 'Stale schedule update'), - force: true, - ); - scheduleSession.dispose(); - await settingsSession.updateState(_settingsHeaderState.copyWith()); - await settingsSession.updateState( - _settingsHeaderState.copyWith(title: 'Preferences'), - ); - - final stateCalls = calls - .where((call) => call.method == 'setState') - .toList(); - expect(stateCalls, hasLength(3)); - expect(stateCalls[0].arguments, containsPair('title', 'July 2026')); - expect(stateCalls[1].arguments, containsPair('title', 'Settings')); - expect(stateCalls[2].arguments, containsPair('title', 'Preferences')); - expect( - stateCalls.skip(1).map((call) => call.arguments), - everyElement(containsPair('backVisible', true)), - ); - }); - - test('closing the active session restores the covered route', () async { - const channel = MethodChannel('busymax_test/headerbar_route_restore'); - final calls = []; - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(channel, (call) async { - calls.add(call); - return call.method == 'initialize' ? true : null; - }); - addTearDown(() { - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(channel, null); - }); - - final service = LinuxHeaderBarService(channel: channel, isLinux: true); - addTearDown(service.dispose); - final scheduleSession = service.claimSession(); - addTearDown(scheduleSession.dispose); - await scheduleSession.updateState(_scheduleHeaderState); - - final settingsSession = service.claimSession(); - addTearDown(settingsSession.dispose); - await settingsSession.updateState(_settingsHeaderState); - await scheduleSession.updateState( - _scheduleHeaderState.copyWith( - title: 'Updated schedule', - searchActive: true, - searchQuery: 'meeting', - ), - ); - - expect(scheduleSession.isAvailable, isTrue); - settingsSession.dispose(); - await pumpEventQueue(); - - final stateCalls = calls - .where((call) => call.method == 'setState') - .toList(); - expect(stateCalls, hasLength(3)); - expect( - stateCalls.last.arguments, - containsPair('title', 'Updated schedule'), - ); - expect(stateCalls.last.arguments, containsPair('backVisible', false)); - expect(stateCalls.last.arguments, containsPair('searchActive', true)); - expect(stateCalls.last.arguments, containsPair('searchQuery', 'meeting')); - expect( - stateCalls.last.arguments, - containsPair('scheduleControlsVisible', true), - ); - }); - - test('native actions belong exclusively to the active session', () async { - final service = LinuxHeaderBarService( - channel: const MethodChannel('busymax_test/headerbar_owned_actions'), - isLinux: false, - ); - addTearDown(service.dispose); - final scheduleSession = service.claimSession(); - addTearDown(scheduleSession.dispose); - final scheduleActions = []; - final scheduleSubscription = scheduleSession.actions.listen( - scheduleActions.add, - ); - addTearDown(scheduleSubscription.cancel); - - final settingsSession = service.claimSession(); - addTearDown(settingsSession.dispose); - final settingsActions = []; - final settingsSubscription = settingsSession.actions.listen( - settingsActions.add, - ); - addTearDown(settingsSubscription.cancel); - - await service.handleNativeMethodCall(const MethodCall('aboutBusyMax')); - await pumpEventQueue(); - - expect(scheduleActions, isEmpty); - expect(settingsActions, [BusyMaxHeaderBarAction.aboutBusyMax]); - - settingsSession.dispose(); - await service.handleNativeMethodCall(const MethodCall('back')); - await pumpEventQueue(); - - expect(scheduleActions, [BusyMaxHeaderBarAction.back]); - expect(settingsActions, [BusyMaxHeaderBarAction.aboutBusyMax]); - }); - - test( - 'native search events belong exclusively to the active session', - () async { - final service = LinuxHeaderBarService( - channel: const MethodChannel('busymax_test/headerbar_owned_search'), - isLinux: false, - ); - addTearDown(service.dispose); - final coveredSession = service.claimSession(); - addTearDown(coveredSession.dispose); - final coveredEvents = []; - final coveredSubscription = coveredSession.searchEvents.listen( - coveredEvents.add, - ); - addTearDown(coveredSubscription.cancel); - - final activeSession = service.claimSession(); - addTearDown(activeSession.dispose); - final activeEvents = []; - final activeSubscription = activeSession.searchEvents.listen( - activeEvents.add, - ); - addTearDown(activeSubscription.cancel); - - await service.handleNativeMethodCall( - const MethodCall('searchQueryChanged', 'planning'), - ); - await service.handleNativeMethodCall( - const MethodCall('searchFocusChanged', true), - ); - await service.handleNativeMethodCall(const MethodCall('searchCleared')); - await service.handleNativeMethodCall( - const MethodCall('searchEscapePressed'), - ); - await service.handleNativeMethodCall( - const MethodCall('searchQueryChanged', 42), - ); - await service.handleNativeMethodCall( - const MethodCall('searchFocusChanged', 'yes'), - ); - await pumpEventQueue(); - - expect(coveredEvents, isEmpty); - expect(activeEvents, hasLength(4)); - expect( - (activeEvents[0] as BusyMaxHeaderBarSearchQueryChanged).query, - 'planning', - ); - expect( - (activeEvents[1] as BusyMaxHeaderBarSearchFocusChanged).focused, - isTrue, - ); - expect(activeEvents[2], isA()); - expect(activeEvents[3], isA()); - }, - ); - - test('only the active route session can focus native search', () async { - const channel = MethodChannel('busymax_test/headerbar_focus_search'); - final calls = []; - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(channel, (call) async { - calls.add(call); - if (call.method == 'initialize' || - call.method == 'focusSearch' || - call.method == 'focusContent') { - return true; - } - return null; - }); - addTearDown(() { - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(channel, null); - }); - - final service = LinuxHeaderBarService(channel: channel, isLinux: true); - addTearDown(service.dispose); - final coveredSession = service.claimSession(); - addTearDown(coveredSession.dispose); - final activeSession = service.claimSession(); - addTearDown(activeSession.dispose); - - expect(await coveredSession.focusSearch(), isFalse); - expect(await activeSession.focusSearch(), isTrue); - expect(calls.where((call) => call.method == 'focusSearch'), hasLength(1)); - expect(await coveredSession.focusContent(), isFalse); - expect(await activeSession.focusContent(), isTrue); - expect(calls.where((call) => call.method == 'focusContent'), hasLength(1)); - }); - - test('native search uses responsive GTK geometry with a scoped Yaru shim', () { - final source = File('linux/runner/my_application.cc').readAsStringSync(); - final geometryCssStart = source.indexOf( - 'g_autofree gchar* native_search_geometry_css =', - ); - final geometryCssEnd = source.indexOf( - 'const gchar* tooltip_background =', - geometryCssStart, - ); - - expect(source, contains('gtk_search_entry_new()')); - expect( - source, - contains( - 'gtk_widget_set_halign(self->header_title_box, GTK_ALIGN_FILL);', - ), - ); - expect( - source, - contains( - 'gtk_stack_set_hhomogeneous(GTK_STACK(self->header_title_stack), FALSE)', - ), - ); - expect( - source, - contains('gtk_entry_set_max_width_chars(GTK_ENTRY(self->search_entry),'), - ); - expect( - source, - contains('gtk_widget_set_halign(self->search_entry, GTK_ALIGN_FILL);'), - ); - expect( - source, - contains('gtk_widget_set_hexpand(self->search_entry, TRUE);'), - ); - expect( - source, - isNot(contains('gtk_widget_set_size_request(self->search_entry')), - ); - expect(source, contains('"busymax-header-search-entry"')); - expect( - source, - contains( - 'gtk_style_context_add_class(gtk_widget_get_style_context(self->search_entry),', - ), - ); - expect(source, contains('kHeaderSearchEntryStyleClass')); - final visibility = RegExp( - r'static void update_header_control_visibility[\s\S]*?' - r'(?=^static void set_header_schedule_controls_visible)', - multiLine: true, - ).firstMatch(source)?.group(0); - expect(visibility, isNotNull); - for (final control in [ - 'today_button', - 'previous_button', - 'next_button', - 'header_view_box', - 'create_button', - 'refresh_button', - ]) { - expect( - visibility, - contains( - RegExp( - 'set_widget_visible\\(self->$control,[\\s\\S]*?search_inactive\\);', - ), - ), - ); - } - expect( - visibility, - contains( - 'set_widget_visible(self->search_button, schedule_controls_visible)', - ), - ); - expect( - visibility, - contains( - RegExp( - r'set_widget_visible\(self->settings_menu_button,\s*' - r'schedule_controls_visible \|\| self->header_back_visible\);', - ), - ), - ); - - final createOffset = source.indexOf( - 'gtk_box_pack_start(GTK_BOX(end_box), self->create_button', - ); - final refreshOffset = source.indexOf( - 'gtk_box_pack_start(GTK_BOX(end_box), self->refresh_button', - ); - final searchOffset = source.indexOf( - 'gtk_box_pack_start(GTK_BOX(end_box), self->search_button', - ); - final menuOffset = source.indexOf( - 'gtk_box_pack_start(GTK_BOX(end_box), self->settings_menu_button', - ); - expect(createOffset, isNonNegative); - expect(refreshOffset, greaterThan(createOffset)); - expect(searchOffset, greaterThan(refreshOffset)); - expect(menuOffset, greaterThan(searchOffset)); - - expect(geometryCssStart, isNonNegative); - expect(geometryCssEnd, greaterThan(geometryCssStart)); - final geometryCss = source.substring(geometryCssStart, geometryCssEnd); - expect(geometryCss, contains('use_legacy_yaru_compatibility')); - expect(geometryCss, contains('"entry.search.%s {"')); - expect(geometryCss, contains('"border-radius: 9px;"')); - expect(geometryCss, contains('kHeaderSearchEntryStyleClass')); - expect(geometryCss, isNot(contains('background'))); - expect(geometryCss, isNot(contains('border-color'))); - expect(geometryCss, isNot(contains('"border:'))); - expect(geometryCss, isNot(contains('box-shadow'))); - expect(geometryCss, isNot(contains('padding'))); - expect(geometryCss, isNot(contains('min-height'))); - expect(geometryCss, isNot(contains('#'))); - expect(geometryCss, isNot(contains('rgba('))); - expect( - source, - contains( - 'const gboolean use_legacy_yaru_compatibility =\n' - ' !self->header_bar_high_contrast &&', - ), - ); - }); - - test('focused native search text wins delayed Dart snapshots', () { - final source = File('linux/runner/my_application.cc').readAsStringSync(); - final stateSetter = RegExp( - r'static void set_header_search_state[\s\S]*?' - r'(?=^static void set_header_view_mode)', - multiLine: true, - ).firstMatch(source)?.group(0); - - expect(source, contains('enum class HeaderSearchQueryUpdateDisposition')); - expect( - source, - contains('resolve_header_search_query_update(false, true, true)'), - ); - expect( - source, - contains('resolve_header_search_query_update(false, true, false)'), - ); - expect( - source, - contains('HeaderSearchQueryUpdateDisposition::kPreserveNativeText'), - ); - expect( - source, - contains( - 'A newer focused native edit must survive a delayed Dart snapshot', - ), - ); - expect(source, contains('native_entry_has_authority')); - expect(source, isNot(contains('echoes_last_native_query'))); - - expect(stateSetter, isNotNull); - final queryOffset = stateSetter!.indexOf( - 'set_header_search_query(self, query, effective_active);', - ); - final activationOffset = stateSetter.indexOf( - 'self->header_search_active = effective_active;', - ); - expect(queryOffset, isNonNegative); - expect(activationOffset, greaterThan(queryOffset)); - }); - - test('native header menus delegate row focus modality to GTK', () { - final source = File('linux/runner/my_application.cc').readAsStringSync(); - - expect(source, contains('gtk_menu_button_new()')); - expect(source, contains('gtk_menu_button_set_menu_model')); - expect(source, contains('g_menu_item_set_action_and_target')); - expect(source, contains('g_simple_action_new_stateful')); - expect(source, contains('GTK_STYLE_CLASS_FLAT')); - expect(source, isNot(contains('GTK_STYLE_CLASS_SUGGESTED_ACTION'))); - expect(source, contains('kHeaderOnboardingTextButtonStyleClass')); - expect( - source, - isNot(contains('button.busymax-header-view-mode-button:focus {"')), - ); - expect(source, isNot(contains('"box-shadow: inset 0 0 0 2px %s;"'))); - expect(source, isNot(contains('outline-style: none'))); - expect(source, isNot(contains('transition: none'))); - expect(source, isNot(contains('popover.busymax-header-popover'))); - expect(source, isNot(contains('kNativePopoverStyleClass'))); - expect(source, isNot(contains('style_header_menu_popover'))); - expect( - source, - contains( - 'gtk_menu_button_set_use_popover(GTK_MENU_BUTTON(button), FALSE)', - ), - ); - expect(source, contains('gtk_menu_button_get_popup')); - expect(source, contains('GTK_IS_MENU(menu)')); - expect(source, contains('gtk_menu_shell_deactivate')); - expect(source, isNot(contains('gtk_popover_'))); - expect(source, isNot(contains('modelbutton:hover'))); - expect(source, contains('tooltip.background')); - expect(source, contains('fl_lookup_map_arg(args, "tooltip")')); - expect( - source, - contains('fl_lookup_string_arg(tooltip, "backgroundColor")'), - ); - expect(source, isNot(contains('button.busymax-header-popover-row'))); - expect(source, isNot(contains('busymax-keyboard-focus'))); - expect(source, isNot(contains('gtk_window_get_focus_visible'))); - expect(source, isNot(contains('configure_header_popover_row'))); - expect(source, isNot(contains('header_popover_row_key_press_cb'))); - expect(source, isNot(contains('header_popover_row_button_press_cb'))); - expect(source, isNot(contains('gtk_widget_set_can_focus(row, FALSE)'))); - }); - - test('native Today control is icon-only and accessible', () { - final source = File('linux/runner/my_application.cc').readAsStringSync(); - - expect(source, contains('resolve_today_icon_name()')); - expect(source, contains('"today-symbolic"')); - expect(source, contains('return "x-office-calendar-symbolic";')); - expect( - source, - contains( - 'create_header_icon_button(resolve_today_icon_name(),\n' - ' "")', - ), - ); - expect( - source, - isNot( - contains( - 'set_button_label_and_tooltip(self->today_button, today, nullptr)', - ), - ), - ); - expect( - source, - contains('set_widget_accessible_name(self->today_button, today);'), - ); - expect( - source, - contains( - 'set_widget_tooltip_with_shortcut(self->today_button, today, today_shortcut);', - ), - ); - }); - - test('native sidebar availability is separate from expanded state', () { - final source = File('linux/runner/my_application.cc').readAsStringSync(); - - expect(source, contains('gboolean header_bar_can_show_sidebar;')); - expect(source, contains('"canShowSidebar"')); - expect( - source, - contains( - 'schedule_controls_visible &&\n' - ' self->header_bar_can_show_sidebar', - ), - ); - expect(source, contains('strcmp(method, "setState") == 0')); - }); - - test('native headerbar methods emit Dart actions', () async { - final service = LinuxHeaderBarService( - channel: const MethodChannel('busymax_test/headerbar_actions'), - isLinux: false, - ); - addTearDown(service.dispose); - final session = service.claimSession(); - addTearDown(session.dispose); - - final nextAction = session.actions.take(7).toList(); - await service.handleNativeMethodCall(const MethodCall('createEvent')); - await service.handleNativeMethodCall(const MethodCall('createTask')); - await service.handleNativeMethodCall(const MethodCall('continueSetup')); - await service.handleNativeMethodCall(const MethodCall('settings')); - await service.handleNativeMethodCall(const MethodCall('keyboardShortcuts')); - await service.handleNativeMethodCall(const MethodCall('reportIssue')); - await service.handleNativeMethodCall(const MethodCall('aboutBusyMax')); - - expect(await nextAction, [ - BusyMaxHeaderBarAction.createEvent, - BusyMaxHeaderBarAction.createTask, - BusyMaxHeaderBarAction.continueSetup, - BusyMaxHeaderBarAction.settings, - BusyMaxHeaderBarAction.keyboardShortcuts, - BusyMaxHeaderBarAction.reportIssue, - BusyMaxHeaderBarAction.aboutBusyMax, - ]); - }); - - test( - 'can force native onboarding controls cleanup past cached state', - () async { - const channel = MethodChannel('busymax_test/headerbar_onboarding_force'); - final calls = []; - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(channel, (call) async { - calls.add(call); - if (call.method == 'initialize') { - return true; - } - return null; - }); - addTearDown(() { - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(channel, null); - }); - - final service = LinuxHeaderBarService(channel: channel, isLinux: true); - addTearDown(service.dispose); - final session = service.claimSession(); - addTearDown(session.dispose); - - await service.initialize(); - await session.setOnboardingControls( - visible: false, - canGoBack: false, - canContinue: false, - backLabel: '', - continueLabel: '', - ); - await session.setOnboardingControls( - visible: false, - canGoBack: false, - canContinue: false, - backLabel: '', - continueLabel: '', - ); - await session.setOnboardingControls( - visible: false, - canGoBack: false, - canContinue: false, - backLabel: '', - continueLabel: '', - force: true, - ); - - final onboardingCalls = calls - .where((call) => call.method == 'setOnboardingControls') - .toList(); - expect(onboardingCalls, hasLength(2)); - expect(onboardingCalls.last.arguments, containsPair('visible', false)); - }, - ); - - test('missing native channel disables service without throwing', () async { - final service = LinuxHeaderBarService( - channel: const MethodChannel('busymax_test/headerbar_missing'), - isLinux: true, - ); - addTearDown(service.dispose); - - await service.initialize(); - - expect(service.isAvailable, isFalse); - }); -} diff --git a/test/platform/windows/windows_desktop_runtime_test.dart b/test/platform/windows/windows_desktop_runtime_test.dart index 358a9ef9..4daecccd 100644 --- a/test/platform/windows/windows_desktop_runtime_test.dart +++ b/test/platform/windows/windows_desktop_runtime_test.dart @@ -7,6 +7,10 @@ import 'package:busymax/src/ui/windows/windows_desktop_runtime.dart'; import 'package:busymax/src/l10n/time_format_scope.dart'; import 'package:busymax/src/features/tray/domain/tray_presentation.dart'; import 'package:busymax/src/features/connectivity/network_connectivity_service.dart'; +import 'package:busymax/src/features/accounts/data/accounts_repository.dart'; +import 'package:busymax/src/features/sync/all_accounts_sync_scheduler.dart'; +import 'package:busymax/src/platform/windows/windows_tray_service.dart'; +import 'package:busymax/src/providers/busy_provider.dart'; import 'package:fluent_ui/fluent_ui.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -214,6 +218,44 @@ void main() { expect(window.hideOnClose, isFalse); await tester.pumpWidget(const SizedBox()); }); + + testWidgets('tray sync continues after an account failure and refreshes', ( + tester, + ) async { + final synced = []; + final failures = []; + final failure = StateError('first account failed'); + final scheduler = AllAccountsSyncScheduler( + listSyncEligibleAccounts: () async => [_account('a'), _account('b')], + syncAccount: (accountId) async { + synced.add(accountId); + if (accountId == 'a') throw failure; + }, + onSyncFailure: (error) async => failures.add(error), + interval: Duration.zero, + ); + addTearDown(scheduler.dispose); + final tray = _Tray(); + await _pumpRuntime( + tester, + _Window(), + tray, + syncScheduler: scheduler, + interactiveRunner: () async => + throw StateError('interactive runner used'), + ); + final beforeRefresh = tray.refreshes; + + await tester + .state(find.byType(WindowsDesktopRuntime)) + .handleTrayCommand(WindowsTrayCommand.synchronize); + + expect(synced, ['a', 'b']); + expect(failures, [same(failure)]); + expect(tray.refreshes, beforeRefresh + 1); + expect(tester.takeException(), isNull); + await tester.pumpWidget(const SizedBox()); + }); } Future _pumpRuntime( @@ -222,6 +264,8 @@ Future _pumpRuntime( _Tray tray, { bool minimized = false, bool showTray = true, + AllAccountsSyncScheduler? syncScheduler, + Future Function()? interactiveRunner, }) async { final settings = AppSettings.defaults().copyWith( showTrayIcon: showTray, @@ -233,6 +277,10 @@ Future _pumpRuntime( MemorySettingsStore(settings.toJson()), ), desktopWindowServiceProvider.overrideWithValue(window), + if (syncScheduler != null) + syncSchedulerProvider.overrideWithValue(syncScheduler), + if (interactiveRunner != null) + allAccountsSyncRunnerProvider.overrideWithValue(interactiveRunner), ], ); addTearDown(container.dispose); @@ -296,6 +344,7 @@ class _Tray implements DesktopTrayService { final List calls; bool canStart = true; Completer? startBarrier; + int refreshes = 0; @override bool isAvailable = false; @override @@ -305,10 +354,21 @@ class _Tray implements DesktopTrayService { } @override - Future refresh() async {} + Future refresh() async { + refreshes++; + } + @override Future stop() async { calls.add('stop'); isAvailable = false; } } + +AccountEntity _account(String id) => AccountEntity( + id: id, + provider: BusyProvider.google, + authority: 'https://accounts.google.com', + providerAccountId: id, + authState: accountAuthStateSignedIn, +); diff --git a/test/platform/windows/windows_diagnostics_dialog_test.dart b/test/platform/windows/windows_diagnostics_dialog_test.dart index d7325373..cbd8980a 100644 --- a/test/platform/windows/windows_diagnostics_dialog_test.dart +++ b/test/platform/windows/windows_diagnostics_dialog_test.dart @@ -1,5 +1,7 @@ import 'package:busymax/l10n/generated/app_localizations.dart'; import 'package:busymax/src/app/app_bootstrap.dart'; +import 'package:busymax/src/core/auth/oauth_models.dart'; +import 'package:busymax/src/core/http/request_dispatch_exception.dart'; import 'package:busymax/src/dav/ical/ical_document.dart'; import 'package:busymax/src/dav/mutation/dav_conflict_repository.dart'; import 'package:busymax/src/dav/mutation/dav_mutation_patch.dart'; @@ -7,6 +9,7 @@ import 'package:busymax/src/dav/mutation/dav_pending_operations.dart'; import 'package:busymax/src/dav/storage/dav_object_repository.dart'; import 'package:busymax/src/db/app_database.dart'; import 'package:busymax/src/features/sync/account_sync_operations.dart'; +import 'package:busymax/src/features/sync/pending_op_resolution_service.dart'; import 'package:busymax/src/providers/busy_provider.dart'; import 'package:busymax/src/ui/windows/windows_diagnostics_dialog.dart'; import 'package:drift/drift.dart' hide isNotNull, isNull; @@ -103,6 +106,84 @@ void main() { }, ); + testWidgets('Windows retry stays blocked when the account needs reconnect', ( + tester, + ) async { + tester.view.physicalSize = const Size(1200, 900); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + final database = AppDatabase(NativeDatabase.memory()); + addTearDown(database.close); + final sync = _RecordingSyncOperations(); + await _seedAccount( + database, + id: 'blocked-google', + provider: BusyProvider.google, + displayName: 'Blocked Google', + authState: 'reauth_required', + ); + await _seedBlockedOperation( + database, + id: 'retry-ineligible', + accountId: 'blocked-google', + ); + final before = await database.pendingOpsDao.getOp('retry-ineligible'); + + await _pumpBlockedOperations(tester, database, sync: sync); + final menu = _operationMenu(tester, 'retry-ineligible'); + final retry = menu.items.whereType().singleWhere( + (item) => (item.text as Text).data == 'Retry', + ); + retry.onPressed!(); + await tester.pumpAndSettle(); + + final after = await database.pendingOpsDao.getOp('retry-ineligible'); + expect(after?.state, before?.state); + expect(after?.nextAttemptAtUtc, before?.nextAttemptAtUtc); + expect(sync.calls, isEmpty); + expect(sync.taskCalls, isEmpty); + expect(find.text('This account needs to be reconnected.'), findsOneWidget); + expect(find.text('Retry completed.'), findsNothing); + await _disposeBlockedOperations(tester); + }); + + testWidgets('Windows discard failure uses a controlled sync message', ( + tester, + ) async { + tester.view.physicalSize = const Size(1200, 900); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + final database = AppDatabase(NativeDatabase.memory()); + addTearDown(database.close); + await _seedAccount( + database, + id: 'account', + provider: BusyProvider.google, + displayName: 'Google', + ); + await _seedBlockedOperation(database, id: 'discard-failure'); + final service = _ThrowingPendingOpResolutionService(database); + + await _pumpBlockedOperations(tester, database, resolutionService: service); + final menu = _operationMenu(tester, 'discard-failure'); + final discard = menu.items.whereType().singleWhere( + (item) => (item.text as Text).data == 'Discard', + ); + discard.onPressed!(); + await tester.pumpAndSettle(); + await tester.tap(find.text('Discard').last); + await tester.pumpAndSettle(); + + expect(find.text('This account needs to be reconnected.'), findsOneWidget); + expect(find.textContaining('KnownUnsentRequestException'), findsNothing); + expect(find.textContaining('OAuthRefreshException'), findsNothing); + expect(find.textContaining('provider-secret'), findsNothing); + expect(await database.pendingOpsDao.getOp('discard-failure'), isNotNull); + await _disposeBlockedOperations(tester); + }); + for (final testCase in const [ ( resolution: DavConflictResolution.keepServer, @@ -340,6 +421,7 @@ Future _seedAccount( required String id, required BusyProvider provider, required String displayName, + String authState = 'signed_in', }) { const now = '2026-09-11T12:00:00.000Z'; return database @@ -356,13 +438,83 @@ Future _seedAccount( ? 'nextcloud_app_password' : 'oauth', displayName: Value(displayName), - authState: const Value('signed_in'), + authState: Value(authState), createdAtUtc: now, updatedAtUtc: now, ), ); } +Future _seedBlockedOperation( + AppDatabase database, { + required String id, + String accountId = 'account', +}) { + const now = '2026-09-11T12:00:00.000Z'; + return database + .into(database.pendingOps) + .insert( + PendingOpsCompanion.insert( + id: id, + accountId: accountId, + provider: const Value('google'), + entityType: 'task', + operation: 'patch_task', + taskListId: const Value('list'), + taskId: const Value('task'), + requestJson: '{}', + state: const Value('failed'), + retryClassification: const Value('permanent'), + nextAttemptAtUtc: const Value('9999-12-31T23:59:59.999Z'), + lastErrorCode: const Value('provider_rejected'), + createdAtUtc: now, + updatedAtUtc: now, + ), + ); +} + +Future _pumpBlockedOperations( + WidgetTester tester, + AppDatabase database, { + _RecordingSyncOperations? sync, + PendingOpResolutionService? resolutionService, +}) async { + await tester.pumpWidget( + ProviderScope( + overrides: [ + databaseProvider.overrideWithValue(database), + if (sync != null) accountSyncOperationsProvider.overrideWithValue(sync), + if (resolutionService != null) + pendingOpResolutionServiceForAccountProvider.overrideWith( + (ref, accountId) => resolutionService, + ), + ], + child: const FluentApp( + localizationsDelegates: [AppLocalizations.delegate], + supportedLocales: AppLocalizations.supportedLocales, + home: ScaffoldPage( + content: WindowsBlockedPendingOperations(redactDetails: false), + ), + ), + ), + ); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 300)); +} + +Future _disposeBlockedOperations(WidgetTester tester) async { + await tester.pumpWidget(const SizedBox.shrink()); + await tester.pump(const Duration(milliseconds: 1)); + await tester.pump(const Duration(milliseconds: 1)); +} + +DropDownButton _operationMenu(WidgetTester tester, String operationId) { + final row = find.byKey(ValueKey('windows-blocked-pending-op-$operationId')); + return tester.widget( + find.descendant(of: row, matching: find.byType(DropDownButton)), + ); +} + final class _RecordingSyncOperations implements AccountSyncOperations { final calls = []; final taskCalls = []; @@ -381,6 +533,33 @@ final class _RecordingSyncOperations implements AccountSyncOperations { } } +final class _ThrowingPendingOpResolutionService + extends PendingOpResolutionService { + _ThrowingPendingOpResolutionService(AppDatabase database) + : super( + database: database, + accountId: 'account', + syncTasks: _noSync, + syncCalendar: _noSync, + ); + + @override + Future discard(String opId) async { + throw const KnownUnsentRequestException( + kind: RequestPreDispatchFailureKind.authentication, + cause: OAuthRefreshException( + 'OAuthRefreshFailed', + 'provider-secret', + statusCode: 400, + oauthError: 'invalid_grant', + oauthErrorDescription: 'provider-secret', + ), + ); + } + + static Future _noSync() async {} +} + const _collectionHref = '/remote.php/dav/calendars/alex/work/'; const _eventHref = '${_collectionHref}event.ics'; const _baseline = '''BEGIN:VCALENDAR\r diff --git a/test/platform/windows/windows_task_details_dialog_test.dart b/test/platform/windows/windows_task_details_dialog_test.dart index 3d976169..3344ea25 100644 --- a/test/platform/windows/windows_task_details_dialog_test.dart +++ b/test/platform/windows/windows_task_details_dialog_test.dart @@ -5,6 +5,7 @@ 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/features/tasks/domain/task_capabilities.dart'; +import 'package:busymax/src/features/tasks/domain/task_mutation_result.dart'; import 'package:busymax/src/features/maps/application/external_location_launcher.dart'; import 'package:busymax/src/features/maps/data/location_resolution_repository.dart'; import 'package:busymax/src/features/maps/domain/geographic_point.dart'; @@ -58,6 +59,9 @@ void main() { nowUtc: () => DateTime.utc(2026, 8, 31), ); Object? openError; + final startedMutations = []; + final committedMutations = []; + var dialogPresentAtCommit = false; await tester.pumpWidget( ProviderScope( @@ -93,6 +97,14 @@ void main() { completed: false, allDay: true, ), + onTaskMutationStarted: startedMutations.add, + onTaskMutationCommitted: (result) { + committedMutations.add(result); + dialogPresentAtCommit = find + .byType(ContentDialog) + .evaluate() + .isNotEmpty; + }, ); } on Object catch (error) { openError = error; @@ -161,6 +173,17 @@ void main() { await tester.tap(find.widgetWithText(FilledButton, 'Discard').last); await tester.pumpAndSettle(); expect(tasks.watchedTaskIds.last, 'parent'); + + final delete = find.widgetWithText(Button, 'Delete'); + await tester.ensureVisible(delete); + await tester.tap(delete); + await tester.pumpAndSettle(); + await tester.tap(find.widgetWithText(FilledButton, 'Delete')); + await tester.pumpAndSettle(); + expect(tasks.deletedTaskIds, ['parent']); + expect(startedMutations.single.kind, TaskMutationKind.deleted); + expect(committedMutations.single.kind, TaskMutationKind.deleted); + expect(dialogPresentAtCommit, isTrue); expect(tester.takeException(), isNull); }); @@ -395,6 +418,7 @@ class _TestTasksRepository extends TasksRepository { : super(database: database, accountId: 'account-1'); final createdSubtaskTitles = []; + final deletedTaskIds = []; final watchedTaskIds = []; final _parent = const TaskEntity( accountId: 'account-1', @@ -467,6 +491,11 @@ class _TestTasksRepository extends TasksRepository { ), ); } + + @override + Future deleteTask(String taskListId, String taskId) async { + deletedTaskIds.add(taskId); + } } const _now = '2026-08-31T00:00:00.000Z'; diff --git a/test/smoke_test.dart b/test/smoke_test.dart index adee2348..4bfbbca3 100644 --- a/test/smoke_test.dart +++ b/test/smoke_test.dart @@ -35,7 +35,7 @@ void main() { await tester.pumpAndSettle(); - expect(find.text('Set Up BusyMax'), findsNothing); + expect(find.text('Set Up BusyMax'), findsOneWidget); expect(find.text('Connect accounts'), findsOneWidget); expect(find.text('Add Google account'), findsOneWidget); expect(find.text('Add Microsoft account'), findsOneWidget); diff --git a/test/support/schedule_date_gesture_suite.dart b/test/support/schedule_date_gesture_suite.dart index e97ff2f7..9bb278ba 100644 --- a/test/support/schedule_date_gesture_suite.dart +++ b/test/support/schedule_date_gesture_suite.dart @@ -1,12 +1,111 @@ import 'package:busymax/src/ui/common/schedule/schedule_interactions.dart'; import 'package:flutter/gestures.dart'; -import 'package:flutter/widgets.dart'; +import 'package:flutter/rendering.dart'; import 'package:flutter/services.dart' show LogicalKeyboardKey; +import 'package:flutter/widgets.dart'; import 'package:flutter_test/flutter_test.dart'; import 'schedule_planner_gesture_suite.dart'; void scheduleDateGestureTests(String platform, PlannerHarnessBuilder harness) { + final idleEventCursor = platform == 'Linux' + ? SystemMouseCursors.click + : SystemMouseCursors.basic; + + testWidgets( + '$platform month move cursor crosses dates and restores over an event', + (tester) async { + const device = 1; + tester.view.physicalSize = const Size(1400, 1100); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + final scenario = PlannerGestureScenario( + items: [ + PlannerGestureScenario.event(), + PlannerGestureScenario.event( + id: 'destination-event', + start: DateTime(2026, 1, 13, 9), + end: DateTime(2026, 1, 13, 10), + ), + ], + ); + await tester.pumpWidget(harness(scenario)); + await tester.pumpAndSettle(); + Finder event(String id) => find.byWidgetPredicate( + (widget) => widget is ScheduleEventInteraction && widget.item.id == id, + ); + final sourceEvent = event('drag-event'); + final sourceRect = tester.getRect(sourceEvent); + final source = tester.getCenter(sourceEvent); + final destination = tester.getCenter(event('destination-event')); + final hoverPositions = [ + tester.getCenter( + find.descendant( + of: sourceEvent, + matching: find.textContaining('drag-event'), + ), + ), + Offset(sourceRect.left + 12, sourceRect.center.dy), + ]; + final icons = find.descendant( + of: sourceEvent, + matching: find.byType(Icon), + ); + if (icons.evaluate().isNotEmpty) { + hoverPositions.add(tester.getCenter(icons.first)); + } + final mouse = await tester.createGesture( + pointer: 81, + kind: PointerDeviceKind.mouse, + ); + await mouse.addPointer(location: hoverPositions.first); + await tester.pump(); + for (final position in hoverPositions) { + await mouse.moveTo(position); + await tester.pump(); + expect( + RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(device), + idleEventCursor, + ); + } + expect(scenario.opened, isEmpty); + expect(scenario.edits, isEmpty); + await mouse.moveTo(source); + await mouse.down(source); + await tester.pump(); + expect( + RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(device), + idleEventCursor, + ); + await mouse.moveTo(Offset.lerp(source, destination, .5)!); + await tester.pump(); + expect( + RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(device), + SystemMouseCursors.move, + ); + await mouse.moveTo(destination); + await tester.pump(); + expect( + RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(device), + SystemMouseCursors.move, + ); + + await mouse.up(); + await tester.pump(); + expect( + RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(device), + idleEventCursor, + ); + await tester.pumpAndSettle(); + expect(scenario.edits, hasLength(1)); + expect(scenario.edits.single.item.id, 'drag-event'); + expect(scenario.edits.single.interval.start, DateTime(2026, 1, 13, 9)); + expect(scenario.opened, isEmpty); + await mouse.removePointer(); + }, + ); + for (final cancellation in ['escape', 'outside', 'no-op', 'snapshot']) { testWidgets('$platform month $cancellation then successful drag', ( tester, diff --git a/test/support/schedule_planner_gesture_suite.dart b/test/support/schedule_planner_gesture_suite.dart index e3dc1566..c5297d19 100644 --- a/test/support/schedule_planner_gesture_suite.dart +++ b/test/support/schedule_planner_gesture_suite.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:ui' show ViewFocusEvent, ViewFocusState, ViewFocusDirection; import 'package:busymax/src/providers/busy_provider.dart'; @@ -5,6 +6,7 @@ import 'package:busymax/src/schedule/schedule_event_rescheduling.dart'; import 'package:busymax/src/schedule/schedule_item.dart'; import 'package:busymax/src/ui/common/schedule/schedule_interactions.dart'; import 'package:flutter/gestures.dart'; +import 'package:flutter/rendering.dart'; import 'package:flutter/services.dart'; import 'package:flutter/widgets.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -25,11 +27,13 @@ class PlannerGestureScenario { final clicks = []; final opened = []; final completed = []; + Completer? saveCompleter; Future save( ScheduleRescheduleRequest request, bool Function() active, ) async { if (active()) edits.add(request); + await saveCompleter?.future; } static CalendarScheduleItem event({ @@ -85,6 +89,11 @@ void schedulePlannerGestureTests( .hitTestable(); ScheduleInteractionRegionState region(WidgetTester tester) => tester.state(find.byType(ScheduleInteractionRegion)); + MouseCursor? cursor(int device) => + RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(device); + final idleEventCursor = platform == 'Linux' + ? SystemMouseCursors.click + : SystemMouseCursors.basic; icv.EventsPlannerState planner(WidgetTester tester) => tester.state(find.byType(icv.EventsPlanner)); Offset slot(WidgetTester tester, int minute, {int dayIndex = 0}) { @@ -104,12 +113,21 @@ void schedulePlannerGestureTests( Future begin( WidgetTester tester, Offset down, - Offset target, - ) async { - final gesture = await tester.startGesture( - down, - kind: PointerDeviceKind.mouse, - ); + Offset target, { + int? device, + }) async { + final TestGesture gesture; + if (device == null) { + gesture = await tester.startGesture(down, kind: PointerDeviceKind.mouse); + } else { + gesture = TestGesture( + dispatcher: tester.sendEventToBinding, + kind: PointerDeviceKind.mouse, + pointer: device, + device: device, + ); + await gesture.down(down); + } await tester.pump(); await gesture.moveTo(Offset.lerp(down, target, .55)!); await tester.pump(); @@ -126,6 +144,254 @@ void schedulePlannerGestureTests( await tester.pump(const Duration(milliseconds: 1)); } + testWidgets( + '$platform event body keeps its opening cursor through an ordinary click', + (tester) async { + const device = 1; + final scenario = PlannerGestureScenario(); + await mount(tester, scenario); + final event = tile(); + final eventRect = tester.getRect(event); + final title = find.descendant( + of: event, + matching: find.text('drag-event'), + ); + final positions = [ + tester.getCenter(title), + Offset(eventRect.left + 6, eventRect.center.dy), + ]; + final icons = find.descendant(of: event, matching: find.byType(Icon)); + if (icons.evaluate().isNotEmpty) { + positions.add(tester.getCenter(icons.first)); + } + + final mouse = await tester.createGesture( + pointer: 71, + kind: PointerDeviceKind.mouse, + ); + await mouse.addPointer(location: positions.first); + await tester.pump(); + for (final position in positions) { + await mouse.moveTo(position); + await tester.pump(); + expect(cursor(device), idleEventCursor); + } + expect(scenario.opened, isEmpty); + expect(scenario.edits, isEmpty); + + final down = positions.first; + await mouse.down(down); + await tester.pump(); + expect(cursor(device), idleEventCursor); + expect(scenario.opened, isEmpty); + expect(region(tester).active, isFalse); + + await mouse.moveBy(const Offset(1, 0)); + await tester.pump(); + expect(cursor(device), idleEventCursor); + expect(region(tester).active, isFalse); + expect(scenario.edits, isEmpty); + + await mouse.up(); + await tester.pump(kDoubleTapTimeout + const Duration(milliseconds: 1)); + await tester.pumpAndSettle(); + expect(scenario.opened, hasLength(1)); + expect(scenario.edits, isEmpty); + expect(cursor(device), idleEventCursor); + await mouse.removePointer(); + }, + ); + + testWidgets('$platform readonly event keeps details and its opening cursor', ( + tester, + ) async { + const device = 1; + final scenario = PlannerGestureScenario( + items: [PlannerGestureScenario.event(readOnly: true)], + ); + await mount(tester, scenario); + final mouse = await tester.createGesture( + pointer: 72, + kind: PointerDeviceKind.mouse, + ); + final center = tester.getCenter(tile()); + await mouse.addPointer(location: center); + await tester.pump(); + expect(cursor(device), idleEventCursor); + expect(find.byType(Draggable), findsNothing); + await mouse.down(center); + await mouse.up(); + await tester.pump(kDoubleTapTimeout + const Duration(milliseconds: 1)); + await tester.pumpAndSettle(); + expect(scenario.opened, hasLength(1)); + expect(scenario.edits, isEmpty); + await mouse.removePointer(); + }); + + testWidgets( + '$platform owned move overrides the surface and restores over another event', + (tester) async { + const device = 1; + final scenario = PlannerGestureScenario( + items: [ + PlannerGestureScenario.event(), + PlannerGestureScenario.event( + id: 'destination-event', + start: DateTime(2026, 1, 12, 11), + end: DateTime(2026, 1, 12, 12), + ), + ], + ); + await mount(tester, scenario); + final source = tester.getCenter(tile()); + final destination = tester.getCenter(tile('destination-event')); + final mouse = await tester.createGesture( + pointer: 73, + kind: PointerDeviceKind.mouse, + ); + await mouse.addPointer(location: source); + await tester.pump(); + expect(cursor(device), idleEventCursor); + await mouse.down(source); + await tester.pump(); + expect(cursor(device), idleEventCursor); + await mouse.moveTo(Offset.lerp(source, destination, .55)!); + await tester.pump(); + expect(region(tester).active, isTrue); + expect(cursor(device), SystemMouseCursors.move); + await mouse.moveTo(destination); + await tester.pump(); + expect(cursor(device), SystemMouseCursors.move); + expect(region(tester).preview?.start, DateTime(2026, 1, 12, 11)); + + await mouse.up(); + await tester.pump(); + expect(cursor(device), idleEventCursor); + await tester.pumpAndSettle(); + expect(scenario.edits, hasLength(1)); + expect(scenario.edits.single.item.id, 'drag-event'); + expect(scenario.opened, isEmpty); + expect(region(tester).active, isFalse); + expect(cursor(device), idleEventCursor); + await mouse.removePointer(); + }, + ); + + testWidgets('$platform release restores cursor while save remains pending', ( + tester, + ) async { + const device = 1; + final scenario = PlannerGestureScenario(); + scenario.saveCompleter = Completer(); + await mount(tester, scenario); + final source = tester.getCenter(tile()); + final destination = + source + Offset(0, 60 * planner(tester).heightPerMinute); + final mouse = await tester.createGesture( + pointer: 74, + kind: PointerDeviceKind.mouse, + ); + await mouse.addPointer(location: source); + await mouse.down(source); + await mouse.moveTo(destination); + await tester.pump(); + expect(cursor(device), SystemMouseCursors.move); + + await mouse.up(); + await tester.pump(); + expect(cursor(device), SystemMouseCursors.basic); + expect(scenario.edits, hasLength(1)); + expect(region(tester).active, isTrue); + expect(region(tester).canMove, isFalse); + + scenario.saveCompleter!.complete(); + await tester.pumpAndSettle(); + expect(scenario.edits, hasLength(1)); + expect(region(tester).active, isFalse); + expect(cursor(device), SystemMouseCursors.basic); + await mouse.removePointer(); + }); + + for (final allDay in [false, true]) { + testWidgets( + '$platform ${allDay ? 'all-day' : 'timed'} resize keeps its directional cursor', + (tester) async { + const device = 1; + final scenario = PlannerGestureScenario( + days: allDay ? 7 : 1, + items: [ + PlannerGestureScenario.event( + allDay: allDay, + start: allDay ? DateTime(2026, 1, 12) : null, + end: allDay ? DateTime(2026, 1, 13) : null, + ), + ], + ); + await mount(tester, scenario); + final handle = find + .byKey(const ValueKey('schedule-resizeStart-drag-event')) + .hitTestable(); + final down = tester.getCenter(handle); + final mouse = await tester.createGesture( + pointer: allDay ? 76 : 75, + kind: PointerDeviceKind.mouse, + ); + await mouse.addPointer(location: down); + await tester.pump(); + expect( + cursor(device), + allDay + ? SystemMouseCursors.resizeLeftRight + : SystemMouseCursors.resizeUpDown, + ); + await mouse.down(down); + await mouse.moveTo( + down + + (allDay + ? Offset(planner(tester).dayWidth, 0) + : Offset(0, -30 * planner(tester).heightPerMinute)), + ); + await tester.pump(); + expect(region(tester).active, isTrue); + expect(cursor(device), isNot(SystemMouseCursors.move)); + await mouse.cancel(); + await tester.pump(kDoubleTapTimeout + const Duration(milliseconds: 1)); + await tester.pumpAndSettle(); + expect(region(tester).active, isFalse); + expect(scenario.edits, isEmpty); + await mouse.removePointer(); + }, + ); + } + + testWidgets('$platform background range selection never uses move cursor', ( + tester, + ) async { + const device = 1; + final scenario = PlannerGestureScenario(items: []); + await mount(tester, scenario); + final start = slot(tester, 9 * 60); + final end = slot(tester, 9 * 60 + 45); + final mouse = await tester.createGesture( + pointer: 77, + kind: PointerDeviceKind.mouse, + ); + await mouse.addPointer(location: start); + await tester.pump(); + expect(cursor(device), SystemMouseCursors.basic); + await mouse.down(start); + await mouse.moveTo(end); + await tester.pump(); + expect(region(tester).active, isTrue); + expect(cursor(device), SystemMouseCursors.basic); + await mouse.cancel(); + await tester.pump(kDoubleTapTimeout + const Duration(milliseconds: 1)); + await tester.pumpAndSettle(); + expect(region(tester).active, isFalse); + expect(scenario.selections, isEmpty); + await mouse.removePointer(); + }); + testWidgets( '$platform mouse event move preserves grab offset and commits once', (tester) async { @@ -196,7 +462,9 @@ void schedulePlannerGestureTests( final target = down + Offset(-p.dayWidth, 30 * p.heightPerMinute); final expectedDate = region(tester).widget.coordinates!.at(target)!.wall; final gesture = await begin(tester, down, target); + expect(cursor(1), SystemMouseCursors.move); await release(tester, gesture); + expect(cursor(1), SystemMouseCursors.basic); expect(scenario.edits, hasLength(1)); expect( scenario.edits.single.interval.start, @@ -275,6 +543,7 @@ void schedulePlannerGestureTests( await mount(tester, scenario); final down = tester.getCenter(tile()); final gesture = await begin(tester, down, down + const Offset(0, 54)); + expect(cursor(1), SystemMouseCursors.move); if (cancellation == 'escape') { await tester.sendKeyEvent(LogicalKeyboardKey.escape); await gesture.up(); @@ -297,17 +566,20 @@ void schedulePlannerGestureTests( await tester.pump(const Duration(milliseconds: 1)); expect(scenario.edits, isEmpty); expect(region(tester).active, isFalse); + expect(cursor(1), SystemMouseCursors.basic); final nextDown = tester.getCenter(tile()); final next = await begin( tester, nextDown, nextDown + Offset(0, 60 * planner(tester).heightPerMinute), ); + expect(cursor(1), SystemMouseCursors.move); await release(tester, next); expect(scenario.edits, hasLength(1)); expect(scenario.edits.single.interval.start, DateTime(2026, 1, 12, 10)); expect(scenario.edits.single.interval.end, DateTime(2026, 1, 12, 11)); expect(region(tester).active, isFalse); + expect(cursor(1), SystemMouseCursors.basic); expect(tester.takeException(), isNull); }); } @@ -426,20 +698,24 @@ void schedulePlannerGestureTests( await mount(tester, scenario); final down = tester.getCenter(tile()); final gesture = await begin(tester, down, down + const Offset(0, 40)); + expect(cursor(1), SystemMouseCursors.move); await gesture.moveTo(down); await release(tester, gesture); expect(scenario.edits, isEmpty); expect(region(tester).active, isFalse); + expect(cursor(1), idleEventCursor); final next = await begin( tester, down, down + Offset(0, 60 * planner(tester).heightPerMinute), ); + expect(cursor(1), SystemMouseCursors.move); await release(tester, next); expect(scenario.edits, hasLength(1)); expect(scenario.edits.single.interval.start, DateTime(2026, 1, 12, 10)); expect(scenario.edits.single.interval.end, DateTime(2026, 1, 12, 11)); expect(region(tester).active, isFalse); + expect(cursor(1), SystemMouseCursors.basic); expect(tester.takeException(), isNull); }, ); @@ -490,6 +766,7 @@ void schedulePlannerGestureTests( await mount(tester, scenario); final down = tester.getCenter(tile()); final gesture = await begin(tester, down, down + const Offset(0, 54)); + expect(cursor(1), SystemMouseCursors.move); final interval = region(tester).preview!; scenario.items = [ ...scenario.items, @@ -502,9 +779,64 @@ void schedulePlannerGestureTests( await tester.pumpWidget(harness(scenario)); await tester.pump(); expect(region(tester).preview!.sameAs(interval), isTrue); + expect(cursor(1), SystemMouseCursors.move); await release(tester, gesture); expect(scenario.edits, hasLength(1)); expect(scenario.edits.single.interval.sameAs(interval), isTrue); + expect(cursor(1), SystemMouseCursors.basic); + }); + + testWidgets('$platform region disposal clears move cursor for a later drag', ( + tester, + ) async { + const removedSourceDevice = 41; + const replacementDevice = 42; + // The all-day source is outside the planner's independent zoom Listener, + // keeping this regression focused on the shared event interaction wrapper. + final scenario = PlannerGestureScenario( + items: [PlannerGestureScenario.event(allDay: true)], + ); + await mount(tester, scenario); + final down = tester.getCenter(tile()); + final removedSourceGesture = await begin( + tester, + down, + down + const Offset(0, 54), + device: removedSourceDevice, + ); + expect(cursor(removedSourceDevice), SystemMouseCursors.move); + + await tester.pumpWidget(const SizedBox.shrink()); + await tester.pump(); + expect(cursor(removedSourceDevice), SystemMouseCursors.basic); + expect(tester.takeException(), isNull, reason: 'during region disposal'); + + final nextScenario = PlannerGestureScenario(); + await mount(tester, nextScenario); + final nextDown = tester.getCenter(tile()); + final next = await begin( + tester, + nextDown, + nextDown + Offset(0, 60 * planner(tester).heightPerMinute), + device: replacementDevice, + ); + expect(cursor(replacementDevice), SystemMouseCursors.move); + + await removedSourceGesture.cancel(); + await tester.pump(); + expect( + tester.takeException(), + isNull, + reason: 'during the removed source gesture late cancellation', + ); + expect(region(tester).active, isTrue); + expect(cursor(replacementDevice), SystemMouseCursors.move); + expect(nextScenario.edits, isEmpty); + + await release(tester, next); + expect(nextScenario.edits, hasLength(1)); + expect(cursor(replacementDevice), SystemMouseCursors.basic); + expect(tester.takeException(), isNull); }); testWidgets( @@ -578,6 +910,7 @@ void schedulePlannerGestureTests( expect(region(tester), same(interaction)); expect(interaction.active, isTrue); expect(interaction.preview!.sameAs(preview), isTrue); + expect(cursor(1), SystemMouseCursors.move); if (drop == 'outside') { await gesture.moveTo(const Offset(-40, -40)); } else if (drop == 'gutter') { @@ -599,6 +932,7 @@ void schedulePlannerGestureTests( } expect(interaction.active, isFalse); expect(interaction.canMove, isTrue); + expect(cursor(1), SystemMouseCursors.basic); final nextDown = tester.getCenter(tile('neighbor')); final next = await begin( tester, diff --git a/test/ui/windows/windows_schedule_commands_test.dart b/test/ui/windows/windows_schedule_commands_test.dart index e73cf120..c1882d78 100644 --- a/test/ui/windows/windows_schedule_commands_test.dart +++ b/test/ui/windows/windows_schedule_commands_test.dart @@ -3,8 +3,6 @@ import 'dart:convert'; import 'package:flutter/services.dart'; import 'package:busymax/src/features/schedule/presentation/schedule_workspace.dart'; import 'package:busymax/src/schedule/schedule_scope.dart'; -import 'package:busymax/src/platform/linux_header_bar_provider.dart'; -import 'package:busymax/src/platform/linux_header_bar_service.dart'; import 'package:busymax/src/platform/common/desktop_services.dart'; import 'package:flutter/material.dart' as material; import 'package:busymax/src/features/connectivity/network_connectivity_service.dart'; @@ -384,12 +382,9 @@ Future _mount( tester.view.physicalSize = const Size(1280, 800); tester.view.devicePixelRatio = 1; addTearDown(tester.view.reset); - final headerBar = LinuxHeaderBarService(isLinux: false); - addTearDown(headerBar.dispose); final container = ProviderContainer( overrides: [ databaseProvider.overrideWithValue(db), - linuxHeaderBarServiceProvider.overrideWithValue(headerBar), desktopWindowServiceProvider.overrideWithValue( const NoOpDesktopWindowService(), ), diff --git a/tool/linux/native_button_reference.cc b/tool/linux/native_button_reference.cc new file mode 100644 index 00000000..11a260ab --- /dev/null +++ b/tool/linux/native_button_reference.cc @@ -0,0 +1,117 @@ +#include + +#include + +namespace { + +GtkWidget* MakeButton(const char* label, const char* role, + GtkStateFlags state, bool enabled) { + GtkWidget* button = gtk_button_new_with_label(label); + GtkStyleContext* context = gtk_widget_get_style_context(button); + if (role != nullptr) { + gtk_style_context_add_class(context, role); + } + gtk_widget_set_sensitive(button, enabled); + if (state != GTK_STATE_FLAG_NORMAL) { + gtk_widget_set_state_flags(button, state, FALSE); + } + return button; +} + +gboolean Capture(gpointer data) { + GtkWidget* window = GTK_WIDGET(data); + GdkWindow* gdk_window = gtk_widget_get_window(window); + const int width = gtk_widget_get_allocated_width(window); + const int height = gtk_widget_get_allocated_height(window); + GdkPixbuf* pixels = + gdk_pixbuf_get_from_window(gdk_window, 0, 0, width, height); + const char* output = static_cast( + g_object_get_data(G_OBJECT(window), "busymax-output")); + GError* error = nullptr; + if (pixels == nullptr || + !gdk_pixbuf_save(pixels, output, "png", &error, nullptr)) { + g_printerr("Could not save native reference: %s\n", + error == nullptr ? "no pixels" : error->message); + } + if (error != nullptr) { + g_error_free(error); + } + if (pixels != nullptr) { + g_object_unref(pixels); + } + gtk_main_quit(); + return G_SOURCE_REMOVE; +} + +} // namespace + +int main(int argc, char** argv) { + gtk_init(&argc, &argv); + if (argc != 2) { + g_printerr("usage: native_button_reference OUTPUT.png\n"); + return 2; + } + + GtkSettings* settings = gtk_settings_get_default(); + gchar* theme = nullptr; + gchar* font = nullptr; + g_object_get(settings, "gtk-theme-name", &theme, "gtk-font-name", &font, + nullptr); + g_print("theme=%s font=%s scale=%d\n", theme, font, + gdk_window_get_scale_factor(gdk_get_default_root_window())); + g_free(theme); + g_free(font); + + GtkWidget* window = gtk_window_new(GTK_WINDOW_TOPLEVEL); + gtk_window_set_title(GTK_WINDOW(window), "Native Ubuntu button reference"); + gtk_window_set_default_size(GTK_WINDOW(window), 760, 390); + gtk_container_set_border_width(GTK_CONTAINER(window), 24); + g_object_set_data_full(G_OBJECT(window), "busymax-output", g_strdup(argv[1]), + g_free); + + GtkWidget* grid = gtk_grid_new(); + gtk_grid_set_row_spacing(GTK_GRID(grid), 12); + gtk_grid_set_column_spacing(GTK_GRID(grid), 20); + gtk_container_add(GTK_CONTAINER(window), grid); + + const char* column_labels[] = {"State", "Standard", "Suggested", + "Destructive"}; + for (int column = 0; column < 4; ++column) { + GtkWidget* label = gtk_label_new(column_labels[column]); + gtk_widget_set_halign(label, GTK_ALIGN_CENTER); + gtk_grid_attach(GTK_GRID(grid), label, column, 0, 1, 1); + } + + struct StateRow { + const char* label; + GtkStateFlags state; + bool enabled; + }; + const StateRow rows[] = { + {"Resting", GTK_STATE_FLAG_NORMAL, true}, + {"Hovered", GTK_STATE_FLAG_PRELIGHT, true}, + {"Pressed", GTK_STATE_FLAG_ACTIVE, true}, + {"Focused", GTK_STATE_FLAG_FOCUSED, true}, + {"Disabled", GTK_STATE_FLAG_INSENSITIVE, false}, + }; + const char* roles[] = {nullptr, GTK_STYLE_CLASS_SUGGESTED_ACTION, + GTK_STYLE_CLASS_DESTRUCTIVE_ACTION}; + for (int row = 0; row < 5; ++row) { + GtkWidget* label = gtk_label_new(rows[row].label); + gtk_widget_set_halign(label, GTK_ALIGN_START); + gtk_grid_attach(GTK_GRID(grid), label, 0, row + 1, 1, 1); + for (int column = 0; column < 3; ++column) { + GtkWidget* button = + MakeButton("Action", roles[column], rows[row].state, + rows[row].enabled); + gtk_widget_set_hexpand(button, TRUE); + gtk_grid_attach(GTK_GRID(grid), button, column + 1, row + 1, 1, 1); + } + } + + g_signal_connect(window, "destroy", G_CALLBACK(gtk_main_quit), nullptr); + gtk_widget_show_all(window); + g_timeout_add(500, Capture, window); + gtk_main(); + return 0; +} diff --git a/tool/linux/native_libadwaita_button_reference.js b/tool/linux/native_libadwaita_button_reference.js new file mode 100755 index 00000000..fc1f93ee --- /dev/null +++ b/tool/linux/native_libadwaita_button_reference.js @@ -0,0 +1,95 @@ +#!/usr/bin/gjs + +// Development-only native reference for the libadwaita generation pinned in +// docs/linux_button_reference.md. It intentionally uses real Gtk.Button +// controls and native CSS states; no BusyMax styling is applied here. +imports.gi.versions.Gtk = '4.0'; +imports.gi.versions.Adw = '1'; + +const { Adw, Gdk, GLib, Gsk, Gtk } = imports.gi; + +if (ARGV.length !== 1) { + printerr('usage: native_libadwaita_button_reference.js OUTPUT.png'); + imports.system.exit(2); +} + +const output = ARGV[0]; +const app = new Adw.Application({ + application_id: 'com.busymax.LibadwaitaButtonReference', +}); + +function addLabel(grid, text, column, row, align = Gtk.Align.CENTER) { + const label = new Gtk.Label({ label: text, halign: align }); + grid.attach(label, column, row, 1, 1); +} + +function addButton(grid, label, column, row, { suggested, focused }) { + const button = new Gtk.Button({ label, hexpand: true }); + if (suggested) { + button.add_css_class('suggested-action'); + } + if (focused) { + button.set_state_flags( + Gtk.StateFlags.FOCUSED | Gtk.StateFlags.FOCUS_VISIBLE, + false, + ); + } + grid.attach(button, column, row, 1, 1); +} + +app.connect('activate', () => { + const window = new Adw.ApplicationWindow({ + application: app, + title: 'Native libadwaita button reference', + default_width: 760, + default_height: 390, + }); + const grid = new Gtk.Grid({ + row_spacing: 18, + column_spacing: 28, + halign: Gtk.Align.CENTER, + valign: Gtk.Align.CENTER, + margin_top: 48, + margin_bottom: 48, + margin_start: 48, + margin_end: 48, + }); + + addLabel(grid, 'State', 0, 0); + addLabel(grid, 'Standard', 1, 0); + addLabel(grid, 'Suggested', 2, 0); + addLabel(grid, 'Resting', 0, 1, Gtk.Align.START); + addButton(grid, 'Action', 1, 1, { suggested: false, focused: false }); + addButton(grid, 'Action', 2, 1, { suggested: true, focused: false }); + addLabel(grid, 'Keyboard-focused', 0, 2, Gtk.Align.START); + addButton(grid, 'Action', 1, 2, { suggested: false, focused: true }); + addButton(grid, 'Action', 2, 2, { suggested: true, focused: true }); + + window.set_content(grid); + window.present(); + + const settings = Gtk.Settings.get_default(); + print( + `theme=${settings.gtk_theme_name} ` + + `font=${settings.gtk_font_name} ` + + `scale=${window.get_scale_factor()}`, + ); + + GLib.timeout_add(GLib.PRIORITY_DEFAULT, 500, () => { + const paintable = new Gtk.WidgetPaintable({ widget: window }); + const width = paintable.get_intrinsic_width(); + const height = paintable.get_intrinsic_height(); + const snapshot = new Gtk.Snapshot(); + paintable.snapshot(snapshot, width, height); + const node = snapshot.to_node(); + const renderer = new Gsk.CairoRenderer(); + renderer.realize_for_display(Gdk.Display.get_default()); + const texture = renderer.render_texture(node, null); + texture.save_to_png(output); + renderer.unrealize(); + app.quit(); + return GLib.SOURCE_REMOVE; + }); +}); + +app.run([]); diff --git a/tool/linux_button_comparison.dart b/tool/linux_button_comparison.dart new file mode 100644 index 00000000..cd21430f --- /dev/null +++ b/tool/linux_button_comparison.dart @@ -0,0 +1,250 @@ +import 'dart:io'; +import 'dart:ui' as ui; + +import 'package:busymax/src/app/app_theme.dart'; +import 'package:busymax/src/app/busymax_design.dart'; +import 'package:busymax/src/app/busymax_surface_colors.dart'; +import 'package:busymax/src/platform/linux_window_service.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; + +const _referenceAccent = Color(0xFFC061CB); +const _screenshotPath = String.fromEnvironment('BUSYMAX_BUTTON_SCREENSHOT'); +final _comparisonBoundaryKey = GlobalKey(); + +Future main() async { + WidgetsFlutterBinding.ensureInitialized(); + FocusManager.instance.highlightStrategy = + FocusHighlightStrategy.alwaysTraditional; + runApp(const LinuxButtonComparisonFixture()); + await WidgetsBinding.instance.endOfFrame; + await const LinuxWindowService().showWindow(); + if (_screenshotPath.isNotEmpty) { + await Future.delayed(const Duration(milliseconds: 500)); + final boundary = + _comparisonBoundaryKey.currentContext?.findRenderObject() + as RenderRepaintBoundary?; + if (boundary == null) { + throw StateError('Button comparison boundary is not mounted.'); + } + final image = await boundary.toImage(); + final bytes = await image.toByteData(format: ui.ImageByteFormat.png); + if (bytes == null) { + throw StateError('Could not encode the button comparison image.'); + } + await File(_screenshotPath).writeAsBytes(bytes.buffer.asUint8List()); + } +} + +/// Development-only visual fixture for comparing production BusyMax buttons +/// with native Ubuntu controls. No button style is defined in this fixture. +class LinuxButtonComparisonFixture extends StatefulWidget { + const LinuxButtonComparisonFixture({super.key}); + + @override + State createState() => + _LinuxButtonComparisonFixtureState(); +} + +class _LinuxButtonComparisonFixtureState + extends State { + var _brightness = Brightness.dark; + var _highContrast = false; + + @override + Widget build(BuildContext context) { + final theme = buildBusyMaxTheme( + brightness: _brightness, + accentColor: _referenceAccent, + gtkFontFamily: 'Ubuntu Sans', + gtkFontSize: 11, + highContrast: _highContrast, + ); + return MaterialApp( + debugShowCheckedModeBanner: false, + theme: theme, + home: RepaintBoundary( + key: _comparisonBoundaryKey, + child: Scaffold( + body: Builder( + builder: (context) { + final colors = BusyMaxSurfaceColors.of(context); + return ListView( + padding: const EdgeInsets.all(24), + children: [ + Row( + children: [ + Expanded( + child: Text( + 'BusyMax Ubuntu button comparison', + style: Theme.of(context).textTheme.titleLarge, + ), + ), + const Text('High contrast'), + Switch( + value: _highContrast, + onChanged: (value) => + setState(() => _highContrast = value), + ), + const SizedBox(width: 12), + const Text('Dark'), + Switch( + value: _brightness == Brightness.dark, + onChanged: (value) => setState( + () => _brightness = value + ? Brightness.dark + : Brightness.light, + ), + ), + ], + ), + const SizedBox(height: 24), + Material( + color: colors.window, + child: const Padding( + padding: EdgeInsets.all(20), + child: _ButtonStateTable(surfaceLabel: 'Window surface'), + ), + ), + const SizedBox(height: 20), + Material( + color: colors.dialog, + shape: Theme.of(context).dialogTheme.shape, + child: const Padding( + padding: EdgeInsets.all(20), + child: _ButtonStateTable(surfaceLabel: 'Dialog surface'), + ), + ), + const SizedBox(height: 20), + Material( + color: colors.popover, + child: const Padding( + padding: EdgeInsets.all(20), + child: _ButtonStateTable(surfaceLabel: 'Popover surface'), + ), + ), + const SizedBox(height: 20), + const Material( + child: BusyMaxEditorHeader( + title: 'Editor header', + cancelLabel: 'Cancel', + saveLabel: 'Save', + onCancel: _noop, + onSave: _noop, + ), + ), + ], + ); + }, + ), + ), + ), + ); + } +} + +void _noop() {} + +enum _FixtureButtonRole { standard, suggested, destructive } + +class _ButtonStateTable extends StatelessWidget { + const _ButtonStateTable({required this.surfaceLabel}); + + final String surfaceLabel; + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(surfaceLabel, style: Theme.of(context).textTheme.titleMedium), + const SizedBox(height: 12), + const Row( + children: [ + SizedBox(width: 96), + Expanded(child: Center(child: Text('Standard'))), + Expanded(child: Center(child: Text('Suggested'))), + Expanded(child: Center(child: Text('Destructive'))), + ], + ), + for (final state in const [ + ({}, 'Resting'), + ({WidgetState.hovered}, 'Hovered'), + ({WidgetState.pressed}, 'Pressed'), + ({WidgetState.focused}, 'Focused'), + ({WidgetState.disabled}, 'Disabled'), + ]) + Padding( + padding: const EdgeInsets.only(top: 10), + child: Row( + children: [ + SizedBox(width: 96, child: Text(state.$2)), + for (final role in _FixtureButtonRole.values) + Expanded( + child: Center( + child: _FixtureButton(role: role, states: state.$1), + ), + ), + ], + ), + ), + ], + ); + } +} + +class _FixtureButton extends StatefulWidget { + const _FixtureButton({required this.role, required this.states}); + + final _FixtureButtonRole role; + final Set states; + + @override + State<_FixtureButton> createState() => _FixtureButtonState(); +} + +class _FixtureButtonState extends State<_FixtureButton> { + late final WidgetStatesController _statesController; + + @override + void initState() { + super.initState(); + _statesController = WidgetStatesController({...widget.states}); + } + + @override + void didUpdateWidget(covariant _FixtureButton oldWidget) { + super.didUpdateWidget(oldWidget); + _statesController.value = {...widget.states}; + } + + @override + void dispose() { + _statesController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final enabled = !widget.states.contains(WidgetState.disabled); + const child = Text('Action'); + return switch (widget.role) { + _FixtureButtonRole.standard => BusyMaxPushButton.standard( + onPressed: enabled ? _noop : null, + statesController: _statesController, + child: child, + ), + _FixtureButtonRole.suggested => BusyMaxPushButton.suggested( + onPressed: enabled ? _noop : null, + statesController: _statesController, + child: child, + ), + _FixtureButtonRole.destructive => BusyMaxPushButton.destructive( + context: context, + onPressed: enabled ? _noop : null, + statesController: _statesController, + child: child, + ), + }; + } +} diff --git a/tool/linux_header_comparison.dart b/tool/linux_header_comparison.dart new file mode 100644 index 00000000..10c6d9e4 --- /dev/null +++ b/tool/linux_header_comparison.dart @@ -0,0 +1,389 @@ +import 'dart:io'; +import 'dart:ui' as ui; + +import 'package:busymax/l10n/generated/app_localizations.dart'; +import 'package:busymax/src/app/app_theme.dart'; +import 'package:busymax/src/app/busymax_design.dart'; +import 'package:busymax/src/app/busymax_surface_colors.dart'; +import 'package:busymax/src/app/linux/linux_header_style.dart'; +import 'package:busymax/src/app/linux/linux_page_frame.dart'; +import 'package:busymax/src/app/linux/linux_window_host.dart'; +import 'package:busymax/src/features/schedule/presentation/schedule_toolbar.dart'; +import 'package:busymax/src/platform/gtk_window_preferences_service.dart'; +import 'package:busymax/src/platform/gtk_header_icon_service.dart'; +import 'package:busymax/src/platform/linux_window_service.dart'; +import 'package:busymax/src/schedule/schedule_range.dart'; +import 'package:busymax/src/schedule/schedule_view_mode.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:ubuntu_localizations/ubuntu_localizations.dart'; +import 'package:yaru/yaru.dart'; + +const _referenceAccent = Color(0xFFE95464); +const _systemControlsWidth = 120.0; +const _screenshotPath = String.fromEnvironment('BUSYMAX_HEADER_SCREENSHOT'); +const _updatedScreenshotPath = String.fromEnvironment( + 'BUSYMAX_HEADER_UPDATED_SCREENSHOT', +); +const _rtl = bool.fromEnvironment('BUSYMAX_HEADER_RTL'); +final _comparisonBoundaryKey = GlobalKey(); + +Future main() async { + WidgetsFlutterBinding.ensureInitialized(); + FocusManager.instance.highlightStrategy = + FocusHighlightStrategy.alwaysTraditional; + final iconService = GtkHeaderIconService(); + await iconService.initialize(); + final searchAsset = iconService.catalog.assetFor( + BusyMaxLinuxHeaderIcon.search, + TextDirection.ltr, + ); + debugPrint( + 'GTK header icons: scale=${iconService.catalog.scale}, ' + 'search=${searchAsset?.resolvedName}, ' + 'pixels=${searchAsset?.pixelWidth}x${searchAsset?.pixelHeight}', + ); + if (_updatedScreenshotPath.isNotEmpty) { + final initialRevision = iconService.catalog.revision; + void captureUpdatedCatalog() { + if (iconService.catalog.revision <= initialRevision) return; + iconService.removeListener(captureUpdatedCatalog); + WidgetsBinding.instance.addPostFrameCallback((_) async { + await Future.delayed(const Duration(milliseconds: 500)); + await _writeScreenshot(_updatedScreenshotPath); + }); + } + + iconService.addListener(captureUpdatedCatalog); + } + runApp(LinuxHeaderComparisonFixture(iconService: iconService)); + await WidgetsBinding.instance.endOfFrame; + await const LinuxWindowService().showWindow(); + if (_screenshotPath.isNotEmpty) { + await Future.delayed(const Duration(milliseconds: 500)); + await _writeScreenshot(_screenshotPath); + } +} + +Future _writeScreenshot(String path) async { + final boundary = + _comparisonBoundaryKey.currentContext?.findRenderObject() + as RenderRepaintBoundary?; + if (boundary == null) { + throw StateError('Header comparison boundary is not mounted.'); + } + final image = await boundary.toImage(); + final bytes = await image.toByteData(format: ui.ImageByteFormat.png); + if (bytes == null) { + throw StateError('Could not encode the header comparison image.'); + } + await File(path).writeAsBytes(bytes.buffer.asUint8List()); + debugPrint('Wrote GTK header comparison screenshot: $path'); +} + +/// Focused visual fixture for the production Linux application-header pieces. +class LinuxHeaderComparisonFixture extends StatefulWidget { + const LinuxHeaderComparisonFixture({super.key, this.iconService}); + + final GtkHeaderIconService? iconService; + + @override + State createState() => + _LinuxHeaderComparisonFixtureState(); +} + +class _LinuxHeaderComparisonFixtureState + extends State { + late final TextEditingController _searchController = TextEditingController( + text: 'planning', + ); + + @override + void dispose() { + _searchController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final theme = buildBusyMaxTheme( + brightness: Brightness.dark, + accentColor: _referenceAccent, + gtkFontFamily: 'Ubuntu Sans', + gtkFontSize: 11, + ); + return GtkHeaderIconScope( + service: widget.iconService ?? GtkHeaderIconService.fallbackInstance, + child: MaterialApp( + debugShowCheckedModeBanner: false, + theme: theme, + locale: const Locale('en'), + localizationsDelegates: const [ + ...AppLocalizations.localizationsDelegates, + ...GlobalUbuntuLocalizations.delegates, + ], + supportedLocales: AppLocalizations.supportedLocales, + home: LinuxWindowMetricsScope( + leftControlInset: 0, + rightControlInset: _systemControlsWidth, + windowActive: true, + preferences: GtkWindowPreferences.defaults(), + child: Directionality( + textDirection: _rtl ? TextDirection.rtl : TextDirection.ltr, + child: Builder( + builder: (context) { + final colors = BusyMaxSurfaceColors.of(context); + return RepaintBoundary( + key: _comparisonBoundaryKey, + child: Scaffold( + backgroundColor: colors.window, + body: Padding( + padding: const EdgeInsets.symmetric(vertical: 18), + child: Column( + children: [ + _scheduleHeader(colors), + const SizedBox(height: 20), + _settingsHeader(colors), + const SizedBox(height: 20), + _inactiveHeader(colors), + const SizedBox(height: 20), + _searchHeader(colors), + ], + ), + ), + ), + ); + }, + ), + ), + ), + ), + ); + } + + Widget _scheduleHeader(BusyMaxSurfaceColors colors) { + return SizedBox( + height: BusyMaxSizes.toolbarHeight, + child: Stack( + children: [ + Row( + children: [ + SizedBox( + width: 280, + child: ColoredBox( + color: colors.sidebar, + child: const BusyMaxLinuxBrandHeader(), + ), + ), + Expanded( + child: ColoredBox( + color: colors.window, + child: const LinuxPageHeaderInsetsScope( + leftObstruction: 0, + rightObstruction: _systemControlsWidth, + child: _ReviewedScheduleHeader(), + ), + ), + ), + ], + ), + const Positioned(right: 0, top: 0, child: _SystemControls()), + ], + ), + ); + } + + Widget _settingsHeader(BusyMaxSurfaceColors colors) { + return ColoredBox( + color: colors.window, + child: BusyMaxLinuxHeaderLayout( + leading: const BusyMaxLinuxHeaderControlGroup( + children: [ + BusyMaxLinuxHeaderIconButton( + icon: BusyMaxLinuxHeaderIcon.back, + tooltip: 'Back', + onPressed: _noop, + ), + ], + ), + title: const BusyMaxLinuxHeaderTitle('Settings'), + trailing: const BusyMaxLinuxHeaderControlGroup( + children: [ + BusyMaxLinuxHeaderIconButton( + key: ValueKey('reviewed-open-menu'), + icon: BusyMaxLinuxHeaderIcon.mainMenu, + tooltip: 'Open menu', + selected: true, + onPressed: _noop, + ), + ], + ), + ), + ); + } + + Widget _inactiveHeader(BusyMaxSurfaceColors colors) { + return LinuxWindowMetricsScope( + leftControlInset: 0, + rightControlInset: 0, + windowActive: false, + preferences: GtkWindowPreferences.defaults(), + child: ColoredBox( + color: colors.window, + child: const BusyMaxLinuxHeaderLayout( + leading: BusyMaxLinuxHeaderControlGroup( + children: [ + BusyMaxLinuxHeaderIconButton( + icon: BusyMaxLinuxHeaderIcon.sidebar, + tooltip: 'Sidebar', + onPressed: _noop, + ), + BusyMaxLinuxHeaderIconButton( + icon: BusyMaxLinuxHeaderIcon.today, + tooltip: 'Today', + onPressed: _noop, + ), + ], + ), + title: BusyMaxLinuxHeaderTitle('Inactive window'), + trailing: BusyMaxLinuxHeaderControlGroup( + children: [ + BusyMaxLinuxHeaderIconButton( + icon: BusyMaxLinuxHeaderIcon.create, + tooltip: 'Disabled create', + onPressed: null, + ), + BusyMaxLinuxHeaderIconButton( + icon: BusyMaxLinuxHeaderIcon.search, + tooltip: 'Search', + onPressed: _noop, + ), + BusyMaxLinuxHeaderIconButton( + icon: BusyMaxLinuxHeaderIcon.mainMenu, + tooltip: 'Menu', + onPressed: _noop, + ), + ], + ), + ), + ), + ); + } + + Widget _searchHeader(BusyMaxSurfaceColors colors) { + return SizedBox( + height: BusyMaxSizes.toolbarHeight, + child: ColoredBox( + color: colors.window, + child: Padding( + padding: const EdgeInsets.all(BusyMaxSpacing.headerInset), + child: Row( + children: [ + Expanded( + child: BusyMaxSearchField( + controller: _searchController, + hintText: 'Search', + onChanged: (_) {}, + onClear: _searchController.clear, + ), + ), + const SizedBox(width: BusyMaxSpacing.headerInset), + const BusyMaxLinuxHeaderControlGroup( + children: [ + SizedBox.square(dimension: BusyMaxSizes.headerIconButton), + BusyMaxLinuxHeaderIconButton( + icon: BusyMaxLinuxHeaderIcon.filter, + tooltip: 'Filters', + onPressed: _noop, + ), + BusyMaxLinuxHeaderIconButton( + icon: BusyMaxLinuxHeaderIcon.close, + tooltip: 'Close', + onPressed: _noop, + ), + BusyMaxLinuxHeaderIconButton( + icon: BusyMaxLinuxHeaderIcon.mainMenu, + tooltip: 'Menu', + onPressed: _noop, + ), + ], + ), + ], + ), + ), + ), + ); + } +} + +class _ReviewedScheduleHeader extends StatelessWidget { + const _ReviewedScheduleHeader(); + + @override + Widget build(BuildContext context) { + return ScheduleToolbar( + mode: ScheduleViewMode.month, + range: ScheduleRange.month( + DateTime(2026, 6, 1), + firstWeekday: DateTime.monday, + ), + selectedDate: DateTime(2026, 6, 11), + onToday: _noop, + onPrevious: _noop, + onNext: _noop, + onModeChanged: (_) {}, + canCreateEvent: false, + canCreateTask: false, + onCreateEvent: _noop, + onCreateTask: _noop, + onRefresh: _noop, + canShowSidebar: true, + sidebarVisible: true, + onToggleSidebar: _noop, + onSearch: _noop, + onMenuSelected: (_) {}, + ); + } +} + +class _SystemControls extends StatelessWidget { + const _SystemControls(); + + @override + Widget build(BuildContext context) { + final foreground = BusyMaxSurfaceColors.of(context).foreground; + return SizedBox( + height: BusyMaxSizes.toolbarHeight, + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: BusyMaxLinuxWindowMetrics.controlHorizontalPadding, + ), + child: Row( + textDirection: TextDirection.ltr, + mainAxisSize: MainAxisSize.min, + children: [ + YaruWindowControl( + iconColor: WidgetStatePropertyAll(foreground), + type: YaruWindowControlType.minimize, + onTap: _noop, + ), + const SizedBox(width: BusyMaxLinuxWindowMetrics.controlSpacing), + YaruWindowControl( + iconColor: WidgetStatePropertyAll(foreground), + type: YaruWindowControlType.maximize, + onTap: _noop, + ), + const SizedBox(width: BusyMaxLinuxWindowMetrics.controlSpacing), + YaruWindowControl( + iconColor: WidgetStatePropertyAll(foreground), + type: YaruWindowControlType.close, + onTap: _noop, + ), + ], + ), + ), + ); + } +} + +void _noop() {} diff --git a/windows/runner/Runner.rc b/windows/runner/Runner.rc index 2f87e6a4..9181573b 100644 --- a/windows/runner/Runner.rc +++ b/windows/runner/Runner.rc @@ -63,13 +63,13 @@ IDI_APP_ICON ICON "resources\\app_icon.ico" #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 +#define VERSION_AS_NUMBER 0,2,3,0 #endif #if defined(FLUTTER_VERSION) #define VERSION_AS_STRING FLUTTER_VERSION #else -#define VERSION_AS_STRING "1.0.0" +#define VERSION_AS_STRING "0.2.3" #endif VS_VERSION_INFO VERSIONINFO