From 7e83b5ace9fbd2c271525b8fafcfb0c0bb2996ab Mon Sep 17 00:00:00 2001 From: albert Date: Wed, 22 Jul 2026 15:51:31 -0700 Subject: [PATCH 01/73] Enhance header bar functionality with sidebar visibility controls --- linux/runner/my_application.cc | 157 +++++++++++++++++++++++++-------- 1 file changed, 118 insertions(+), 39 deletions(-) diff --git a/linux/runner/my_application.cc b/linux/runner/my_application.cc index 4ffac61..2fe7dd3 100644 --- a/linux/runner/my_application.cc +++ b/linux/runner/my_application.cc @@ -90,6 +90,7 @@ struct _MyApplication { gchar* header_bar_shade_color; gchar* header_bar_modal_barrier_color; gint header_bar_sidebar_width; + gboolean header_bar_can_show_sidebar; gboolean header_bar_sidebar_visible; gboolean header_bar_modal_barrier_visible; GtkWindow* main_window; @@ -585,7 +586,8 @@ static void set_main_flutter_view_background(MyApplication* self) { } static gint header_sidebar_effective_width(MyApplication* self) { - if (!self->header_bar_sidebar_visible) { + if (!self->header_bar_can_show_sidebar || + !self->header_bar_sidebar_visible) { return 0; } return self->header_bar_sidebar_width; @@ -733,6 +735,10 @@ static void refresh_header_bar_css(MyApplication* self) { ".busymax-titlebar button.busymax-header-view-mode-button:active {" "background-color: %s;" "}" + ".busymax-titlebar button.busymax-header-button:focus," + ".busymax-titlebar button.busymax-header-view-mode-button:focus {" + "box-shadow: inset 0 0 0 2px %s;" + "}" ".busymax-titlebar button.busymax-header-button:disabled," ".busymax-titlebar button.busymax-header-view-mode-button:disabled {" "color: %s;" @@ -748,6 +754,9 @@ static void refresh_header_bar_css(MyApplication* self) { "color: %s;" "background-color: %s;" "}" + ".busymax-titlebar button.busymax-header-primary-button:focus {" + "box-shadow: inset 0 0 0 2px %s;" + "}" ".busymax-titlebar button.busymax-header-primary-button:disabled {" "color: %s;" "background-color: transparent;" @@ -828,11 +837,8 @@ static void refresh_header_bar_css(MyApplication* self) { "}" "popover.busymax-header-popover " "button.busymax-header-popover-row:focus {" - "border-color: transparent;" - "outline-color: transparent;" - "outline-style: none;" - "outline-width: 0;" - "box-shadow: none;" + "background-color: %s;" + "box-shadow: inset 0 0 0 2px %s;" "}" "popover.busymax-header-popover " "button.busymax-header-popover-row:active," @@ -877,15 +883,17 @@ static void refresh_header_bar_css(MyApplication* self) { background_color, modal_barrier_color, modal_barrier_color, foreground_color, control_color, kHeaderButtonHeight, kHeaderButtonHeight, kHeaderButtonHorizontalPadding, kHeaderButtonRadius, - control_hover_color, control_pressed_color, foreground_disabled_color, + control_hover_color, control_pressed_color, accent_color, + foreground_disabled_color, accent_foreground_color, accent_color, accent_foreground_color, - accent_color, foreground_disabled_color, + accent_color, accent_foreground_color, foreground_disabled_color, control_hover_color, control_hover_color, foreground_disabled_color, kHeaderButtonHeight, popover_background_color, foreground_color, foreground_color, border_color, shade_color, kHeaderButtonHeight, kHeaderButtonHorizontalPadding, kHeaderButtonRadius, - control_hover_color, foreground_color, muted_foreground_color, + control_hover_color, control_hover_color, accent_color, foreground_color, + muted_foreground_color, kHeaderButtonRadius, shade_color, kHeaderTooltipVerticalPadding, kHeaderTooltipHorizontalPadding, kHeaderButtonRadius); @@ -1404,6 +1412,13 @@ static void set_header_view_mode_labels(MyApplication* self, update_header_view_mode_label(self); } +static void set_header_title(MyApplication* self, const gchar* title) { + if (self->header_title_label != nullptr && + GTK_IS_LABEL(self->header_title_label) && title != nullptr) { + gtk_label_set_text(GTK_LABEL(self->header_title_label), title); + } +} + static void set_header_view_mode(MyApplication* self, const gchar* mode) { if (header_view_mode_action(mode) == nullptr) { return; @@ -1439,39 +1454,44 @@ static void update_header_title_box_geometry(MyApplication* self) { gtk_widget_set_size_request(self->header_title_box, width, -1); } -static void set_header_schedule_controls_visible(MyApplication* self, - gboolean visible) { - self->header_schedule_controls_visible = visible; +static void update_header_control_visibility(MyApplication* self) { + const gboolean schedule_controls_visible = + self->header_schedule_controls_visible; set_widget_visible(self->header_start_box, - visible || self->header_back_visible); - set_widget_visible(self->sidebar_collapsed_toggle_button, visible); - set_widget_visible(self->today_button, visible); + 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); set_widget_visible(self->previous_button, - visible && self->header_navigation_visible); + schedule_controls_visible && + self->header_navigation_visible); set_widget_visible(self->next_button, - visible && self->header_navigation_visible); - set_widget_visible(self->header_view_box, visible); - set_widget_visible(self->search_button, visible); - set_widget_visible(self->create_button, visible); - set_widget_visible(self->refresh_button, visible); + schedule_controls_visible && + self->header_navigation_visible); + set_widget_visible(self->header_view_box, schedule_controls_visible); + set_widget_visible(self->search_button, schedule_controls_visible); + set_widget_visible(self->create_button, schedule_controls_visible); + set_widget_visible(self->refresh_button, schedule_controls_visible); update_header_title_balance_spacer(self); } +static void set_header_schedule_controls_visible(MyApplication* self, + gboolean visible) { + self->header_schedule_controls_visible = visible; + update_header_control_visibility(self); +} + static void set_header_navigation_visible(MyApplication* self, gboolean visible) { self->header_navigation_visible = visible; - set_widget_visible(self->previous_button, - self->header_schedule_controls_visible && visible); - set_widget_visible(self->next_button, - self->header_schedule_controls_visible && visible); + update_header_control_visibility(self); } static void set_header_back_visible(MyApplication* self, gboolean visible) { self->header_back_visible = visible; - set_widget_visible(self->back_button, visible); - set_widget_visible(self->header_start_box, - visible || self->header_schedule_controls_visible); - update_header_title_balance_spacer(self); + update_header_control_visibility(self); } static void set_header_onboarding_controls(MyApplication* self, FlValue* args) { @@ -1499,13 +1519,19 @@ static void set_header_onboarding_controls(MyApplication* self, FlValue* args) { static void set_header_sidebar_visible(MyApplication* self, gboolean visible) { self->header_bar_sidebar_visible = visible; - set_widget_visible(self->sidebar_collapsed_toggle_button, - self->header_schedule_controls_visible); set_toggle_button_active(self, self->sidebar_collapsed_toggle_button, visible); 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; @@ -1515,6 +1541,51 @@ static void set_header_sidebar_width(MyApplication* self, gdouble width) { refresh_header_bar_css(self); } +static void set_header_bar_state(MyApplication* self, FlValue* args) { + if (args == nullptr || fl_value_get_type(args) != FL_VALUE_TYPE_MAP) { + 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; + if (fl_lookup_optional_bool_arg(args, "canRefresh", &value)) { + set_widget_sensitive(self->refresh_button, value); + } + if (fl_lookup_optional_bool_arg(args, "canCreate", &value)) { + set_widget_sensitive(self->create_button, value); + } + if (fl_lookup_optional_bool_arg(args, "searchActive", &value)) { + set_toggle_button_active(self, self->search_button, value); + } + + 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); + } + 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; + } + + 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"); @@ -1839,12 +1910,11 @@ static void header_bar_method_call_cb(FlMethodChannel* channel, 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) { - const gchar* value = fl_method_string_arg(args); - if (self->header_title_label != nullptr && - GTK_IS_LABEL(self->header_title_label) && value != nullptr) { - gtk_label_set_text(GTK_LABEL(self->header_title_label), value); - } + 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)); @@ -1864,8 +1934,17 @@ static void header_bar_method_call_cb(FlMethodChannel* channel, } else if (strcmp(method, "setSearchActive") == 0) { set_toggle_button_active(self, self->search_button, fl_method_bool_arg(args)); respond_success(method_call); + } 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) { - set_header_sidebar_visible(self, fl_method_bool_arg(args)); + 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)); @@ -2081,6 +2160,7 @@ static FlValue* get_gtk_theme_colors() { lookup_context_color(window_context, "theme_unfocused_fg_color", &muted_foreground_color); lookup_context_color(window_context, "borders", &border_color); + lookup_context_color(window_context, "wm_shadow", &shade_color); lookup_context_color(window_context, "theme_selected_bg_color", &accent_color); @@ -2105,8 +2185,6 @@ static FlValue* get_gtk_theme_colors() { subtle_border_color.alpha *= 0.56; sidebar_border_color = border_color; sidebar_border_color.alpha *= 0.72; - shade_color = border_color; - shade_color.alpha *= 0.72; } FlValue* result = fl_value_new_map(); @@ -3246,6 +3324,7 @@ static void my_application_init(MyApplication* self) { self->header_bar_shade_color = nullptr; self->header_bar_modal_barrier_color = nullptr; 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->main_window = nullptr; From cb5b90683de2e32dc4fe41bcb357226958d8be32 Mon Sep 17 00:00:00 2001 From: albert Date: Wed, 22 Jul 2026 15:51:55 -0700 Subject: [PATCH 02/73] Add Linux header bar service integration and enhance sidebar functionality --- lib/l10n/app_de.arb | 53 ++- lib/l10n/app_en.arb | 53 ++- lib/l10n/app_es.arb | 53 ++- lib/l10n/app_fr.arb | 53 ++- lib/l10n/generated/app_localizations.dart | 270 ++++++++++++++ lib/l10n/generated/app_localizations_de.dart | 177 +++++++++ lib/l10n/generated/app_localizations_en.dart | 174 +++++++++ lib/l10n/generated/app_localizations_es.dart | 177 +++++++++ lib/l10n/generated/app_localizations_fr.dart | 176 +++++++++ lib/src/app/busymax_about_dialog.dart | 27 +- lib/src/app/busymax_app.dart | 19 +- lib/src/app/busymax_design.dart | 345 +++++++++--------- lib/src/app/busymax_dialogs.dart | 128 +++++-- .../busymax_keyboard_shortcuts_dialog.dart | 50 ++- lib/src/app/busymax_layout.dart | 5 + lib/src/app/busymax_shortcuts.dart | 24 ++ lib/src/app/busymax_surface_colors.dart | 7 + lib/src/app/busymax_yaru_theme.dart | 25 +- .../auth/presentation/sign_in_screen.dart | 36 +- .../event_description_editor.dart | 12 +- .../calendar/presentation/event_editor.dart | 65 ++-- .../presentation/compact_agenda_panel.dart | 5 +- .../schedule/presentation/mini_calendar.dart | 36 +- .../presentation/schedule_agenda_view.dart | 4 +- .../presentation/schedule_create_menu.dart | 28 +- .../presentation/schedule_day_week_view.dart | 7 +- .../presentation/schedule_empty_states.dart | 88 +++++ .../presentation/schedule_event_block.dart | 209 +++++++---- .../presentation/schedule_sidebar.dart | 22 +- .../presentation/schedule_task_chip.dart | 6 + .../presentation/schedule_toolbar.dart | 189 +++++++--- .../presentation/schedule_workspace.dart | 205 ++++++----- .../presentation/settings_screen.dart | 227 +++++++++--- .../tasks/presentation/new_task_dialog.dart | 1 + .../presentation/task_details_editor.dart | 7 + .../tasks/presentation/task_details_pane.dart | 1 + .../tasks/presentation/tasks_workspace.dart | 9 +- .../platform/linux_header_bar_service.dart | 174 ++++++++- test/app/about_dialog_test.dart | 11 +- test/app/busymax_dialogs_test.dart | 138 +++++++ test/app/busymax_grouped_surface_test.dart | 241 ++++++++++++ test/app/keyboard_shortcuts_dialog_test.dart | 10 +- test/app/localization_audit_test.dart | 114 ++++++ test/app/native_ui_audit_test.dart | 28 +- test/app/theme_localization_test.dart | 30 +- .../presentation/event_editor_test.dart | 2 +- .../schedule_create_menu_test.dart | 69 ++++ .../presentation/schedule_toolbar_test.dart | 106 ++++++ .../presentation/schedule_views_test.dart | 153 ++++++-- .../schedule_workspace_states_test.dart | 115 ++++++ .../presentation/settings_screen_test.dart | 80 +++- .../linux_header_bar_service_test.dart | 125 +++++++ 52 files changed, 3699 insertions(+), 670 deletions(-) create mode 100644 lib/src/app/busymax_shortcuts.dart create mode 100644 test/app/busymax_dialogs_test.dart create mode 100644 test/app/busymax_grouped_surface_test.dart create mode 100644 test/app/localization_audit_test.dart create mode 100644 test/features/schedule/presentation/schedule_create_menu_test.dart create mode 100644 test/features/schedule/presentation/schedule_toolbar_test.dart create mode 100644 test/features/schedule/presentation/schedule_workspace_states_test.dart diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index 73ef7f3..492439e 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -42,6 +42,12 @@ "allDay": "Ganztägig", "moreItems": "+{count} weitere", "noEventsOrTasks": "Keine Termine oder Aufgaben", + "scheduleLoading": "Zeitplan wird geladen...", + "scheduleUnavailable": "Zeitplan nicht verfügbar", + "scheduleNoSources": "Keine sichtbaren Kalender oder Aufgabenlisten", + "scheduleNoSourcesDescription": "Wählen Sie in den Einstellungen aus, was angezeigt werden soll, und aktualisieren Sie anschließend.", + "scheduleSignInRequired": "Konto verbinden", + "scheduleSignInDescription": "Melden Sie sich an, um Kalender und Aufgaben zu synchronisieren.", "trayAgendaLoading": "Agenda wird geladen...", "trayAgendaSignInRequired": "Melden Sie sich an, um die Agenda anzuzeigen.", "trayAgendaNoSources": "Keine sichtbaren Kalender oder Aufgabenlisten.", @@ -97,6 +103,28 @@ "conference": "Konferenz", "noConference": "Keine Konferenz", "providerCalendar": "Anbieterkalender", + "formatBoldShortLabel": "F", + "formatBoldTooltip": "Fett", + "formatItalicShortLabel": "K", + "formatItalicTooltip": "Kursiv", + "formatUnderlineShortLabel": "U", + "formatUnderlineTooltip": "Unterstrichen", + "reminderMinutesBefore": "{minutes, plural, =1{1 Minute vorher} other{{minutes} Minuten vorher}}", + "@reminderMinutesBefore": {"placeholders": {"minutes": {"type": "int"}}}, + "reminderHoursBefore": "{hours, plural, =1{1 Stunde vorher} other{{hours} Stunden vorher}}", + "@reminderHoursBefore": {"placeholders": {"hours": {"type": "int"}}}, + "reminderDaysBefore": "{days, plural, =1{1 Tag vorher} other{{days} Tage vorher}}", + "@reminderDaysBefore": {"placeholders": {"days": {"type": "int"}}}, + "availabilityFree": "Frei", + "availabilityTentative": "Mit Vorbehalt", + "availabilityOutOfOffice": "Abwesend", + "availabilityWorkingElsewhere": "An einem anderen Ort", + "visibilityDefault": "Standard", + "visibilityPublic": "Öffentlich", + "visibilityPrivate": "Privat", + "visibilityConfidential": "Vertraulich", + "sensitivityNormal": "Normal", + "sensitivityPersonal": "Persönlich", "tasks": "Aufgaben", "allTasks": "Alle Aufgaben", "tasksInList": "Aufgaben in {title}", @@ -284,11 +312,20 @@ "deleteLocalDataConfirmation": "Dies entfernt das lokale Konto, synchronisierte Aufgaben und ausstehende Offline-Änderungen von diesem Gerät.", "sync": "Synchronisierung", "manualFullSync": "Manuelle vollständige Synchronisierung", + "runInBackgroundWhenClosed": "Nach dem Schließen des Fensters weiter ausführen", + "showTrayIcon": "Symbol im Benachrichtigungsbereich anzeigen", + "startMinimizedToTray": "Minimiert im Benachrichtigungsbereich starten", "syncComplete": "Synchronisierung abgeschlossen.", "syncFailed": "Synchronisierung fehlgeschlagen: {error}", "notifySyncFailures": "Benachrichtigungen bei Synchronisierungsfehlern", "notifyConflicts": "Benachrichtigungen bei Konflikten", "notifyDueToday": "Benachrichtigungen für heute fällige Aufgaben", + "eventReminders": "Terminerinnerungen", + "taskReminders": "Aufgabenerinnerungen", + "notificationDetailLevel": "Detailgrad der Benachrichtigungen", + "notificationDetailPrivate": "Privat", + "notificationDetailNormal": "Normal", + "quietHours": "Ruhezeiten", "notifications": "Benachrichtigungen", "appearance": "Darstellung", "theme": "Theme", @@ -331,5 +368,19 @@ "conflictNotificationBody": "Eine ausstehende lokale Änderung wurde blockiert. {summary}", "dueTodayNotificationTitle": "Heute fällige Aufgaben", "dueTodayNotificationBody": "{count, plural, =1{Eine Aufgabe ist heute fällig.} other{{count} Aufgaben sind heute fällig.}}", - "notificationDetailsHidden": "Details werden durch Datenschutzeinstellungen ausgeblendet." + "notificationDetailsHidden": "Details werden durch Datenschutzeinstellungen ausgeblendet.", + "previousMonth": "Vorheriger Monat", + "nextMonth": "Nächster Monat", + "openMonthView": "Monatsansicht öffnen", + "previousYear": "Vorheriges Jahr", + "nextYear": "Nächstes Jahr", + "openYearView": "Jahresansicht öffnen", + "weekNumberTooltip": "Woche {number}", + "@weekNumberTooltip": {"placeholders": {"number": {"type": "int"}}}, + "resizeAllDayPanel": "Ganztägigen Bereich vergrößern oder verkleinern", + "scheduleItemCount": "{count, plural, =1{1 Eintrag} other{{count} Einträge}}", + "@scheduleItemCount": {"placeholders": {"count": {"type": "int"}}}, + "readOnlyCalendar": "Dieser Kalender ist schreibgeschützt.", + "deleteCalendarConfirmation": "\"{title}\" löschen?", + "@deleteCalendarConfirmation": {"placeholders": {"title": {"type": "String"}}} } diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 9b283bd..d343142 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -43,6 +43,12 @@ "moreItems": "+{count} more", "@moreItems": {"placeholders": {"count": {"type": "int"}}}, "noEventsOrTasks": "No events or tasks", + "scheduleLoading": "Loading schedule...", + "scheduleUnavailable": "Schedule unavailable", + "scheduleNoSources": "No visible calendars or task lists", + "scheduleNoSourcesDescription": "Choose what to show in Settings, then refresh.", + "scheduleSignInRequired": "Connect an account", + "scheduleSignInDescription": "Sign in to sync calendars and tasks.", "trayAgendaLoading": "Loading agenda...", "trayAgendaSignInRequired": "Sign in to show agenda.", "trayAgendaNoSources": "No visible calendars or task lists.", @@ -99,6 +105,28 @@ "conference": "Conference", "noConference": "No conference", "providerCalendar": "Provider calendar", + "formatBoldShortLabel": "B", + "formatBoldTooltip": "Bold", + "formatItalicShortLabel": "I", + "formatItalicTooltip": "Italic", + "formatUnderlineShortLabel": "U", + "formatUnderlineTooltip": "Underline", + "reminderMinutesBefore": "{minutes, plural, =1{1 minute before} other{{minutes} minutes before}}", + "@reminderMinutesBefore": {"placeholders": {"minutes": {"type": "int"}}}, + "reminderHoursBefore": "{hours, plural, =1{1 hour before} other{{hours} hours before}}", + "@reminderHoursBefore": {"placeholders": {"hours": {"type": "int"}}}, + "reminderDaysBefore": "{days, plural, =1{1 day before} other{{days} days before}}", + "@reminderDaysBefore": {"placeholders": {"days": {"type": "int"}}}, + "availabilityFree": "Free", + "availabilityTentative": "Tentative", + "availabilityOutOfOffice": "Out of office", + "availabilityWorkingElsewhere": "Working elsewhere", + "visibilityDefault": "Default", + "visibilityPublic": "Public", + "visibilityPrivate": "Private", + "visibilityConfidential": "Confidential", + "sensitivityNormal": "Normal", + "sensitivityPersonal": "Personal", "tasks": "Tasks", "allTasks": "All tasks", "tasksInList": "Tasks in {title}", @@ -298,12 +326,21 @@ "deleteLocalDataConfirmation": "This removes the local account, synced tasks, and pending offline changes from this device.", "sync": "Sync", "manualFullSync": "Manual full sync", + "runInBackgroundWhenClosed": "Continue running when the window is closed", + "showTrayIcon": "Show tray icon", + "startMinimizedToTray": "Start minimized to the tray", "syncComplete": "Sync complete.", "syncFailed": "Sync failed: {error}", "@syncFailed": {"placeholders": {"error": {"type": "String"}}}, "notifySyncFailures": "Notifications on sync failure", "notifyConflicts": "Notifications on conflicts", "notifyDueToday": "Due-today notifications", + "eventReminders": "Event reminders", + "taskReminders": "Task reminders", + "notificationDetailLevel": "Notification detail level", + "notificationDetailPrivate": "Private", + "notificationDetailNormal": "Normal", + "quietHours": "Quiet hours", "notifications": "Notifications", "appearance": "Appearance", "theme": "Theme", @@ -353,5 +390,19 @@ "dueTodayNotificationTitle": "Tasks due today", "dueTodayNotificationBody": "{count, plural, =1{One task is due today.} other{{count} tasks are due today.}}", "@dueTodayNotificationBody": {"placeholders": {"count": {"type": "int"}}}, - "notificationDetailsHidden": "Details are hidden by privacy settings." + "notificationDetailsHidden": "Details are hidden by privacy settings.", + "previousMonth": "Previous month", + "nextMonth": "Next month", + "openMonthView": "Open month view", + "previousYear": "Previous year", + "nextYear": "Next year", + "openYearView": "Open year view", + "weekNumberTooltip": "Week {number}", + "@weekNumberTooltip": {"placeholders": {"number": {"type": "int"}}}, + "resizeAllDayPanel": "Resize the all-day panel", + "scheduleItemCount": "{count, plural, =1{1 item} other{{count} items}}", + "@scheduleItemCount": {"placeholders": {"count": {"type": "int"}}}, + "readOnlyCalendar": "This calendar is read-only.", + "deleteCalendarConfirmation": "Delete \"{title}\"?", + "@deleteCalendarConfirmation": {"placeholders": {"title": {"type": "String"}}} } diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index ddc7368..dc79c3a 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -42,6 +42,12 @@ "allDay": "Todo el día", "moreItems": "+{count} más", "noEventsOrTasks": "No hay eventos ni tareas", + "scheduleLoading": "Cargando la agenda...", + "scheduleUnavailable": "Agenda no disponible", + "scheduleNoSources": "No hay calendarios ni listas de tareas visibles", + "scheduleNoSourcesDescription": "Elige qué mostrar en Ajustes y, después, actualiza.", + "scheduleSignInRequired": "Conectar una cuenta", + "scheduleSignInDescription": "Inicia sesión para sincronizar calendarios y tareas.", "trayAgendaLoading": "Cargando agenda...", "trayAgendaSignInRequired": "Inicia sesión para mostrar la agenda.", "trayAgendaNoSources": "No hay calendarios ni listas de tareas visibles.", @@ -97,6 +103,28 @@ "conference": "Conferencia", "noConference": "Sin conferencia", "providerCalendar": "Calendario del proveedor", + "formatBoldShortLabel": "N", + "formatBoldTooltip": "Negrita", + "formatItalicShortLabel": "C", + "formatItalicTooltip": "Cursiva", + "formatUnderlineShortLabel": "S", + "formatUnderlineTooltip": "Subrayado", + "reminderMinutesBefore": "{minutes, plural, =1{1 minuto antes} other{{minutes} minutos antes}}", + "@reminderMinutesBefore": {"placeholders": {"minutes": {"type": "int"}}}, + "reminderHoursBefore": "{hours, plural, =1{1 hora antes} other{{hours} horas antes}}", + "@reminderHoursBefore": {"placeholders": {"hours": {"type": "int"}}}, + "reminderDaysBefore": "{days, plural, =1{1 día antes} other{{days} días antes}}", + "@reminderDaysBefore": {"placeholders": {"days": {"type": "int"}}}, + "availabilityFree": "Libre", + "availabilityTentative": "Provisional", + "availabilityOutOfOffice": "Fuera de la oficina", + "availabilityWorkingElsewhere": "Trabajando en otro lugar", + "visibilityDefault": "Predeterminada", + "visibilityPublic": "Pública", + "visibilityPrivate": "Privada", + "visibilityConfidential": "Confidencial", + "sensitivityNormal": "Normal", + "sensitivityPersonal": "Personal", "tasks": "Tareas", "allTasks": "Todas las tareas", "tasksInList": "Tareas en {title}", @@ -284,11 +312,20 @@ "deleteLocalDataConfirmation": "Esto elimina de este dispositivo la cuenta local, las tareas sincronizadas y los cambios sin conexión pendientes.", "sync": "Sincronización", "manualFullSync": "Sincronización completa manual", + "runInBackgroundWhenClosed": "Seguir ejecutándose al cerrar la ventana", + "showTrayIcon": "Mostrar el icono en la bandeja del sistema", + "startMinimizedToTray": "Iniciar minimizado en la bandeja del sistema", "syncComplete": "Sincronización completa.", "syncFailed": "Error de sincronización: {error}", "notifySyncFailures": "Notificaciones de errores de sincronización", "notifyConflicts": "Notificaciones de conflictos", "notifyDueToday": "Notificaciones de tareas para hoy", + "eventReminders": "Recordatorios de eventos", + "taskReminders": "Recordatorios de tareas", + "notificationDetailLevel": "Nivel de detalle de las notificaciones", + "notificationDetailPrivate": "Privado", + "notificationDetailNormal": "Normal", + "quietHours": "Horario silencioso", "notifications": "Notificaciones", "appearance": "Apariencia", "theme": "Tema", @@ -331,5 +368,19 @@ "conflictNotificationBody": "Se bloqueó un cambio local pendiente. {summary}", "dueTodayNotificationTitle": "Tareas que vencen hoy", "dueTodayNotificationBody": "{count, plural, =1{Una tarea vence hoy.} other{{count} tareas vencen hoy.}}", - "notificationDetailsHidden": "Los detalles están ocultos por la configuración de privacidad." + "notificationDetailsHidden": "Los detalles están ocultos por la configuración de privacidad.", + "previousMonth": "Mes anterior", + "nextMonth": "Mes siguiente", + "openMonthView": "Abrir la vista mensual", + "previousYear": "Año anterior", + "nextYear": "Año siguiente", + "openYearView": "Abrir la vista anual", + "weekNumberTooltip": "Semana {number}", + "@weekNumberTooltip": {"placeholders": {"number": {"type": "int"}}}, + "resizeAllDayPanel": "Cambiar el tamaño del panel de todo el día", + "scheduleItemCount": "{count, plural, =1{1 elemento} other{{count} elementos}}", + "@scheduleItemCount": {"placeholders": {"count": {"type": "int"}}}, + "readOnlyCalendar": "Este calendario es de solo lectura.", + "deleteCalendarConfirmation": "¿Eliminar \"{title}\"?", + "@deleteCalendarConfirmation": {"placeholders": {"title": {"type": "String"}}} } diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index ef966a6..aab6a15 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -42,6 +42,12 @@ "allDay": "Toute la journée", "moreItems": "+{count} de plus", "noEventsOrTasks": "Aucun événement ni tâche", + "scheduleLoading": "Chargement du planning...", + "scheduleUnavailable": "Planning indisponible", + "scheduleNoSources": "Aucun calendrier ni liste de tâches visible", + "scheduleNoSourcesDescription": "Choisissez les éléments à afficher dans les paramètres, puis actualisez.", + "scheduleSignInRequired": "Connecter un compte", + "scheduleSignInDescription": "Connectez-vous pour synchroniser vos calendriers et vos tâches.", "trayAgendaLoading": "Chargement de l’agenda...", "trayAgendaSignInRequired": "Connectez-vous pour afficher l’agenda.", "trayAgendaNoSources": "Aucun calendrier ni liste de tâches visible.", @@ -97,6 +103,28 @@ "conference": "Conférence", "noConference": "Aucune conférence", "providerCalendar": "Calendrier du fournisseur", + "formatBoldShortLabel": "G", + "formatBoldTooltip": "Gras", + "formatItalicShortLabel": "I", + "formatItalicTooltip": "Italique", + "formatUnderlineShortLabel": "S", + "formatUnderlineTooltip": "Souligné", + "reminderMinutesBefore": "{minutes, plural, =1{1 minute avant} other{{minutes} minutes avant}}", + "@reminderMinutesBefore": {"placeholders": {"minutes": {"type": "int"}}}, + "reminderHoursBefore": "{hours, plural, =1{1 heure avant} other{{hours} heures avant}}", + "@reminderHoursBefore": {"placeholders": {"hours": {"type": "int"}}}, + "reminderDaysBefore": "{days, plural, =1{1 jour avant} other{{days} jours avant}}", + "@reminderDaysBefore": {"placeholders": {"days": {"type": "int"}}}, + "availabilityFree": "Disponible", + "availabilityTentative": "Provisoire", + "availabilityOutOfOffice": "Absent du bureau", + "availabilityWorkingElsewhere": "Travail ailleurs", + "visibilityDefault": "Par défaut", + "visibilityPublic": "Publique", + "visibilityPrivate": "Privée", + "visibilityConfidential": "Confidentielle", + "sensitivityNormal": "Normale", + "sensitivityPersonal": "Personnelle", "tasks": "Tâches", "allTasks": "Toutes les tâches", "tasksInList": "Tâches dans {title}", @@ -284,11 +312,20 @@ "deleteLocalDataConfirmation": "Cela supprime de cet appareil le compte local, les tâches synchronisées et les changements hors ligne en attente.", "sync": "Synchronisation", "manualFullSync": "Synchronisation complète manuelle", + "runInBackgroundWhenClosed": "Continuer à s’exécuter après la fermeture de la fenêtre", + "showTrayIcon": "Afficher l’icône dans la zone de notification", + "startMinimizedToTray": "Démarrer réduit dans la zone de notification", "syncComplete": "Synchronisation terminée.", "syncFailed": "Échec de la synchronisation : {error}", "notifySyncFailures": "Notifications d’échec de synchronisation", "notifyConflicts": "Notifications de conflits", "notifyDueToday": "Notifications des tâches dues aujourd’hui", + "eventReminders": "Rappels d’événements", + "taskReminders": "Rappels de tâches", + "notificationDetailLevel": "Niveau de détail des notifications", + "notificationDetailPrivate": "Privé", + "notificationDetailNormal": "Normal", + "quietHours": "Plages horaires silencieuses", "notifications": "Notifications", "appearance": "Apparence", "theme": "Thème", @@ -331,5 +368,19 @@ "conflictNotificationBody": "Une modification locale en attente a été bloquée. {summary}", "dueTodayNotificationTitle": "Tâches dues aujourd’hui", "dueTodayNotificationBody": "{count, plural, =1{Une tâche est due aujourd’hui.} other{{count} tâches sont dues aujourd’hui.}}", - "notificationDetailsHidden": "Les détails sont masqués par les paramètres de confidentialité." + "notificationDetailsHidden": "Les détails sont masqués par les paramètres de confidentialité.", + "previousMonth": "Mois précédent", + "nextMonth": "Mois suivant", + "openMonthView": "Ouvrir la vue mensuelle", + "previousYear": "Année précédente", + "nextYear": "Année suivante", + "openYearView": "Ouvrir la vue annuelle", + "weekNumberTooltip": "Semaine {number}", + "@weekNumberTooltip": {"placeholders": {"number": {"type": "int"}}}, + "resizeAllDayPanel": "Redimensionner le volet des événements sur toute la journée", + "scheduleItemCount": "{count, plural, =1{1 élément} other{{count} éléments}}", + "@scheduleItemCount": {"placeholders": {"count": {"type": "int"}}}, + "readOnlyCalendar": "Ce calendrier est en lecture seule.", + "deleteCalendarConfirmation": "Supprimer \"{title}\" ?", + "@deleteCalendarConfirmation": {"placeholders": {"title": {"type": "String"}}} } diff --git a/lib/l10n/generated/app_localizations.dart b/lib/l10n/generated/app_localizations.dart index 5795906..3e23ace 100644 --- a/lib/l10n/generated/app_localizations.dart +++ b/lib/l10n/generated/app_localizations.dart @@ -354,6 +354,42 @@ abstract class AppLocalizations { /// **'No events or tasks'** String get noEventsOrTasks; + /// No description provided for @scheduleLoading. + /// + /// In en, this message translates to: + /// **'Loading schedule...'** + String get scheduleLoading; + + /// No description provided for @scheduleUnavailable. + /// + /// In en, this message translates to: + /// **'Schedule unavailable'** + String get scheduleUnavailable; + + /// No description provided for @scheduleNoSources. + /// + /// In en, this message translates to: + /// **'No visible calendars or task lists'** + String get scheduleNoSources; + + /// No description provided for @scheduleNoSourcesDescription. + /// + /// In en, this message translates to: + /// **'Choose what to show in Settings, then refresh.'** + String get scheduleNoSourcesDescription; + + /// No description provided for @scheduleSignInRequired. + /// + /// In en, this message translates to: + /// **'Connect an account'** + String get scheduleSignInRequired; + + /// No description provided for @scheduleSignInDescription. + /// + /// In en, this message translates to: + /// **'Sign in to sync calendars and tasks.'** + String get scheduleSignInDescription; + /// No description provided for @trayAgendaLoading. /// /// In en, this message translates to: @@ -684,6 +720,120 @@ abstract class AppLocalizations { /// **'Provider calendar'** String get providerCalendar; + /// No description provided for @formatBoldShortLabel. + /// + /// In en, this message translates to: + /// **'B'** + String get formatBoldShortLabel; + + /// No description provided for @formatBoldTooltip. + /// + /// In en, this message translates to: + /// **'Bold'** + String get formatBoldTooltip; + + /// No description provided for @formatItalicShortLabel. + /// + /// In en, this message translates to: + /// **'I'** + String get formatItalicShortLabel; + + /// No description provided for @formatItalicTooltip. + /// + /// In en, this message translates to: + /// **'Italic'** + String get formatItalicTooltip; + + /// No description provided for @formatUnderlineShortLabel. + /// + /// In en, this message translates to: + /// **'U'** + String get formatUnderlineShortLabel; + + /// No description provided for @formatUnderlineTooltip. + /// + /// In en, this message translates to: + /// **'Underline'** + String get formatUnderlineTooltip; + + /// No description provided for @reminderMinutesBefore. + /// + /// In en, this message translates to: + /// **'{minutes, plural, =1{1 minute before} other{{minutes} minutes before}}'** + String reminderMinutesBefore(int minutes); + + /// No description provided for @reminderHoursBefore. + /// + /// In en, this message translates to: + /// **'{hours, plural, =1{1 hour before} other{{hours} hours before}}'** + String reminderHoursBefore(int hours); + + /// No description provided for @reminderDaysBefore. + /// + /// In en, this message translates to: + /// **'{days, plural, =1{1 day before} other{{days} days before}}'** + String reminderDaysBefore(int days); + + /// No description provided for @availabilityFree. + /// + /// In en, this message translates to: + /// **'Free'** + String get availabilityFree; + + /// No description provided for @availabilityTentative. + /// + /// In en, this message translates to: + /// **'Tentative'** + String get availabilityTentative; + + /// No description provided for @availabilityOutOfOffice. + /// + /// In en, this message translates to: + /// **'Out of office'** + String get availabilityOutOfOffice; + + /// No description provided for @availabilityWorkingElsewhere. + /// + /// In en, this message translates to: + /// **'Working elsewhere'** + String get availabilityWorkingElsewhere; + + /// No description provided for @visibilityDefault. + /// + /// In en, this message translates to: + /// **'Default'** + String get visibilityDefault; + + /// No description provided for @visibilityPublic. + /// + /// In en, this message translates to: + /// **'Public'** + String get visibilityPublic; + + /// No description provided for @visibilityPrivate. + /// + /// In en, this message translates to: + /// **'Private'** + String get visibilityPrivate; + + /// No description provided for @visibilityConfidential. + /// + /// In en, this message translates to: + /// **'Confidential'** + String get visibilityConfidential; + + /// No description provided for @sensitivityNormal. + /// + /// In en, this message translates to: + /// **'Normal'** + String get sensitivityNormal; + + /// No description provided for @sensitivityPersonal. + /// + /// In en, this message translates to: + /// **'Personal'** + String get sensitivityPersonal; + /// No description provided for @tasks. /// /// In en, this message translates to: @@ -1794,6 +1944,24 @@ abstract class AppLocalizations { /// **'Manual full sync'** String get manualFullSync; + /// No description provided for @runInBackgroundWhenClosed. + /// + /// In en, this message translates to: + /// **'Continue running when the window is closed'** + String get runInBackgroundWhenClosed; + + /// No description provided for @showTrayIcon. + /// + /// In en, this message translates to: + /// **'Show tray icon'** + String get showTrayIcon; + + /// No description provided for @startMinimizedToTray. + /// + /// In en, this message translates to: + /// **'Start minimized to the tray'** + String get startMinimizedToTray; + /// No description provided for @syncComplete. /// /// In en, this message translates to: @@ -1824,6 +1992,42 @@ abstract class AppLocalizations { /// **'Due-today notifications'** String get notifyDueToday; + /// No description provided for @eventReminders. + /// + /// In en, this message translates to: + /// **'Event reminders'** + String get eventReminders; + + /// No description provided for @taskReminders. + /// + /// In en, this message translates to: + /// **'Task reminders'** + String get taskReminders; + + /// No description provided for @notificationDetailLevel. + /// + /// In en, this message translates to: + /// **'Notification detail level'** + String get notificationDetailLevel; + + /// No description provided for @notificationDetailPrivate. + /// + /// In en, this message translates to: + /// **'Private'** + String get notificationDetailPrivate; + + /// No description provided for @notificationDetailNormal. + /// + /// In en, this message translates to: + /// **'Normal'** + String get notificationDetailNormal; + + /// No description provided for @quietHours. + /// + /// In en, this message translates to: + /// **'Quiet hours'** + String get quietHours; + /// No description provided for @notifications. /// /// In en, this message translates to: @@ -2081,6 +2285,72 @@ abstract class AppLocalizations { /// In en, this message translates to: /// **'Details are hidden by privacy settings.'** String get notificationDetailsHidden; + + /// No description provided for @previousMonth. + /// + /// In en, this message translates to: + /// **'Previous month'** + String get previousMonth; + + /// No description provided for @nextMonth. + /// + /// In en, this message translates to: + /// **'Next month'** + String get nextMonth; + + /// No description provided for @openMonthView. + /// + /// In en, this message translates to: + /// **'Open month view'** + String get openMonthView; + + /// No description provided for @previousYear. + /// + /// In en, this message translates to: + /// **'Previous year'** + String get previousYear; + + /// No description provided for @nextYear. + /// + /// In en, this message translates to: + /// **'Next year'** + String get nextYear; + + /// No description provided for @openYearView. + /// + /// In en, this message translates to: + /// **'Open year view'** + String get openYearView; + + /// No description provided for @weekNumberTooltip. + /// + /// In en, this message translates to: + /// **'Week {number}'** + String weekNumberTooltip(int number); + + /// No description provided for @resizeAllDayPanel. + /// + /// In en, this message translates to: + /// **'Resize the all-day panel'** + String get resizeAllDayPanel; + + /// No description provided for @scheduleItemCount. + /// + /// In en, this message translates to: + /// **'{count, plural, =1{1 item} other{{count} items}}'** + String scheduleItemCount(int count); + + /// No description provided for @readOnlyCalendar. + /// + /// In en, this message translates to: + /// **'This calendar is read-only.'** + String get readOnlyCalendar; + + /// No description provided for @deleteCalendarConfirmation. + /// + /// In en, this message translates to: + /// **'Delete \"{title}\"?'** + String deleteCalendarConfirmation(String title); } class _AppLocalizationsDelegate diff --git a/lib/l10n/generated/app_localizations_de.dart b/lib/l10n/generated/app_localizations_de.dart index c758ce0..c5bc7be 100644 --- a/lib/l10n/generated/app_localizations_de.dart +++ b/lib/l10n/generated/app_localizations_de.dart @@ -142,6 +142,27 @@ class AppLocalizationsDe extends AppLocalizations { @override String get noEventsOrTasks => 'Keine Termine oder Aufgaben'; + @override + String get scheduleLoading => 'Zeitplan wird geladen...'; + + @override + String get scheduleUnavailable => 'Zeitplan nicht verfügbar'; + + @override + String get scheduleNoSources => + 'Keine sichtbaren Kalender oder Aufgabenlisten'; + + @override + String get scheduleNoSourcesDescription => + 'Wählen Sie in den Einstellungen aus, was angezeigt werden soll, und aktualisieren Sie anschließend.'; + + @override + String get scheduleSignInRequired => 'Konto verbinden'; + + @override + String get scheduleSignInDescription => + 'Melden Sie sich an, um Kalender und Aufgaben zu synchronisieren.'; + @override String get trayAgendaLoading => 'Agenda wird geladen...'; @@ -312,6 +333,87 @@ class AppLocalizationsDe extends AppLocalizations { @override String get providerCalendar => 'Anbieterkalender'; + @override + String get formatBoldShortLabel => 'F'; + + @override + String get formatBoldTooltip => 'Fett'; + + @override + String get formatItalicShortLabel => 'K'; + + @override + String get formatItalicTooltip => 'Kursiv'; + + @override + String get formatUnderlineShortLabel => 'U'; + + @override + String get formatUnderlineTooltip => 'Unterstrichen'; + + @override + String reminderMinutesBefore(int minutes) { + String _temp0 = intl.Intl.pluralLogic( + minutes, + locale: localeName, + other: '$minutes Minuten vorher', + one: '1 Minute vorher', + ); + return '$_temp0'; + } + + @override + String reminderHoursBefore(int hours) { + String _temp0 = intl.Intl.pluralLogic( + hours, + locale: localeName, + other: '$hours Stunden vorher', + one: '1 Stunde vorher', + ); + return '$_temp0'; + } + + @override + String reminderDaysBefore(int days) { + String _temp0 = intl.Intl.pluralLogic( + days, + locale: localeName, + other: '$days Tage vorher', + one: '1 Tag vorher', + ); + return '$_temp0'; + } + + @override + String get availabilityFree => 'Frei'; + + @override + String get availabilityTentative => 'Mit Vorbehalt'; + + @override + String get availabilityOutOfOffice => 'Abwesend'; + + @override + String get availabilityWorkingElsewhere => 'An einem anderen Ort'; + + @override + String get visibilityDefault => 'Standard'; + + @override + String get visibilityPublic => 'Öffentlich'; + + @override + String get visibilityPrivate => 'Privat'; + + @override + String get visibilityConfidential => 'Vertraulich'; + + @override + String get sensitivityNormal => 'Normal'; + + @override + String get sensitivityPersonal => 'Persönlich'; + @override String get tasks => 'Aufgaben'; @@ -911,6 +1013,17 @@ class AppLocalizationsDe extends AppLocalizations { @override String get manualFullSync => 'Manuelle vollständige Synchronisierung'; + @override + String get runInBackgroundWhenClosed => + 'Nach dem Schließen des Fensters weiter ausführen'; + + @override + String get showTrayIcon => 'Symbol im Benachrichtigungsbereich anzeigen'; + + @override + String get startMinimizedToTray => + 'Minimiert im Benachrichtigungsbereich starten'; + @override String get syncComplete => 'Synchronisierung abgeschlossen.'; @@ -929,6 +1042,24 @@ class AppLocalizationsDe extends AppLocalizations { @override String get notifyDueToday => 'Benachrichtigungen für heute fällige Aufgaben'; + @override + String get eventReminders => 'Terminerinnerungen'; + + @override + String get taskReminders => 'Aufgabenerinnerungen'; + + @override + String get notificationDetailLevel => 'Detailgrad der Benachrichtigungen'; + + @override + String get notificationDetailPrivate => 'Privat'; + + @override + String get notificationDetailNormal => 'Normal'; + + @override + String get quietHours => 'Ruhezeiten'; + @override String get notifications => 'Benachrichtigungen'; @@ -1085,4 +1216,50 @@ class AppLocalizationsDe extends AppLocalizations { @override String get notificationDetailsHidden => 'Details werden durch Datenschutzeinstellungen ausgeblendet.'; + + @override + String get previousMonth => 'Vorheriger Monat'; + + @override + String get nextMonth => 'Nächster Monat'; + + @override + String get openMonthView => 'Monatsansicht öffnen'; + + @override + String get previousYear => 'Vorheriges Jahr'; + + @override + String get nextYear => 'Nächstes Jahr'; + + @override + String get openYearView => 'Jahresansicht öffnen'; + + @override + String weekNumberTooltip(int number) { + return 'Woche $number'; + } + + @override + String get resizeAllDayPanel => + 'Ganztägigen Bereich vergrößern oder verkleinern'; + + @override + String scheduleItemCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count Einträge', + one: '1 Eintrag', + ); + return '$_temp0'; + } + + @override + String get readOnlyCalendar => 'Dieser Kalender ist schreibgeschützt.'; + + @override + String deleteCalendarConfirmation(String title) { + return '\"$title\" löschen?'; + } } diff --git a/lib/l10n/generated/app_localizations_en.dart b/lib/l10n/generated/app_localizations_en.dart index 684be92..f441904 100644 --- a/lib/l10n/generated/app_localizations_en.dart +++ b/lib/l10n/generated/app_localizations_en.dart @@ -142,6 +142,26 @@ class AppLocalizationsEn extends AppLocalizations { @override String get noEventsOrTasks => 'No events or tasks'; + @override + String get scheduleLoading => 'Loading schedule...'; + + @override + String get scheduleUnavailable => 'Schedule unavailable'; + + @override + String get scheduleNoSources => 'No visible calendars or task lists'; + + @override + String get scheduleNoSourcesDescription => + 'Choose what to show in Settings, then refresh.'; + + @override + String get scheduleSignInRequired => 'Connect an account'; + + @override + String get scheduleSignInDescription => + 'Sign in to sync calendars and tasks.'; + @override String get trayAgendaLoading => 'Loading agenda...'; @@ -310,6 +330,87 @@ class AppLocalizationsEn extends AppLocalizations { @override String get providerCalendar => 'Provider calendar'; + @override + String get formatBoldShortLabel => 'B'; + + @override + String get formatBoldTooltip => 'Bold'; + + @override + String get formatItalicShortLabel => 'I'; + + @override + String get formatItalicTooltip => 'Italic'; + + @override + String get formatUnderlineShortLabel => 'U'; + + @override + String get formatUnderlineTooltip => 'Underline'; + + @override + String reminderMinutesBefore(int minutes) { + String _temp0 = intl.Intl.pluralLogic( + minutes, + locale: localeName, + other: '$minutes minutes before', + one: '1 minute before', + ); + return '$_temp0'; + } + + @override + String reminderHoursBefore(int hours) { + String _temp0 = intl.Intl.pluralLogic( + hours, + locale: localeName, + other: '$hours hours before', + one: '1 hour before', + ); + return '$_temp0'; + } + + @override + String reminderDaysBefore(int days) { + String _temp0 = intl.Intl.pluralLogic( + days, + locale: localeName, + other: '$days days before', + one: '1 day before', + ); + return '$_temp0'; + } + + @override + String get availabilityFree => 'Free'; + + @override + String get availabilityTentative => 'Tentative'; + + @override + String get availabilityOutOfOffice => 'Out of office'; + + @override + String get availabilityWorkingElsewhere => 'Working elsewhere'; + + @override + String get visibilityDefault => 'Default'; + + @override + String get visibilityPublic => 'Public'; + + @override + String get visibilityPrivate => 'Private'; + + @override + String get visibilityConfidential => 'Confidential'; + + @override + String get sensitivityNormal => 'Normal'; + + @override + String get sensitivityPersonal => 'Personal'; + @override String get tasks => 'Tasks'; @@ -902,6 +1003,16 @@ class AppLocalizationsEn extends AppLocalizations { @override String get manualFullSync => 'Manual full sync'; + @override + String get runInBackgroundWhenClosed => + 'Continue running when the window is closed'; + + @override + String get showTrayIcon => 'Show tray icon'; + + @override + String get startMinimizedToTray => 'Start minimized to the tray'; + @override String get syncComplete => 'Sync complete.'; @@ -919,6 +1030,24 @@ class AppLocalizationsEn extends AppLocalizations { @override String get notifyDueToday => 'Due-today notifications'; + @override + String get eventReminders => 'Event reminders'; + + @override + String get taskReminders => 'Task reminders'; + + @override + String get notificationDetailLevel => 'Notification detail level'; + + @override + String get notificationDetailPrivate => 'Private'; + + @override + String get notificationDetailNormal => 'Normal'; + + @override + String get quietHours => 'Quiet hours'; + @override String get notifications => 'Notifications'; @@ -1072,4 +1201,49 @@ class AppLocalizationsEn extends AppLocalizations { @override String get notificationDetailsHidden => 'Details are hidden by privacy settings.'; + + @override + String get previousMonth => 'Previous month'; + + @override + String get nextMonth => 'Next month'; + + @override + String get openMonthView => 'Open month view'; + + @override + String get previousYear => 'Previous year'; + + @override + String get nextYear => 'Next year'; + + @override + String get openYearView => 'Open year view'; + + @override + String weekNumberTooltip(int number) { + return 'Week $number'; + } + + @override + String get resizeAllDayPanel => 'Resize the all-day panel'; + + @override + String scheduleItemCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count items', + one: '1 item', + ); + return '$_temp0'; + } + + @override + String get readOnlyCalendar => 'This calendar is read-only.'; + + @override + String deleteCalendarConfirmation(String title) { + return 'Delete \"$title\"?'; + } } diff --git a/lib/l10n/generated/app_localizations_es.dart b/lib/l10n/generated/app_localizations_es.dart index 519ff32..c39d303 100644 --- a/lib/l10n/generated/app_localizations_es.dart +++ b/lib/l10n/generated/app_localizations_es.dart @@ -144,6 +144,27 @@ class AppLocalizationsEs extends AppLocalizations { @override String get noEventsOrTasks => 'No hay eventos ni tareas'; + @override + String get scheduleLoading => 'Cargando la agenda...'; + + @override + String get scheduleUnavailable => 'Agenda no disponible'; + + @override + String get scheduleNoSources => + 'No hay calendarios ni listas de tareas visibles'; + + @override + String get scheduleNoSourcesDescription => + 'Elige qué mostrar en Ajustes y, después, actualiza.'; + + @override + String get scheduleSignInRequired => 'Conectar una cuenta'; + + @override + String get scheduleSignInDescription => + 'Inicia sesión para sincronizar calendarios y tareas.'; + @override String get trayAgendaLoading => 'Cargando agenda...'; @@ -314,6 +335,87 @@ class AppLocalizationsEs extends AppLocalizations { @override String get providerCalendar => 'Calendario del proveedor'; + @override + String get formatBoldShortLabel => 'N'; + + @override + String get formatBoldTooltip => 'Negrita'; + + @override + String get formatItalicShortLabel => 'C'; + + @override + String get formatItalicTooltip => 'Cursiva'; + + @override + String get formatUnderlineShortLabel => 'S'; + + @override + String get formatUnderlineTooltip => 'Subrayado'; + + @override + String reminderMinutesBefore(int minutes) { + String _temp0 = intl.Intl.pluralLogic( + minutes, + locale: localeName, + other: '$minutes minutos antes', + one: '1 minuto antes', + ); + return '$_temp0'; + } + + @override + String reminderHoursBefore(int hours) { + String _temp0 = intl.Intl.pluralLogic( + hours, + locale: localeName, + other: '$hours horas antes', + one: '1 hora antes', + ); + return '$_temp0'; + } + + @override + String reminderDaysBefore(int days) { + String _temp0 = intl.Intl.pluralLogic( + days, + locale: localeName, + other: '$days días antes', + one: '1 día antes', + ); + return '$_temp0'; + } + + @override + String get availabilityFree => 'Libre'; + + @override + String get availabilityTentative => 'Provisional'; + + @override + String get availabilityOutOfOffice => 'Fuera de la oficina'; + + @override + String get availabilityWorkingElsewhere => 'Trabajando en otro lugar'; + + @override + String get visibilityDefault => 'Predeterminada'; + + @override + String get visibilityPublic => 'Pública'; + + @override + String get visibilityPrivate => 'Privada'; + + @override + String get visibilityConfidential => 'Confidencial'; + + @override + String get sensitivityNormal => 'Normal'; + + @override + String get sensitivityPersonal => 'Personal'; + @override String get tasks => 'Tareas'; @@ -910,6 +1012,17 @@ class AppLocalizationsEs extends AppLocalizations { @override String get manualFullSync => 'Sincronización completa manual'; + @override + String get runInBackgroundWhenClosed => + 'Seguir ejecutándose al cerrar la ventana'; + + @override + String get showTrayIcon => 'Mostrar el icono en la bandeja del sistema'; + + @override + String get startMinimizedToTray => + 'Iniciar minimizado en la bandeja del sistema'; + @override String get syncComplete => 'Sincronización completa.'; @@ -928,6 +1041,25 @@ class AppLocalizationsEs extends AppLocalizations { @override String get notifyDueToday => 'Notificaciones de tareas para hoy'; + @override + String get eventReminders => 'Recordatorios de eventos'; + + @override + String get taskReminders => 'Recordatorios de tareas'; + + @override + String get notificationDetailLevel => + 'Nivel de detalle de las notificaciones'; + + @override + String get notificationDetailPrivate => 'Privado'; + + @override + String get notificationDetailNormal => 'Normal'; + + @override + String get quietHours => 'Horario silencioso'; + @override String get notifications => 'Notificaciones'; @@ -1085,4 +1217,49 @@ class AppLocalizationsEs extends AppLocalizations { @override String get notificationDetailsHidden => 'Los detalles están ocultos por la configuración de privacidad.'; + + @override + String get previousMonth => 'Mes anterior'; + + @override + String get nextMonth => 'Mes siguiente'; + + @override + String get openMonthView => 'Abrir la vista mensual'; + + @override + String get previousYear => 'Año anterior'; + + @override + String get nextYear => 'Año siguiente'; + + @override + String get openYearView => 'Abrir la vista anual'; + + @override + String weekNumberTooltip(int number) { + return 'Semana $number'; + } + + @override + String get resizeAllDayPanel => 'Cambiar el tamaño del panel de todo el día'; + + @override + String scheduleItemCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count elementos', + one: '1 elemento', + ); + return '$_temp0'; + } + + @override + String get readOnlyCalendar => 'Este calendario es de solo lectura.'; + + @override + String deleteCalendarConfirmation(String title) { + return '¿Eliminar \"$title\"?'; + } } diff --git a/lib/l10n/generated/app_localizations_fr.dart b/lib/l10n/generated/app_localizations_fr.dart index 302b989..2f83315 100644 --- a/lib/l10n/generated/app_localizations_fr.dart +++ b/lib/l10n/generated/app_localizations_fr.dart @@ -143,6 +143,26 @@ class AppLocalizationsFr extends AppLocalizations { @override String get noEventsOrTasks => 'Aucun événement ni tâche'; + @override + String get scheduleLoading => 'Chargement du planning...'; + + @override + String get scheduleUnavailable => 'Planning indisponible'; + + @override + String get scheduleNoSources => 'Aucun calendrier ni liste de tâches visible'; + + @override + String get scheduleNoSourcesDescription => + 'Choisissez les éléments à afficher dans les paramètres, puis actualisez.'; + + @override + String get scheduleSignInRequired => 'Connecter un compte'; + + @override + String get scheduleSignInDescription => + 'Connectez-vous pour synchroniser vos calendriers et vos tâches.'; + @override String get trayAgendaLoading => 'Chargement de l’agenda...'; @@ -313,6 +333,87 @@ class AppLocalizationsFr extends AppLocalizations { @override String get providerCalendar => 'Calendrier du fournisseur'; + @override + String get formatBoldShortLabel => 'G'; + + @override + String get formatBoldTooltip => 'Gras'; + + @override + String get formatItalicShortLabel => 'I'; + + @override + String get formatItalicTooltip => 'Italique'; + + @override + String get formatUnderlineShortLabel => 'S'; + + @override + String get formatUnderlineTooltip => 'Souligné'; + + @override + String reminderMinutesBefore(int minutes) { + String _temp0 = intl.Intl.pluralLogic( + minutes, + locale: localeName, + other: '$minutes minutes avant', + one: '1 minute avant', + ); + return '$_temp0'; + } + + @override + String reminderHoursBefore(int hours) { + String _temp0 = intl.Intl.pluralLogic( + hours, + locale: localeName, + other: '$hours heures avant', + one: '1 heure avant', + ); + return '$_temp0'; + } + + @override + String reminderDaysBefore(int days) { + String _temp0 = intl.Intl.pluralLogic( + days, + locale: localeName, + other: '$days jours avant', + one: '1 jour avant', + ); + return '$_temp0'; + } + + @override + String get availabilityFree => 'Disponible'; + + @override + String get availabilityTentative => 'Provisoire'; + + @override + String get availabilityOutOfOffice => 'Absent du bureau'; + + @override + String get availabilityWorkingElsewhere => 'Travail ailleurs'; + + @override + String get visibilityDefault => 'Par défaut'; + + @override + String get visibilityPublic => 'Publique'; + + @override + String get visibilityPrivate => 'Privée'; + + @override + String get visibilityConfidential => 'Confidentielle'; + + @override + String get sensitivityNormal => 'Normale'; + + @override + String get sensitivityPersonal => 'Personnelle'; + @override String get tasks => 'Tâches'; @@ -910,6 +1011,17 @@ class AppLocalizationsFr extends AppLocalizations { @override String get manualFullSync => 'Synchronisation complète manuelle'; + @override + String get runInBackgroundWhenClosed => + 'Continuer à s’exécuter après la fermeture de la fenêtre'; + + @override + String get showTrayIcon => 'Afficher l’icône dans la zone de notification'; + + @override + String get startMinimizedToTray => + 'Démarrer réduit dans la zone de notification'; + @override String get syncComplete => 'Synchronisation terminée.'; @@ -927,6 +1039,24 @@ class AppLocalizationsFr extends AppLocalizations { @override String get notifyDueToday => 'Notifications des tâches dues aujourd’hui'; + @override + String get eventReminders => 'Rappels d’événements'; + + @override + String get taskReminders => 'Rappels de tâches'; + + @override + String get notificationDetailLevel => 'Niveau de détail des notifications'; + + @override + String get notificationDetailPrivate => 'Privé'; + + @override + String get notificationDetailNormal => 'Normal'; + + @override + String get quietHours => 'Plages horaires silencieuses'; + @override String get notifications => 'Notifications'; @@ -1083,4 +1213,50 @@ class AppLocalizationsFr extends AppLocalizations { @override String get notificationDetailsHidden => 'Les détails sont masqués par les paramètres de confidentialité.'; + + @override + String get previousMonth => 'Mois précédent'; + + @override + String get nextMonth => 'Mois suivant'; + + @override + String get openMonthView => 'Ouvrir la vue mensuelle'; + + @override + String get previousYear => 'Année précédente'; + + @override + String get nextYear => 'Année suivante'; + + @override + String get openYearView => 'Ouvrir la vue annuelle'; + + @override + String weekNumberTooltip(int number) { + return 'Semaine $number'; + } + + @override + String get resizeAllDayPanel => + 'Redimensionner le volet des événements sur toute la journée'; + + @override + String scheduleItemCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count éléments', + one: '1 élément', + ); + return '$_temp0'; + } + + @override + String get readOnlyCalendar => 'Ce calendrier est en lecture seule.'; + + @override + String deleteCalendarConfirmation(String title) { + return 'Supprimer \"$title\" ?'; + } } diff --git a/lib/src/app/busymax_about_dialog.dart b/lib/src/app/busymax_about_dialog.dart index 02c7db8..373a7aa 100644 --- a/lib/src/app/busymax_about_dialog.dart +++ b/lib/src/app/busymax_about_dialog.dart @@ -10,6 +10,7 @@ import '../features/feedback/presentation/feedback_dialog.dart'; import '../l10n/l10n.dart'; import '../platform/linux_header_bar_service.dart'; import 'busymax_design.dart'; +import 'busymax_dialogs.dart'; const _busyMaxWebsiteUri = 'https://github.com/busystack/busymax'; const _busyMaxIssuesUri = 'https://github.com/busystack/busymax/issues'; @@ -19,24 +20,14 @@ Future showBusyMaxAboutDialog( required FeedbackSubmissionService feedbackSubmissionService, LinuxHeaderBarService? headerBarService, }) async { - final service = headerBarService; - if (service != null) { - unawaited(service.setModalBarrierVisible(true)); - } - _BusyMaxAboutAction? action; - try { - action = await showDialog<_BusyMaxAboutAction>( - context: context, - builder: (dialogContext) => BusyMaxAboutDialog( - onSendFeedback: () => - Navigator.of(dialogContext).pop(_BusyMaxAboutAction.sendFeedback), - ), - ); - } finally { - if (service != null) { - unawaited(service.setModalBarrierVisible(false)); - } - } + final action = await showBusyMaxModalDialog<_BusyMaxAboutAction>( + context, + headerBarService: headerBarService, + builder: (dialogContext) => BusyMaxAboutDialog( + onSendFeedback: () => + Navigator.of(dialogContext).pop(_BusyMaxAboutAction.sendFeedback), + ), + ); if (action == _BusyMaxAboutAction.sendFeedback && context.mounted) { await showBusyMaxFeedbackDialog( context, diff --git a/lib/src/app/busymax_app.dart b/lib/src/app/busymax_app.dart index 3331e9a..719577b 100644 --- a/lib/src/app/busymax_app.dart +++ b/lib/src/app/busymax_app.dart @@ -2,7 +2,6 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:flutter/services.dart'; import 'package:system_theme/system_theme.dart'; import 'package:ubuntu_localizations/ubuntu_localizations.dart'; @@ -14,6 +13,7 @@ import '../platform/main_window_command_bridge.dart'; import 'app_bootstrap.dart'; import 'app_router.dart'; import 'busymax_keyboard_shortcuts_dialog.dart'; +import 'busymax_shortcuts.dart'; import '../../l10n/generated/app_localizations.dart'; import 'busymax_yaru_theme.dart'; import 'busymax_design.dart'; @@ -124,8 +124,9 @@ class _BusyMaxAppState extends ConsumerState { ); return Shortcuts( shortcuts: const { - SingleActivator(LogicalKeyboardKey.slash, control: true): + BusyMaxShortcutActivators.keyboardShortcuts: _KeyboardShortcutsIntent(), + BusyMaxShortcutActivators.settings: _OpenSettingsIntent(), }, child: Actions( actions: { @@ -147,6 +148,12 @@ class _BusyMaxAppState extends ConsumerState { return null; }, ), + _OpenSettingsIntent: CallbackAction<_OpenSettingsIntent>( + onInvoke: (intent) { + router.go('/settings'); + return null; + }, + ), }, child: MainWindowCommandBridge( child: _BusyMaxWindowCornerClip( @@ -167,9 +174,7 @@ class _BusyMaxAppState extends ConsumerState { final colorScheme = Theme.of(context).colorScheme; final l10n = AppLocalizations.of(context); final materialL10n = MaterialLocalizations.of(context); - final modalBarrierColor = Theme.of( - context, - ).colorScheme.scrim.withValues(alpha: 0.32); + final modalBarrierColor = busyMaxModalBarrierColor(context); final preferDark = Theme.of(context).brightness == Brightness.dark; final labels = BusyMaxHeaderBarLabels( today: l10n.today, @@ -337,6 +342,10 @@ class _KeyboardShortcutsIntent extends Intent { const _KeyboardShortcutsIntent(); } +class _OpenSettingsIntent extends Intent { + const _OpenSettingsIntent(); +} + class _BusyMaxWindowCornerClip extends StatelessWidget { const _BusyMaxWindowCornerClip({required this.child}); diff --git a/lib/src/app/busymax_design.dart b/lib/src/app/busymax_design.dart index 531de32..42e0ea6 100644 --- a/lib/src/app/busymax_design.dart +++ b/lib/src/app/busymax_design.dart @@ -1,3 +1,6 @@ +import 'dart:async'; + +import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:ubuntu_widgets/ubuntu_widgets.dart'; import 'package:yaru/yaru.dart'; @@ -58,6 +61,15 @@ abstract final class BusyMaxElevation { static const double window = 12; } +abstract final class BusyMaxAlpha { + static const double modalBarrier = 0.32; +} + +abstract final class BusyMaxMotion { + static const Duration dialogInsets = Duration(milliseconds: 160); + static const Curve dialogInsetsCurve = Curves.easeOutCubic; +} + abstract final class BusyMaxShadow { static const double floatingBlur = 24; static const Offset floatingOffset = Offset(0, 8); @@ -73,8 +85,11 @@ abstract final class BusyMaxShadow { return BusyMaxSurfaceColors.of(context).shade; } - static Color tooltipColor(BuildContext context) { - return _scaleAlpha(floatingColor(context), 1.45); + /// Flutter's physical-elevation renderer applies its own ambient and spot + /// opacity. It therefore needs the theme's unattenuated semantic shadow, + /// unlike [BoxShadow], which consumes the GTK shade alpha directly. + static Color physicalColor(BuildContext context) { + return Theme.of(context).colorScheme.shadow; } static List floatingShadows(Color color) { @@ -179,7 +194,7 @@ class BusyMaxPopoverSurface extends StatelessWidget { ), color: color, elevation: BusyMaxElevation.tooltip, - shadowColor: BusyMaxShadow.tooltipColor(context), + shadowColor: BusyMaxShadow.physicalColor(context), clipBehavior: Clip.antiAlias, child: Padding( padding: EdgeInsets.only( @@ -427,7 +442,7 @@ MenuStyle busyMaxDropdownMenuStyle(BuildContext context, {double? minWidth}) { popupTheme.color ?? colorScheme.surfaceContainerHigh, ), surfaceTintColor: const WidgetStatePropertyAll(Colors.transparent), - shadowColor: WidgetStatePropertyAll(BusyMaxShadow.floatingColor(context)), + shadowColor: WidgetStatePropertyAll(BusyMaxShadow.physicalColor(context)), elevation: const WidgetStatePropertyAll(BusyMaxElevation.popover), padding: const WidgetStatePropertyAll(EdgeInsets.symmetric(vertical: 4)), shape: WidgetStatePropertyAll( @@ -668,7 +683,9 @@ Color busyMaxEditorRowHoverColor(BuildContext context) { } Color busyMaxModalBarrierColor(BuildContext context) { - return Theme.of(context).colorScheme.scrim.withValues(alpha: 0.32); + return Theme.of( + context, + ).colorScheme.scrim.withValues(alpha: BusyMaxAlpha.modalBarrier); } Color busyMaxPanelBorder(BuildContext context) { @@ -782,12 +799,14 @@ class BusyMaxSurface extends StatelessWidget { required this.child, this.filled = true, this.color, + this.side = BorderSide.none, this.clipBehavior = Clip.antiAlias, }); final Widget child; final bool filled; final Color? color; + final BorderSide side; final Clip clipBehavior; @override @@ -797,9 +816,9 @@ class BusyMaxSurface extends StatelessWidget { return Material( color: filled ? color ?? surfaceColors.card : Colors.transparent, elevation: filled ? BusyMaxElevation.surface : 0, - shadowColor: BusyMaxShadow.floatingColor(context), + shadowColor: BusyMaxShadow.physicalColor(context), surfaceTintColor: Colors.transparent, - shape: RoundedRectangleBorder(borderRadius: borderRadius), + shape: RoundedRectangleBorder(borderRadius: borderRadius, side: side), clipBehavior: clipBehavior, child: child, ); @@ -818,14 +837,10 @@ class BusyMaxGroupedSurface extends StatelessWidget { @override Widget build(BuildContext context) { - final borderRadius = BorderRadius.circular(BusyMaxRadius.md); final surfaceColors = BusyMaxSurfaceColors.of(context); - return Material( - color: surfaceColors.control, - elevation: BusyMaxElevation.surface, - shadowColor: BusyMaxShadow.floatingColor(context), - surfaceTintColor: Colors.transparent, - shape: RoundedRectangleBorder(borderRadius: borderRadius), + return BusyMaxSurface( + color: surfaceColors.groupedSurface, + side: BorderSide(color: surfaceColors.subtleBorder), clipBehavior: clipBehavior, child: child, ); @@ -861,101 +876,10 @@ class _BusyMaxGroupedListSurface extends StatelessWidget { } } -class _BusyMaxRowTile extends StatelessWidget { - const _BusyMaxRowTile({ - this.title, - this.titleText, - this.subtitle, - this.leading, - this.trailing, - this.onTap, - this.enabled = true, - this.autofocus = false, - this.hoverColor, - }) : assert((title != null) ^ (titleText != null)); - - final Widget? title; - final String? titleText; - final Widget? subtitle; - final Widget? leading; - final Widget? trailing; - final VoidCallback? onTap; - final bool enabled; - final bool autofocus; - final Color? hoverColor; - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - final surfaceColors = BusyMaxSurfaceColors.of(context); - final effectiveHoverColor = - hoverColor ?? busyMaxEditorRowHoverColor(context); - final titleWidget = DefaultTextStyle.merge( - style: theme.textTheme.labelLarge?.copyWith( - color: enabled ? null : theme.disabledColor, - ), - child: title ?? Text(titleText!), - ); - final subtitleWidget = subtitle != null - ? DefaultTextStyle.merge( - style: theme.textTheme.labelMedium?.copyWith( - color: enabled ? null : theme.disabledColor, - ), - child: subtitle!, - ) - : null; - - return ConstrainedBox( - constraints: const BoxConstraints(minHeight: 54), - child: Material( - color: surfaceColors.control, - surfaceTintColor: Colors.transparent, - child: InkWell( - onTap: enabled ? onTap : null, - autofocus: autofocus, - hoverColor: effectiveHoverColor, - focusColor: surfaceColors.controlHover, - highlightColor: surfaceColors.controlActive, - splashColor: Colors.transparent, - splashFactory: NoSplash.splashFactory, - child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: BusyMaxSpacing.md, - vertical: BusyMaxSpacing.sm, - ), - child: Row( - children: [ - if (leading != null) ...[ - leading!, - const SizedBox(width: BusyMaxSpacing.md), - ], - Expanded( - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - titleWidget, - if (subtitleWidget != null) ...[ - subtitleWidget, - const SizedBox(height: 1), - ], - ], - ), - ), - if (trailing != null) ...[ - const SizedBox(width: BusyMaxSpacing.md), - trailing!, - ], - ], - ), - ), - ), - ), - ); - } -} +typedef BusyMaxRowActivationCallback = + void Function(BuildContext context, Offset? globalPosition); -class BusyMaxActionRow extends StatelessWidget { +class BusyMaxActionRow extends StatefulWidget { const BusyMaxActionRow({ super.key, required this.title, @@ -965,13 +889,13 @@ class BusyMaxActionRow extends StatelessWidget { this.leading, this.trailing, this.onTap, - this.onPointerDown, + this.onActivated, this.enabled = true, this.tooltip, this.destructive = false, this.autofocus = false, this.hoverColor, - }); + }) : assert(onTap == null || onActivated == null); final String title; final String? subtitle; @@ -980,57 +904,123 @@ class BusyMaxActionRow extends StatelessWidget { final Widget? leading; final Widget? trailing; final VoidCallback? onTap; - final ValueChanged? onPointerDown; + final BusyMaxRowActivationCallback? onActivated; final bool enabled; final String? tooltip; final bool destructive; final bool autofocus; final Color? hoverColor; + @override + State createState() => _BusyMaxActionRowState(); +} + +class _BusyMaxActionRowState extends State { + int? _primaryPointer; + Offset? _pointerDownPosition; + + @override + void didUpdateWidget(covariant BusyMaxActionRow oldWidget) { + super.didUpdateWidget(oldWidget); + if (!widget.enabled || widget.onActivated == null) { + _clearPointer(); + } + } + @override Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; - final titleStyle = destructive ? TextStyle(color: colorScheme.error) : null; - final row = _BusyMaxRowTile( - leading: leading, + final titleStyle = widget.destructive + ? TextStyle(color: colorScheme.error) + : null; + final interactive = + widget.enabled && (widget.onTap != null || widget.onActivated != null); + final row = YaruListTile.square( + leading: widget.leading, title: - titleWidget ?? + widget.titleWidget ?? Text( - title, + widget.title, maxLines: 1, overflow: TextOverflow.ellipsis, style: titleStyle, ), subtitle: - subtitleWidget ?? - (subtitle == null || subtitle!.isEmpty + widget.subtitleWidget ?? + (widget.subtitle == null || widget.subtitle!.isEmpty ? null - : Text(subtitle!, maxLines: 1, overflow: TextOverflow.ellipsis)), - trailing: trailing, - enabled: enabled, - autofocus: autofocus, - hoverColor: hoverColor, - onTap: enabled ? onTap : null, + : Text( + widget.subtitle!, + maxLines: 1, + overflow: TextOverflow.ellipsis, + )), + trailing: widget.trailing, + enabled: widget.enabled, + autofocus: widget.autofocus, + hoverColor: widget.hoverColor, + onTap: interactive ? _activate : null, ); - final trackedRow = onPointerDown == null + final trackedRow = widget.onActivated == null ? row : Listener( - onPointerDown: enabled - ? (event) => onPointerDown!(event.position) - : null, + onPointerDown: widget.enabled ? _handlePointerDown : null, + onPointerUp: widget.enabled ? _handlePointerUp : null, + onPointerCancel: widget.enabled ? _handlePointerCancel : null, child: row, ); - if (enabled || tooltip == null) { + if (widget.enabled || widget.tooltip == null) { return trackedRow; } return Tooltip( - message: tooltip!, + message: widget.tooltip!, child: Opacity(opacity: 0.6, child: IgnorePointer(child: trackedRow)), ); } + + void _handlePointerDown(PointerDownEvent event) { + if (event.buttons != kPrimaryButton) { + return; + } + _primaryPointer = event.pointer; + _pointerDownPosition = event.position; + } + + void _handlePointerUp(PointerUpEvent event) { + if (_primaryPointer != event.pointer) { + return; + } + final pointer = event.pointer; + scheduleMicrotask(() { + if (mounted && _primaryPointer == pointer) { + _clearPointer(); + } + }); + } + + void _handlePointerCancel(PointerCancelEvent event) { + if (_primaryPointer == event.pointer) { + _clearPointer(); + } + } + + void _activate() { + final onActivated = widget.onActivated; + if (onActivated == null) { + widget.onTap?.call(); + return; + } + final globalPosition = _pointerDownPosition; + _clearPointer(); + onActivated(context, globalPosition); + } + + void _clearPointer() { + _primaryPointer = null; + _pointerDownPosition = null; + } } class BusyMaxCategoryEditorRow extends StatelessWidget { @@ -1409,7 +1399,7 @@ class _BusyMaxCategoryAutocompleteOptions extends StatelessWidget { child: Material( color: popupTheme.color ?? colorScheme.surfaceContainerHigh, elevation: BusyMaxElevation.popover, - shadowColor: BusyMaxShadow.floatingColor(context), + shadowColor: BusyMaxShadow.physicalColor(context), surfaceTintColor: Colors.transparent, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(BusyMaxRadius.headerButton), @@ -1521,7 +1511,7 @@ class BusyMaxCalendarValueRow extends StatelessWidget { @override Widget build(BuildContext context) { - final row = _BusyMaxRowTile( + final row = YaruListTile.square( leading: leading, title: Text(label, maxLines: 1, overflow: TextOverflow.ellipsis), subtitle: Text(value, maxLines: 1, overflow: TextOverflow.ellipsis), @@ -1629,7 +1619,7 @@ class BusyMaxComboRow extends StatelessWidget { @override Widget build(BuildContext context) { - final row = _BusyMaxRowTile( + final row = YaruListTile.square( leading: leading, titleText: title, subtitle: subtitle == null ? null : Text(subtitle!), @@ -1664,14 +1654,32 @@ class BusyMaxComboRow extends StatelessWidget { enabled: enabled, ); - if (enabled || tooltip == null) { + if (enabled) { return row; } - return Tooltip( - message: tooltip!, - child: Opacity(opacity: 0.6, child: IgnorePointer(child: row)), + final disabledRow = Semantics( + container: true, + button: true, + enabled: false, + label: subtitle == null || subtitle!.isEmpty + ? title + : '$title, $subtitle', + value: labelFor(selected), + child: ExcludeSemantics( + child: Opacity( + opacity: 0.6, + child: ExcludeFocus(child: IgnorePointer(child: row)), + ), + ), ); + return tooltip == null + ? disabledRow + : Tooltip( + message: tooltip!, + excludeFromSemantics: true, + child: disabledRow, + ); } } @@ -1695,14 +1703,13 @@ class BusyMaxSwitchRow extends StatelessWidget { @override Widget build(BuildContext context) { - return MergeSemantics( - child: YaruSwitchListTile( - value: value, - onChanged: enabled ? onChanged : null, - secondary: leading, - title: Text(title), - subtitle: subtitle == null ? null : Text(subtitle!), - ), + return YaruSwitchListTile( + value: value, + onChanged: enabled ? onChanged : null, + secondary: leading, + title: Text(title), + subtitle: subtitle == null ? null : Text(subtitle!), + shape: const RoundedRectangleBorder(), ); } } @@ -1727,6 +1734,9 @@ class BusyMaxMenuEntry { final bool destructive; } +typedef BusyMaxMenuTriggerBuilder = + Widget Function(BuildContext context, VoidCallback onPressed); + class BusyMaxMenuButton extends StatefulWidget { const BusyMaxMenuButton({ super.key, @@ -1735,6 +1745,8 @@ class BusyMaxMenuButton extends StatefulWidget { required this.onSelected, this.icon = const Icon(YaruIcons.view_more), this.minMenuWidth = 180, + this.menuPosition = const Offset(0, BusyMaxSizes.headerIconButton), + this.triggerBuilder, }); final String tooltip; @@ -1742,6 +1754,8 @@ class BusyMaxMenuButton extends StatefulWidget { final List> entries; final ValueChanged onSelected; final double minMenuWidth; + final Offset? menuPosition; + final BusyMaxMenuTriggerBuilder? triggerBuilder; @override State> createState() => _BusyMaxMenuButtonState(); @@ -1758,6 +1772,10 @@ class _BusyMaxMenuButtonState extends State> { crossAxisUnconstrained: false, style: busyMaxDropdownMenuStyle(context, minWidth: widget.minMenuWidth), builder: (context, controller, child) { + final triggerBuilder = widget.triggerBuilder; + if (triggerBuilder != null) { + return triggerBuilder(context, () => _toggleMenu(controller)); + } return YaruIconButton( tooltip: widget.tooltip, iconSize: BusyMaxSizes.headerIcon, @@ -1768,15 +1786,7 @@ class _BusyMaxMenuButtonState extends State> { ), child: widget.icon, ), - onPressed: () { - if (controller.isOpen) { - controller.close(); - return; - } - controller.open( - position: const Offset(0, BusyMaxSizes.headerIconButton), - ); - }, + onPressed: () => _toggleMenu(controller), style: busyMaxHeaderIconButtonStyle( foregroundColor: colorScheme.onSurfaceVariant, backgroundColor: busyMaxSubtleButtonBackground(context), @@ -1796,6 +1806,19 @@ class _BusyMaxMenuButtonState extends State> { ], ); } + + void _toggleMenu(MenuController controller) { + if (controller.isOpen) { + controller.close(); + return; + } + final position = widget.menuPosition; + if (position == null) { + controller.open(); + } else { + controller.open(position: position); + } + } } class _BusyMaxMenuEntryButton extends StatelessWidget { @@ -2381,15 +2404,11 @@ class BusyMaxDialogShell extends StatelessWidget { if (actions.isNotEmpty) Padding( padding: const EdgeInsets.all(BusyMaxSpacing.lg), - child: Row( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - for (final action in actions) ...[ - action, - if (action != actions.last) - const SizedBox(width: BusyMaxSpacing.sm), - ], - ], + child: OverflowBar( + alignment: MainAxisAlignment.end, + spacing: BusyMaxSpacing.sm, + overflowSpacing: BusyMaxSpacing.sm, + children: actions, ), ), ], diff --git a/lib/src/app/busymax_dialogs.dart b/lib/src/app/busymax_dialogs.dart index 455c90b..6e114f4 100644 --- a/lib/src/app/busymax_dialogs.dart +++ b/lib/src/app/busymax_dialogs.dart @@ -1,46 +1,79 @@ -import 'dart:async'; - import 'package:flutter/material.dart'; import '../platform/linux_header_bar_service.dart'; import 'busymax_design.dart'; +import 'busymax_shortcuts.dart'; -Future showBusyMaxModalEditorDialog( +const _modalShortcuts = { + BusyMaxShortcutActivators.keyboardShortcuts: + DoNothingAndStopPropagationIntent(), + BusyMaxShortcutActivators.settings: DoNothingAndStopPropagationIntent(), +}; + +final _modalDepths = Map.identity(); + +Future showBusyMaxModalDialog( BuildContext context, { required WidgetBuilder builder, LinuxHeaderBarService? headerBarService, - double maxWidth = BusyMaxSizes.compactDetailsWidth, - double? maxHeight = 760, + Color? barrierColor, + bool barrierDismissible = true, }) async { - final service = headerBarService; - if (service != null) { - unawaited(service.setModalBarrierVisible(true)); + final previousFocus = FocusManager.instance.primaryFocus; + await acquireBusyMaxModalBarrier(headerBarService); + if (!context.mounted) { + await releaseBusyMaxModalBarrier(headerBarService); + return null; } + try { return await showDialog( context: context, - barrierColor: busyMaxModalBarrierColor(context), - builder: (dialogContext) { - return Dialog( - backgroundColor: Colors.transparent, - surfaceTintColor: Colors.transparent, - elevation: 0, - insetPadding: const EdgeInsets.all(BusyMaxSpacing.lg), - child: BusyMaxModalEditorSurface( - maxWidth: maxWidth, - maxHeight: maxHeight, - child: builder(dialogContext), - ), - ); - }, + barrierColor: barrierColor ?? busyMaxModalBarrierColor(context), + barrierDismissible: barrierDismissible, + traversalEdgeBehavior: TraversalEdgeBehavior.closedLoop, + builder: (dialogContext) => + Shortcuts(shortcuts: _modalShortcuts, child: builder(dialogContext)), ); } finally { - if (service != null) { - unawaited(service.setModalBarrierVisible(false)); + await releaseBusyMaxModalBarrier(headerBarService); + if (previousFocus?.context != null && previousFocus!.canRequestFocus) { + previousFocus.requestFocus(); } } } +Future showBusyMaxModalEditorDialog( + BuildContext context, { + required WidgetBuilder builder, + LinuxHeaderBarService? headerBarService, + double maxWidth = BusyMaxSizes.compactDetailsWidth, + double? maxHeight = 760, +}) async { + return showBusyMaxModalDialog( + context, + headerBarService: headerBarService, + builder: (dialogContext) { + final reduceMotion = MediaQuery.disableAnimationsOf(dialogContext); + return Dialog( + backgroundColor: Colors.transparent, + surfaceTintColor: Colors.transparent, + elevation: 0, + insetPadding: const EdgeInsets.all(BusyMaxSpacing.lg), + insetAnimationDuration: reduceMotion + ? Duration.zero + : BusyMaxMotion.dialogInsets, + insetAnimationCurve: BusyMaxMotion.dialogInsetsCurve, + child: BusyMaxModalEditorSurface( + maxWidth: maxWidth, + maxHeight: maxHeight, + child: builder(dialogContext), + ), + ); + }, + ); +} + Future showBusyMaxTextPrompt( BuildContext context, { required String title, @@ -49,11 +82,13 @@ Future showBusyMaxTextPrompt( String? initialValue, String? message, Color? barrierColor, -}) { - return showDialog( - context: context, + LinuxHeaderBarService? headerBarService, +}) async { + return showBusyMaxModalDialog( + context, + headerBarService: headerBarService, barrierColor: barrierColor, - builder: (context) => BusyMaxPromptDialog( + builder: (dialogContext) => BusyMaxPromptDialog( title: title, label: label, actionLabel: actionLabel, @@ -70,11 +105,13 @@ Future showBusyMaxConfirm( required String confirmLabel, bool destructive = false, Color? barrierColor, + LinuxHeaderBarService? headerBarService, }) async { - final confirmed = await showDialog( - context: context, + final confirmed = await showBusyMaxModalDialog( + context, + headerBarService: headerBarService, barrierColor: barrierColor, - builder: (context) => BusyMaxConfirmDialog( + builder: (dialogContext) => BusyMaxConfirmDialog( title: title, message: message, confirmLabel: confirmLabel, @@ -83,3 +120,32 @@ 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. +Future acquireBusyMaxModalBarrier(LinuxHeaderBarService? service) async { + if (service == null) { + return; + } + final depth = _modalDepths[service] ?? 0; + _modalDepths[service] = depth + 1; + if (depth == 0) { + await service.setModalBarrierVisible(true); + } +} + +/// Releases a barrier acquired by [acquireBusyMaxModalBarrier]. +Future releaseBusyMaxModalBarrier(LinuxHeaderBarService? service) async { + if (service == null) { + return; + } + final depth = _modalDepths[service] ?? 0; + if (depth <= 1) { + _modalDepths.remove(service); + await service.setModalBarrierVisible(false); + return; + } + _modalDepths[service] = depth - 1; +} diff --git a/lib/src/app/busymax_keyboard_shortcuts_dialog.dart b/lib/src/app/busymax_keyboard_shortcuts_dialog.dart index 9b69e1e..72ad604 100644 --- a/lib/src/app/busymax_keyboard_shortcuts_dialog.dart +++ b/lib/src/app/busymax_keyboard_shortcuts_dialog.dart @@ -1,29 +1,20 @@ -import 'dart:async'; - import 'package:flutter/material.dart'; import '../l10n/l10n.dart'; import '../platform/linux_header_bar_service.dart'; import 'busymax_design.dart'; +import 'busymax_dialogs.dart'; +import 'busymax_shortcuts.dart'; Future showBusyMaxKeyboardShortcutsDialog( BuildContext context, { LinuxHeaderBarService? headerBarService, }) async { - final service = headerBarService; - if (service != null) { - unawaited(service.setModalBarrierVisible(true)); - } - try { - await showDialog( - context: context, - builder: (context) => const BusyMaxKeyboardShortcutsDialog(), - ); - } finally { - if (service != null) { - unawaited(service.setModalBarrierVisible(false)); - } - } + await showBusyMaxModalDialog( + context, + headerBarService: headerBarService, + builder: (context) => const BusyMaxKeyboardShortcutsDialog(), + ); } class BusyMaxKeyboardShortcutsDialog extends StatelessWidget { @@ -68,7 +59,25 @@ class BusyMaxKeyboardShortcutsDialog extends StatelessWidget { title: l10n.keyboardShortcuts, subtitle: l10n.shortcutKeyboardShortcutsDescription, leading: const Icon(Icons.keyboard_alt_outlined), - trailing: const _KeyboardShortcutBadge('Ctrl+/'), + trailing: const _KeyboardShortcutBadge( + BusyMaxShortcutLabels.keyboardShortcuts, + ), + ), + BusyMaxActionRow( + title: l10n.settings, + leading: const Icon(Icons.settings_outlined), + trailing: const _KeyboardShortcutBadge( + BusyMaxShortcutLabels.settings, + ), + ), + BusyMaxActionRow( + title: MaterialLocalizations.of( + context, + ).searchFieldLabel, + leading: const Icon(Icons.search), + trailing: const _KeyboardShortcutBadge( + BusyMaxShortcutLabels.search, + ), ), ], ), @@ -99,6 +108,13 @@ class BusyMaxKeyboardShortcutsDialog extends StatelessWidget { title: l10n.shortcutGroupCreateAndEdit, filled: true, children: [ + BusyMaxActionRow( + title: l10n.create, + leading: const Icon(Icons.add), + trailing: const _KeyboardShortcutBadge( + BusyMaxShortcutLabels.create, + ), + ), BusyMaxActionRow( title: l10n.newEvent, leading: const Icon(Icons.event_outlined), diff --git a/lib/src/app/busymax_layout.dart b/lib/src/app/busymax_layout.dart index 7d12550..4b0f1ab 100644 --- a/lib/src/app/busymax_layout.dart +++ b/lib/src/app/busymax_layout.dart @@ -8,8 +8,13 @@ abstract final class BusyMaxLayoutRules { static const double detailsBreakpoint = kYaruMasterDetailBreakpoint * 2; static const double taskPageMinWidth = kYaruMasterDetailBreakpoint - BusyMaxSizes.sidebarWidth / 2; + static const double settingsSidebarBreakpoint = + BusyMaxSizes.sidebarWidth + 520; static bool showSidebar(double width) => width >= sidebarBreakpoint; static bool showPersistentDetails(double width) => width >= detailsBreakpoint; + + static bool showSettingsSidebar(double width) => + width >= settingsSidebarBreakpoint; } diff --git a/lib/src/app/busymax_shortcuts.dart b/lib/src/app/busymax_shortcuts.dart new file mode 100644 index 0000000..55cb1a6 --- /dev/null +++ b/lib/src/app/busymax_shortcuts.dart @@ -0,0 +1,24 @@ +import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; + +abstract final class BusyMaxShortcutActivators { + static const keyboardShortcuts = SingleActivator( + LogicalKeyboardKey.slash, + control: true, + ); + static const settings = SingleActivator( + LogicalKeyboardKey.comma, + control: true, + ); + static const search = SingleActivator(LogicalKeyboardKey.keyF, control: true); + static const create = SingleActivator(LogicalKeyboardKey.keyN, control: true); + static const dismiss = SingleActivator(LogicalKeyboardKey.escape); +} + +abstract final class BusyMaxShortcutLabels { + static const keyboardShortcuts = 'Ctrl+/'; + static const settings = 'Ctrl+,'; + static const search = 'Ctrl+F'; + static const create = 'Ctrl+N'; + static const dismiss = 'Esc'; +} diff --git a/lib/src/app/busymax_surface_colors.dart b/lib/src/app/busymax_surface_colors.dart index 23ee3ab..25ba651 100644 --- a/lib/src/app/busymax_surface_colors.dart +++ b/lib/src/app/busymax_surface_colors.dart @@ -10,6 +10,7 @@ class BusyMaxSurfaceColors extends ThemeExtension { required this.headerbar, required this.headerbarFlat, required this.card, + required this.groupedSurface, required this.dialog, required this.popover, required this.control, @@ -33,6 +34,7 @@ class BusyMaxSurfaceColors extends ThemeExtension { final Color headerbar; final Color headerbarFlat; final Color card; + final Color groupedSurface; final Color dialog; final Color popover; final Color control; @@ -63,6 +65,7 @@ class BusyMaxSurfaceColors extends ThemeExtension { Color? headerbar, Color? headerbarFlat, Color? card, + Color? groupedSurface, Color? dialog, Color? popover, Color? control, @@ -86,6 +89,7 @@ class BusyMaxSurfaceColors extends ThemeExtension { headerbar: headerbar ?? this.headerbar, headerbarFlat: headerbarFlat ?? this.headerbarFlat, card: card ?? this.card, + groupedSurface: groupedSurface ?? this.groupedSurface, dialog: dialog ?? this.dialog, popover: popover ?? this.popover, control: control ?? this.control, @@ -120,6 +124,7 @@ class BusyMaxSurfaceColors extends ThemeExtension { headerbar: Color.lerp(headerbar, other.headerbar, t)!, headerbarFlat: Color.lerp(headerbarFlat, other.headerbarFlat, t)!, card: Color.lerp(card, other.card, t)!, + groupedSurface: Color.lerp(groupedSurface, other.groupedSurface, t)!, dialog: Color.lerp(dialog, other.dialog, t)!, popover: Color.lerp(popover, other.popover, t)!, control: Color.lerp(control, other.control, t)!, @@ -152,6 +157,7 @@ BusyMaxSurfaceColors busyMaxFallbackSurfaceColors(Brightness brightness) { headerbar: Color(0xFFFFFFFF), headerbarFlat: Color(0xFFFFFFFF), card: Color(0xFFFFFFFF), + groupedSurface: Color(0xFFFFFFFF), dialog: Color(0xFFFAFAFB), popover: Color(0xFFFFFFFF), control: Color.fromRGBO(0, 0, 0, 0.06), @@ -175,6 +181,7 @@ BusyMaxSurfaceColors busyMaxFallbackSurfaceColors(Brightness brightness) { headerbar: Color(0xFF2E2E32), headerbarFlat: Color(0xFF1D1D20), card: Color(0xFF222226), + groupedSurface: Color(0xFF383838), dialog: Color(0xFF222226), popover: Color(0xFF383838), control: Color.fromRGBO(255, 255, 255, 0.10), diff --git a/lib/src/app/busymax_yaru_theme.dart b/lib/src/app/busymax_yaru_theme.dart index af45e9b..cab84a4 100644 --- a/lib/src/app/busymax_yaru_theme.dart +++ b/lib/src/app/busymax_yaru_theme.dart @@ -254,6 +254,7 @@ class BusyMaxYaruTheme { brightness: brightness, colorScheme: colorScheme, primaryColor: accentColor, + shadowColor: colorScheme.shadow, scaffoldBackgroundColor: colors.window, canvasColor: colors.window, cardColor: colors.card, @@ -397,7 +398,7 @@ class BusyMaxYaruTheme { color: colors.popover, surfaceTintColor: colors.popover, elevation: BusyMaxElevation.popover, - shadowColor: colors.shade, + shadowColor: colorScheme.shadow, menuPadding: const EdgeInsets.symmetric(vertical: 4), iconColor: colors.mutedForeground, iconSize: 16, @@ -642,6 +643,14 @@ class _BusyMaxResolvedSurfaceColors { runtime.headerbarFlat, brightness: brightness, ); + final runtimeCard = _runtimeSurfaceColor( + runtime.card, + brightness: brightness, + ); + final runtimePopover = _runtimeElevatedSurfaceColor( + runtime.popover, + brightness: brightness, + ); final view = runtimeView ?? fallback.view; final sidebar = _firstDistinctSemanticColor( @@ -649,7 +658,11 @@ class _BusyMaxResolvedSurfaceColors { from: [view, window], ) ?? _derivedSidebarColor(brightness, view); - final readableBackgrounds = [window, view, sidebar]; + final groupedSurface = + runtimePopover ?? + runtimeCard ?? + (brightness == Brightness.light ? view : fallback.groupedSurface); + final readableBackgrounds = [window, view, sidebar, groupedSurface]; return fallback.copyWith( window: window, @@ -658,12 +671,10 @@ class _BusyMaxResolvedSurfaceColors { secondarySidebar: runtimeSecondarySidebar, headerbar: runtimeHeaderbar, headerbarFlat: runtimeHeaderbarFlat ?? view, - card: _runtimeSurfaceColor(runtime.card, brightness: brightness), + card: runtimeCard, + groupedSurface: groupedSurface, dialog: _runtimeSurfaceColor(runtime.dialog, brightness: brightness), - popover: _runtimeElevatedSurfaceColor( - runtime.popover, - brightness: brightness, - ), + popover: runtimePopover, control: _runtimeControlColor(runtime.control, brightness: brightness), controlHover: _runtimeControlColor( runtime.controlHover, diff --git a/lib/src/features/auth/presentation/sign_in_screen.dart b/lib/src/features/auth/presentation/sign_in_screen.dart index 5a7d1de..355ff8f 100644 --- a/lib/src/features/auth/presentation/sign_in_screen.dart +++ b/lib/src/features/auth/presentation/sign_in_screen.dart @@ -217,16 +217,20 @@ class _SignInScreenState extends ConsumerState { } final service = ref.read(linuxHeaderBarServiceProvider); unawaited(() async { - await service.initialize(); - if (!mounted || - _finishingSetup || - generation != _headerBarUpdateGeneration) { - return; - } - await service.setScheduleControlsVisible(false); - await service.setBackVisible(false); - await service.setSidebarVisible(false); - await service.setTitleRange(title); + await service.updateState( + BusyMaxHeaderBarState( + title: title, + viewMode: ref.read(appSettingsControllerProvider).scheduleViewMode, + canRefresh: false, + canCreate: false, + searchActive: false, + canShowSidebar: false, + sidebarVisible: false, + navigationVisible: false, + scheduleControlsVisible: false, + backVisible: false, + ), + ); if (!mounted || _finishingSetup || generation != _headerBarUpdateGeneration) { @@ -239,10 +243,6 @@ class _SignInScreenState extends ConsumerState { backLabel: backLabel, continueLabel: continueLabel, ); - await service.setCanRefresh(false); - await service.setCanCreate(false); - await service.setSearchActive(false); - await service.setModalBarrierVisible(false); }()); }); } @@ -587,13 +587,13 @@ class _PreferencesOnboardingStep extends StatelessWidget { onSelected: settingsController.setThemeModePreference, ), BusyMaxSwitchRow( - title: 'Run in background when window is closed', + title: l10n.runInBackgroundWhenClosed, value: settings.runInBackgroundWhenClosed, onChanged: settingsController.setRunInBackgroundWhenClosed, leading: const Icon(YaruIcons.window), ), BusyMaxSwitchRow( - title: 'Show tray icon', + title: l10n.showTrayIcon, value: settings.showTrayIcon, onChanged: settingsController.setShowTrayIcon, leading: const Icon(YaruIcons.pin), @@ -605,13 +605,13 @@ class _PreferencesOnboardingStep extends StatelessWidget { filled: true, children: [ BusyMaxSwitchRow( - title: 'Event reminders', + title: l10n.eventReminders, value: settings.notifyEventReminders, onChanged: settingsController.setNotifyEventReminders, leading: const Icon(YaruIcons.calendar_day), ), BusyMaxSwitchRow( - title: 'Task reminders', + title: l10n.taskReminders, value: settings.notifyTaskReminders, onChanged: settingsController.setNotifyTaskReminders, leading: const Icon(YaruIcons.checkmark), diff --git a/lib/src/features/calendar/presentation/event_description_editor.dart b/lib/src/features/calendar/presentation/event_description_editor.dart index 30a38a0..b923f51 100644 --- a/lib/src/features/calendar/presentation/event_description_editor.dart +++ b/lib/src/features/calendar/presentation/event_description_editor.dart @@ -102,8 +102,8 @@ class _EventDescriptionEditorState extends State { child: Row( children: [ _FormatButton( - label: 'B', - tooltip: 'Bold', + label: context.l10n.formatBoldShortLabel, + tooltip: context.l10n.formatBoldTooltip, active: _controller.selectionHasStyle( CalendarDescriptionInlineStyle.bold, ), @@ -112,8 +112,8 @@ class _EventDescriptionEditorState extends State { ), const SizedBox(width: BusyMaxSpacing.xs), _FormatButton( - label: 'I', - tooltip: 'Italic', + label: context.l10n.formatItalicShortLabel, + tooltip: context.l10n.formatItalicTooltip, active: _controller.selectionHasStyle( CalendarDescriptionInlineStyle.italic, ), @@ -123,8 +123,8 @@ class _EventDescriptionEditorState extends State { ), const SizedBox(width: BusyMaxSpacing.xs), _FormatButton( - label: 'U', - tooltip: 'Underline', + label: context.l10n.formatUnderlineShortLabel, + tooltip: context.l10n.formatUnderlineTooltip, active: _controller.selectionHasStyle( CalendarDescriptionInlineStyle.underline, ), diff --git a/lib/src/features/calendar/presentation/event_editor.dart b/lib/src/features/calendar/presentation/event_editor.dart index a2bc8aa..6ac8c41 100644 --- a/lib/src/features/calendar/presentation/event_editor.dart +++ b/lib/src/features/calendar/presentation/event_editor.dart @@ -462,7 +462,7 @@ class _EventEditorState extends State { leading: const Icon(Icons.notifications_outlined), values: _reminderValuesFor(minutes[index]), selected: minutes[index], - labelFor: _reminderLabel, + labelFor: (value) => _reminderLabel(context, value), onSelected: (value) { _setReminderMinutes(provider, [ ...minutes.take(index), @@ -629,9 +629,11 @@ class _EventEditorState extends State { leading: const Icon(Icons.work_outline), values: values, selected: selected, - labelFor: _availabilityLabel, - selectedBuilder: (context, value) => - _eventEditorSelectedValue(context, _availabilityLabel(value)), + labelFor: (value) => _availabilityLabel(context, value), + selectedBuilder: (context, value) => _eventEditorSelectedValue( + context, + _availabilityLabel(context, value), + ), onSelected: (value) { setState(() { _draft = _draft.copyWith(showAs: value); @@ -652,9 +654,9 @@ class _EventEditorState extends State { leading: const Icon(Icons.visibility_outlined), values: values, selected: selected, - labelFor: _titleCase, + labelFor: (value) => _visibilityLabel(context, value), selectedBuilder: (context, value) => - _eventEditorSelectedValue(context, _titleCase(value)), + _eventEditorSelectedValue(context, _visibilityLabel(context, value)), onSelected: (value) { setState(() { _draft = _draft.copyWith(visibilityOrSensitivity: value); @@ -666,7 +668,7 @@ class _EventEditorState extends State { void _addGuest() { final email = _guestController.text.trim(); if (!_looksLikeEmail(email)) { - setState(() => _guestError = 'Enter a valid email address'); + setState(() => _guestError = context.l10n.feedbackInvalidEmail); return; } if (_draft.attendees.any((attendee) => attendee.email == email)) { @@ -1028,32 +1030,41 @@ int _nextReminderMinute(List existing) { return _eventReminderMinuteOptions.first; } -String _reminderLabel(int minutes) { - return switch (minutes) { - 5 => '5 minutes before', - 10 => '10 minutes before', - 30 => '30 minutes before', - 60 => '1 hour before', - 1440 => '1 day before', - _ => '$minutes minutes before', - }; +String _reminderLabel(BuildContext context, int minutes) { + final l10n = context.l10n; + const minutesPerDay = Duration.minutesPerHour * Duration.hoursPerDay; + if (minutes % minutesPerDay == 0) { + return l10n.reminderDaysBefore(minutes ~/ minutesPerDay); + } + if (minutes % Duration.minutesPerHour == 0) { + return l10n.reminderHoursBefore(minutes ~/ Duration.minutesPerHour); + } + return l10n.reminderMinutesBefore(minutes); } -String _availabilityLabel(String value) { +String _availabilityLabel(BuildContext context, String value) { + final l10n = context.l10n; return switch (value) { - 'opaque' => 'Busy', - 'transparent' => 'Free', - 'oof' => 'Out of office', - 'workingElsewhere' => 'Working elsewhere', - _ => _titleCase(value), + 'opaque' || 'busy' => l10n.busy, + 'transparent' || 'free' => l10n.availabilityFree, + 'tentative' => l10n.availabilityTentative, + 'oof' => l10n.availabilityOutOfOffice, + 'workingElsewhere' => l10n.availabilityWorkingElsewhere, + _ => value, }; } -String _titleCase(String value) { - if (value.isEmpty) { - return value; - } - return value[0].toUpperCase() + value.substring(1); +String _visibilityLabel(BuildContext context, String value) { + final l10n = context.l10n; + return switch (value) { + 'default' => l10n.visibilityDefault, + 'public' => l10n.visibilityPublic, + 'private' => l10n.visibilityPrivate, + 'confidential' => l10n.visibilityConfidential, + 'normal' => l10n.sensitivityNormal, + 'personal' => l10n.sensitivityPersonal, + _ => value, + }; } bool _looksLikeEmail(String value) { diff --git a/lib/src/features/schedule/presentation/compact_agenda_panel.dart b/lib/src/features/schedule/presentation/compact_agenda_panel.dart index a7ced03..e1bd597 100644 --- a/lib/src/features/schedule/presentation/compact_agenda_panel.dart +++ b/lib/src/features/schedule/presentation/compact_agenda_panel.dart @@ -1311,7 +1311,6 @@ class _CompactAgendaRow extends StatelessWidget { @override Widget build(BuildContext context) { final task = item is TaskScheduleItem ? item as TaskScheduleItem : null; - Offset? pointerDownPosition; return AnimatedOpacity( opacity: mutating ? 0.48 : 1, duration: const Duration(milliseconds: 120), @@ -1331,8 +1330,8 @@ class _CompactAgendaRow extends StatelessWidget { ), ), enabled: !mutating, - onPointerDown: (position) => pointerDownPosition = position, - onTap: () => unawaited(onOpenItem(context, item, pointerDownPosition)), + onActivated: (rowContext, globalPosition) => + unawaited(onOpenItem(rowContext, item, globalPosition)), ), ); } diff --git a/lib/src/features/schedule/presentation/mini_calendar.dart b/lib/src/features/schedule/presentation/mini_calendar.dart index e04ad46..7233eed 100644 --- a/lib/src/features/schedule/presentation/mini_calendar.dart +++ b/lib/src/features/schedule/presentation/mini_calendar.dart @@ -6,6 +6,7 @@ import 'package:yaru/yaru.dart'; import '../../../app/busymax_design.dart'; import '../../../app/busymax_surface_colors.dart'; +import '../../../l10n/l10n.dart'; import '../../../schedule/schedule_item.dart'; import '../../../schedule/schedule_projection.dart'; @@ -31,6 +32,7 @@ class MiniCalendar extends StatelessWidget { @override Widget build(BuildContext context) { + final l10n = context.l10n; final first = DateTime(selectedDate.year, selectedDate.month); final start = _calendarStartForMonth(first, firstWeekday); final groupedItems = ScheduleProjection.groupByDay(items); @@ -49,16 +51,16 @@ class MiniCalendar extends StatelessWidget { children: [ Expanded( child: _MiniCalendarStepper( - label: _monthName(selectedDate), - previousTooltip: 'Previous month', - nextTooltip: 'Next month', + label: DateFormat.MMMM(locale).format(selectedDate), + previousTooltip: l10n.previousMonth, + nextTooltip: l10n.nextMonth, onPrevious: () => onSelected( DateTime(selectedDate.year, selectedDate.month - 1), ), onNext: () => onSelected( DateTime(selectedDate.year, selectedDate.month + 1), ), - labelTooltip: 'Open month', + labelTooltip: l10n.openMonthView, onLabelPressed: () => onMonthSelected(first), ), ), @@ -66,15 +68,15 @@ class MiniCalendar extends StatelessWidget { Expanded( child: _MiniCalendarStepper( label: '${selectedDate.year}', - previousTooltip: 'Previous year', - nextTooltip: 'Next year', + previousTooltip: l10n.previousYear, + nextTooltip: l10n.nextYear, onPrevious: () => onSelected( DateTime(selectedDate.year - 1, selectedDate.month), ), onNext: () => onSelected( DateTime(selectedDate.year + 1, selectedDate.month), ), - labelTooltip: 'Open year', + labelTooltip: l10n.openYearView, onLabelPressed: () => onYearSelected(DateTime(selectedDate.year)), ), @@ -216,7 +218,7 @@ class _MiniCalendarWeekNumberButton extends StatelessWidget { final weekNumber = _isoWeekNumber(weekStart); return Center( child: Tooltip( - message: 'Week $weekNumber', + message: context.l10n.weekNumberTooltip(weekNumber), child: TextButton( onPressed: () => onSelected(weekStart), style: @@ -512,24 +514,6 @@ class _MiniCalendarStepper extends StatelessWidget { } } -String _monthName(DateTime date) { - const months = [ - 'January', - 'February', - 'March', - 'April', - 'May', - 'June', - 'July', - 'August', - 'September', - 'October', - 'November', - 'December', - ]; - return months[date.month - 1]; -} - bool _sameDay(DateTime a, DateTime b) { return a.year == b.year && a.month == b.month && a.day == b.day; } diff --git a/lib/src/features/schedule/presentation/schedule_agenda_view.dart b/lib/src/features/schedule/presentation/schedule_agenda_view.dart index 69e4824..1fee7de 100644 --- a/lib/src/features/schedule/presentation/schedule_agenda_view.dart +++ b/lib/src/features/schedule/presentation/schedule_agenda_view.dart @@ -204,7 +204,6 @@ class _AgendaRow extends StatelessWidget { @override Widget build(BuildContext context) { final task = item is TaskScheduleItem ? item as TaskScheduleItem : null; - Offset? pointerDownPosition; final onAnchorAvailable = this.onAnchorAvailable; if (onAnchorAvailable != null) { WidgetsBinding.instance.addPostFrameCallback((_) { @@ -227,8 +226,7 @@ class _AgendaRow extends StatelessWidget { ? null : (value) => onTaskCompletionChanged!(value ?? false), ), - onPointerDown: (position) => pointerDownPosition = position, - onTap: () => onTap(context, pointerDownPosition), + onActivated: onTap, ); } } diff --git a/lib/src/features/schedule/presentation/schedule_create_menu.dart b/lib/src/features/schedule/presentation/schedule_create_menu.dart index 8e3d4a8..aefee9e 100644 --- a/lib/src/features/schedule/presentation/schedule_create_menu.dart +++ b/lib/src/features/schedule/presentation/schedule_create_menu.dart @@ -1,16 +1,20 @@ import 'package:flutter/material.dart'; import '../../../app/busymax_design.dart'; +import '../../../app/busymax_dialogs.dart'; import '../../../l10n/l10n.dart'; +import '../../../platform/linux_header_bar_service.dart'; enum ScheduleCreateChoice { event, task } Future showScheduleCreateMenu({ required BuildContext context, + LinuxHeaderBarService? headerBarService, }) { - return showDialog( - context: context, - builder: (context) { + return showBusyMaxModalDialog( + context, + headerBarService: headerBarService, + builder: (dialogContext) { return Dialog( child: ConstrainedBox( constraints: const BoxConstraints(maxWidth: 320), @@ -21,20 +25,22 @@ Future showScheduleCreateMenu({ crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Text( - context.l10n.createChoiceTitle, - style: Theme.of(context).textTheme.titleMedium, + dialogContext.l10n.createChoiceTitle, + style: Theme.of(dialogContext).textTheme.titleMedium, ), const SizedBox(height: BusyMaxSpacing.md), BusyMaxPushButton.outlined( - onPressed: () => - Navigator.of(context).pop(ScheduleCreateChoice.event), - child: Text(context.l10n.createEventAtTime), + onPressed: () => Navigator.of( + dialogContext, + ).pop(ScheduleCreateChoice.event), + child: Text(dialogContext.l10n.createEventAtTime), ), const SizedBox(height: BusyMaxSpacing.sm), BusyMaxPushButton.outlined( - onPressed: () => - Navigator.of(context).pop(ScheduleCreateChoice.task), - child: Text(context.l10n.createTaskAtDate), + onPressed: () => Navigator.of( + dialogContext, + ).pop(ScheduleCreateChoice.task), + child: Text(dialogContext.l10n.createTaskAtDate), ), ], ), 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 9e6a05b..a64c998 100644 --- a/lib/src/features/schedule/presentation/schedule_day_week_view.dart +++ b/lib/src/features/schedule/presentation/schedule_day_week_view.dart @@ -667,11 +667,12 @@ class _AllDayResizeHandle extends StatelessWidget { Widget build(BuildContext context) { final surfaceColors = BusyMaxSurfaceColors.of(context); final colorScheme = Theme.of(context).colorScheme; + final resizeLabel = context.l10n.resizeAllDayPanel; return Tooltip( - message: 'Resize all-day panel', + message: resizeLabel, child: Semantics( button: true, - label: 'Resize all-day panel', + label: resizeLabel, child: MouseRegion( cursor: SystemMouseCursors.resizeUpDown, child: GestureDetector( @@ -848,7 +849,7 @@ class _ScheduleIcvEvent { else entries.first.event.copyWith( title: entries.first.item.title, - description: '${entries.length} items', + description: context.l10n.scheduleItemCount(entries.length), data: _ScheduleSlotGroup([for (final entry in entries) entry.item]), eventType: _ScheduleSlotGroup, ), diff --git a/lib/src/features/schedule/presentation/schedule_empty_states.dart b/lib/src/features/schedule/presentation/schedule_empty_states.dart index 22fb00f..b3e03e7 100644 --- a/lib/src/features/schedule/presentation/schedule_empty_states.dart +++ b/lib/src/features/schedule/presentation/schedule_empty_states.dart @@ -3,6 +3,94 @@ import 'package:flutter/material.dart'; import '../../../app/busymax_design.dart'; import '../../../l10n/l10n.dart'; +class ScheduleLoadingState extends StatelessWidget { + const ScheduleLoadingState({super.key}); + + @override + Widget build(BuildContext context) { + final label = context.l10n.scheduleLoading; + return Semantics( + container: true, + liveRegion: true, + label: label, + child: ExcludeSemantics( + child: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const CircularProgressIndicator(), + const SizedBox(height: BusyMaxSpacing.lg), + Text( + label, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + ), + ); + } +} + +class ScheduleNoSourcesState extends StatelessWidget { + const ScheduleNoSourcesState({ + super.key, + required this.hasAccounts, + required this.onOpenSettings, + this.onRefresh, + }); + + final bool hasAccounts; + final VoidCallback onOpenSettings; + final VoidCallback? onRefresh; + + @override + Widget build(BuildContext context) { + return BusyMaxEmptyState( + icon: Icons.calendar_month_outlined, + title: hasAccounts + ? context.l10n.scheduleNoSources + : context.l10n.scheduleSignInRequired, + message: hasAccounts + ? context.l10n.scheduleNoSourcesDescription + : context.l10n.scheduleSignInDescription, + actions: [ + BusyMaxPushButton.filled( + onPressed: onOpenSettings, + child: Text(context.l10n.settings), + ), + if (onRefresh != null) + BusyMaxPushButton.outlined( + onPressed: onRefresh, + child: Text(context.l10n.trayAgendaRefresh), + ), + ], + ); + } +} + +class ScheduleUnavailableState extends StatelessWidget { + const ScheduleUnavailableState({super.key, required this.onRetry}); + + final VoidCallback onRetry; + + @override + Widget build(BuildContext context) { + return BusyMaxEmptyState( + icon: Icons.sync_problem_outlined, + title: context.l10n.scheduleUnavailable, + actions: [ + BusyMaxPushButton.filled( + onPressed: onRetry, + child: Text(context.l10n.retry), + ), + ], + ); + } +} + class ScheduleEmptyState extends StatelessWidget { const ScheduleEmptyState({ super.key, diff --git a/lib/src/features/schedule/presentation/schedule_event_block.dart b/lib/src/features/schedule/presentation/schedule_event_block.dart index adf6908..87398c0 100644 --- a/lib/src/features/schedule/presentation/schedule_event_block.dart +++ b/lib/src/features/schedule/presentation/schedule_event_block.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import '../../../app/busymax_design.dart'; import '../../../app/busymax_surface_colors.dart'; @@ -7,7 +8,7 @@ import '../../../schedule/schedule_item.dart'; import '../../../schedule/schedule_projection.dart'; import 'schedule_item_selection.dart'; -class ScheduleEventBlock extends StatelessWidget { +class ScheduleEventBlock extends StatefulWidget { const ScheduleEventBlock({ super.key, required this.item, @@ -23,78 +24,136 @@ class ScheduleEventBlock extends StatelessWidget { final bool compact; final ScheduleItemTapCallback? onTap; + @override + State createState() => _ScheduleEventBlockState(); +} + +class _ScheduleEventBlockState extends State { + static const _activationShortcuts = { + SingleActivator(LogicalKeyboardKey.enter): ActivateIntent(), + SingleActivator(LogicalKeyboardKey.space): ActivateIntent(), + }; + + Offset? _pointerDownPosition; + var _showFocusHighlight = false; + @override Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; final surfaceColors = BusyMaxSurfaceColors.of(context); - final blockHeight = scheduleSafeBlockHeight(height); - final blockWidth = scheduleSafeBlockWidth(width); - final dense = compact || blockHeight < 36; + final blockHeight = scheduleSafeBlockHeight(widget.height); + final blockWidth = scheduleSafeBlockWidth(widget.width); + final dense = widget.compact || blockHeight < 36; final verticalPadding = dense ? 2.0 : 5.0; final contentHeight = blockHeight - verticalPadding * 2; final timeRange = _timeRange(context); - final showTime = !item.allDay && !dense && contentHeight >= 34; + final showTime = !widget.item.allDay && !dense && contentHeight >= 34; final titleMaxLines = showTime || contentHeight < 36 ? 1 : 2; final tooltipDetails = _tooltipDetails(context); + final interactive = widget.onTap != null; + final focusBorder = BorderSide(color: colorScheme.primary, width: 2); - Offset? pointerDownPosition; - return GestureDetector( - behavior: HitTestBehavior.opaque, - onTapDown: onTap == null - ? null - : (details) => pointerDownPosition = details.globalPosition, - onTap: onTap == null ? null : () => onTap!(context, pointerDownPosition), - child: Tooltip( - message: tooltipDetails.isEmpty - ? item.title - : '${item.title}\n$tooltipDetails', - waitDuration: const Duration(milliseconds: 600), - child: SizedBox( - width: blockWidth, - height: blockHeight, - child: Material( - color: Colors.transparent, - child: Container( - clipBehavior: Clip.antiAlias, - padding: EdgeInsets.symmetric( - horizontal: dense ? 6 : 8, - vertical: verticalPadding, - ), - decoration: BoxDecoration( - color: surfaceColors.control, - borderRadius: BorderRadius.circular(BusyMaxRadius.sm), - border: Border( - left: BorderSide(color: surfaceColors.subtleBorder, width: 4), - ), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisAlignment: showTime - ? MainAxisAlignment.spaceBetween - : MainAxisAlignment.center, - mainAxisSize: MainAxisSize.max, - children: [ - Flexible( - child: Text( - item.title, - maxLines: titleMaxLines, - overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.bodySmall?.copyWith( - fontWeight: FontWeight.w600, - color: colorScheme.onSurface, + return Semantics( + container: true, + button: interactive, + enabled: interactive, + label: _semanticsLabel(context), + onTap: interactive ? _activateWithoutPointer : null, + excludeSemantics: true, + child: FocusableActionDetector( + enabled: interactive, + mouseCursor: interactive ? SystemMouseCursors.click : MouseCursor.defer, + shortcuts: _activationShortcuts, + actions: >{ + ActivateIntent: CallbackAction( + onInvoke: (_) { + _activateWithoutPointer(); + return null; + }, + ), + }, + onShowFocusHighlight: (value) { + if (_showFocusHighlight != value) { + setState(() => _showFocusHighlight = value); + } + }, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTapDown: !interactive + ? null + : (details) => _pointerDownPosition = details.globalPosition, + onTapCancel: !interactive ? null : () => _pointerDownPosition = null, + onTap: interactive ? _activateFromPointer : null, + onSecondaryTapDown: !interactive + ? null + : (details) => _pointerDownPosition = details.globalPosition, + onSecondaryTap: interactive ? _activateFromPointer : null, + child: Tooltip( + excludeFromSemantics: true, + message: tooltipDetails.isEmpty + ? widget.item.title + : '${widget.item.title}\n$tooltipDetails', + waitDuration: const Duration(milliseconds: 600), + child: SizedBox( + width: blockWidth, + height: blockHeight, + child: Material( + color: Colors.transparent, + child: Container( + clipBehavior: Clip.antiAlias, + padding: EdgeInsets.symmetric( + horizontal: dense ? 6 : 8, + vertical: verticalPadding, + ), + decoration: BoxDecoration( + color: surfaceColors.control, + borderRadius: BorderRadius.circular(BusyMaxRadius.sm), + border: Border( + left: BorderSide( + color: _showFocusHighlight + ? colorScheme.primary + : surfaceColors.subtleBorder, + width: 4, ), + top: _showFocusHighlight ? focusBorder : BorderSide.none, + right: _showFocusHighlight + ? focusBorder + : BorderSide.none, + bottom: _showFocusHighlight + ? focusBorder + : BorderSide.none, ), ), - if (showTime && timeRange.isNotEmpty) - Text( - timeRange, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.labelSmall?.copyWith( - color: colorScheme.onSurfaceVariant, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: showTime + ? MainAxisAlignment.spaceBetween + : MainAxisAlignment.center, + mainAxisSize: MainAxisSize.max, + children: [ + Flexible( + child: Text( + widget.item.title, + maxLines: titleMaxLines, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.bodySmall + ?.copyWith( + fontWeight: FontWeight.w600, + color: colorScheme.onSurface, + ), + ), ), - ), - ], + if (showTime && timeRange.isNotEmpty) + Text( + timeRange, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.labelSmall + ?.copyWith(color: colorScheme.onSurfaceVariant), + ), + ], + ), + ), ), ), ), @@ -105,25 +164,47 @@ class ScheduleEventBlock extends StatelessWidget { String _tooltipDetails(BuildContext context) { final parts = [ - if (!item.allDay && item.start != null) _timeRange(context), - if (item.location != null && item.location!.isNotEmpty) item.location!, - ScheduleProjection.sourceLabelForScheduleItem(item), + if (!widget.item.allDay && widget.item.start != null) _timeRange(context), + if (widget.item.location != null && widget.item.location!.isNotEmpty) + widget.item.location!, + ScheduleProjection.sourceLabelForScheduleItem(widget.item), ]; return parts.join(' · '); } + String _semanticsLabel(BuildContext context) { + return [ + widget.item.title, + scheduleTimeRange(context, widget.item), + if (widget.item.location != null && widget.item.location!.isNotEmpty) + widget.item.location!, + ScheduleProjection.sourceLabelForScheduleItem(widget.item), + ].where((part) => part.isNotEmpty).join(', '); + } + String _timeRange(BuildContext context) { - final start = item.start; + final start = widget.item.start; if (start == null) { return ''; } final startText = _formatTime(context, start); - final end = item.end; + final end = widget.item.end; if (end == null) { return startText; } return '$startText-${_formatTime(context, end)}'; } + + void _activateFromPointer() { + final pointerDownPosition = _pointerDownPosition; + _pointerDownPosition = null; + widget.onTap?.call(context, pointerDownPosition); + } + + void _activateWithoutPointer() { + _pointerDownPosition = null; + widget.onTap?.call(context); + } } double? scheduleSafeBlockWidth(double? width) { diff --git a/lib/src/features/schedule/presentation/schedule_sidebar.dart b/lib/src/features/schedule/presentation/schedule_sidebar.dart index c177fd3..4f2a689 100644 --- a/lib/src/features/schedule/presentation/schedule_sidebar.dart +++ b/lib/src/features/schedule/presentation/schedule_sidebar.dart @@ -129,14 +129,14 @@ class _SourceRow extends ConsumerWidget { label: context.l10n.rename, icon: Icons.edit_outlined, enabled: !source.readOnly, - tooltip: source.readOnly ? 'Read-only calendar.' : null, + tooltip: source.readOnly ? context.l10n.readOnlyCalendar : null, ), BusyMaxMenuEntry( value: 'delete', label: context.l10n.delete, icon: YaruIcons.trash, enabled: !source.readOnly, - tooltip: source.readOnly ? 'Read-only calendar.' : null, + tooltip: source.readOnly ? context.l10n.readOnlyCalendar : null, destructive: true, ), ], @@ -456,7 +456,7 @@ class _TaskListScheduleRow extends ConsumerWidget { list.accountId, list.id, ); - final title = _taskListLabel(account, list); + final title = _taskListLabel(context, account, list); return _CompactSourceRow( title: title, leading: _SourceDot( @@ -527,10 +527,14 @@ class _TaskListScheduleRow extends ConsumerWidget { } } -String _taskListLabel(AccountEntity account, TaskListEntity list) { +String _taskListLabel( + BuildContext context, + AccountEntity account, + TaskListEntity list, +) { final provider = account.provider == BusyProvider.google - ? 'Google Tasks' - : 'Microsoft To Do'; + ? context.l10n.googleTasksProvider + : context.l10n.microsoftTodoProvider; final title = list.title.trim(); if (title.isEmpty || title.toLowerCase() == provider.toLowerCase() || @@ -644,6 +648,7 @@ Future _renameCalendar( label: context.l10n.title, actionLabel: context.l10n.rename, initialValue: source.summary, + headerBarService: ref.read(linuxHeaderBarServiceProvider), ); if (title == null || title.trim().isEmpty || title.trim() == source.summary) { return; @@ -661,9 +666,10 @@ Future _deleteCalendar( final confirmed = await showBusyMaxConfirm( context, title: context.l10n.delete, - message: 'Delete "${source.summary}"?', + message: context.l10n.deleteCalendarConfirmation(source.summary), confirmLabel: context.l10n.delete, destructive: true, + headerBarService: ref.read(linuxHeaderBarServiceProvider), ); if (!confirmed) { return; @@ -682,6 +688,7 @@ Future _renameTaskList( label: context.l10n.title, actionLabel: context.l10n.rename, initialValue: list.title, + headerBarService: ref.read(linuxHeaderBarServiceProvider), ); if (title == null || title.trim().isEmpty || title.trim() == list.title) { return; @@ -702,6 +709,7 @@ Future _deleteTaskList( message: context.l10n.deleteListConfirmation(list.title), confirmLabel: context.l10n.delete, destructive: true, + headerBarService: ref.read(linuxHeaderBarServiceProvider), ); if (!confirmed) { return; diff --git a/lib/src/features/schedule/presentation/schedule_task_chip.dart b/lib/src/features/schedule/presentation/schedule_task_chip.dart index 898d9e5..7ccd2af 100644 --- a/lib/src/features/schedule/presentation/schedule_task_chip.dart +++ b/lib/src/features/schedule/presentation/schedule_task_chip.dart @@ -68,6 +68,12 @@ class ScheduleTaskChip extends StatelessWidget { onTap: onTap == null ? null : () => onTap!(context, pointerDownPosition), + onSecondaryTapDown: onTap == null + ? null + : (details) => pointerDownPosition = details.globalPosition, + onSecondaryTap: onTap == null + ? null + : () => onTap!(context, pointerDownPosition), child: Container( padding: EdgeInsets.symmetric( horizontal: horizontalPadding, diff --git a/lib/src/features/schedule/presentation/schedule_toolbar.dart b/lib/src/features/schedule/presentation/schedule_toolbar.dart index bdcf67a..d0f91a0 100644 --- a/lib/src/features/schedule/presentation/schedule_toolbar.dart +++ b/lib/src/features/schedule/presentation/schedule_toolbar.dart @@ -7,6 +7,8 @@ import '../../../l10n/l10n.dart'; import '../../../schedule/schedule_range.dart'; import '../../../schedule/schedule_view_mode.dart'; +enum ScheduleToolbarMenuAction { refresh, settings, keyboardShortcuts, about } + class ScheduleToolbar extends StatelessWidget { const ScheduleToolbar({ super.key, @@ -20,6 +22,12 @@ class ScheduleToolbar extends StatelessWidget { required this.canCreate, required this.onCreate, required this.onRefresh, + this.canRefresh = true, + this.canShowSidebar = false, + this.sidebarVisible = false, + this.onToggleSidebar, + this.onSearch, + this.onMenuSelected, }); final ScheduleViewMode mode; @@ -32,78 +40,141 @@ class ScheduleToolbar extends StatelessWidget { final bool canCreate; final VoidCallback onCreate; final VoidCallback onRefresh; + final bool canRefresh; + final bool canShowSidebar; + final bool sidebarVisible; + final VoidCallback? onToggleSidebar; + final VoidCallback? onSearch; + final ValueChanged? onMenuSelected; @override Widget build(BuildContext context) { final showPaging = mode != ScheduleViewMode.agenda; - return SizedBox( - height: BusyMaxSizes.toolbarHeight, - child: Row( - children: [ - const SizedBox(width: BusyMaxSpacing.sm), - BusyMaxPushButton.outlined( - onPressed: onToday, - child: Text(context.l10n.today), - ), - const SizedBox(width: BusyMaxSpacing.sm), - if (showPaging) ...[ - YaruIconButton( - tooltip: MaterialLocalizations.of(context).previousPageTooltip, - icon: const Icon(YaruIcons.arrow_left), - onPressed: onPrevious, - ), - YaruIconButton( - tooltip: MaterialLocalizations.of(context).nextPageTooltip, - icon: const Icon(YaruIcons.arrow_right), - onPressed: onNext, - ), - const SizedBox(width: BusyMaxSpacing.sm), - ], - Expanded( - child: Text( - _rangeLabel(context, mode, range, selectedDate), - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.titleMedium, - ), - ), - Flexible( - fit: FlexFit.loose, - child: SingleChildScrollView( - scrollDirection: Axis.horizontal, - child: Row( - children: [ + return LayoutBuilder( + builder: (context, constraints) { + final compact = constraints.maxWidth < 760; + return SizedBox( + height: BusyMaxSizes.toolbarHeight, + child: Row( + children: [ + const SizedBox(width: BusyMaxSpacing.sm), + if (canShowSidebar && onToggleSidebar != null) + YaruIconButton( + tooltip: context.l10n.toggleSidebar, + icon: Icon( + sidebarVisible + ? Icons.vertical_split + : Icons.vertical_split_outlined, + ), + onPressed: onToggleSidebar, + ), + BusyMaxPushButton.outlined( + onPressed: onToday, + child: Text(context.l10n.today), + ), + const SizedBox(width: BusyMaxSpacing.sm), + if (showPaging) ...[ + YaruIconButton( + tooltip: MaterialLocalizations.of( + context, + ).previousPageTooltip, + icon: const Icon(YaruIcons.arrow_left), + onPressed: onPrevious, + ), + YaruIconButton( + tooltip: MaterialLocalizations.of(context).nextPageTooltip, + icon: const Icon(YaruIcons.arrow_right), + onPressed: onNext, + ), + const SizedBox(width: BusyMaxSpacing.sm), + ], + Expanded( + child: Text( + _rangeLabel(context, mode, range, selectedDate), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.titleMedium, + ), + ), + BusyMaxMenuButton( + tooltip: _modeLabel(context, mode), + icon: Icon(_modeIcon(mode)), + entries: [ for (final value in ScheduleViewMode.values) - Padding( - padding: const EdgeInsets.only(right: BusyMaxSpacing.xs), - child: BusyMaxPushButton.outlined( - onPressed: mode == value - ? null - : () => onModeChanged(value), - child: Text(_modeLabel(context, value)), - ), + BusyMaxMenuEntry( + value: value, + label: _modeLabel(context, value), + icon: _modeIcon(value), + checked: mode == value, ), ], + onSelected: onModeChanged, ), - ), - ), - YaruIconButton( - tooltip: context.l10n.create, - icon: const Icon(YaruIcons.plus), - onPressed: canCreate ? onCreate : null, - ), - YaruIconButton( - tooltip: context.l10n.refreshAll, - icon: const Icon(YaruIcons.refresh), - onPressed: onRefresh, + if (onSearch != null) + YaruIconButton( + tooltip: MaterialLocalizations.of(context).searchFieldLabel, + icon: const Icon(YaruIcons.search), + onPressed: onSearch, + ), + YaruIconButton( + tooltip: context.l10n.create, + icon: const Icon(YaruIcons.plus), + onPressed: canCreate ? onCreate : null, + ), + if (!compact) + YaruIconButton( + tooltip: context.l10n.refreshAll, + icon: const Icon(YaruIcons.refresh), + onPressed: canRefresh ? onRefresh : null, + ), + 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, + ), + BusyMaxMenuEntry( + value: ScheduleToolbarMenuAction.keyboardShortcuts, + label: context.l10n.keyboardShortcuts, + icon: Icons.keyboard_alt_outlined, + ), + BusyMaxMenuEntry( + value: ScheduleToolbarMenuAction.about, + label: context.l10n.aboutBusyMax, + icon: Icons.info_outline, + ), + ], + onSelected: onMenuSelected!, + ), + const SizedBox(width: BusyMaxSpacing.sm), + ], ), - const SizedBox(width: BusyMaxSpacing.sm), - ], - ), + ); + }, ); } } +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, + }; +} + String _rangeLabel( BuildContext context, ScheduleViewMode mode, diff --git a/lib/src/features/schedule/presentation/schedule_workspace.dart b/lib/src/features/schedule/presentation/schedule_workspace.dart index 0e98768..7624f26 100644 --- a/lib/src/features/schedule/presentation/schedule_workspace.dart +++ b/lib/src/features/schedule/presentation/schedule_workspace.dart @@ -14,6 +14,7 @@ import '../../../app/busymax_design.dart'; import '../../../app/busymax_dialogs.dart'; import '../../../app/busymax_keyboard_shortcuts_dialog.dart'; import '../../../app/busymax_layout.dart'; +import '../../../app/busymax_shortcuts.dart'; import '../../../core/logging/redacting_logger.dart'; import '../../../features/accounts/data/accounts_repository.dart'; import '../../../features/calendar/data/calendar_repository.dart'; @@ -39,6 +40,7 @@ import '../../tasks/presentation/task_details_pane.dart'; import 'schedule_agenda_view.dart'; import 'schedule_create_menu.dart'; import 'schedule_day_week_view.dart'; +import 'schedule_empty_states.dart'; import 'schedule_item_details_popover.dart'; import 'schedule_item_exporter.dart'; import 'schedule_item_selection.dart'; @@ -86,7 +88,6 @@ class _ScheduleWorkspaceState extends ConsumerState { var _agendaOverdueTaskLimit = _agendaInitialTaskBucketLimit; var _agendaNoDateTaskLimit = _agendaInitialTaskBucketLimit; ScheduleViewMode? _lastSettingsMode; - _HeaderBarStateSnapshot? _lastHeaderBarState; @override void initState() { @@ -102,7 +103,7 @@ class _ScheduleWorkspaceState extends ConsumerState { HardwareKeyboard.instance.removeHandler(_handleScheduleShortcutEvent); if (_taskDetailsTarget != null) { unawaited( - ref.read(linuxHeaderBarServiceProvider).setModalBarrierVisible(false), + releaseBusyMaxModalBarrier(ref.read(linuxHeaderBarServiceProvider)), ); } unawaited(_headerBarActions?.cancel()); @@ -130,6 +131,8 @@ class _ScheduleWorkspaceState extends ConsumerState { final accounts = accountsState.valueOrNull ?? const []; final accountsLoading = accountsState.isLoading && accountsState.valueOrNull == null; + final accountsUnavailable = + accountsState.hasError && accountsState.valueOrNull == null; final accountIds = accounts.map((account) => account.id).toList(); final sourcesStream = ref .watch(calendarRepositoryProvider) @@ -141,6 +144,8 @@ class _ScheduleWorkspaceState extends ConsumerState { final sourcesLoading = sourcesSnapshot.connectionState == ConnectionState.waiting && !sourcesSnapshot.hasData; + final sourcesUnavailable = + sourcesSnapshot.hasError && !sourcesSnapshot.hasData; final sources = sourcesSnapshot.data ?? const []; return FutureBuilder>( future: _taskListsForAccounts(accounts), @@ -148,6 +153,8 @@ class _ScheduleWorkspaceState extends ConsumerState { final taskListsLoading = listsSnapshot.connectionState == ConnectionState.waiting && !listsSnapshot.hasData; + final taskListsUnavailable = + listsSnapshot.hasError && !listsSnapshot.hasData; final taskLists = listsSnapshot.data ?? const []; final visibility = ScheduleSourceVisibility.fromSources( calendarSources: sources, @@ -182,6 +189,11 @@ class _ScheduleWorkspaceState extends ConsumerState { sourcesLoading || taskListsLoading || itemsLoading; + final scheduleUnavailable = + accountsUnavailable || + sourcesUnavailable || + taskListsUnavailable || + (snapshot.hasError && !snapshot.hasData); final scopedItems = ScheduleProjection.filterByScope( snapshot.data?.items ?? const [], _scope, @@ -218,6 +230,9 @@ class _ScheduleWorkspaceState extends ConsumerState { : _mode; _consumePendingCommand(visibleSources, accounts); final showFallbackHeader = _showFlutterHeaderFallback; + final canShowFallbackSidebar = BusyMaxLayoutRules.showSidebar( + MediaQuery.sizeOf(context).width, + ); final main = Column( children: [ if (showFallbackHeader) ...[ @@ -229,9 +244,21 @@ class _ScheduleWorkspaceState extends ConsumerState { onPrevious: _previous, onNext: _next, onModeChanged: _setMode, - canCreate: accounts.isNotEmpty, + canCreate: + accounts.isNotEmpty || visibleSources.isNotEmpty, onCreate: _openCreateAtSelectedDate, 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), ], @@ -248,6 +275,7 @@ class _ScheduleWorkspaceState extends ConsumerState { Expanded( child: _ScheduleBody( isLoading: scheduleLoading, + isUnavailable: scheduleUnavailable, mode: displayMode, range: displayRange, selectedDate: searchHasQuery @@ -259,7 +287,13 @@ class _ScheduleWorkspaceState extends ConsumerState { hasAnySources: visibility.hasCalendarSources || visibility.hasTaskLists, + hasAccounts: accounts.isNotEmpty, items: items, + onOpenSettings: () => context.go('/settings'), + onRetry: _retrySchedule, + onRefresh: accounts.isEmpty + ? null + : () => unawaited(_refreshAll()), onDaySelected: _setDate, onYearDaySelected: _openDay, onMonthSelected: _setMonth, @@ -424,46 +458,40 @@ class _ScheduleWorkspaceState extends ConsumerState { ); final sidebarVisible = showSidebar && !_sidebarCollapsed; final canCreate = accounts.isNotEmpty || visibleSources.isNotEmpty; - final headerBarState = _HeaderBarStateSnapshot( - titleRange: titleRange, + final headerBarState = BusyMaxHeaderBarState( + title: titleRange, viewMode: _mode, canRefresh: accounts.isNotEmpty, canCreate: canCreate, searchActive: _searchActive, + canShowSidebar: showSidebar, sidebarVisible: sidebarVisible, navigationVisible: _mode != ScheduleViewMode.agenda, + scheduleControlsVisible: true, + backVisible: false, ); - if (_lastHeaderBarState == headerBarState) { - return; - } - _lastHeaderBarState = headerBarState; WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted) { return; } final service = ref.read(linuxHeaderBarServiceProvider); - unawaited(service.setScheduleControlsVisible(true)); - unawaited(service.setBackVisible(false)); - unawaited( - service.setOnboardingControls( - visible: false, - canGoBack: false, - canContinue: false, - backLabel: '', - continueLabel: '', - force: true, - ), - ); - unawaited(service.setTitleRange(headerBarState.titleRange)); - unawaited(service.setViewMode(headerBarState.viewMode)); - unawaited(service.setNavigationVisible(headerBarState.navigationVisible)); - unawaited(service.setCanRefresh(headerBarState.canRefresh)); - unawaited(service.setCanCreate(headerBarState.canCreate)); - unawaited(service.setSearchActive(headerBarState.searchActive)); - unawaited(service.setSidebarVisible(headerBarState.sidebarVisible)); + unawaited(service.updateState(headerBarState)); }); } + void _handleFallbackToolbarMenu(ScheduleToolbarMenuAction action) { + switch (action) { + case ScheduleToolbarMenuAction.refresh: + _handleHeaderBarAction(BusyMaxHeaderBarAction.refresh); + case ScheduleToolbarMenuAction.settings: + _handleHeaderBarAction(BusyMaxHeaderBarAction.settings); + case ScheduleToolbarMenuAction.keyboardShortcuts: + _handleHeaderBarAction(BusyMaxHeaderBarAction.keyboardShortcuts); + case ScheduleToolbarMenuAction.about: + _handleHeaderBarAction(BusyMaxHeaderBarAction.aboutBusyMax); + } + } + void _handleHeaderBarAction(BusyMaxHeaderBarAction action) { switch (action) { case BusyMaxHeaderBarAction.back: @@ -887,10 +915,29 @@ class _ScheduleWorkspaceState extends ConsumerState { } bool _handleScheduleShortcutEvent(KeyEvent event) { - if (event is! KeyDownEvent || !_canHandleScheduleShortcut()) { + if (event is! KeyDownEvent || !_canHandleRouteShortcut()) { return false; } final keyboard = HardwareKeyboard.instance; + if (BusyMaxShortcutActivators.search.accepts(event, keyboard)) { + if (!_searchActive) { + setState(() => _searchActive = true); + } + _focusSearch(); + return true; + } + if (BusyMaxShortcutActivators.create.accepts(event, keyboard)) { + _openCreateAtSelectedDate(); + return true; + } + if (_searchActive && + BusyMaxShortcutActivators.dismiss.accepts(event, keyboard)) { + _closeSearch(); + return true; + } + if (!_canHandleScheduleShortcut()) { + return false; + } if (keyboard.isControlPressed || keyboard.isAltPressed || keyboard.isMetaPressed) { @@ -967,11 +1014,7 @@ class _ScheduleWorkspaceState extends ConsumerState { } bool _canHandleScheduleShortcut() { - if (!mounted || _searchActive || _taskDetailsTarget != null) { - return false; - } - final route = ModalRoute.of(context); - if (route != null && !route.isCurrent) { + if (!_canHandleRouteShortcut() || _searchActive) { return false; } final focusContext = FocusManager.instance.primaryFocus?.context; @@ -982,6 +1025,17 @@ class _ScheduleWorkspaceState extends ConsumerState { focusContext.findAncestorWidgetOfExactType() == null; } + bool _canHandleRouteShortcut() { + if (!mounted || _taskDetailsTarget != null) { + return false; + } + final route = ModalRoute.of(context); + if (route != null && !route.isCurrent) { + return false; + } + return true; + } + void _loadMoreAgendaDays() { if (_mode != ScheduleViewMode.agenda) { return; @@ -1020,7 +1074,10 @@ class _ScheduleWorkspaceState extends ConsumerState { List sources, DateTime start, ) async { - final choice = await showScheduleCreateMenu(context: context); + final choice = await showScheduleCreateMenu( + context: context, + headerBarService: ref.read(linuxHeaderBarServiceProvider), + ); if (!mounted || choice == null) { return; } @@ -1159,7 +1216,7 @@ class _ScheduleWorkspaceState extends ConsumerState { ); }); unawaited( - ref.read(linuxHeaderBarServiceProvider).setModalBarrierVisible(true), + acquireBusyMaxModalBarrier(ref.read(linuxHeaderBarServiceProvider)), ); } @@ -1169,7 +1226,7 @@ class _ScheduleWorkspaceState extends ConsumerState { } setState(() => _taskDetailsTarget = null); unawaited( - ref.read(linuxHeaderBarServiceProvider).setModalBarrierVisible(false), + releaseBusyMaxModalBarrier(ref.read(linuxHeaderBarServiceProvider)), ); } @@ -1245,9 +1302,10 @@ class _ScheduleWorkspaceState extends ConsumerState { : context.l10n.deleteTask, message: item is TaskScheduleItem ? context.l10n.deleteTaskConfirmation(item.title) - : 'Delete "${item.title}"?', + : context.l10n.deleteCalendarConfirmation(item.title), confirmLabel: context.l10n.delete, destructive: true, + headerBarService: ref.read(linuxHeaderBarServiceProvider), ); if (!confirmed) { return; @@ -1352,6 +1410,11 @@ class _ScheduleWorkspaceState extends ConsumerState { } } + void _retrySchedule() { + ref.invalidate(accountsStreamProvider); + setState(() {}); + } + void _consumePendingCommand( List sources, List accounts, @@ -1532,51 +1595,6 @@ class _ScheduleSearchField extends StatelessWidget { } } -@immutable -class _HeaderBarStateSnapshot { - const _HeaderBarStateSnapshot({ - required this.titleRange, - required this.viewMode, - required this.canRefresh, - required this.canCreate, - required this.searchActive, - required this.sidebarVisible, - required this.navigationVisible, - }); - - final String titleRange; - final ScheduleViewMode viewMode; - final bool canRefresh; - final bool canCreate; - final bool searchActive; - final bool sidebarVisible; - final bool navigationVisible; - - @override - bool operator ==(Object other) { - return identical(this, other) || - other is _HeaderBarStateSnapshot && - titleRange == other.titleRange && - viewMode == other.viewMode && - canRefresh == other.canRefresh && - canCreate == other.canCreate && - searchActive == other.searchActive && - sidebarVisible == other.sidebarVisible && - navigationVisible == other.navigationVisible; - } - - @override - int get hashCode => Object.hash( - titleRange, - viewMode, - canRefresh, - canCreate, - searchActive, - sidebarVisible, - navigationVisible, - ); -} - class _ScheduleItemsResult { const _ScheduleItemsResult({ required this.items, @@ -1592,6 +1610,7 @@ class _ScheduleItemsResult { class _ScheduleBody extends StatelessWidget { const _ScheduleBody({ required this.isLoading, + required this.isUnavailable, required this.mode, required this.range, required this.selectedDate, @@ -1599,7 +1618,11 @@ class _ScheduleBody extends StatelessWidget { required this.dayStartMinute, required this.dayEndMinute, required this.hasAnySources, + required this.hasAccounts, required this.items, + required this.onOpenSettings, + required this.onRetry, + required this.onRefresh, required this.onDaySelected, required this.onYearDaySelected, required this.onMonthSelected, @@ -1620,6 +1643,7 @@ class _ScheduleBody extends StatelessWidget { }); final bool isLoading; + final bool isUnavailable; final ScheduleViewMode mode; final ScheduleRange range; final DateTime selectedDate; @@ -1627,7 +1651,11 @@ class _ScheduleBody extends StatelessWidget { final int dayStartMinute; final int dayEndMinute; final bool hasAnySources; + final bool hasAccounts; final List items; + final VoidCallback onOpenSettings; + final VoidCallback onRetry; + final VoidCallback? onRefresh; final ValueChanged onDaySelected; final ValueChanged onYearDaySelected; final ValueChanged onMonthSelected; @@ -1649,11 +1677,18 @@ class _ScheduleBody extends StatelessWidget { @override Widget build(BuildContext context) { + if (isUnavailable) { + return ScheduleUnavailableState(onRetry: onRetry); + } if (isLoading) { - return const SizedBox.expand(); + return const ScheduleLoadingState(); } if (!hasAnySources) { - return const SizedBox.expand(); + return ScheduleNoSourcesState( + hasAccounts: hasAccounts, + onOpenSettings: onOpenSettings, + onRefresh: onRefresh, + ); } return switch (mode) { ScheduleViewMode.day => ScheduleDayWeekView( diff --git a/lib/src/features/settings/presentation/settings_screen.dart b/lib/src/features/settings/presentation/settings_screen.dart index 2d96b16..6bc7d7c 100644 --- a/lib/src/features/settings/presentation/settings_screen.dart +++ b/lib/src/features/settings/presentation/settings_screen.dart @@ -12,6 +12,7 @@ import '../../../app/app_bootstrap.dart'; import '../../../app/busymax_design.dart'; import '../../../app/busymax_dialogs.dart'; import '../../../app/busymax_keyboard_shortcuts_dialog.dart'; +import '../../../app/busymax_layout.dart'; import '../../../google_tasks/oauth/oauth_models.dart'; import '../../../l10n/l10n.dart'; import '../../../platform/linux_header_bar_service.dart'; @@ -61,7 +62,6 @@ class _SettingsScreenState extends ConsumerState { final themeController = ref.read(busyMaxThemeControllerProvider); final l10n = context.l10n; final title = _settingsPageLabel(context, _page); - _updateSettingsHeaderBar(context, title); final pageBody = switch (_page) { SettingsPage.accounts => _AccountManagementSection( @@ -116,19 +116,19 @@ class _SettingsScreenState extends ConsumerState { : () => _fullSync(context, ref, accounts), ), BusyMaxSwitchRow( - title: 'Run in background when window is closed', + title: l10n.runInBackgroundWhenClosed, value: settings.runInBackgroundWhenClosed, onChanged: settingsController.setRunInBackgroundWhenClosed, leading: const Icon(YaruIcons.window), ), BusyMaxSwitchRow( - title: 'Show tray icon', + title: l10n.showTrayIcon, value: settings.showTrayIcon, onChanged: settingsController.setShowTrayIcon, leading: const Icon(YaruIcons.pin), ), BusyMaxSwitchRow( - title: 'Start minimized to tray', + title: l10n.startMinimizedToTray, value: settings.startMinimizedToTray, onChanged: settingsController.setStartMinimizedToTray, leading: const Icon(YaruIcons.window_minimize), @@ -153,13 +153,13 @@ class _SettingsScreenState extends ConsumerState { filled: true, children: [ BusyMaxSwitchRow( - title: 'Event reminders', + title: l10n.eventReminders, value: settings.notifyEventReminders, onChanged: settingsController.setNotifyEventReminders, leading: const Icon(YaruIcons.calendar_day), ), BusyMaxSwitchRow( - title: 'Task reminders', + title: l10n.taskReminders, value: settings.notifyTaskReminders, onChanged: settingsController.setNotifyTaskReminders, leading: const Icon(YaruIcons.checkmark), @@ -183,15 +183,15 @@ class _SettingsScreenState extends ConsumerState { leading: const Icon(YaruIcons.warning), ), BusyMaxComboRow( - title: 'Notification detail level', + title: l10n.notificationDetailLevel, leading: const Icon(YaruIcons.eye), values: NotificationDetailLevel.values, selected: settings.notificationDetailLevel, - labelFor: _notificationDetailLabel, + labelFor: (value) => _notificationDetailLabel(context, value), onSelected: settingsController.setNotificationDetailLevel, ), BusyMaxSwitchRow( - title: 'Quiet hours', + title: l10n.quietHours, value: settings.quietHoursEnabled, onChanged: settingsController.setQuietHoursEnabled, leading: const Icon(YaruIcons.clear_night), @@ -220,33 +220,62 @@ class _SettingsScreenState extends ConsumerState { }; return Scaffold( - body: Row( - children: [ - SizedBox( - width: BusyMaxSizes.sidebarWidth, - child: _SettingsSidebar( - selected: _page, - onSelected: (page) => setState(() => _page = page), - ), - ), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - if (_showFallbackHeader) - _SettingsFallbackHeader(title: title, onBack: _goBack), - Expanded( - child: BusyMaxClamp( - maxWidth: 760, - margin: EdgeInsets.zero, - padding: const EdgeInsets.fromLTRB(16, 12, 16, 24), - child: pageBody, + backgroundColor: BusyMaxSurfaceColors.of(context).view, + 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 (!showSidebar) + Padding( + padding: const EdgeInsets.fromLTRB( + BusyMaxSpacing.lg, + BusyMaxSpacing.md, + BusyMaxSpacing.lg, + 0, + ), + child: _SettingsPageSelector( + selected: _page, + onSelected: (page) => setState(() => _page = page), ), ), - ], - ), - ), - ], + 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( + selected: _page, + onSelected: (page) => setState(() => _page = page), + ), + ), + Expanded(child: content), + ], + ); + }, ), ); } @@ -269,6 +298,18 @@ class _SettingsScreenState extends ConsumerState { _headerBarReady = true; _nativeHeaderBarAvailable = service.isAvailable; }); + if (service.isAvailable) { + unawaited( + service.setOnboardingControls( + visible: false, + canGoBack: false, + canContinue: false, + backLabel: '', + continueLabel: '', + force: true, + ), + ); + } } void _handleHeaderBarAction(BusyMaxHeaderBarAction action) { @@ -306,30 +347,36 @@ class _SettingsScreenState extends ConsumerState { context.go('/schedule'); } - void _updateSettingsHeaderBar(BuildContext context, String title) { + void _updateSettingsHeaderBar( + BuildContext context, + String title, { + required AppSettings settings, + required bool showSidebar, + }) { + if (!_nativeHeaderBarAvailable) { + return; + } WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted) { return; } final service = ref.read(linuxHeaderBarServiceProvider); - unawaited(() async { - await service.initialize(); - await service.setScheduleControlsVisible(false); - await service.setBackVisible(true); - await service.setOnboardingControls( - visible: false, - canGoBack: false, - canContinue: false, - backLabel: '', - continueLabel: '', - force: true, - ); - await service.setTitleRange(title); - await service.setCanRefresh(false); - await service.setCanCreate(false); - await service.setSearchActive(false); - await service.setSidebarVisible(true); - }()); + unawaited( + service.updateState( + BusyMaxHeaderBarState( + title: title, + viewMode: settings.scheduleViewMode, + canRefresh: false, + canCreate: false, + searchActive: false, + canShowSidebar: showSidebar, + sidebarVisible: showSidebar, + navigationVisible: false, + scheduleControlsVisible: false, + backVisible: true, + ), + ), + ); }); } @@ -414,6 +461,7 @@ class _SettingsScreenState extends ConsumerState { message: context.l10n.deleteLocalDataConfirmation, confirmLabel: context.l10n.delete, destructive: true, + headerBarService: ref.read(linuxHeaderBarServiceProvider), ); if (!context.mounted || !confirmed) { return; @@ -434,7 +482,10 @@ class _SettingsScreenState extends ConsumerState { WidgetRef ref, String accountId, ) async { - final title = await _taskListTitleDialog(context); + final title = await _taskListTitleDialog( + context, + ref.read(linuxHeaderBarServiceProvider), + ); if (title == null || title.trim().isEmpty) { return; } @@ -506,6 +557,58 @@ class _SettingsSidebar extends StatelessWidget { } } +class _SettingsPageSelector extends StatelessWidget { + const _SettingsPageSelector({ + required this.selected, + required this.onSelected, + }); + + final SettingsPage selected; + final ValueChanged onSelected; + + @override + Widget build(BuildContext context) { + return SizedBox( + key: const ValueKey('settings-page-selector'), + width: double.infinity, + child: BusyMaxMenuButton( + tooltip: _settingsPageLabel(context, selected), + minMenuWidth: BusyMaxSizes.sidebarWidth, + menuPosition: null, + entries: [ + for (final page in SettingsPage.values) + BusyMaxMenuEntry( + value: page, + label: _settingsPageLabel(context, page), + icon: _settingsPageIcon(page), + checked: page == selected, + ), + ], + onSelected: onSelected, + triggerBuilder: (context, onPressed) { + return BusyMaxPushButton.outlined( + onPressed: onPressed, + child: Row( + children: [ + Icon(_settingsPageIcon(selected)), + const SizedBox(width: BusyMaxSpacing.sm), + Expanded( + child: Text( + _settingsPageLabel(context, selected), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + const Icon(YaruIcons.pan_down), + ], + ), + ); + }, + ), + ); + } +} + class _SettingsSidebarItem extends StatelessWidget { const _SettingsSidebarItem({ required this.page, @@ -671,12 +774,16 @@ String _timeOfDayLabel(BuildContext context, int minute) { ); } -Future _taskListTitleDialog(BuildContext context) { +Future _taskListTitleDialog( + BuildContext context, + LinuxHeaderBarService headerBarService, +) { return showBusyMaxTextPrompt( context, title: context.l10n.newList, label: context.l10n.title, actionLabel: context.l10n.create, + headerBarService: headerBarService, ); } @@ -880,10 +987,14 @@ String _themeModeLabel( }; } -String _notificationDetailLabel(NotificationDetailLevel level) { +String _notificationDetailLabel( + BuildContext context, + NotificationDetailLevel level, +) { + final l10n = context.l10n; return switch (level) { - NotificationDetailLevel.private => 'Private', - NotificationDetailLevel.normal => 'Normal', + NotificationDetailLevel.private => l10n.notificationDetailPrivate, + NotificationDetailLevel.normal => l10n.notificationDetailNormal, }; } diff --git a/lib/src/features/tasks/presentation/new_task_dialog.dart b/lib/src/features/tasks/presentation/new_task_dialog.dart index a8eca2c..7dc7c5e 100644 --- a/lib/src/features/tasks/presentation/new_task_dialog.dart +++ b/lib/src/features/tasks/presentation/new_task_dialog.dart @@ -159,6 +159,7 @@ class _NewTaskEditorPanelState extends ConsumerState { showDeleteAction: false, 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 091904b..bd6ae37 100644 --- a/lib/src/features/tasks/presentation/task_details_editor.dart +++ b/lib/src/features/tasks/presentation/task_details_editor.dart @@ -9,6 +9,7 @@ import '../../../app/busymax_design.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 '../../../task_providers/task_provider.dart'; import '../../task_lists/data/task_lists_repository.dart'; import '../data/tasks_repository.dart'; @@ -48,6 +49,7 @@ class TaskDetailsEditor extends StatefulWidget { this.confirmTaskSwitch = true, this.useNativeDatePicker = true, this.dialogBarrierColor, + this.headerBarService, this.canSaveDraft, }); @@ -85,6 +87,7 @@ class TaskDetailsEditor extends StatefulWidget { final bool confirmTaskSwitch; final bool useNativeDatePicker; final Color? dialogBarrierColor; + final LinuxHeaderBarService? headerBarService; final bool Function(TaskDetailsDraft draft)? canSaveDraft; @override @@ -663,6 +666,7 @@ class _TaskDetailsEditorState extends State { confirmLabel: context.l10n.discard, destructive: true, barrierColor: widget.dialogBarrierColor, + headerBarService: widget.headerBarService, ); if (!discard || !mounted) { return; @@ -685,6 +689,7 @@ class _TaskDetailsEditorState extends State { confirmLabel: context.l10n.discard, destructive: true, barrierColor: widget.dialogBarrierColor, + headerBarService: widget.headerBarService, ); if (!mounted) { return; @@ -704,6 +709,7 @@ 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; @@ -724,6 +730,7 @@ 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 6047934..51138c4 100644 --- a/lib/src/features/tasks/presentation/task_details_pane.dart +++ b/lib/src/features/tasks/presentation/task_details_pane.dart @@ -290,6 +290,7 @@ class _TaskDetailsPaneState extends ConsumerState { onTaskSwitchCancelled: widget.onTaskSwitchCancelled, onDirtyChanged: _setEditorDirty, dialogBarrierColor: widget.dialogBarrierColor, + headerBarService: ref.read(linuxHeaderBarServiceProvider), ); } diff --git a/lib/src/features/tasks/presentation/tasks_workspace.dart b/lib/src/features/tasks/presentation/tasks_workspace.dart index 1a14861..89beb8e 100644 --- a/lib/src/features/tasks/presentation/tasks_workspace.dart +++ b/lib/src/features/tasks/presentation/tasks_workspace.dart @@ -57,7 +57,7 @@ class _TasksWorkspaceState extends ConsumerState { @override void dispose() { if (_detailsTarget != null) { - unawaited(_headerBarService.setModalBarrierVisible(false)); + unawaited(releaseBusyMaxModalBarrier(_headerBarService)); } super.dispose(); } @@ -170,7 +170,7 @@ class _TasksWorkspaceState extends ConsumerState { ); _detailsDirty = false; }); - unawaited(_headerBarService.setModalBarrierVisible(true)); + unawaited(acquireBusyMaxModalBarrier(_headerBarService)); } void _openTaskDetails( @@ -190,7 +190,7 @@ class _TasksWorkspaceState extends ConsumerState { ); _detailsDirty = false; }); - unawaited(_headerBarService.setModalBarrierVisible(true)); + unawaited(acquireBusyMaxModalBarrier(_headerBarService)); } void _closeTaskDetails() { @@ -201,7 +201,7 @@ class _TasksWorkspaceState extends ConsumerState { _detailsTarget = null; _detailsDirty = false; }); - unawaited(_headerBarService.setModalBarrierVisible(false)); + unawaited(releaseBusyMaxModalBarrier(_headerBarService)); } Future _requestCloseTaskDetails(BuildContext context) async { @@ -215,6 +215,7 @@ class _TasksWorkspaceState extends ConsumerState { message: context.l10n.discardChangesConfirmation, confirmLabel: context.l10n.discard, destructive: true, + headerBarService: _headerBarService, ); if (!discard || !mounted) { return; diff --git a/lib/src/platform/linux_header_bar_service.dart b/lib/src/platform/linux_header_bar_service.dart index ba17d9f..6fab876 100644 --- a/lib/src/platform/linux_header_bar_service.dart +++ b/lib/src/platform/linux_header_bar_service.dart @@ -235,6 +235,117 @@ class BusyMaxHeaderBarTheme { ); } +/// 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.canCreate, + required this.searchActive, + required this.canShowSidebar, + required this.sidebarVisible, + required this.navigationVisible, + required this.scheduleControlsVisible, + required this.backVisible, + }); + + static const int schemaVersion = 1; + + final String title; + final ScheduleViewMode viewMode; + final bool canRefresh; + final bool canCreate; + final bool searchActive; + + /// 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, + 'canCreate': canCreate, + 'searchActive': searchActive, + 'canShowSidebar': canShowSidebar, + 'sidebarVisible': sidebarVisible, + 'navigationVisible': navigationVisible, + 'scheduleControlsVisible': scheduleControlsVisible, + 'backVisible': backVisible, + }; + } + + BusyMaxHeaderBarState copyWith({ + String? title, + ScheduleViewMode? viewMode, + bool? canRefresh, + bool? canCreate, + bool? searchActive, + bool? canShowSidebar, + bool? sidebarVisible, + bool? navigationVisible, + bool? scheduleControlsVisible, + bool? backVisible, + }) { + return BusyMaxHeaderBarState( + title: title ?? this.title, + viewMode: viewMode ?? this.viewMode, + canRefresh: canRefresh ?? this.canRefresh, + canCreate: canCreate ?? this.canCreate, + searchActive: searchActive ?? this.searchActive, + 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 && + canCreate == other.canCreate && + searchActive == other.searchActive && + canShowSidebar == other.canShowSidebar && + sidebarVisible == other.sidebarVisible && + navigationVisible == other.navigationVisible && + scheduleControlsVisible == other.scheduleControlsVisible && + backVisible == other.backVisible; + } + + @override + int get hashCode => Object.hash( + title, + viewMode, + canRefresh, + canCreate, + searchActive, + canShowSidebar, + sidebarVisible, + navigationVisible, + scheduleControlsVisible, + backVisible, + ); +} + class LinuxHeaderBarService { LinuxHeaderBarService({ MethodChannel channel = const MethodChannel( @@ -255,6 +366,7 @@ class LinuxHeaderBarService { bool? _canRefresh; bool? _canCreate; bool? _searchActive; + bool? _canShowSidebar; bool? _sidebarVisible; bool? _navigationVisible; bool? _scheduleControlsVisible; @@ -264,6 +376,7 @@ class LinuxHeaderBarService { double? _sidebarWidth; BusyMaxHeaderBarLabels? _labels; BusyMaxHeaderBarTheme? _theme; + BusyMaxHeaderBarState? _state; bool get isAvailable => _available; @@ -286,6 +399,34 @@ class LinuxHeaderBarService { } } + /// Applies all screen-owned header state in one native transaction. + /// + /// Equal state is not sent twice. Set [force] when the native widgets may + /// have been recreated independently of this service instance. + Future updateState( + BusyMaxHeaderBarState state, { + bool force = false, + }) async { + if (!_available) { + return; + } + if (!force && _state == state) { + return; + } + _state = state; + _titleRange = state.title; + _viewMode = state.viewMode; + _canRefresh = state.canRefresh; + _canCreate = state.canCreate; + _searchActive = state.searchActive; + _canShowSidebar = state.canShowSidebar; + _sidebarVisible = state.sidebarVisible; + _navigationVisible = state.navigationVisible; + _scheduleControlsVisible = state.scheduleControlsVisible; + _backVisible = state.backVisible; + await _invokeIfAvailable('setState', state.toJson()); + } + Future setTitleRange(String value) async { if (!_available) { return; @@ -293,6 +434,7 @@ class LinuxHeaderBarService { if (_titleRange == value) { return; } + _state = null; _titleRange = value; await _invokeIfAvailable('setTitleRange', value); } @@ -304,6 +446,7 @@ class LinuxHeaderBarService { if (_viewMode == mode) { return; } + _state = null; _viewMode = mode; await _invokeIfAvailable('setViewMode', mode.name); } @@ -315,6 +458,7 @@ class LinuxHeaderBarService { if (_canRefresh == value) { return; } + _state = null; _canRefresh = value; await _invokeIfAvailable('setCanRefresh', value); } @@ -326,6 +470,7 @@ class LinuxHeaderBarService { if (_canCreate == value) { return; } + _state = null; _canCreate = value; await _invokeIfAvailable('setCanCreate', value); } @@ -359,17 +504,41 @@ class LinuxHeaderBarService { if (_searchActive == value) { return; } + _state = null; _searchActive = value; await _invokeIfAvailable('setSearchActive', value); } + /// Sets whether the current layout can present a sidebar. + /// + /// Prefer [updateState] for screen transitions. This compatibility method + /// exists for callers that have not migrated to the atomic state contract. + Future setCanShowSidebar(bool value) async { + if (!_available) { + return; + } + if (_canShowSidebar == value) { + return; + } + _state = null; + _canShowSidebar = value; + await _invokeIfAvailable('setCanShowSidebar', value); + } + Future setSidebarVisible(bool value) async { if (!_available) { return; } - if (_sidebarVisible == value) { + final restoresSidebarAvailability = value && _canShowSidebar == false; + if (_sidebarVisible == value && !restoresSidebarAvailability) { return; } + _state = null; + if (value) { + // Preserve the legacy contract: requesting a visible sidebar also makes + // its native toggle available. Atomic callers should set both fields. + _canShowSidebar = true; + } _sidebarVisible = value; await _invokeIfAvailable('setSidebarVisible', value); } @@ -381,6 +550,7 @@ class LinuxHeaderBarService { if (_navigationVisible == value) { return; } + _state = null; _navigationVisible = value; await _invokeIfAvailable('setNavigationVisible', value); } @@ -392,6 +562,7 @@ class LinuxHeaderBarService { if (_scheduleControlsVisible == value) { return; } + _state = null; _scheduleControlsVisible = value; await _invokeIfAvailable('setScheduleControlsVisible', value); } @@ -403,6 +574,7 @@ class LinuxHeaderBarService { if (_backVisible == value) { return; } + _state = null; _backVisible = value; await _invokeIfAvailable('setBackVisible', value); } diff --git a/test/app/about_dialog_test.dart b/test/app/about_dialog_test.dart index 3dc2675..b56e88b 100644 --- a/test/app/about_dialog_test.dart +++ b/test/app/about_dialog_test.dart @@ -38,9 +38,16 @@ void main() { 'lib/src/app/busymax_about_dialog.dart', ).readAsStringSync(); final design = File('lib/src/app/busymax_design.dart').readAsStringSync(); + final dialogs = File( + 'lib/src/app/busymax_dialogs.dart', + ).readAsStringSync(); - expect(source, contains('setModalBarrierVisible(true)')); - expect(source, contains('setModalBarrierVisible(false)')); + expect(source, contains('showBusyMaxModalDialog')); + expect(source, contains('headerBarService: headerBarService')); + expect(dialogs, contains('acquireBusyMaxModalBarrier')); + expect(dialogs, contains('releaseBusyMaxModalBarrier')); + expect(dialogs, contains('setModalBarrierVisible(true)')); + expect(dialogs, contains('setModalBarrierVisible(false)')); expect(source, isNot(contains('barrierColor: Colors.transparent'))); expect(source, contains('BusyMaxDialogCloseButton')); expect(design, contains('CircleBorder()')); diff --git a/test/app/busymax_dialogs_test.dart b/test/app/busymax_dialogs_test.dart new file mode 100644 index 0000000..9cf303e --- /dev/null +++ b/test/app/busymax_dialogs_test.dart @@ -0,0 +1,138 @@ +import 'package:busymax/src/app/busymax_design.dart'; +import 'package:busymax/src/app/busymax_dialogs.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 '../test_localized_app.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + testWidgets('modal coordinator synchronizes the native barrier', ( + 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(); + + late BuildContext hostContext; + await tester.pumpWidget( + localizedTestApp( + child: Builder( + builder: (context) { + hostContext = context; + return const SizedBox(); + }, + ), + ), + ); + + final result = showBusyMaxConfirm( + hostContext, + title: 'Remove item?', + message: 'This action cannot be undone.', + confirmLabel: 'Remove', + destructive: true, + headerBarService: service, + ); + await tester.pumpAndSettle(); + + expect(find.byType(BusyMaxConfirmDialog), findsOneWidget); + expect( + calls.where((call) => call.method == 'setModalBarrierVisible'), + hasLength(1), + ); + expect(calls.last.arguments, isTrue); + + await tester.tap(find.text('Remove')); + await tester.pumpAndSettle(); + + expect(await result, isTrue); + final barrierCalls = calls + .where((call) => call.method == 'setModalBarrierVisible') + .toList(); + expect(barrierCalls, hasLength(2)); + expect(barrierCalls.first.arguments, isTrue); + expect(barrierCalls.last.arguments, isFalse); + }); + + testWidgets('nested modals keep the native barrier active', (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( + child: Builder( + builder: (context) { + hostContext = context; + return const SizedBox(); + }, + ), + ), + ); + + final first = showBusyMaxModalDialog( + hostContext, + headerBarService: service, + builder: (context) => const Dialog(child: Text('First dialog')), + ); + await tester.pumpAndSettle(); + final second = showBusyMaxModalDialog( + hostContext, + headerBarService: service, + builder: (context) => const Dialog(child: Text('Second dialog')), + ); + await tester.pumpAndSettle(); + + expect( + calls.where((call) => call.method == 'setModalBarrierVisible'), + hasLength(1), + ); + + Navigator.of(hostContext, rootNavigator: true).pop(); + await tester.pumpAndSettle(); + await second; + expect( + calls.where((call) => call.method == 'setModalBarrierVisible'), + hasLength(1), + ); + + Navigator.of(hostContext, rootNavigator: true).pop(); + await tester.pumpAndSettle(); + await first; + + final barrierCalls = calls + .where((call) => call.method == 'setModalBarrierVisible') + .toList(); + expect(barrierCalls, hasLength(2)); + expect(barrierCalls.last.arguments, isFalse); + }); +} diff --git a/test/app/busymax_grouped_surface_test.dart b/test/app/busymax_grouped_surface_test.dart new file mode 100644 index 0000000..8c8d214 --- /dev/null +++ b/test/app/busymax_grouped_surface_test.dart @@ -0,0 +1,241 @@ +import 'package:busymax/src/app/busymax_design.dart'; +import 'package:busymax/src/app/busymax_yaru_theme.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:yaru/yaru.dart'; + +void main() { + for (final brightness in Brightness.values) { + testWidgets( + 'grouped list uses the semantic $brightness surface and Yaru rows', + (tester) async { + final theme = BusyMaxYaruTheme.build( + brightness: brightness, + accentColor: const Color(0xFF3584E4), + ); + final colors = theme.extension()!; + + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: Scaffold( + body: BusyMaxGroupedList( + filled: true, + children: [ + BusyMaxActionRow(title: 'Calendar', onTap: () {}), + const BusyMaxSwitchRow( + title: 'Notifications', + value: true, + onChanged: _ignoreBool, + ), + ], + ), + ), + ), + ); + + final groupedSurface = find.byType(BusyMaxGroupedSurface); + expect(groupedSurface, findsOneWidget); + final materialSurface = tester.widget( + find.descendant( + of: groupedSurface, + matching: find.byWidgetPredicate( + (widget) => + widget is Material && widget.color == colors.groupedSurface, + ), + ), + ); + expect(materialSurface.elevation, BusyMaxElevation.surface); + expect(materialSurface.shadowColor, theme.colorScheme.shadow); + final shape = materialSurface.shape! as RoundedRectangleBorder; + expect(shape.side.color, colors.subtleBorder); + expect( + find.descendant( + of: groupedSurface, + matching: find.byType(YaruListTile), + ), + findsNWidgets(2), + ); + + final materialLayers = tester.widgetList( + find.descendant(of: groupedSurface, matching: find.byType(Material)), + ); + expect( + materialLayers.where((material) => material.color == colors.control), + isEmpty, + ); + }, + ); + } + + testWidgets('action row distinguishes keyboard and pointer activation', ( + tester, + ) async { + final activations = []; + await tester.pumpWidget( + _testApp( + BusyMaxActionRow( + title: 'Calendar', + onActivated: (_, globalPosition) { + activations.add(globalPosition); + }, + ), + ), + ); + + await tester.sendKeyEvent(LogicalKeyboardKey.tab); + await tester.pump(); + await tester.sendKeyEvent(LogicalKeyboardKey.enter); + await tester.pump(); + + expect(activations, [isNull]); + + final pointerPosition = tester.getCenter(find.text('Calendar')); + await tester.tapAt(pointerPosition); + await tester.pump(); + + expect(activations, [isNull, pointerPosition]); + + final tile = tester.widget( + find.descendant( + of: find.byType(BusyMaxActionRow), + matching: find.byType(YaruListTile), + ), + ); + tile.onTap!(); + + expect(activations, [isNull, pointerPosition, isNull]); + }); + + testWidgets('nested checkbox does not activate its action row', ( + tester, + ) async { + final activations = []; + var completionChanges = 0; + await tester.pumpWidget( + _testApp( + BusyMaxActionRow( + title: 'Prepare release', + trailing: YaruCheckbox( + value: false, + onChanged: (_) => completionChanges += 1, + ), + onActivated: (_, globalPosition) { + activations.add(globalPosition); + }, + ), + ), + ); + + await tester.tap(find.byType(YaruCheckbox)); + await tester.pump(); + + expect(completionChanges, 1); + expect(activations, isEmpty); + + final tile = tester.widget( + find.descendant( + of: find.byType(BusyMaxActionRow), + matching: find.byType(YaruListTile), + ), + ); + tile.onTap!(); + + expect(activations, [isNull]); + }); + + testWidgets('disabled combo row cannot open or receive keyboard focus', ( + tester, + ) async { + final selected = []; + await tester.pumpWidget( + _testApp( + BusyMaxComboRow( + title: 'Calendar', + values: const ['Personal', 'Work'], + selected: 'Personal', + labelFor: (value) => value, + onSelected: selected.add, + enabled: false, + ), + ), + ); + + final combo = find.byType(BusyMaxComboRow); + final trigger = find.descendant( + of: combo, + matching: find.byType(OutlinedButton), + ); + expect(trigger, findsOneWidget); + + await tester.tap(trigger, warnIfMissed: false); + await tester.pumpAndSettle(); + await tester.sendKeyEvent(LogicalKeyboardKey.tab); + await tester.sendKeyEvent(LogicalKeyboardKey.enter); + await tester.pumpAndSettle(); + + expect(find.byType(MenuItemButton), findsNothing); + expect(selected, isEmpty); + final disabledSemantics = tester.widget( + find.descendant( + of: combo, + matching: find.byWidgetPredicate( + (widget) => + widget is Semantics && widget.properties.label == 'Calendar', + ), + ), + ); + expect(disabledSemantics.properties.button, isTrue); + expect(disabledSemantics.properties.enabled, isFalse); + expect(disabledSemantics.properties.value, 'Personal'); + }); + + testWidgets('switch row exposes one merged toggle interaction', ( + tester, + ) async { + final values = []; + await tester.pumpWidget( + _testApp( + BusyMaxSwitchRow( + title: 'Notifications', + value: true, + onChanged: values.add, + ), + ), + ); + final semanticsHandle = tester.ensureSemantics(); + + expect( + find.descendant( + of: find.byType(BusyMaxSwitchRow), + matching: find.byType(MergeSemantics), + ), + findsOneWidget, + ); + expect(find.bySemanticsLabel('Notifications'), findsOneWidget); + + await tester.tap(find.text('Notifications')); + await tester.pump(); + expect(values, [isFalse]); + + await tester.tap(find.byType(YaruSwitch)); + await tester.pump(); + expect(values, [isFalse, isFalse]); + semanticsHandle.dispose(); + }); +} + +void _ignoreBool(bool value) {} + +Widget _testApp(Widget child) { + return MaterialApp( + theme: BusyMaxYaruTheme.build( + brightness: Brightness.light, + accentColor: const Color(0xFF3584E4), + ), + home: Scaffold( + body: Center(child: SizedBox(width: 480, child: child)), + ), + ); +} diff --git a/test/app/keyboard_shortcuts_dialog_test.dart b/test/app/keyboard_shortcuts_dialog_test.dart index f18feb4..ea503cc 100644 --- a/test/app/keyboard_shortcuts_dialog_test.dart +++ b/test/app/keyboard_shortcuts_dialog_test.dart @@ -22,6 +22,9 @@ void main() { expect(find.text('Task editing'), findsOneWidget); expect(find.text('Compact agenda'), findsOneWidget); expect(find.text('Ctrl+/'), findsOneWidget); + expect(find.text('Ctrl+,'), findsOneWidget); + expect(find.text('Ctrl+F'), findsOneWidget); + expect(find.text('Ctrl+N'), findsOneWidget); expect(find.text('Shift+Right'), findsOneWidget); expect(find.text('Shift+Left'), findsOneWidget); expect(find.text('T'), findsOneWidget); @@ -45,10 +48,15 @@ void main() { final service = File( 'lib/src/platform/linux_header_bar_service.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('LogicalKeyboardKey.slash')); + expect(app, contains('BusyMaxShortcutActivators.keyboardShortcuts')); + expect(shortcuts, contains('LogicalKeyboardKey.slash')); + expect(shortcuts, contains('LogicalKeyboardKey.comma')); expect(service, contains('keyboardShortcuts')); expect(native, contains('"Keyboard Shortcuts"')); expect(native, contains('"keyboardShortcuts"')); diff --git a/test/app/localization_audit_test.dart b/test/app/localization_audit_test.dart new file mode 100644 index 0000000..da5e274 --- /dev/null +++ b/test/app/localization_audit_test.dart @@ -0,0 +1,114 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test('localized UI surfaces do not hardcode user-facing text', () { + final failures = []; + + for (final path in _auditedUiPaths) { + final source = File(path).readAsStringSync(); + for (final match in _userFacingLiteralPattern.allMatches(source)) { + final literal = match.group(1)!; + if (_isTechnicalLiteral(literal)) { + continue; + } + failures.add('$path:${_lineForOffset(source, match.start)}: $literal'); + } + } + + expect(failures, isEmpty, reason: failures.join('\n')); + }); + + test('translated ARB catalogs match the English template', () { + final templateFile = File('lib/l10n/app_en.arb'); + final templateArb = _decodeArb(templateFile); + final templateMessages = _messages(templateArb); + final failures = []; + + for (final path in _translatedArbPaths) { + final file = File(path); + final messages = _messages(_decodeArb(file)); + final templateKeys = templateMessages.keys.toSet(); + final translatedKeys = messages.keys.toSet(); + + for (final key + in templateKeys.difference(translatedKeys).toList()..sort()) { + failures.add('$path: missing message $key'); + } + for (final key + in translatedKeys.difference(templateKeys).toList()..sort()) { + failures.add('$path: unexpected message $key'); + } + for (final key in templateKeys.intersection(translatedKeys)) { + final translation = messages[key]!; + if (translation.trim().isEmpty) { + failures.add('$path: $key has an empty translation'); + } + for (final placeholder in _declaredPlaceholders(templateArb, key)) { + if (!translation.contains('{$placeholder')) { + failures.add('$path: $key does not use {$placeholder}'); + } + } + } + } + + expect(failures, isEmpty, reason: failures.join('\n')); + }); +} + +const _auditedUiPaths = [ + 'lib/src/features/settings/presentation/settings_screen.dart', + 'lib/src/features/auth/presentation/sign_in_screen.dart', + 'lib/src/features/calendar/presentation/event_description_editor.dart', + 'lib/src/features/calendar/presentation/event_editor.dart', + 'lib/src/features/schedule/presentation/mini_calendar.dart', + 'lib/src/features/schedule/presentation/schedule_day_week_view.dart', + 'lib/src/features/schedule/presentation/schedule_sidebar.dart', +]; + +const _translatedArbPaths = [ + 'lib/l10n/app_de.arb', + 'lib/l10n/app_es.arb', + 'lib/l10n/app_fr.arb', +]; + +final _userFacingLiteralPattern = RegExp( + r"(?:\bText\(\s*|\b(?:title|subtitle|tooltip|label|message|description|semanticLabel|labelText|hintText|helperText):\s*)'([^']*[A-Za-z][^']*)'", +); + +bool _isTechnicalLiteral(String literal) { + return RegExp(r'^\$\{?[A-Za-z_]\w*(?:\.[A-Za-z_]\w*)*\}?$').hasMatch(literal); +} + +Map _decodeArb(File file) { + return (jsonDecode(file.readAsStringSync()) as Map).cast(); +} + +Map _messages(Map arb) { + return { + for (final entry in arb.entries) + if (!entry.key.startsWith('@') && entry.value is String) + entry.key: entry.value! as String, + }; +} + +Iterable _declaredPlaceholders( + Map templateArb, + String key, +) sync* { + final metadata = templateArb['@$key']; + if (metadata is! Map) { + return; + } + final placeholders = metadata['placeholders']; + if (placeholders is! Map) { + return; + } + yield* placeholders.keys.cast(); +} + +int _lineForOffset(String source, int offset) { + return '\n'.allMatches(source.substring(0, offset)).length + 1; +} diff --git a/test/app/native_ui_audit_test.dart b/test/app/native_ui_audit_test.dart index 985d579..f706f9d 100644 --- a/test/app/native_ui_audit_test.dart +++ b/test/app/native_ui_audit_test.dart @@ -61,25 +61,21 @@ void main() { expect(design, contains('class BusyMaxGroupedList')); expect(design, contains('class BusyMaxActionRow')); expect(design, contains('class BusyMaxComboRow')); - expect(design, contains('class _BusyMaxRowTile')); + expect(design, contains('class BusyMaxGroupedSurface')); expect(design, contains('class BusyMaxSwitchRow')); expect(design, contains('class BusyMaxDialogShell')); expect(design, contains('class BusyMaxModalEditorSurface')); expect(design, contains('Color busyMaxModalBarrierColor')); expect(design, contains('abstract final class BusyMaxElevation')); - expect(design, contains('elevation: BusyMaxElevation.surface')); - expect( - design, - contains('shadowColor: BusyMaxShadow.floatingColor(context)'), - ); + expect(design, contains('BusyMaxShadow.physicalColor(context)')); + expect(design, isNot(contains('lightSurfaceShadowMinimum'))); expect(design, contains('final bool filled;')); expect(design, contains('BusyMaxSurfaceColors.of(context)')); expect(design, contains('surfaceColors.card')); + expect(design, contains('surfaceColors.groupedSurface')); expect(design, contains('surfaceColors.control')); - expect(design, contains('color: surfaceColors.control')); - expect(design, contains('highlightColor: surfaceColors.controlActive')); - expect(design, isNot(contains('YaruTileList(children: children)'))); - expect(design, isNot(contains('YaruListTile.square'))); + expect(design, contains('YaruListTile.square(')); + expect(design, isNot(contains('class _BusyMaxRowTile'))); expect(taskDetails, contains('BusyMaxClamp')); expect(taskDetails, contains('BusyMaxGroupedList')); @@ -108,8 +104,11 @@ void main() { expect(settings, isNot(contains('SettingsPage.appearance'))); expect(settings, isNot(contains('SettingsPage.localization'))); expect(settings, isNot(contains('l10n.themeFamily'))); - expect(settings, contains('setBackVisible(true)')); - expect(settings, contains('setSidebarVisible(true)')); + expect(settings, contains('service.updateState(')); + expect(settings, contains('BusyMaxHeaderBarState(')); + expect(settings, contains('backVisible: true')); + expect(settings, contains('canShowSidebar: showSidebar')); + expect(settings, contains('sidebarVisible: showSidebar')); expect(newTaskDialog, contains('showBusyMaxModalEditorDialog')); expect(newTaskDialog, contains('TaskDetailsEditor')); expect(newTaskDialog, isNot(contains('BusyMaxDialogShell'))); @@ -740,6 +739,11 @@ void main() { expect(source, contains('lookup_context_color')); expect(source, contains('theme_bg_color')); expect(source, contains('theme_base_color')); + expect( + source, + contains('lookup_context_color(window_context, "wm_shadow"'), + ); + expect(source, isNot(contains('shade_color = border_color'))); expect(source, contains('GTK_STYLE_CLASS_SIDEBAR')); expect(source, contains('GTK_STYLE_CLASS_VIEW')); expect(source, contains('gtk_style_context_get_property')); diff --git a/test/app/theme_localization_test.dart b/test/app/theme_localization_test.dart index 5ef6707..1c7a3b2 100644 --- a/test/app/theme_localization_test.dart +++ b/test/app/theme_localization_test.dart @@ -127,12 +127,14 @@ void main() { expect(lightColors.view, const Color(0xFFFFFFFF)); expect(lightColors.sidebar, const Color(0xFFEBEBED)); expect(lightColors.card, const Color(0xFFFFFFFF)); + expect(lightColors.groupedSurface, const Color(0xFFFFFFFF)); expect(lightColors.dialog, const Color(0xFFFAFAFB)); expect(lightColors.popover, const Color(0xFFFFFFFF)); expect(darkColors.window, const Color(0xFF1D1D20)); expect(darkColors.view, const Color(0xFF1D1D20)); expect(darkColors.sidebar, const Color(0xFF2E2E32)); expect(darkColors.card, const Color(0xFF222226)); + expect(darkColors.groupedSurface, const Color(0xFF383838)); expect(darkColors.dialog, const Color(0xFF222226)); expect(darkColors.popover, const Color(0xFF383838)); expect(darkColors.view, isNot(const Color(0xFF3E3E3E))); @@ -184,12 +186,14 @@ void main() { final updated = base.copyWith( window: const Color(0xFF010203), sidebar: const Color(0xFF040506), + groupedSurface: const Color(0xFF060708), disabledForeground: const Color(0xFF070809), shade: const Color(0xFF0A0B0C), ); expect(updated.window, const Color(0xFF010203)); expect(updated.sidebar, const Color(0xFF040506)); + expect(updated.groupedSurface, const Color(0xFF060708)); expect(updated.disabledForeground, const Color(0xFF070809)); expect(updated.shade, const Color(0xFF0A0B0C)); expect(updated.view, base.view); @@ -208,6 +212,10 @@ void main() { expect(midpoint.window, Color.lerp(start.window, end.window, 0.5)); expect(midpoint.sidebar, Color.lerp(start.sidebar, end.sidebar, 0.5)); + expect( + midpoint.groupedSurface, + Color.lerp(start.groupedSurface, end.groupedSurface, 0.5), + ); expect(midpoint.dialog, Color.lerp(start.dialog, end.dialog, 0.5)); expect( midpoint.disabledForeground, @@ -492,7 +500,9 @@ void main() { expect(theme.colorScheme.onSurfaceVariant, gtkColors.mutedForeground); expect(theme.dialogTheme.backgroundColor, gtkColors.dialog); expect(theme.popupMenuTheme.color, gtkColors.popover); - expect(theme.extension()?.sidebar, gtkColors.sidebar); + final colors = theme.extension()!; + expect(colors.sidebar, gtkColors.sidebar); + expect(colors.groupedSurface, gtkColors.popover); }); test('BusyMax theme ignores light GTK runtime shade samples', () { @@ -510,7 +520,10 @@ void main() { final colors = theme.extension()!; expect(colors.shade, busyMaxFallbackSurfaceColors(Brightness.light).shade); - expect(theme.popupMenuTheme.shadowColor, colors.shade); + expect(colors.groupedSurface, gtkColors.view); + expect(theme.shadowColor, theme.colorScheme.shadow); + expect(theme.popupMenuTheme.shadowColor, theme.colorScheme.shadow); + expect(theme.popupMenuTheme.shadowColor, isNot(colors.shade)); }); test('BusyMax theme ignores too-dark GTK popover samples', () { @@ -526,11 +539,11 @@ void main() { gtkThemeColors: gtkColors, ); + final colors = theme.extension()!; + expect(theme.popupMenuTheme.color, const Color(0xFF383838)); - expect( - theme.extension()?.popover, - const Color(0xFF383838), - ); + expect(colors.popover, const Color(0xFF383838)); + expect(colors.groupedSurface, const Color(0xFF383838)); }); test('BusyMax theme ignores blue purple GTK dark surface samples', () { @@ -566,6 +579,7 @@ void main() { expect(colors.control, const Color.fromRGBO(255, 255, 255, 0.10)); expect(colors.controlHover, const Color.fromRGBO(255, 255, 255, 0.14)); expect(colors.popover, const Color(0xFF383838)); + expect(colors.groupedSurface, const Color(0xFF383838)); }); test('BusyMax theme ignores GTK accent control samples', () { @@ -906,7 +920,9 @@ void main() { expect(shellSource, contains('filled: false')); expect(shellSource, isNot(contains('filled: true'))); expect(source, contains('final title = context.l10n.onboardingSetupTitle')); - expect(source, contains('setTitleRange(title)')); + expect(source, contains('service.updateState(')); + expect(source, contains('BusyMaxHeaderBarState(')); + expect(source, contains('title: title')); expect(source, isNot(contains('class _OnboardingHeader'))); expect(source, isNot(contains('class _OnboardingProgressDots'))); expect(source, isNot(contains('Border(top: BorderSide'))); diff --git a/test/features/calendar/presentation/event_editor_test.dart b/test/features/calendar/presentation/event_editor_test.dart index f6a3cdd..2e36f99 100644 --- a/test/features/calendar/presentation/event_editor_test.dart +++ b/test/features/calendar/presentation/event_editor_test.dart @@ -1028,7 +1028,7 @@ void main() { expect(dialogs, contains('setModalBarrierVisible(true)')); expect( dialogs, - contains('barrierColor: busyMaxModalBarrierColor(context)'), + contains('barrierColor ?? busyMaxModalBarrierColor(context)'), ); expect(dialogs, contains('BusyMaxModalEditorSurface')); expect(workspace, contains('showBusyMaxEventEditorDialog')); diff --git a/test/features/schedule/presentation/schedule_create_menu_test.dart b/test/features/schedule/presentation/schedule_create_menu_test.dart new file mode 100644 index 0000000..1dbd39f --- /dev/null +++ b/test/features/schedule/presentation/schedule_create_menu_test.dart @@ -0,0 +1,69 @@ +import 'package:busymax/src/features/schedule/presentation/schedule_create_menu.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 '../../../test_localized_app.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + testWidgets('create chooser synchronizes the native modal barrier', ( + tester, + ) async { + const channel = MethodChannel('busymax_test/create_chooser_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( + child: Builder( + builder: (context) { + hostContext = context; + return const SizedBox(); + }, + ), + ), + ); + + final result = showScheduleCreateMenu( + context: hostContext, + headerBarService: service, + ); + await tester.pumpAndSettle(); + + expect(find.text('Create'), findsOneWidget); + expect(find.text('Event'), findsOneWidget); + expect(find.text('Task'), findsOneWidget); + final barrierCallsWhileOpen = calls + .where((call) => call.method == 'setModalBarrierVisible') + .toList(); + expect(barrierCallsWhileOpen, hasLength(1)); + expect(barrierCallsWhileOpen.single.arguments, isTrue); + + await tester.tap(find.text('Task')); + await tester.pumpAndSettle(); + + expect(await result, ScheduleCreateChoice.task); + final barrierCalls = calls + .where((call) => call.method == 'setModalBarrierVisible') + .toList(); + expect(barrierCalls, hasLength(2)); + expect(barrierCalls.first.arguments, isTrue); + expect(barrierCalls.last.arguments, isFalse); + }); +} diff --git a/test/features/schedule/presentation/schedule_toolbar_test.dart b/test/features/schedule/presentation/schedule_toolbar_test.dart new file mode 100644 index 0000000..c3854f9 --- /dev/null +++ b/test/features/schedule/presentation/schedule_toolbar_test.dart @@ -0,0 +1,106 @@ +import 'package:busymax/src/features/schedule/presentation/schedule_toolbar.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_test/flutter_test.dart'; + +import '../../../test_localized_app.dart'; + +void main() { + testWidgets('fallback toolbar exposes the complete shell command set', ( + tester, + ) async { + var sidebarToggles = 0; + var searches = 0; + ScheduleViewMode? selectedMode; + ScheduleToolbarMenuAction? selectedMenuAction; + + await tester.pumpWidget( + localizedTestApp( + child: Scaffold( + body: SizedBox( + width: 1000, + child: ScheduleToolbar( + mode: ScheduleViewMode.week, + range: ScheduleRange.week(DateTime(2026, 7, 22)), + selectedDate: DateTime(2026, 7, 22), + onToday: () {}, + onPrevious: () {}, + onNext: () {}, + onModeChanged: (value) => selectedMode = value, + canCreate: true, + onCreate: () {}, + onRefresh: () {}, + canShowSidebar: true, + sidebarVisible: true, + onToggleSidebar: () => sidebarToggles++, + onSearch: () => searches++, + onMenuSelected: (value) => selectedMenuAction = value, + ), + ), + ), + ), + ); + + await tester.tap(find.byTooltip('Toggle Sidebar')); + await tester.tap(find.byTooltip('Search')); + expect(sidebarToggles, 1); + expect(searches, 1); + + await tester.tap(find.byTooltip('Week')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Month')); + await tester.pumpAndSettle(); + expect(selectedMode, ScheduleViewMode.month); + + await tester.tap(find.byTooltip('Main Menu')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Settings')); + await tester.pumpAndSettle(); + expect(selectedMenuAction, ScheduleToolbarMenuAction.settings); + }); + + testWidgets('compact fallback moves refresh into the main menu', ( + tester, + ) async { + var refreshes = 0; + ScheduleToolbarMenuAction? selectedMenuAction; + + await tester.pumpWidget( + localizedTestApp( + child: Scaffold( + body: SizedBox( + width: 700, + child: ScheduleToolbar( + mode: ScheduleViewMode.agenda, + range: ScheduleRange.day(DateTime(2026, 7, 22)), + selectedDate: DateTime(2026, 7, 22), + onToday: () {}, + onPrevious: () {}, + onNext: () {}, + onModeChanged: (_) {}, + canCreate: true, + onCreate: () {}, + onRefresh: () => refreshes++, + onMenuSelected: (value) { + selectedMenuAction = value; + if (value == ScheduleToolbarMenuAction.refresh) { + refreshes++; + } + }, + ), + ), + ), + ), + ); + + expect(find.byTooltip('Refresh all'), findsNothing); + await tester.tap(find.byTooltip('Main Menu')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Refresh all')); + await tester.pumpAndSettle(); + + expect(selectedMenuAction, ScheduleToolbarMenuAction.refresh); + expect(refreshes, 1); + }); +} diff --git a/test/features/schedule/presentation/schedule_views_test.dart b/test/features/schedule/presentation/schedule_views_test.dart index 14cb258..7e08ab6 100644 --- a/test/features/schedule/presentation/schedule_views_test.dart +++ b/test/features/schedule/presentation/schedule_views_test.dart @@ -12,7 +12,9 @@ import 'package:busymax/src/features/schedule/presentation/schedule_month_view.d import 'package:busymax/src/schedule/schedule_item.dart'; import 'package:busymax/src/schedule/schedule_range.dart'; import 'package:busymax/src/task_providers/task_provider.dart'; +import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:infinite_calendar_view/infinite_calendar_view.dart' as icv; @@ -130,6 +132,73 @@ void main() { expect(find.text('Design review'), findsOneWidget); }); + testWidgets('event block is semantically labeled and keyboard-activatable', ( + tester, + ) async { + final selectedDate = DateTime(2026, 1, 15); + final item = _itemsFor( + selectedDate, + ).whereType().first; + var activationCount = 0; + Offset? activationPosition = Offset.zero; + + await tester.pumpWidget( + localizedTestApp( + alwaysUse24HourFormat: true, + child: Scaffold( + body: Center( + child: ScheduleEventBlock( + item: item, + width: 180, + height: 54, + onTap: (_, [globalPosition]) { + activationCount += 1; + activationPosition = globalPosition; + }, + ), + ), + ), + ), + ); + + final eventSemantics = find.descendant( + of: find.byType(ScheduleEventBlock), + matching: find.byWidgetPredicate( + (widget) => + widget is Semantics && + widget.properties.label?.startsWith('Design review') == true, + ), + ); + expect(eventSemantics, findsOneWidget); + final semantics = tester.widget(eventSemantics); + expect(semantics.properties.button, isTrue); + expect(semantics.properties.enabled, isTrue); + expect(semantics.properties.label, contains('09:00-10:00')); + expect(semantics.properties.label, contains('Work')); + expect(semantics.properties.onTap, isNotNull); + + await tester.sendKeyEvent(LogicalKeyboardKey.tab); + await tester.pump(); + + final focusedSurface = find.descendant( + of: find.byType(ScheduleEventBlock), + matching: find.byWidgetPredicate((widget) { + if (widget is! Container || widget.decoration is! BoxDecoration) { + return false; + } + final border = (widget.decoration! as BoxDecoration).border; + return border is Border && border.top.width == 2; + }), + ); + expect(focusedSurface, findsOneWidget); + + await tester.sendKeyEvent(LogicalKeyboardKey.enter); + await tester.sendKeyEvent(LogicalKeyboardKey.space); + + expect(activationCount, 2); + expect(activationPosition, isNull); + }); + testWidgets('same-slot day items render in a horizontal strip', ( tester, ) async { @@ -334,6 +403,7 @@ void main() { ) async { final selectedDate = DateTime(2026, 1, 15); ScheduleItem? selectedItem; + Offset? pointerPosition; final event = _itemsFor( selectedDate, ).whereType().first; @@ -345,7 +415,10 @@ void main() { child: ScheduleItemChip( item: event, height: 34, - onTap: (_, [_]) => selectedItem = event, + onTap: (_, [globalPosition]) { + selectedItem = event; + pointerPosition = globalPosition; + }, ), ), ), @@ -355,6 +428,21 @@ void main() { await tester.tap(find.byType(ScheduleEventBlock).first); expect(selectedItem, isA()); + expect(pointerPosition, isNotNull); + + selectedItem = null; + pointerPosition = null; + final center = tester.getCenter(find.byType(ScheduleEventBlock).first); + final secondaryClick = await tester.createGesture( + kind: PointerDeviceKind.mouse, + buttons: kSecondaryMouseButton, + ); + await secondaryClick.addPointer(location: center); + await secondaryClick.down(center); + await secondaryClick.up(); + + expect(selectedItem, isA()); + expect(pointerPosition, center); }); testWidgets('schedule item details popover offers export, edit, and delete', ( @@ -399,6 +487,19 @@ void main() { expect(find.text('Edit event'), findsNothing); expect(find.text('Delete'), findsNothing); + final popoverSurfaceFinder = find.byWidgetPredicate( + (widget) => + widget is PhysicalShape && + widget.elevation == BusyMaxElevation.tooltip, + ); + final popoverSurface = tester.widget(popoverSurfaceFinder); + final popoverContext = tester.element(popoverSurfaceFinder); + expect( + popoverSurface.shadowColor, + Theme.of(popoverContext).colorScheme.shadow, + ); + expect(popoverSurface.shadowColor.a, 1); + final editCenter = tester.getCenter(find.byIcon(Icons.edit_outlined)); final deleteCenter = tester.getCenter(find.byIcon(Icons.delete_outline)); final closeCenter = tester.getCenter(find.byIcon(Icons.close)); @@ -1066,8 +1167,9 @@ void main() { expect(source, contains('linuxHeaderBarServiceProvider')); expect(source, contains('final showFallbackHeader')); expect(source, contains('if (showFallbackHeader)')); - expect(source, contains('setTitleRange(headerBarState.titleRange)')); - expect(source, contains('setViewMode(headerBarState.viewMode)')); + expect(source, contains('final headerBarState = BusyMaxHeaderBarState(')); + expect(source, contains('service.updateState(headerBarState)')); + expect(source, contains('onMenuSelected: _handleFallbackToolbarMenu')); }); test('native headerbar actions are wired to schedule commands', () { @@ -1098,6 +1200,9 @@ void main() { ).readAsStringSync(); expect(source, contains('HardwareKeyboard.instance.addHandler')); + expect(source, contains('route != null && !route.isCurrent')); + expect(source, contains('BusyMaxShortcutActivators.search.accepts')); + expect(source, contains('BusyMaxShortcutActivators.create.accepts')); expect(source, contains('LogicalKeyboardKey.arrowRight')); expect(source, contains('_next();')); expect(source, contains('LogicalKeyboardKey.arrowLeft')); @@ -1240,7 +1345,10 @@ void main() { ); expect(design, isNot(contains('final Color? surfaceColor;'))); expect(design, isNot(contains('color: color ?? surfaceColors.control'))); - expect(design, contains('color: surfaceColors.control')); + expect(design, contains('color: surfaceColors.groupedSurface')); + expect(design, contains('BusyMaxShadow.physicalColor(context)')); + expect(design, isNot(contains('lightSurfaceShadowMinimum'))); + expect(design, isNot(contains('class _BusyMaxRowTile'))); }); test('sidebar does not render redundant provider group titles', () { @@ -1317,28 +1425,30 @@ void main() { ); expect(source, isNot(contains('GridView.builder'))); expect(source, contains('const SizedBox(width: BusyMaxSpacing.xs)')); - expect(source, contains('label: _monthName(selectedDate)')); + expect( + source, + contains('label: DateFormat.MMMM(locale).format(selectedDate)'), + ); expect(source, contains('BusyMaxSpacing.headerInset')); expect( source, isNot(contains('padding: const EdgeInsets.all(BusyMaxSpacing.md)')), ); - expect(source, contains("labelTooltip: 'Open month'")); + expect(source, contains('labelTooltip: l10n.openMonthView')); expect(source, contains('onMonthSelected(first)')); expect(source, contains('busyMaxHeaderTextButtonStyle')); expect(source, contains("label: '\${selectedDate.year}'")); - expect(source, contains("labelTooltip: 'Open year'")); + expect(source, contains('labelTooltip: l10n.openYearView')); expect(source, contains('onYearSelected(')); - expect(source, contains('String _monthName(DateTime date)')); - expect(source, contains('return months[date.month - 1];')); + expect(source, isNot(contains('String _monthName(DateTime date)'))); expect( source, isNot(contains("return '\${months[date.month - 1]} \${date.year}';")), ); - expect(source, contains("'Previous month'")); - expect(source, contains("'Next month'")); - expect(source, contains("'Previous year'")); - expect(source, contains("'Next year'")); + expect(source, contains('previousTooltip: l10n.previousMonth')); + expect(source, contains('nextTooltip: l10n.nextMonth')); + expect(source, contains('previousTooltip: l10n.previousYear')); + expect(source, contains('nextTooltip: l10n.nextYear')); expect(source, contains('selectedDate.year - 1')); expect(source, contains('selectedDate.year + 1')); expect(source, contains('busyMaxHeaderIconButtonStyle')); @@ -1348,7 +1458,7 @@ void main() { expect(source, contains('_isoWeekNumber')); expect(source, contains('DateTime.daysPerWeek')); expect(source, contains('TextButton(')); - expect(source, contains("message: 'Week \$weekNumber'")); + expect(source, contains('context.l10n.weekNumberTooltip(weekNumber)')); expect(source, contains('onSelected(weekStart)')); expect(source, contains('BoxShape.circle')); expect(source, contains('customBorder: const CircleBorder()')); @@ -1555,7 +1665,7 @@ void main() { ), ); - await tester.tap(find.byTooltip('Open month')); + await tester.tap(find.byTooltip('Open month view')); expect(selectedMonth, DateTime(2026, 5)); }); @@ -1581,7 +1691,7 @@ void main() { ), ); - await tester.tap(find.byTooltip('Open year')); + await tester.tap(find.byTooltip('Open year view')); expect(selectedYear, DateTime(2026)); }); @@ -1769,7 +1879,7 @@ void main() { ).readAsStringSync(); expect(eventBlock, contains('color: surfaceColors.control')); - expect(eventBlock, contains('color: surfaceColors.subtleBorder')); + expect(eventBlock, contains('surfaceColors.subtleBorder')); expect(taskChip, contains('color: surfaceColors.control')); expect(taskChip, contains('color: surfaceColors.subtleBorder')); expect(taskChip, contains('YaruCheckbox(')); @@ -1932,13 +2042,10 @@ void main() { workspace, contains('navigationVisible: _mode != ScheduleViewMode.agenda'), ); - expect( - workspace, - contains( - 'service.setNavigationVisible(headerBarState.navigationVisible)', - ), - ); + expect(workspace, contains('service.updateState(headerBarState)')); + expect(headerService, contains('class BusyMaxHeaderBarState')); expect(headerService, contains('Future setNavigationVisible')); + expect(nativeRunner, contains('set_header_bar_state')); expect(nativeRunner, contains('set_header_navigation_visible')); expect(nativeRunner, contains('setNavigationVisible')); }); diff --git a/test/features/schedule/presentation/schedule_workspace_states_test.dart b/test/features/schedule/presentation/schedule_workspace_states_test.dart new file mode 100644 index 0000000..c6c2109 --- /dev/null +++ b/test/features/schedule/presentation/schedule_workspace_states_test.dart @@ -0,0 +1,115 @@ +import 'dart:async'; + +import 'package:busymax/src/app/app_bootstrap.dart'; +import 'package:busymax/src/db/app_database.dart'; +import 'package:busymax/src/features/accounts/data/accounts_repository.dart'; +import 'package:busymax/src/features/schedule/presentation/schedule_empty_states.dart'; +import 'package:busymax/src/features/schedule/presentation/schedule_workspace.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 '../../../test_localized_app.dart'; + +void main() { + testWidgets('schedule exposes a labeled loading state', (tester) async { + final accounts = StreamController>(); + addTearDown(accounts.close); + + await _pumpWorkspace(tester, accountsFactory: () => accounts.stream); + await tester.pump(); + + expect(find.byType(ScheduleLoadingState), findsOneWidget); + expect(find.text('Loading schedule...'), findsOneWidget); + }); + + testWidgets('schedule errors are generic and retry the data pipeline', ( + tester, + ) async { + var attempts = 0; + await _pumpWorkspace( + tester, + accountsFactory: () { + attempts += 1; + if (attempts == 1) { + return Stream>.error( + StateError('private account database details'), + ); + } + return Stream.value(const []); + }, + ); + await tester.pumpAndSettle(); + + expect(find.byType(ScheduleUnavailableState), findsOneWidget); + expect(find.text('Schedule unavailable'), findsOneWidget); + expect( + find.textContaining('private account database details'), + findsNothing, + ); + + await tester.tap(find.text('Retry')); + await tester.pumpAndSettle(); + + expect(attempts, 2); + expect(find.byType(ScheduleUnavailableState), findsNothing); + expect(find.byType(ScheduleNoSourcesState), findsOneWidget); + expect(find.text('Connect an account'), findsOneWidget); + expect(find.text('Settings'), findsOneWidget); + }); + + testWidgets('standard search shortcut opens and dismisses schedule search', ( + tester, + ) async { + await _pumpWorkspace( + tester, + accountsFactory: () => Stream.value(const []), + ); + await tester.pumpAndSettle(); + + await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft); + await tester.sendKeyEvent(LogicalKeyboardKey.keyF); + await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft); + await tester.pump(); + + expect(find.byType(TextField), findsOneWidget); + + await tester.sendKeyEvent(LogicalKeyboardKey.escape); + await tester.pump(); + + expect(find.byType(TextField), findsNothing); + }); +} + +Future _pumpWorkspace( + WidgetTester tester, { + required Stream> Function() accountsFactory, +}) async { + final database = AppDatabase.memoryForTests(); + addTearDown(database.close); + final headerBarService = LinuxHeaderBarService(isLinux: false); + addTearDown(headerBarService.dispose); + + await tester.pumpWidget( + ProviderScope( + overrides: [ + databaseProvider.overrideWithValue(database), + accountsStreamProvider.overrideWith((ref) => accountsFactory()), + localTimeZoneProvider.overrideWithValue('UTC'), + localSettingsStoreProvider.overrideWithValue(_MemorySettingsStore()), + linuxHeaderBarServiceProvider.overrideWithValue(headerBarService), + ], + child: localizedTestApp(child: const ScheduleWorkspace()), + ), + ); +} + +class _MemorySettingsStore implements LocalSettingsStore { + @override + Future> load() async => {}; + + @override + Future save(Map json) async {} +} diff --git a/test/features/settings/presentation/settings_screen_test.dart b/test/features/settings/presentation/settings_screen_test.dart index be79b2e..f78baf8 100644 --- a/test/features/settings/presentation/settings_screen_test.dart +++ b/test/features/settings/presentation/settings_screen_test.dart @@ -6,11 +6,13 @@ import 'package:flutter/services.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/busymax_yaru_theme.dart'; import 'package:busymax/src/config/build_config.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/settings/presentation/settings_screen.dart'; import 'package:busymax/src/features/sync/sync_auth_error.dart'; +import 'package:busymax/src/platform/gtk_font_service.dart'; import 'package:busymax/src/features/task_lists/data/task_lists_repository.dart'; import 'package:busymax/src/features/tasks/presentation/tasks_selection_state.dart'; import 'package:busymax/src/task_providers/task_provider.dart'; @@ -139,7 +141,7 @@ void main() { ); addTearDown(container.dispose); - await _pumpSettings(tester, container); + await _pumpSettings(tester, container, logicalSize: const Size(1000, 700)); expect(find.text('Add Google account'), findsOneWidget); expect(find.text('Theme'), findsNothing); @@ -164,6 +166,43 @@ void main() { expect(find.text('Add Google account'), findsNothing); }); + testWidgets('Settings content uses the native view surface', (tester) async { + final container = _container( + selectedAccountId: 'google:g', + authRepository: _FakeAuthRepository(), + accounts: const [_googleAccount], + buildConfig: _configuredBuildConfig, + activeAccountIdOverride: null, + ); + addTearDown(container.dispose); + + const gtkColors = GtkThemeColors( + brightness: Brightness.light, + window: Color(0xFFF0F1F2), + view: Color(0xFFFFFFFF), + sidebar: Color(0xFFE5E6E7), + ); + final theme = BusyMaxYaruTheme.build( + brightness: Brightness.light, + accentColor: BusyMaxLinuxPalette.ubuntuOrangeAccent, + gtkThemeColors: gtkColors, + ); + + await tester.pumpWidget( + UncontrolledProviderScope( + container: container, + child: localizedTestApp( + child: Theme(data: theme, child: const SettingsScreen()), + ), + ), + ); + await tester.pumpAndSettle(); + + final scaffold = tester.widget(find.byType(Scaffold)); + expect(scaffold.backgroundColor, gtkColors.view); + expect(scaffold.backgroundColor, isNot(gtkColors.window)); + }); + test('Settings sidebar items have native-feeling side padding', () { final source = File( 'lib/src/features/settings/presentation/settings_screen.dart', @@ -190,7 +229,7 @@ void main() { ); addTearDown(container.dispose); - await _pumpSettings(tester, container); + await _pumpSettings(tester, container, logicalSize: const Size(1000, 700)); await tester.tap(find.text('Diagnostics')); await tester.pumpAndSettle(); @@ -204,6 +243,32 @@ void main() { await tester.pump(); }); + testWidgets('Settings uses single-pane navigation at narrow widths', ( + tester, + ) async { + final container = _container( + selectedAccountId: 'google:g', + authRepository: _FakeAuthRepository(), + accounts: const [_googleAccount], + buildConfig: _configuredBuildConfig, + activeAccountIdOverride: null, + ); + addTearDown(container.dispose); + + await _pumpSettings(tester, container, logicalSize: const Size(640, 700)); + + expect(find.text('Accounts'), findsWidgets); + expect(find.text('Schedule'), findsNothing); + + await tester.tap(find.byKey(const ValueKey('settings-page-selector'))); + await tester.pumpAndSettle(); + await tester.tap(find.text('Schedule')); + await tester.pumpAndSettle(); + + expect(find.text('Day starts at'), findsOneWidget); + expect(find.text('Day ends at'), findsOneWidget); + }); + test('Schedule display hours persist and keep a valid range', () async { final store = _MemorySettingsStore(); final first = AppSettingsController(store); @@ -356,8 +421,15 @@ const _useDefaultActiveAccountId = '__busymax_default_active_account__'; Future _pumpSettings( WidgetTester tester, - ProviderContainer container, -) async { + ProviderContainer container, { + Size? logicalSize, +}) async { + if (logicalSize != null) { + tester.view.devicePixelRatio = 1; + tester.view.physicalSize = logicalSize; + addTearDown(tester.view.resetDevicePixelRatio); + addTearDown(tester.view.resetPhysicalSize); + } await tester.pumpWidget( UncontrolledProviderScope( container: container, diff --git a/test/platform/linux_header_bar_service_test.dart b/test/platform/linux_header_bar_service_test.dart index a230dd3..9861031 100644 --- a/test/platform/linux_header_bar_service_test.dart +++ b/test/platform/linux_header_bar_service_test.dart @@ -1,3 +1,5 @@ +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'; @@ -161,6 +163,129 @@ void main() { ); }); + 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); + const state = BusyMaxHeaderBarState( + title: 'July 2026', + viewMode: ScheduleViewMode.month, + canRefresh: true, + canCreate: false, + searchActive: true, + canShowSidebar: false, + sidebarVisible: false, + navigationVisible: true, + scheduleControlsVisible: true, + backVisible: false, + ); + + await service.initialize(); + await service.updateState(state); + await service.updateState(state.copyWith()); + await service.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, + 'canCreate': false, + 'searchActive': true, + 'canShowSidebar': false, + 'sidebarVisible': false, + 'navigationVisible': true, + 'scheduleControlsVisible': true, + 'backVisible': false, + }); + expect(calls[2].arguments, containsPair('title', 'August 2026')); + }, + ); + + test('legacy changes invalidate the cached atomic state', () async { + const channel = MethodChannel('busymax_test/headerbar_state_compatibility'); + 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); + const state = BusyMaxHeaderBarState( + title: 'Schedule', + viewMode: ScheduleViewMode.week, + canRefresh: true, + canCreate: true, + searchActive: false, + canShowSidebar: true, + sidebarVisible: true, + navigationVisible: true, + scheduleControlsVisible: true, + backVisible: false, + ); + + await service.initialize(); + await service.updateState(state); + await service.setCanShowSidebar(false); + await service.setSidebarVisible(true); + await service.updateState(state); + + expect(calls.map((call) => call.method), [ + 'initialize', + 'setState', + 'setCanShowSidebar', + 'setSidebarVisible', + 'setState', + ]); + }); + + test('native header controls keep visible keyboard focus indicators', () { + final source = File('linux/runner/my_application.cc').readAsStringSync(); + + expect(source, contains('button.busymax-header-view-mode-button:focus {"')); + expect(source, contains('button.busymax-header-popover-row:focus {"')); + expect(source, contains('"box-shadow: inset 0 0 0 2px %s;"')); + }); + + 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'), From 9b84b35ddcdbb8ef48a231caae2d8bb7d4ad6d76 Mon Sep 17 00:00:00 2001 From: albert Date: Wed, 22 Jul 2026 16:26:24 -0700 Subject: [PATCH 03/73] Refactor Linux header bar integration to use session-based state management --- .../auth/presentation/sign_in_screen.dart | 26 +- .../presentation/schedule_workspace.dart | 22 +- .../presentation/settings_screen.dart | 22 +- .../platform/linux_header_bar_service.dart | 353 ++++++++++-------- test/app/native_ui_audit_test.dart | 7 +- test/app/theme_localization_test.dart | 3 +- .../auth/presentation/auth_routing_test.dart | 6 + .../presentation/schedule_views_test.dart | 11 +- .../linux_header_bar_service_test.dart | 271 ++++++++++---- 9 files changed, 454 insertions(+), 267 deletions(-) diff --git a/lib/src/features/auth/presentation/sign_in_screen.dart b/lib/src/features/auth/presentation/sign_in_screen.dart index 355ff8f..6581b20 100644 --- a/lib/src/features/auth/presentation/sign_in_screen.dart +++ b/lib/src/features/auth/presentation/sign_in_screen.dart @@ -40,16 +40,22 @@ class _SignInScreenState extends ConsumerState { var _nativeHeaderBarAvailable = false; var _finishingSetup = false; 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(); } @@ -176,15 +182,13 @@ class _SignInScreenState extends ConsumerState { } Future _initializeHeaderBar() async { - final service = ref.read(linuxHeaderBarServiceProvider); - await service.initialize(); + await _headerBarSession.initialize(); if (!mounted) { return; } - _headerBarActions = service.actions.listen(_handleHeaderBarAction); setState(() { _headerBarReady = true; - _nativeHeaderBarAvailable = service.isAvailable; + _nativeHeaderBarAvailable = _headerBarSession.isAvailable; }); } @@ -215,9 +219,8 @@ class _SignInScreenState extends ConsumerState { generation != _headerBarUpdateGeneration) { return; } - final service = ref.read(linuxHeaderBarServiceProvider); unawaited(() async { - await service.updateState( + await _headerBarSession.updateState( BusyMaxHeaderBarState( title: title, viewMode: ref.read(appSettingsControllerProvider).scheduleViewMode, @@ -236,7 +239,7 @@ class _SignInScreenState extends ConsumerState { generation != _headerBarUpdateGeneration) { return; } - await service.setOnboardingControls( + await _headerBarSession.setOnboardingControls( visible: true, canGoBack: canGoBack, canContinue: canContinue, @@ -248,6 +251,9 @@ class _SignInScreenState extends ConsumerState { } void _handleHeaderBarAction(BusyMaxHeaderBarAction action) { + if (!_headerBarSession.isCurrent) { + return; + } if (action == BusyMaxHeaderBarAction.back) { _previousStep(); return; @@ -357,9 +363,8 @@ class _SignInScreenState extends ConsumerState { } Future _clearOnboardingHeaderBar() async { - final service = ref.read(linuxHeaderBarServiceProvider); - await service.initialize(); - await service.setOnboardingControls( + await _headerBarSession.initialize(); + await _headerBarSession.setOnboardingControls( visible: false, canGoBack: false, canContinue: false, @@ -367,7 +372,6 @@ class _SignInScreenState extends ConsumerState { continueLabel: '', force: true, ); - await service.setBackVisible(false); } } diff --git a/lib/src/features/schedule/presentation/schedule_workspace.dart b/lib/src/features/schedule/presentation/schedule_workspace.dart index 7624f26..eb78e61 100644 --- a/lib/src/features/schedule/presentation/schedule_workspace.dart +++ b/lib/src/features/schedule/presentation/schedule_workspace.dart @@ -68,6 +68,7 @@ class _ScheduleWorkspaceState extends ConsumerState { var _mode = ScheduleViewMode.week; late ScheduleScope _scope; _TaskDetailsTarget? _taskDetailsTarget; + late final LinuxHeaderBarSession _headerBarSession; StreamSubscription? _headerBarActions; var _headerBarReady = false; var _nativeHeaderBarAvailable = false; @@ -95,12 +96,17 @@ class _ScheduleWorkspaceState extends ConsumerState { _scope = widget.initialScope; _applyInitialScope(); HardwareKeyboard.instance.addHandler(_handleScheduleShortcutEvent); + _headerBarSession = ref.read(linuxHeaderBarServiceProvider).claimSession(); + _headerBarActions = _headerBarSession.actions.listen( + _handleHeaderBarAction, + ); unawaited(_initializeHeaderBar()); } @override void dispose() { HardwareKeyboard.instance.removeHandler(_handleScheduleShortcutEvent); + _headerBarSession.dispose(); if (_taskDetailsTarget != null) { unawaited( releaseBusyMaxModalBarrier(ref.read(linuxHeaderBarServiceProvider)), @@ -415,19 +421,17 @@ class _ScheduleWorkspaceState extends ConsumerState { } Future _initializeHeaderBar() async { - final service = ref.read(linuxHeaderBarServiceProvider); - await service.initialize(); + await _headerBarSession.initialize(); if (!mounted) { return; } - _headerBarActions = service.actions.listen(_handleHeaderBarAction); setState(() { _headerBarReady = true; - _nativeHeaderBarAvailable = service.isAvailable; + _nativeHeaderBarAvailable = _headerBarSession.isAvailable; }); - if (service.isAvailable) { + if (_headerBarSession.isAvailable) { unawaited( - service.setOnboardingControls( + _headerBarSession.setOnboardingControls( visible: false, canGoBack: false, canContinue: false, @@ -474,8 +478,7 @@ class _ScheduleWorkspaceState extends ConsumerState { if (!mounted) { return; } - final service = ref.read(linuxHeaderBarServiceProvider); - unawaited(service.updateState(headerBarState)); + unawaited(_headerBarSession.updateState(headerBarState)); }); } @@ -493,6 +496,9 @@ class _ScheduleWorkspaceState extends ConsumerState { } void _handleHeaderBarAction(BusyMaxHeaderBarAction action) { + if (!_headerBarSession.isCurrent) { + return; + } switch (action) { case BusyMaxHeaderBarAction.back: case BusyMaxHeaderBarAction.continueSetup: diff --git a/lib/src/features/settings/presentation/settings_screen.dart b/lib/src/features/settings/presentation/settings_screen.dart index 6bc7d7c..b70122b 100644 --- a/lib/src/features/settings/presentation/settings_screen.dart +++ b/lib/src/features/settings/presentation/settings_screen.dart @@ -34,6 +34,7 @@ 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; @@ -42,11 +43,16 @@ class _SettingsScreenState extends ConsumerState { @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(); } @@ -288,19 +294,17 @@ class _SettingsScreenState extends ConsumerState { } Future _initializeHeaderBar() async { - final service = ref.read(linuxHeaderBarServiceProvider); - _headerBarActions = service.actions.listen(_handleHeaderBarAction); - await service.initialize(); + await _headerBarSession.initialize(); if (!mounted) { return; } setState(() { _headerBarReady = true; - _nativeHeaderBarAvailable = service.isAvailable; + _nativeHeaderBarAvailable = _headerBarSession.isAvailable; }); - if (service.isAvailable) { + if (_headerBarSession.isAvailable) { unawaited( - service.setOnboardingControls( + _headerBarSession.setOnboardingControls( visible: false, canGoBack: false, canContinue: false, @@ -313,6 +317,9 @@ class _SettingsScreenState extends ConsumerState { } void _handleHeaderBarAction(BusyMaxHeaderBarAction action) { + if (!_headerBarSession.isCurrent) { + return; + } if (action == BusyMaxHeaderBarAction.back) { _goBack(); return; @@ -360,9 +367,8 @@ class _SettingsScreenState extends ConsumerState { if (!mounted) { return; } - final service = ref.read(linuxHeaderBarServiceProvider); unawaited( - service.updateState( + _headerBarSession.updateState( BusyMaxHeaderBarState( title: title, viewMode: settings.scheduleViewMode, diff --git a/lib/src/platform/linux_header_bar_service.dart b/lib/src/platform/linux_header_bar_service.dart index 6fab876..0f8a48e 100644 --- a/lib/src/platform/linux_header_bar_service.dart +++ b/lib/src/platform/linux_header_bar_service.dart @@ -357,20 +357,11 @@ class LinuxHeaderBarService { final MethodChannel _channel; final bool _isLinux; - final _actions = StreamController.broadcast(); + final _sessions = []; - bool _initialized = false; + Future? _initialization; bool _available = false; - String? _titleRange; - ScheduleViewMode? _viewMode; - bool? _canRefresh; - bool? _canCreate; - bool? _searchActive; - bool? _canShowSidebar; - bool? _sidebarVisible; - bool? _navigationVisible; - bool? _scheduleControlsVisible; - bool? _backVisible; + bool _disposed = false; _BusyMaxOnboardingControlsState? _onboardingControls; bool? _modalBarrierVisible; double? _sidebarWidth; @@ -380,30 +371,52 @@ class LinuxHeaderBarService { bool get isAvailable => _available; - Stream get actions => _actions.stream; + /// 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 (_initialized) { + Future _initialize() async { + if (_disposed) { return; } - _initialized = true; _channel.setMethodCallHandler(handleNativeMethodCall); if (!_isLinux) { _available = false; return; } try { - _available = await _channel.invokeMethod('initialize') ?? false; + final available = + await _channel.invokeMethod('initialize') ?? false; + if (!_disposed) { + _available = available; + } } on MissingPluginException { - _available = false; + if (!_disposed) { + _available = false; + } } } /// Applies all screen-owned header state in one native transaction. /// - /// Equal state is not sent twice. Set [force] when the native widgets may - /// have been recreated independently of this service instance. - Future updateState( + /// 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 { @@ -414,67 +427,9 @@ class LinuxHeaderBarService { return; } _state = state; - _titleRange = state.title; - _viewMode = state.viewMode; - _canRefresh = state.canRefresh; - _canCreate = state.canCreate; - _searchActive = state.searchActive; - _canShowSidebar = state.canShowSidebar; - _sidebarVisible = state.sidebarVisible; - _navigationVisible = state.navigationVisible; - _scheduleControlsVisible = state.scheduleControlsVisible; - _backVisible = state.backVisible; await _invokeIfAvailable('setState', state.toJson()); } - Future setTitleRange(String value) async { - if (!_available) { - return; - } - if (_titleRange == value) { - return; - } - _state = null; - _titleRange = value; - await _invokeIfAvailable('setTitleRange', value); - } - - Future setViewMode(ScheduleViewMode mode) async { - if (!_available) { - return; - } - if (_viewMode == mode) { - return; - } - _state = null; - _viewMode = mode; - await _invokeIfAvailable('setViewMode', mode.name); - } - - Future setCanRefresh(bool value) async { - if (!_available) { - return; - } - if (_canRefresh == value) { - return; - } - _state = null; - _canRefresh = value; - await _invokeIfAvailable('setCanRefresh', value); - } - - Future setCanCreate(bool value) async { - if (!_available) { - return; - } - if (_canCreate == value) { - return; - } - _state = null; - _canCreate = value; - await _invokeIfAvailable('setCanCreate', value); - } - Future setLocalizedLabels(BusyMaxHeaderBarLabels labels) async { if (!_available) { return; @@ -497,89 +452,7 @@ class LinuxHeaderBarService { await _invokeIfAvailable('setSidebarWidth', value); } - Future setSearchActive(bool value) async { - if (!_available) { - return; - } - if (_searchActive == value) { - return; - } - _state = null; - _searchActive = value; - await _invokeIfAvailable('setSearchActive', value); - } - - /// Sets whether the current layout can present a sidebar. - /// - /// Prefer [updateState] for screen transitions. This compatibility method - /// exists for callers that have not migrated to the atomic state contract. - Future setCanShowSidebar(bool value) async { - if (!_available) { - return; - } - if (_canShowSidebar == value) { - return; - } - _state = null; - _canShowSidebar = value; - await _invokeIfAvailable('setCanShowSidebar', value); - } - - Future setSidebarVisible(bool value) async { - if (!_available) { - return; - } - final restoresSidebarAvailability = value && _canShowSidebar == false; - if (_sidebarVisible == value && !restoresSidebarAvailability) { - return; - } - _state = null; - if (value) { - // Preserve the legacy contract: requesting a visible sidebar also makes - // its native toggle available. Atomic callers should set both fields. - _canShowSidebar = true; - } - _sidebarVisible = value; - await _invokeIfAvailable('setSidebarVisible', value); - } - - Future setNavigationVisible(bool value) async { - if (!_available) { - return; - } - if (_navigationVisible == value) { - return; - } - _state = null; - _navigationVisible = value; - await _invokeIfAvailable('setNavigationVisible', value); - } - - Future setScheduleControlsVisible(bool value) async { - if (!_available) { - return; - } - if (_scheduleControlsVisible == value) { - return; - } - _state = null; - _scheduleControlsVisible = value; - await _invokeIfAvailable('setScheduleControlsVisible', value); - } - - Future setBackVisible(bool value) async { - if (!_available) { - return; - } - if (_backVisible == value) { - return; - } - _state = null; - _backVisible = value; - await _invokeIfAvailable('setBackVisible', value); - } - - Future setOnboardingControls({ + Future _setOnboardingControls({ required bool visible, required bool canGoBack, required bool canContinue, @@ -604,6 +477,25 @@ class LinuxHeaderBarService { 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 setModalBarrierVisible(bool value) async { if (!_available) { return; @@ -629,14 +521,14 @@ class LinuxHeaderBarService { @visibleForTesting Future handleNativeMethodCall(MethodCall call) async { final action = _actionForMethod(call.method); - if (action != null && !_actions.isClosed) { - _actions.add(action); + if (action != null) { + _activeSession?._dispatch(action); } return null; } Future _invokeIfAvailable(String method, Object? arguments) async { - if (!_available) { + if (_disposed || !_available) { return; } try { @@ -670,7 +562,138 @@ class LinuxHeaderBarService { } 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(); + 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; + + 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); + } + + Future setOnboardingControls({ + required bool visible, + required bool canGoBack, + required bool canContinue, + required String backLabel, + required String continueLabel, + bool force = false, + }) async { + if (_disposed) { + return; + } + final state = _BusyMaxOnboardingControlsState( + visible: visible, + canGoBack: canGoBack, + canContinue: canContinue, + backLabel: backLabel, + continueLabel: continueLabel, + ); + _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, + 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, + force: true, + ); + } + } + + void _dispatch(BusyMaxHeaderBarAction action) { + if (isCurrent && !_actions.isClosed) { + _actions.add(action); + } + } + + void dispose() { + if (_disposed) { + return; + } + _disposed = true; + _service._releaseSession(this); + unawaited(_actions.close()); + } + + void _disposeFromService() { + if (_disposed) { + return; + } + _disposed = true; unawaited(_actions.close()); } } diff --git a/test/app/native_ui_audit_test.dart b/test/app/native_ui_audit_test.dart index f706f9d..5b26e5e 100644 --- a/test/app/native_ui_audit_test.dart +++ b/test/app/native_ui_audit_test.dart @@ -104,7 +104,8 @@ void main() { expect(settings, isNot(contains('SettingsPage.appearance'))); expect(settings, isNot(contains('SettingsPage.localization'))); expect(settings, isNot(contains('l10n.themeFamily'))); - expect(settings, contains('service.updateState(')); + expect(settings, contains('.claimSession()')); + expect(settings, contains('_headerBarSession.updateState(')); expect(settings, contains('BusyMaxHeaderBarState(')); expect(settings, contains('backVisible: true')); expect(settings, contains('canShowSidebar: showSidebar')); @@ -372,8 +373,8 @@ void main() { expect(signIn, contains('generation != _headerBarUpdateGeneration')); expect(signIn, contains('_headerBarUpdateGeneration++;')); expect(signIn, contains('force: true')); - expect(schedule, contains('if (service.isAvailable)')); - expect(schedule, contains('service.setOnboardingControls(')); + expect(schedule, contains('if (_headerBarSession.isAvailable)')); + expect(schedule, contains('_headerBarSession.setOnboardingControls(')); expect(schedule, contains('force: true')); expect( source, diff --git a/test/app/theme_localization_test.dart b/test/app/theme_localization_test.dart index 1c7a3b2..7a37d3f 100644 --- a/test/app/theme_localization_test.dart +++ b/test/app/theme_localization_test.dart @@ -920,7 +920,8 @@ void main() { expect(shellSource, contains('filled: false')); expect(shellSource, isNot(contains('filled: true'))); expect(source, contains('final title = context.l10n.onboardingSetupTitle')); - expect(source, contains('service.updateState(')); + expect(source, contains('.claimSession()')); + expect(source, contains('_headerBarSession.updateState(')); expect(source, contains('BusyMaxHeaderBarState(')); expect(source, contains('title: title')); expect(source, isNot(contains('class _OnboardingHeader'))); diff --git a/test/features/auth/presentation/auth_routing_test.dart b/test/features/auth/presentation/auth_routing_test.dart index a0a9049..c214ff0 100644 --- a/test/features/auth/presentation/auth_routing_test.dart +++ b/test/features/auth/presentation/auth_routing_test.dart @@ -22,6 +22,7 @@ import 'package:busymax/src/google_tasks/api/google_tasks_api_surface.dart'; import 'package:busymax/src/google_tasks/oauth/oauth_models.dart'; import 'package:busymax/src/google_tasks/oauth/oauth_service.dart'; import 'package:busymax/src/google_tasks/oauth/oauth_token_store.dart'; +import 'package:busymax/src/platform/linux_header_bar_service.dart'; import 'package:busymax/src/task_providers/task_provider.dart'; void main() { @@ -496,6 +497,11 @@ Future _pumpApp( onSignedIn ?? (accountId, initial) async {}, ), syncEngineProvider.overrideWithValue(null), + linuxHeaderBarServiceProvider.overrideWith((ref) { + final service = LinuxHeaderBarService(isLinux: false); + ref.onDispose(service.dispose); + return service; + }), ], child: const BusyMaxApp(), ), diff --git a/test/features/schedule/presentation/schedule_views_test.dart b/test/features/schedule/presentation/schedule_views_test.dart index 7e08ab6..1f1c90b 100644 --- a/test/features/schedule/presentation/schedule_views_test.dart +++ b/test/features/schedule/presentation/schedule_views_test.dart @@ -1168,7 +1168,8 @@ void main() { expect(source, contains('final showFallbackHeader')); expect(source, contains('if (showFallbackHeader)')); expect(source, contains('final headerBarState = BusyMaxHeaderBarState(')); - expect(source, contains('service.updateState(headerBarState)')); + expect(source, contains('.claimSession()')); + expect(source, contains('_headerBarSession.updateState(headerBarState)')); expect(source, contains('onMenuSelected: _handleFallbackToolbarMenu')); }); @@ -2042,9 +2043,13 @@ void main() { workspace, contains('navigationVisible: _mode != ScheduleViewMode.agenda'), ); - expect(workspace, contains('service.updateState(headerBarState)')); + expect( + workspace, + contains('_headerBarSession.updateState(headerBarState)'), + ); expect(headerService, contains('class BusyMaxHeaderBarState')); - expect(headerService, contains('Future setNavigationVisible')); + 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')); diff --git a/test/platform/linux_header_bar_service_test.dart b/test/platform/linux_header_bar_service_test.dart index 9861031..5e15df7 100644 --- a/test/platform/linux_header_bar_service_test.dart +++ b/test/platform/linux_header_bar_service_test.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:io'; import 'package:busymax/src/platform/linux_header_bar_service.dart'; @@ -5,10 +6,36 @@ 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, + canCreate: true, + searchActive: false, + canShowSidebar: true, + sidebarVisible: true, + navigationVisible: true, + scheduleControlsVisible: true, + backVisible: false, +); + +const _settingsHeaderState = BusyMaxHeaderBarState( + title: 'Settings', + viewMode: ScheduleViewMode.month, + canRefresh: false, + canCreate: false, + searchActive: false, + canShowSidebar: true, + sidebarVisible: true, + navigationVisible: false, + scheduleControlsVisible: false, + backVisible: true, +); + void main() { TestWidgetsFlutterBinding.ensureInitialized(); - test('sends schedule range and state updates to native headerbar', () async { + test('sends application-wide state to the native headerbar', () async { const channel = MethodChannel('busymax_test/headerbar_updates'); final calls = []; TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger @@ -26,12 +53,10 @@ void main() { final service = LinuxHeaderBarService(channel: channel, isLinux: true); addTearDown(service.dispose); + final session = service.claimSession(); + addTearDown(session.dispose); await service.initialize(); - await service.setTitleRange('Jun 7-13, 2026'); - await service.setViewMode(ScheduleViewMode.week); - await service.setCanRefresh(true); - await service.setCanCreate(true); await service.setLocalizedLabels( const BusyMaxHeaderBarLabels( today: 'Today', @@ -54,11 +79,7 @@ void main() { ), ); await service.setSidebarWidth(300); - await service.setSearchActive(false); - await service.setSidebarVisible(true); - await service.setNavigationVisible(false); - await service.setBackVisible(false); - await service.setOnboardingControls( + await session.setOnboardingControls( visible: true, canGoBack: false, canContinue: true, @@ -92,40 +113,29 @@ void main() { calls.map((call) => call.method), containsAllInOrder([ 'initialize', - 'setTitleRange', - 'setViewMode', - 'setCanRefresh', - 'setCanCreate', 'setLocalizedLabels', 'setSidebarWidth', - 'setSearchActive', - 'setSidebarVisible', - 'setNavigationVisible', - 'setBackVisible', 'setOnboardingControls', 'setModalBarrierVisible', 'setTheme', ]), ); - expect(calls[1].arguments, 'Jun 7-13, 2026'); - expect(calls[2].arguments, 'week'); - expect(calls[5].arguments, containsPair('today', 'Today')); - expect(calls[5].arguments, containsPair('year', 'Year')); - expect(calls[5].arguments, containsPair('create', 'Create')); - expect(calls[5].arguments, containsPair('menu', 'Menu')); - expect(calls[5].arguments, containsPair('sidebar', 'Toggle Sidebar')); - expect(calls[5].arguments, containsPair('back', 'Back')); - expect(calls[5].arguments, containsPair('settings', 'Settings')); + 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('menu', 'Menu')); + expect(calls[1].arguments, containsPair('sidebar', 'Toggle Sidebar')); + expect(calls[1].arguments, containsPair('back', 'Back')); + expect(calls[1].arguments, containsPair('settings', 'Settings')); expect( - calls[5].arguments, + calls[1].arguments, containsPair('keyboardShortcuts', 'Keyboard Shortcuts'), ); - expect(calls[5].arguments, containsPair('aboutBusyMax', 'About BusyMax')); - expect(calls[6].arguments, 300); - expect(calls[9].arguments, false); - expect(calls[11].arguments, containsPair('visible', true)); - expect(calls[11].arguments, containsPair('canContinue', true)); - expect(calls[11].arguments, containsPair('continueLabel', 'Continue')); + expect(calls[1].arguments, containsPair('aboutBusyMax', 'About BusyMax')); + expect(calls[2].arguments, 300); + expect(calls[3].arguments, containsPair('visible', true)); + expect(calls[3].arguments, containsPair('canContinue', true)); + expect(calls[3].arguments, containsPair('continueLabel', 'Continue')); expect(calls.last.arguments, containsPair('preferDark', true)); expect(calls.last.arguments, containsPair('backgroundColor', '#1D1D20')); expect( @@ -180,6 +190,8 @@ void main() { 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, @@ -194,9 +206,9 @@ void main() { ); await service.initialize(); - await service.updateState(state); - await service.updateState(state.copyWith()); - await service.updateState(state.copyWith(title: 'August 2026')); + await session.updateState(state); + await session.updateState(state.copyWith()); + await session.updateState(state.copyWith(title: 'August 2026')); expect(calls.map((call) => call.method), [ 'initialize', @@ -220,8 +232,44 @@ void main() { }, ); - test('legacy changes invalidate the cached atomic state', () async { - const channel = MethodChannel('busymax_test/headerbar_state_compatibility'); + 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 { @@ -235,32 +283,116 @@ void main() { final service = LinuxHeaderBarService(channel: channel, isLinux: true); addTearDown(service.dispose); - const state = BusyMaxHeaderBarState( - title: 'Schedule', - viewMode: ScheduleViewMode.week, - canRefresh: true, - canCreate: true, - searchActive: false, - canShowSidebar: true, - sidebarVisible: true, - navigationVisible: true, - scheduleControlsVisible: true, - backVisible: false, + 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'), ); - await service.initialize(); - await service.updateState(state); - await service.setCanShowSidebar(false); - await service.setSidebarVisible(true); - await service.updateState(state); - - expect(calls.map((call) => call.method), [ - 'initialize', - 'setState', - 'setCanShowSidebar', - 'setSidebarVisible', - 'setState', - ]); + 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'), + ); + + 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('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 header controls keep visible keyboard focus indicators', () { @@ -292,8 +424,10 @@ void main() { isLinux: false, ); addTearDown(service.dispose); + final session = service.claimSession(); + addTearDown(session.dispose); - final nextAction = service.actions.take(5).toList(); + final nextAction = session.actions.take(5).toList(); await service.handleNativeMethodCall(const MethodCall('create')); await service.handleNativeMethodCall(const MethodCall('continueSetup')); await service.handleNativeMethodCall(const MethodCall('settings')); @@ -329,23 +463,25 @@ void main() { final service = LinuxHeaderBarService(channel: channel, isLinux: true); addTearDown(service.dispose); + final session = service.claimSession(); + addTearDown(session.dispose); await service.initialize(); - await service.setOnboardingControls( + await session.setOnboardingControls( visible: false, canGoBack: false, canContinue: false, backLabel: '', continueLabel: '', ); - await service.setOnboardingControls( + await session.setOnboardingControls( visible: false, canGoBack: false, canContinue: false, backLabel: '', continueLabel: '', ); - await service.setOnboardingControls( + await session.setOnboardingControls( visible: false, canGoBack: false, canContinue: false, @@ -370,7 +506,6 @@ void main() { addTearDown(service.dispose); await service.initialize(); - await service.setTitleRange('Settings'); expect(service.isAvailable, isFalse); }); From e37077f0850d375c7ad21933e55438baf75ad02e Mon Sep 17 00:00:00 2001 From: albert Date: Wed, 22 Jul 2026 21:05:44 -0700 Subject: [PATCH 04/73] Implemented search architecture Linux now uses a real GtkSearchEntry in the native header, without custom search CSS, fixed geometry, icons, borders, or Material styling --- lib/l10n/app_de.arb | 10 +- lib/l10n/app_en.arb | 14 +- lib/l10n/app_es.arb | 10 +- lib/l10n/app_fr.arb | 10 +- lib/l10n/generated/app_localizations.dart | 64 +- lib/l10n/generated/app_localizations_de.dart | 34 +- lib/l10n/generated/app_localizations_en.dart | 35 +- lib/l10n/generated/app_localizations_es.dart | 32 +- lib/l10n/generated/app_localizations_fr.dart | 35 +- lib/main.dart | 27 +- lib/src/app/app_bootstrap.dart | 8 +- lib/src/app/app_router.dart | 50 +- lib/src/app/app_settings.dart | 180 +- lib/src/app/app_theme.dart | 9 +- lib/src/app/busymax_app.dart | 27 +- lib/src/app/busymax_design.dart | 784 ++++++--- lib/src/app/busymax_dialogs.dart | 43 +- lib/src/app/busymax_surface_colors.dart | 2 +- lib/src/app/busymax_yaru_theme.dart | 601 +++---- .../auth/presentation/sign_in_screen.dart | 31 +- .../calendar/data/calendar_repository.dart | 112 +- .../calendar/presentation/event_editor.dart | 39 +- .../presentation/feedback_dialog.dart | 283 +-- .../desktop_notification_service.dart | 12 +- .../application/compact_agenda_data.dart | 18 +- .../application/compact_agenda_snapshot.dart | 10 + .../presentation/compact_agenda_app.dart | 18 + .../presentation/compact_agenda_panel.dart | 100 +- .../presentation/schedule_agenda_view.dart | 7 +- .../schedule_anchored_popover.dart | 382 ++++ .../presentation/schedule_create_menu.dart | 22 +- .../presentation/schedule_empty_states.dart | 41 +- .../presentation/schedule_event_block.dart | 6 +- .../schedule_item_details_popover.dart | 386 +---- .../presentation/schedule_month_view.dart | 40 +- .../presentation/schedule_more_popover.dart | 117 +- .../presentation/schedule_sidebar.dart | 3 +- .../presentation/schedule_toolbar.dart | 43 +- .../presentation/schedule_workspace.dart | 1537 ++++++++++++----- .../presentation/settings_screen.dart | 203 ++- .../presentation/task_lists_sidebar.dart | 6 +- .../desktop_date_time_fields.dart | 35 +- .../tasks/presentation/task_filters.dart | 4 +- .../tasks/presentation/task_tree_view.dart | 4 +- .../tasks/presentation/tasks_workspace.dart | 2 +- lib/src/platform/gtk_font_service.dart | 109 +- .../platform/linux_header_bar_provider.dart | 14 + .../platform/linux_header_bar_service.dart | 174 +- lib/src/schedule/schedule_filters.dart | 24 +- lib/src/schedule/schedule_item.dart | 26 + lib/src/schedule/schedule_projection.dart | 16 + lib/src/schedule/schedule_repository.dart | 109 +- .../schedule/schedule_source_visibility.dart | 9 +- linux/runner/my_application.cc | 645 +++++-- test/app/app_settings_test.dart | 176 ++ test/app/busymax_dialogs_test.dart | 187 ++ test/app/busymax_grouped_surface_test.dart | 397 ++++- test/app/busymax_search_field_test.dart | 67 + test/app/high_contrast_theme_test.dart | 126 ++ test/app/native_ui_audit_test.dart | 87 +- test/app/theme_localization_test.dart | 311 +++- .../auth/presentation/auth_routing_test.dart | 85 +- .../data/calendar_repository_test.dart | 106 ++ .../presentation/event_editor_test.dart | 74 +- .../presentation/feedback_dialog_test.dart | 31 +- .../desktop_notification_service_test.dart | 42 +- .../compact_agenda_panel_test.dart | 66 +- .../schedule_create_menu_test.dart | 31 + .../presentation/schedule_toolbar_test.dart | 182 +- .../presentation/schedule_views_test.dart | 318 +++- .../schedule_workspace_states_test.dart | 119 +- ...chedule_workspace_task_mutations_test.dart | 96 +- .../schedule/schedule_search_test.dart | 156 +- .../schedule_source_visibility_test.dart | 45 + .../presentation/settings_screen_test.dart | 154 +- .../desktop_date_time_fields_test.dart | 108 ++ .../presentation/task_details_pane_test.dart | 17 +- test/platform/gtk_font_service_test.dart | 103 ++ .../linux_header_bar_service_test.dart | 197 ++- 79 files changed, 7579 insertions(+), 2264 deletions(-) create mode 100644 lib/src/features/schedule/presentation/schedule_anchored_popover.dart create mode 100644 lib/src/platform/linux_header_bar_provider.dart create mode 100644 test/app/app_settings_test.dart create mode 100644 test/app/busymax_search_field_test.dart create mode 100644 test/app/high_contrast_theme_test.dart create mode 100644 test/features/schedule/schedule_source_visibility_test.dart create mode 100644 test/features/tasks/presentation/desktop_date_time_fields_test.dart diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index 492439e..4a34978 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -48,6 +48,8 @@ "scheduleNoSourcesDescription": "Wählen Sie in den Einstellungen aus, was angezeigt werden soll, und aktualisieren Sie anschließend.", "scheduleSignInRequired": "Konto verbinden", "scheduleSignInDescription": "Melden Sie sich an, um Kalender und Aufgaben zu synchronisieren.", + "scheduleNoSearchResults": "Keine passenden Termine oder Aufgaben", + "scheduleNoSearchResultsDescription": "Versuchen Sie eine andere Suche oder löschen Sie die aktuellen Filter.", "trayAgendaLoading": "Agenda wird geladen...", "trayAgendaSignInRequired": "Melden Sie sich an, um die Agenda anzuzeigen.", "trayAgendaNoSources": "Keine sichtbaren Kalender oder Aufgabenlisten.", @@ -89,6 +91,8 @@ "eventTitle": "Termintitel", "location": "Ort", "timeSlot": "Zeitfenster", + "timeMode": "Zeit", + "timeModeDescription": "Nur Daten verwenden oder genaue Uhrzeiten festlegen.", "startDateTime": "Startdatum/-zeit", "endDateTime": "Enddatum/-zeit", "doesNotRepeat": "Wiederholt sich nicht", @@ -111,6 +115,7 @@ "formatUnderlineTooltip": "Unterstrichen", "reminderMinutesBefore": "{minutes, plural, =1{1 Minute vorher} other{{minutes} Minuten vorher}}", "@reminderMinutesBefore": {"placeholders": {"minutes": {"type": "int"}}}, + "reminderAtStart": "Zum Startzeitpunkt", "reminderHoursBefore": "{hours, plural, =1{1 Stunde vorher} other{{hours} Stunden vorher}}", "@reminderHoursBefore": {"placeholders": {"hours": {"type": "int"}}}, "reminderDaysBefore": "{days, plural, =1{1 Tag vorher} other{{days} Tage vorher}}", @@ -315,6 +320,7 @@ "runInBackgroundWhenClosed": "Nach dem Schließen des Fensters weiter ausführen", "showTrayIcon": "Symbol im Benachrichtigungsbereich anzeigen", "startMinimizedToTray": "Minimiert im Benachrichtigungsbereich starten", + "requiresTrayIcon": "Erfordert das Symbol im Benachrichtigungsbereich.", "syncComplete": "Synchronisierung abgeschlossen.", "syncFailed": "Synchronisierung fehlgeschlagen: {error}", "notifySyncFailures": "Benachrichtigungen bei Synchronisierungsfehlern", @@ -326,6 +332,9 @@ "notificationDetailPrivate": "Privat", "notificationDetailNormal": "Normal", "quietHours": "Ruhezeiten", + "quietHoursDescription": "Benachrichtigungen während dieses Zeitraums pausieren.", + "quietHoursStart": "Beginn der Ruhezeit", + "quietHoursEnd": "Ende der Ruhezeit", "notifications": "Benachrichtigungen", "appearance": "Darstellung", "theme": "Theme", @@ -338,7 +347,6 @@ "currentLocale": "Aktuelle Sprache", "privacy": "Datenschutz", "redactTaskContentInDiagnostics": "Aufgabeninhalte in Diagnosen schwärzen", - "detailedNotifications": "Detaillierte Benachrichtigungstexte", "developerDiagnostics": "Entwicklerdiagnose", "diagnostics": "Diagnose", "apiInspectorDisabled": "API-Inspektor anzeigen", diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index d343142..97112ff 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -39,7 +39,7 @@ "hideFromSchedule": "Hide from schedule", "showInSchedule": "Show in schedule", "noCalendarsSynced": "No calendars synced yet.", - "allDay": "All Day", + "allDay": "All day", "moreItems": "+{count} more", "@moreItems": {"placeholders": {"count": {"type": "int"}}}, "noEventsOrTasks": "No events or tasks", @@ -49,6 +49,8 @@ "scheduleNoSourcesDescription": "Choose what to show in Settings, then refresh.", "scheduleSignInRequired": "Connect an account", "scheduleSignInDescription": "Sign in to sync calendars and tasks.", + "scheduleNoSearchResults": "No matching events or tasks", + "scheduleNoSearchResultsDescription": "Try a different search or clear the current filters.", "trayAgendaLoading": "Loading agenda...", "trayAgendaSignInRequired": "Sign in to show agenda.", "trayAgendaNoSources": "No visible calendars or task lists.", @@ -90,7 +92,9 @@ "editEvent": "Edit event", "eventTitle": "Event title", "location": "Location", - "timeSlot": "Time Slot", + "timeSlot": "Time slot", + "timeMode": "Time", + "timeModeDescription": "Use dates only or set specific times.", "startDateTime": "Start date/time", "endDateTime": "End date/time", "doesNotRepeat": "Does not repeat", @@ -113,6 +117,7 @@ "formatUnderlineTooltip": "Underline", "reminderMinutesBefore": "{minutes, plural, =1{1 minute before} other{{minutes} minutes before}}", "@reminderMinutesBefore": {"placeholders": {"minutes": {"type": "int"}}}, + "reminderAtStart": "At start", "reminderHoursBefore": "{hours, plural, =1{1 hour before} other{{hours} hours before}}", "@reminderHoursBefore": {"placeholders": {"hours": {"type": "int"}}}, "reminderDaysBefore": "{days, plural, =1{1 day before} other{{days} days before}}", @@ -329,6 +334,7 @@ "runInBackgroundWhenClosed": "Continue running when the window is closed", "showTrayIcon": "Show tray icon", "startMinimizedToTray": "Start minimized to the tray", + "requiresTrayIcon": "Requires the tray icon.", "syncComplete": "Sync complete.", "syncFailed": "Sync failed: {error}", "@syncFailed": {"placeholders": {"error": {"type": "String"}}}, @@ -341,6 +347,9 @@ "notificationDetailPrivate": "Private", "notificationDetailNormal": "Normal", "quietHours": "Quiet hours", + "quietHoursDescription": "Pause notifications during this period.", + "quietHoursStart": "Quiet hours start", + "quietHoursEnd": "Quiet hours end", "notifications": "Notifications", "appearance": "Appearance", "theme": "Theme", @@ -353,7 +362,6 @@ "currentLocale": "Current locale", "privacy": "Privacy", "redactTaskContentInDiagnostics": "Redact task content in diagnostics", - "detailedNotifications": "Detailed notification text", "developerDiagnostics": "Developer diagnostics", "diagnostics": "Diagnostics", "apiInspectorDisabled": "Show API inspector", diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index dc79c3a..849dbbc 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -48,6 +48,8 @@ "scheduleNoSourcesDescription": "Elige qué mostrar en Ajustes y, después, actualiza.", "scheduleSignInRequired": "Conectar una cuenta", "scheduleSignInDescription": "Inicia sesión para sincronizar calendarios y tareas.", + "scheduleNoSearchResults": "No hay eventos ni tareas coincidentes", + "scheduleNoSearchResultsDescription": "Prueba con otra búsqueda o borra los filtros actuales.", "trayAgendaLoading": "Cargando agenda...", "trayAgendaSignInRequired": "Inicia sesión para mostrar la agenda.", "trayAgendaNoSources": "No hay calendarios ni listas de tareas visibles.", @@ -89,6 +91,8 @@ "eventTitle": "Título del evento", "location": "Ubicación", "timeSlot": "Franja horaria", + "timeMode": "Hora", + "timeModeDescription": "Usa solo fechas o define horas concretas.", "startDateTime": "Fecha/hora de inicio", "endDateTime": "Fecha/hora de fin", "doesNotRepeat": "No se repite", @@ -111,6 +115,7 @@ "formatUnderlineTooltip": "Subrayado", "reminderMinutesBefore": "{minutes, plural, =1{1 minuto antes} other{{minutes} minutos antes}}", "@reminderMinutesBefore": {"placeholders": {"minutes": {"type": "int"}}}, + "reminderAtStart": "A la hora de inicio", "reminderHoursBefore": "{hours, plural, =1{1 hora antes} other{{hours} horas antes}}", "@reminderHoursBefore": {"placeholders": {"hours": {"type": "int"}}}, "reminderDaysBefore": "{days, plural, =1{1 día antes} other{{days} días antes}}", @@ -315,6 +320,7 @@ "runInBackgroundWhenClosed": "Seguir ejecutándose al cerrar la ventana", "showTrayIcon": "Mostrar el icono en la bandeja del sistema", "startMinimizedToTray": "Iniciar minimizado en la bandeja del sistema", + "requiresTrayIcon": "Requiere el icono de la bandeja del sistema.", "syncComplete": "Sincronización completa.", "syncFailed": "Error de sincronización: {error}", "notifySyncFailures": "Notificaciones de errores de sincronización", @@ -326,6 +332,9 @@ "notificationDetailPrivate": "Privado", "notificationDetailNormal": "Normal", "quietHours": "Horario silencioso", + "quietHoursDescription": "Pausar las notificaciones durante este período.", + "quietHoursStart": "Inicio del horario silencioso", + "quietHoursEnd": "Fin del horario silencioso", "notifications": "Notificaciones", "appearance": "Apariencia", "theme": "Tema", @@ -338,7 +347,6 @@ "currentLocale": "Configuración regional actual", "privacy": "Privacidad", "redactTaskContentInDiagnostics": "Ocultar contenido de tareas en diagnósticos", - "detailedNotifications": "Texto detallado en notificaciones", "developerDiagnostics": "Diagnósticos de desarrollo", "diagnostics": "Diagnósticos", "apiInspectorDisabled": "Mostrar inspector de API", diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index aab6a15..0d44534 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -48,6 +48,8 @@ "scheduleNoSourcesDescription": "Choisissez les éléments à afficher dans les paramètres, puis actualisez.", "scheduleSignInRequired": "Connecter un compte", "scheduleSignInDescription": "Connectez-vous pour synchroniser vos calendriers et vos tâches.", + "scheduleNoSearchResults": "Aucun événement ni aucune tâche ne correspond", + "scheduleNoSearchResultsDescription": "Essayez une autre recherche ou effacez les filtres actuels.", "trayAgendaLoading": "Chargement de l’agenda...", "trayAgendaSignInRequired": "Connectez-vous pour afficher l’agenda.", "trayAgendaNoSources": "Aucun calendrier ni liste de tâches visible.", @@ -89,6 +91,8 @@ "eventTitle": "Titre de l’événement", "location": "Lieu", "timeSlot": "Créneau", + "timeMode": "Horaire", + "timeModeDescription": "Utilisez uniquement les dates ou définissez des heures précises.", "startDateTime": "Date/heure de début", "endDateTime": "Date/heure de fin", "doesNotRepeat": "Ne se répète pas", @@ -111,6 +115,7 @@ "formatUnderlineTooltip": "Souligné", "reminderMinutesBefore": "{minutes, plural, =1{1 minute avant} other{{minutes} minutes avant}}", "@reminderMinutesBefore": {"placeholders": {"minutes": {"type": "int"}}}, + "reminderAtStart": "À l’heure de début", "reminderHoursBefore": "{hours, plural, =1{1 heure avant} other{{hours} heures avant}}", "@reminderHoursBefore": {"placeholders": {"hours": {"type": "int"}}}, "reminderDaysBefore": "{days, plural, =1{1 jour avant} other{{days} jours avant}}", @@ -315,6 +320,7 @@ "runInBackgroundWhenClosed": "Continuer à s’exécuter après la fermeture de la fenêtre", "showTrayIcon": "Afficher l’icône dans la zone de notification", "startMinimizedToTray": "Démarrer réduit dans la zone de notification", + "requiresTrayIcon": "Nécessite l’icône de la zone de notification.", "syncComplete": "Synchronisation terminée.", "syncFailed": "Échec de la synchronisation : {error}", "notifySyncFailures": "Notifications d’échec de synchronisation", @@ -326,6 +332,9 @@ "notificationDetailPrivate": "Privé", "notificationDetailNormal": "Normal", "quietHours": "Plages horaires silencieuses", + "quietHoursDescription": "Mettre les notifications en pause pendant cette période.", + "quietHoursStart": "Début des plages silencieuses", + "quietHoursEnd": "Fin des plages silencieuses", "notifications": "Notifications", "appearance": "Apparence", "theme": "Thème", @@ -338,7 +347,6 @@ "currentLocale": "Paramètres régionaux actuels", "privacy": "Confidentialité", "redactTaskContentInDiagnostics": "Masquer le contenu des tâches dans les diagnostics", - "detailedNotifications": "Texte de notification détaillé", "developerDiagnostics": "Diagnostics développeur", "diagnostics": "Diagnostics", "apiInspectorDisabled": "Afficher l’inspecteur API", diff --git a/lib/l10n/generated/app_localizations.dart b/lib/l10n/generated/app_localizations.dart index 3e23ace..565d797 100644 --- a/lib/l10n/generated/app_localizations.dart +++ b/lib/l10n/generated/app_localizations.dart @@ -339,7 +339,7 @@ abstract class AppLocalizations { /// No description provided for @allDay. /// /// In en, this message translates to: - /// **'All Day'** + /// **'All day'** String get allDay; /// No description provided for @moreItems. @@ -390,6 +390,18 @@ abstract class AppLocalizations { /// **'Sign in to sync calendars and tasks.'** String get scheduleSignInDescription; + /// No description provided for @scheduleNoSearchResults. + /// + /// In en, this message translates to: + /// **'No matching events or tasks'** + String get scheduleNoSearchResults; + + /// No description provided for @scheduleNoSearchResultsDescription. + /// + /// In en, this message translates to: + /// **'Try a different search or clear the current filters.'** + String get scheduleNoSearchResultsDescription; + /// No description provided for @trayAgendaLoading. /// /// In en, this message translates to: @@ -633,9 +645,21 @@ abstract class AppLocalizations { /// No description provided for @timeSlot. /// /// In en, this message translates to: - /// **'Time Slot'** + /// **'Time slot'** String get timeSlot; + /// No description provided for @timeMode. + /// + /// In en, this message translates to: + /// **'Time'** + String get timeMode; + + /// No description provided for @timeModeDescription. + /// + /// In en, this message translates to: + /// **'Use dates only or set specific times.'** + String get timeModeDescription; + /// No description provided for @startDateTime. /// /// In en, this message translates to: @@ -762,6 +786,12 @@ abstract class AppLocalizations { /// **'{minutes, plural, =1{1 minute before} other{{minutes} minutes before}}'** String reminderMinutesBefore(int minutes); + /// No description provided for @reminderAtStart. + /// + /// In en, this message translates to: + /// **'At start'** + String get reminderAtStart; + /// No description provided for @reminderHoursBefore. /// /// In en, this message translates to: @@ -1962,6 +1992,12 @@ abstract class AppLocalizations { /// **'Start minimized to the tray'** String get startMinimizedToTray; + /// No description provided for @requiresTrayIcon. + /// + /// In en, this message translates to: + /// **'Requires the tray icon.'** + String get requiresTrayIcon; + /// No description provided for @syncComplete. /// /// In en, this message translates to: @@ -2028,6 +2064,24 @@ abstract class AppLocalizations { /// **'Quiet hours'** String get quietHours; + /// No description provided for @quietHoursDescription. + /// + /// In en, this message translates to: + /// **'Pause notifications during this period.'** + String get quietHoursDescription; + + /// No description provided for @quietHoursStart. + /// + /// In en, this message translates to: + /// **'Quiet hours start'** + String get quietHoursStart; + + /// No description provided for @quietHoursEnd. + /// + /// In en, this message translates to: + /// **'Quiet hours end'** + String get quietHoursEnd; + /// No description provided for @notifications. /// /// In en, this message translates to: @@ -2100,12 +2154,6 @@ abstract class AppLocalizations { /// **'Redact task content in diagnostics'** String get redactTaskContentInDiagnostics; - /// No description provided for @detailedNotifications. - /// - /// In en, this message translates to: - /// **'Detailed notification text'** - String get detailedNotifications; - /// No description provided for @developerDiagnostics. /// /// In en, this message translates to: diff --git a/lib/l10n/generated/app_localizations_de.dart b/lib/l10n/generated/app_localizations_de.dart index c5bc7be..62c2367 100644 --- a/lib/l10n/generated/app_localizations_de.dart +++ b/lib/l10n/generated/app_localizations_de.dart @@ -163,6 +163,13 @@ class AppLocalizationsDe extends AppLocalizations { String get scheduleSignInDescription => 'Melden Sie sich an, um Kalender und Aufgaben zu synchronisieren.'; + @override + String get scheduleNoSearchResults => 'Keine passenden Termine oder Aufgaben'; + + @override + String get scheduleNoSearchResultsDescription => + 'Versuchen Sie eine andere Suche oder löschen Sie die aktuellen Filter.'; + @override String get trayAgendaLoading => 'Agenda wird geladen...'; @@ -291,6 +298,13 @@ class AppLocalizationsDe extends AppLocalizations { @override String get timeSlot => 'Zeitfenster'; + @override + String get timeMode => 'Zeit'; + + @override + String get timeModeDescription => + 'Nur Daten verwenden oder genaue Uhrzeiten festlegen.'; + @override String get startDateTime => 'Startdatum/-zeit'; @@ -362,6 +376,9 @@ class AppLocalizationsDe extends AppLocalizations { return '$_temp0'; } + @override + String get reminderAtStart => 'Zum Startzeitpunkt'; + @override String reminderHoursBefore(int hours) { String _temp0 = intl.Intl.pluralLogic( @@ -1024,6 +1041,10 @@ class AppLocalizationsDe extends AppLocalizations { String get startMinimizedToTray => 'Minimiert im Benachrichtigungsbereich starten'; + @override + String get requiresTrayIcon => + 'Erfordert das Symbol im Benachrichtigungsbereich.'; + @override String get syncComplete => 'Synchronisierung abgeschlossen.'; @@ -1060,6 +1081,16 @@ class AppLocalizationsDe extends AppLocalizations { @override String get quietHours => 'Ruhezeiten'; + @override + String get quietHoursDescription => + 'Benachrichtigungen während dieses Zeitraums pausieren.'; + + @override + String get quietHoursStart => 'Beginn der Ruhezeit'; + + @override + String get quietHoursEnd => 'Ende der Ruhezeit'; + @override String get notifications => 'Benachrichtigungen'; @@ -1097,9 +1128,6 @@ class AppLocalizationsDe extends AppLocalizations { String get redactTaskContentInDiagnostics => 'Aufgabeninhalte in Diagnosen schwärzen'; - @override - String get detailedNotifications => 'Detaillierte Benachrichtigungstexte'; - @override String get developerDiagnostics => 'Entwicklerdiagnose'; diff --git a/lib/l10n/generated/app_localizations_en.dart b/lib/l10n/generated/app_localizations_en.dart index f441904..19e570f 100644 --- a/lib/l10n/generated/app_localizations_en.dart +++ b/lib/l10n/generated/app_localizations_en.dart @@ -132,7 +132,7 @@ class AppLocalizationsEn extends AppLocalizations { String get noCalendarsSynced => 'No calendars synced yet.'; @override - String get allDay => 'All Day'; + String get allDay => 'All day'; @override String moreItems(int count) { @@ -162,6 +162,13 @@ class AppLocalizationsEn extends AppLocalizations { String get scheduleSignInDescription => 'Sign in to sync calendars and tasks.'; + @override + String get scheduleNoSearchResults => 'No matching events or tasks'; + + @override + String get scheduleNoSearchResultsDescription => + 'Try a different search or clear the current filters.'; + @override String get trayAgendaLoading => 'Loading agenda...'; @@ -286,7 +293,13 @@ class AppLocalizationsEn extends AppLocalizations { String get location => 'Location'; @override - String get timeSlot => 'Time Slot'; + String get timeSlot => 'Time slot'; + + @override + String get timeMode => 'Time'; + + @override + String get timeModeDescription => 'Use dates only or set specific times.'; @override String get startDateTime => 'Start date/time'; @@ -359,6 +372,9 @@ class AppLocalizationsEn extends AppLocalizations { return '$_temp0'; } + @override + String get reminderAtStart => 'At start'; + @override String reminderHoursBefore(int hours) { String _temp0 = intl.Intl.pluralLogic( @@ -1013,6 +1029,9 @@ class AppLocalizationsEn extends AppLocalizations { @override String get startMinimizedToTray => 'Start minimized to the tray'; + @override + String get requiresTrayIcon => 'Requires the tray icon.'; + @override String get syncComplete => 'Sync complete.'; @@ -1048,6 +1067,15 @@ class AppLocalizationsEn extends AppLocalizations { @override String get quietHours => 'Quiet hours'; + @override + String get quietHoursDescription => 'Pause notifications during this period.'; + + @override + String get quietHoursStart => 'Quiet hours start'; + + @override + String get quietHoursEnd => 'Quiet hours end'; + @override String get notifications => 'Notifications'; @@ -1085,9 +1113,6 @@ class AppLocalizationsEn extends AppLocalizations { String get redactTaskContentInDiagnostics => 'Redact task content in diagnostics'; - @override - String get detailedNotifications => 'Detailed notification text'; - @override String get developerDiagnostics => 'Developer diagnostics'; diff --git a/lib/l10n/generated/app_localizations_es.dart b/lib/l10n/generated/app_localizations_es.dart index c39d303..7dfa09d 100644 --- a/lib/l10n/generated/app_localizations_es.dart +++ b/lib/l10n/generated/app_localizations_es.dart @@ -165,6 +165,13 @@ class AppLocalizationsEs extends AppLocalizations { String get scheduleSignInDescription => 'Inicia sesión para sincronizar calendarios y tareas.'; + @override + String get scheduleNoSearchResults => 'No hay eventos ni tareas coincidentes'; + + @override + String get scheduleNoSearchResultsDescription => + 'Prueba con otra búsqueda o borra los filtros actuales.'; + @override String get trayAgendaLoading => 'Cargando agenda...'; @@ -293,6 +300,12 @@ class AppLocalizationsEs extends AppLocalizations { @override String get timeSlot => 'Franja horaria'; + @override + String get timeMode => 'Hora'; + + @override + String get timeModeDescription => 'Usa solo fechas o define horas concretas.'; + @override String get startDateTime => 'Fecha/hora de inicio'; @@ -364,6 +377,9 @@ class AppLocalizationsEs extends AppLocalizations { return '$_temp0'; } + @override + String get reminderAtStart => 'A la hora de inicio'; + @override String reminderHoursBefore(int hours) { String _temp0 = intl.Intl.pluralLogic( @@ -1023,6 +1039,9 @@ class AppLocalizationsEs extends AppLocalizations { String get startMinimizedToTray => 'Iniciar minimizado en la bandeja del sistema'; + @override + String get requiresTrayIcon => 'Requiere el icono de la bandeja del sistema.'; + @override String get syncComplete => 'Sincronización completa.'; @@ -1060,6 +1079,16 @@ class AppLocalizationsEs extends AppLocalizations { @override String get quietHours => 'Horario silencioso'; + @override + String get quietHoursDescription => + 'Pausar las notificaciones durante este período.'; + + @override + String get quietHoursStart => 'Inicio del horario silencioso'; + + @override + String get quietHoursEnd => 'Fin del horario silencioso'; + @override String get notifications => 'Notificaciones'; @@ -1097,9 +1126,6 @@ class AppLocalizationsEs extends AppLocalizations { String get redactTaskContentInDiagnostics => 'Ocultar contenido de tareas en diagnósticos'; - @override - String get detailedNotifications => 'Texto detallado en notificaciones'; - @override String get developerDiagnostics => 'Diagnósticos de desarrollo'; diff --git a/lib/l10n/generated/app_localizations_fr.dart b/lib/l10n/generated/app_localizations_fr.dart index 2f83315..ce4b495 100644 --- a/lib/l10n/generated/app_localizations_fr.dart +++ b/lib/l10n/generated/app_localizations_fr.dart @@ -163,6 +163,14 @@ class AppLocalizationsFr extends AppLocalizations { String get scheduleSignInDescription => 'Connectez-vous pour synchroniser vos calendriers et vos tâches.'; + @override + String get scheduleNoSearchResults => + 'Aucun événement ni aucune tâche ne correspond'; + + @override + String get scheduleNoSearchResultsDescription => + 'Essayez une autre recherche ou effacez les filtres actuels.'; + @override String get trayAgendaLoading => 'Chargement de l’agenda...'; @@ -291,6 +299,13 @@ class AppLocalizationsFr extends AppLocalizations { @override String get timeSlot => 'Créneau'; + @override + String get timeMode => 'Horaire'; + + @override + String get timeModeDescription => + 'Utilisez uniquement les dates ou définissez des heures précises.'; + @override String get startDateTime => 'Date/heure de début'; @@ -362,6 +377,9 @@ class AppLocalizationsFr extends AppLocalizations { return '$_temp0'; } + @override + String get reminderAtStart => 'À l’heure de début'; + @override String reminderHoursBefore(int hours) { String _temp0 = intl.Intl.pluralLogic( @@ -1022,6 +1040,10 @@ class AppLocalizationsFr extends AppLocalizations { String get startMinimizedToTray => 'Démarrer réduit dans la zone de notification'; + @override + String get requiresTrayIcon => + 'Nécessite l’icône de la zone de notification.'; + @override String get syncComplete => 'Synchronisation terminée.'; @@ -1057,6 +1079,16 @@ class AppLocalizationsFr extends AppLocalizations { @override String get quietHours => 'Plages horaires silencieuses'; + @override + String get quietHoursDescription => + 'Mettre les notifications en pause pendant cette période.'; + + @override + String get quietHoursStart => 'Début des plages silencieuses'; + + @override + String get quietHoursEnd => 'Fin des plages silencieuses'; + @override String get notifications => 'Notifications'; @@ -1094,9 +1126,6 @@ class AppLocalizationsFr extends AppLocalizations { String get redactTaskContentInDiagnostics => 'Masquer le contenu des tâches dans les diagnostics'; - @override - String get detailedNotifications => 'Texte de notification détaillé'; - @override String get developerDiagnostics => 'Diagnostics développeur'; diff --git a/lib/main.dart b/lib/main.dart index 9bc7b76..48db015 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -10,18 +10,43 @@ import 'src/config/build_config.dart'; import 'src/core/logging/redacting_logger.dart'; import 'src/features/schedule/application/compact_agenda_data.dart'; import 'src/features/schedule/presentation/compact_agenda_app.dart'; +import 'src/platform/gtk_font_service.dart'; import 'src/platform/main_window_command_client.dart'; import 'src/platform/busymax_window_args.dart'; Future main(List args) async { WidgetsFlutterBinding.ensureInitialized(); - await SystemTheme.accentColor.load(); + final systemAccentFuture = SystemTheme.accentColor.load(); + final initialGtkFontFuture = const GtkFontService().getGtkFont(); + final initialAppSettingsFuture = loadInitialAppSettings( + const JsonFileLocalSettingsStore(), + ); + final initialAppSettings = await initialAppSettingsFuture; + final gtkThemeService = const GtkThemeService(); + await gtkThemeService.setPreferDark( + switch (initialAppSettings.themeModePreference) { + BusyMaxThemeModePreference.system => null, + BusyMaxThemeModePreference.light => false, + BusyMaxThemeModePreference.dark => true, + }, + ); + final desktopSettings = await Future.wait([ + systemAccentFuture, + initialGtkFontFuture, + gtkThemeService.getGtkThemeColors(), + ]); configureLogging(); + final initialGtkFont = desktopSettings[1] as GtkFontSettings?; + final initialGtkThemeColors = desktopSettings[2] as GtkThemeColors?; + final windowController = await WindowController.fromCurrentEngine(); final windowArgs = BusyMaxWindowArgs.parse(windowController.arguments); final overrides = [ buildConfigProvider.overrideWithValue(BuildConfig.fromEnvironment()), + initialAppSettingsProvider.overrideWithValue(initialAppSettings), + initialGtkFontSettingsProvider.overrideWithValue(initialGtkFont), + initialGtkThemeColorsProvider.overrideWithValue(initialGtkThemeColors), ]; switch (windowArgs.kind) { diff --git a/lib/src/app/app_bootstrap.dart b/lib/src/app/app_bootstrap.dart index 0baa7e6..fac8081 100644 --- a/lib/src/app/app_bootstrap.dart +++ b/lib/src/app/app_bootstrap.dart @@ -36,7 +36,6 @@ import '../microsoft_todo/api/microsoft_todo_api_client.dart'; import '../microsoft_todo/api/microsoft_todo_google_tasks_adapter.dart'; import '../microsoft_todo/oauth/microsoft_oauth_service.dart'; import '../platform/compact_agenda_window_service.dart'; -import '../platform/linux_header_bar_service.dart'; import '../platform/linux_window_service.dart'; import '../task_providers/task_provider.dart'; import '../schedule/schedule_commands.dart'; @@ -45,6 +44,7 @@ import 'app_router.dart'; import 'app_settings.dart'; export '../app/app_settings.dart'; +export '../platform/linux_header_bar_provider.dart'; final buildConfigProvider = Provider( (ref) => BuildConfig.fromEnvironment(), @@ -139,12 +139,6 @@ final linuxWindowServiceProvider = Provider( (ref) => const LinuxWindowService(), ); -final linuxHeaderBarServiceProvider = Provider((ref) { - final service = LinuxHeaderBarService(); - ref.onDispose(service.dispose); - return service; -}); - final compactAgendaWindowServiceProvider = Provider( (ref) { return const CompactAgendaWindowService(); diff --git a/lib/src/app/app_router.dart b/lib/src/app/app_router.dart index d603c32..5a91211 100644 --- a/lib/src/app/app_router.dart +++ b/lib/src/app/app_router.dart @@ -53,22 +53,22 @@ final appRouterProvider = Provider((ref) { ), GoRoute( path: '/tasks', - builder: (context, state) => - const ScheduleWorkspace(initialScope: ScheduleScope.tasks), - routes: [ - GoRoute( - path: ':listId', - builder: (context, state) => - const ScheduleWorkspace(initialScope: ScheduleScope.tasks), - routes: [ - GoRoute( - path: ':taskId', - builder: (context, state) => - const ScheduleWorkspace(initialScope: ScheduleScope.tasks), - ), - ], - ), - ], + pageBuilder: (context, state) => _tasksWorkspacePage(), + ), + GoRoute( + path: r'/tasks/:taskRoute(.*)', + redirect: (context, state) { + final segmentCount = state.uri.pathSegments.length; + return segmentCount == 3 || segmentCount == 4 ? null : '/tasks'; + }, + pageBuilder: (context, state) { + final segments = state.uri.pathSegments; + return _tasksWorkspacePage( + accountId: segments[1], + taskListId: segments[2], + taskId: segments.length == 4 ? segments[3] : null, + ); + }, ), GoRoute( path: '/settings', @@ -82,6 +82,24 @@ final appRouterProvider = Provider((ref) { ); }); +const _tasksWorkspacePageKey = ValueKey('tasks-workspace'); + +Page _tasksWorkspacePage({ + String? accountId, + String? taskListId, + String? taskId, +}) { + return NoTransitionPage( + key: _tasksWorkspacePageKey, + child: ScheduleWorkspace( + initialScope: ScheduleScope.tasks, + initialTaskAccountId: accountId, + initialTaskListId: taskListId, + initialTaskId: taskId, + ), + ); +} + class _SplashScreen extends StatelessWidget { const _SplashScreen(); diff --git a/lib/src/app/app_settings.dart b/lib/src/app/app_settings.dart index 2fd7568..186cbd1 100644 --- a/lib/src/app/app_settings.dart +++ b/lib/src/app/app_settings.dart @@ -47,7 +47,6 @@ class AppSettings { required this.quietHoursStart, required this.quietHoursEnd, required this.redactTaskContentInDiagnostics, - required this.detailedNotifications, required this.lastDueTodayNotificationDate, required this.taskListScheduleVisibility, required this.scheduleViewMode, @@ -73,7 +72,6 @@ class AppSettings { quietHoursStart: '22:00', quietHoursEnd: '07:00', redactTaskContentInDiagnostics: true, - detailedNotifications: false, lastDueTodayNotificationDate: null, taskListScheduleVisibility: {}, scheduleViewMode: ScheduleViewMode.week, @@ -102,16 +100,37 @@ class AppSettings { fallbackStart: defaults.scheduleDayStartMinute, fallbackEnd: defaults.scheduleDayEndMinute, ); - final detailedNotifications = - json['detailedNotifications'] as bool? ?? - defaults.detailedNotifications; - final notificationDetailLevel = detailedNotifications - ? NotificationDetailLevel.normal - : _enumFromName( - NotificationDetailLevel.values, - json['notificationDetailLevel'], - defaults.notificationDetailLevel, - ); + final notificationDetailLevel = + _enumFromNameOrNull( + NotificationDetailLevel.values, + json['notificationDetailLevel'], + ) ?? + switch (json['detailedNotifications']) { + true => NotificationDetailLevel.normal, + false => NotificationDetailLevel.private, + _ => defaults.notificationDetailLevel, + }; + var quietHoursStart = _normalizedTimeOfDay( + json['quietHoursStart'], + defaults.quietHoursStart, + ); + var quietHoursEnd = _normalizedTimeOfDay( + json['quietHoursEnd'], + defaults.quietHoursEnd, + ); + if (quietHoursStart == quietHoursEnd) { + quietHoursStart = defaults.quietHoursStart; + quietHoursEnd = defaults.quietHoursEnd; + } + final runInBackgroundWhenClosed = + json['runInBackgroundWhenClosed'] as bool? ?? + defaults.runInBackgroundWhenClosed; + final startMinimizedToTray = + json['startMinimizedToTray'] as bool? ?? defaults.startMinimizedToTray; + final showTrayIcon = + (json['showTrayIcon'] as bool? ?? defaults.showTrayIcon) || + runInBackgroundWhenClosed || + startMinimizedToTray; return AppSettings( themeFamily: _enumFromName( BusyMaxThemeFamily.values, @@ -134,26 +153,19 @@ class AppSettings { defaults.notifyEventReminders, notifyTaskReminders: json['notifyTaskReminders'] as bool? ?? defaults.notifyTaskReminders, - runInBackgroundWhenClosed: - json['runInBackgroundWhenClosed'] as bool? ?? - defaults.runInBackgroundWhenClosed, - showTrayIcon: json['showTrayIcon'] as bool? ?? defaults.showTrayIcon, - startMinimizedToTray: - json['startMinimizedToTray'] as bool? ?? - defaults.startMinimizedToTray, + runInBackgroundWhenClosed: runInBackgroundWhenClosed, + showTrayIcon: showTrayIcon, + startMinimizedToTray: startMinimizedToTray, quitExitsCompletely: json['quitExitsCompletely'] as bool? ?? defaults.quitExitsCompletely, notificationDetailLevel: notificationDetailLevel, quietHoursEnabled: json['quietHoursEnabled'] as bool? ?? defaults.quietHoursEnabled, - quietHoursStart: - json['quietHoursStart']?.toString() ?? defaults.quietHoursStart, - quietHoursEnd: - json['quietHoursEnd']?.toString() ?? defaults.quietHoursEnd, + quietHoursStart: quietHoursStart, + quietHoursEnd: quietHoursEnd, redactTaskContentInDiagnostics: json['redactTaskContentInDiagnostics'] as bool? ?? defaults.redactTaskContentInDiagnostics, - detailedNotifications: detailedNotifications, lastDueTodayNotificationDate: json['lastDueTodayNotificationDate'] ?.toString(), taskListScheduleVisibility: _boolMap(json['taskListScheduleVisibility']), @@ -183,7 +195,6 @@ class AppSettings { final String quietHoursStart; final String quietHoursEnd; final bool redactTaskContentInDiagnostics; - final bool detailedNotifications; final String? lastDueTodayNotificationDate; final Map taskListScheduleVisibility; final ScheduleViewMode scheduleViewMode; @@ -210,7 +221,6 @@ class AppSettings { 'quietHoursStart': quietHoursStart, 'quietHoursEnd': quietHoursEnd, 'redactTaskContentInDiagnostics': redactTaskContentInDiagnostics, - 'detailedNotifications': detailedNotifications, 'lastDueTodayNotificationDate': lastDueTodayNotificationDate, 'taskListScheduleVisibility': taskListScheduleVisibility, 'scheduleViewMode': scheduleViewMode.name, @@ -236,7 +246,6 @@ class AppSettings { String? quietHoursStart, String? quietHoursEnd, bool? redactTaskContentInDiagnostics, - bool? detailedNotifications, String? lastDueTodayNotificationDate, Map? taskListScheduleVisibility, ScheduleViewMode? scheduleViewMode, @@ -273,8 +282,6 @@ class AppSettings { quietHoursEnd: quietHoursEnd ?? this.quietHoursEnd, redactTaskContentInDiagnostics: redactTaskContentInDiagnostics ?? this.redactTaskContentInDiagnostics, - detailedNotifications: - detailedNotifications ?? this.detailedNotifications, lastDueTodayNotificationDate: clearLastDueTodayNotificationDate ? null : lastDueTodayNotificationDate ?? this.lastDueTodayNotificationDate, @@ -332,9 +339,15 @@ class JsonFileLocalSettingsStore implements LocalSettingsStore { typedef _AppSettingsMutation = AppSettings Function(AppSettings current); class AppSettingsController extends StateNotifier { - AppSettingsController(this._store) : super(AppSettings.defaults()) { + AppSettingsController(this._store, {AppSettings? initialSettings}) + : super(initialSettings ?? AppSettings.defaults()) { _persistenceState = state; - _loadFuture = _load(); + if (initialSettings == null) { + _loadFuture = _load(); + } else { + _loadComplete = true; + _loadFuture = Future.value(); + } _writeTail = _loadFuture; } @@ -413,17 +426,31 @@ class AppSettingsController extends StateNotifier { Future setRunInBackgroundWhenClosed(bool enabled) { return _mutate( - (current) => current.copyWith(runInBackgroundWhenClosed: enabled), + (current) => current.copyWith( + runInBackgroundWhenClosed: enabled, + showTrayIcon: enabled ? true : current.showTrayIcon, + ), ); } Future setShowTrayIcon(bool enabled) { - return _mutate((current) => current.copyWith(showTrayIcon: enabled)); + return _mutate( + (current) => current.copyWith( + showTrayIcon: enabled, + runInBackgroundWhenClosed: enabled + ? current.runInBackgroundWhenClosed + : false, + startMinimizedToTray: enabled ? current.startMinimizedToTray : false, + ), + ); } Future setStartMinimizedToTray(bool enabled) { return _mutate( - (current) => current.copyWith(startMinimizedToTray: enabled), + (current) => current.copyWith( + startMinimizedToTray: enabled, + showTrayIcon: enabled ? true : current.showTrayIcon, + ), ); } @@ -433,10 +460,7 @@ class AppSettingsController extends StateNotifier { Future setNotificationDetailLevel(NotificationDetailLevel level) { return _mutate( - (current) => current.copyWith( - notificationDetailLevel: level, - detailedNotifications: level != NotificationDetailLevel.private, - ), + (current) => current.copyWith(notificationDetailLevel: level), ); } @@ -444,20 +468,29 @@ class AppSettingsController extends StateNotifier { return _mutate((current) => current.copyWith(quietHoursEnabled: enabled)); } - Future setRedactTaskContentInDiagnostics(bool enabled) { - return _mutate( - (current) => current.copyWith(redactTaskContentInDiagnostics: enabled), - ); + Future setQuietHoursStart(String time) { + return _mutate((current) { + final normalized = _normalizedTimeOfDay(time, current.quietHoursStart); + if (normalized == current.quietHoursEnd) { + return current; + } + return current.copyWith(quietHoursStart: normalized); + }); + } + + Future setQuietHoursEnd(String time) { + return _mutate((current) { + final normalized = _normalizedTimeOfDay(time, current.quietHoursEnd); + if (normalized == current.quietHoursStart) { + return current; + } + return current.copyWith(quietHoursEnd: normalized); + }); } - Future setDetailedNotifications(bool enabled) { + Future setRedactTaskContentInDiagnostics(bool enabled) { return _mutate( - (current) => current.copyWith( - detailedNotifications: enabled, - notificationDetailLevel: enabled - ? NotificationDetailLevel.normal - : NotificationDetailLevel.private, - ), + (current) => current.copyWith(redactTaskContentInDiagnostics: enabled), ); } @@ -480,12 +513,7 @@ class AppSettingsController extends StateNotifier { } Future _load() async { - AppSettings loaded; - try { - loaded = AppSettings.fromJson(await _store.load()); - } on Object { - loaded = AppSettings.defaults(); - } + final loaded = await loadInitialAppSettings(_store); _persistenceState = loaded; var merged = loaded; @@ -547,9 +575,14 @@ final localSettingsStoreProvider = Provider( (ref) => const JsonFileLocalSettingsStore(), ); +final initialAppSettingsProvider = Provider((ref) => null); + final appSettingsControllerProvider = StateNotifierProvider((ref) { - return AppSettingsController(ref.watch(localSettingsStoreProvider)); + return AppSettingsController( + ref.watch(localSettingsStoreProvider), + initialSettings: ref.watch(initialAppSettingsProvider), + ); }); final busyMaxThemeControllerProvider = Provider((ref) { @@ -558,16 +591,47 @@ final busyMaxThemeControllerProvider = Provider((ref) { ); }); +Future loadInitialAppSettings(LocalSettingsStore store) async { + try { + return AppSettings.fromJson(await store.load()); + } on Object { + return AppSettings.defaults(); + } +} + T _enumFromName(List values, Object? name, T fallback) { + return _enumFromNameOrNull(values, name) ?? fallback; +} + +T? _enumFromNameOrNull(List values, Object? name) { if (name == null) { - return fallback; + return null; } for (final value in values) { if (value.name == name.toString()) { return value; } } - return fallback; + return null; +} + +String _normalizedTimeOfDay(Object? value, String fallback) { + final parts = value?.toString().trim().split(':') ?? const []; + if (parts.length != 2) { + return fallback; + } + final hour = int.tryParse(parts[0]); + final minute = int.tryParse(parts[1]); + if (hour == null || + minute == null || + hour < 0 || + hour > 23 || + minute < 0 || + minute > 59) { + return fallback; + } + return '${hour.toString().padLeft(2, '0')}:' + '${minute.toString().padLeft(2, '0')}'; } int _minuteOfDay(Object? value, int fallback, {bool allowEndOfDay = false}) { diff --git a/lib/src/app/app_theme.dart b/lib/src/app/app_theme.dart index b319eff..e920bac 100644 --- a/lib/src/app/app_theme.dart +++ b/lib/src/app/app_theme.dart @@ -11,14 +11,21 @@ ThemeData buildBusyMaxTheme({ String? gtkFontFamily, double? gtkFontSize, GtkThemeColors? gtkThemeColors, + bool highContrast = false, }) { + final effectiveAccentColor = highContrast + ? brightness == Brightness.dark + ? Colors.white + : Colors.black + : accentColor; return switch (family) { BusyMaxThemeFamily.yaru => BusyMaxYaruTheme.build( brightness: brightness, - accentColor: accentColor, + accentColor: effectiveAccentColor, gtkFontFamily: gtkFontFamily, gtkFontSize: gtkFontSize, gtkThemeColors: gtkThemeColors, + highContrast: highContrast, ), }; } diff --git a/lib/src/app/busymax_app.dart b/lib/src/app/busymax_app.dart index 719577b..e35eff2 100644 --- a/lib/src/app/busymax_app.dart +++ b/lib/src/app/busymax_app.dart @@ -104,6 +104,24 @@ class _BusyMaxAppState extends ConsumerState { gtkFontSize: gtkFont?.size, gtkThemeColors: gtkThemeColors, ), + highContrastTheme: buildBusyMaxTheme( + brightness: Brightness.light, + accentColor: accentColor, + family: settings.themeFamily, + gtkFontFamily: gtkFont?.family, + gtkFontSize: gtkFont?.size, + gtkThemeColors: gtkThemeColors, + highContrast: true, + ), + highContrastDarkTheme: buildBusyMaxTheme( + brightness: Brightness.dark, + accentColor: accentColor, + family: settings.themeFamily, + gtkFontFamily: gtkFont?.family, + gtkFontSize: gtkFont?.size, + gtkThemeColors: gtkThemeColors, + highContrast: true, + ), themeMode: settings.themeMode, localizationsDelegates: const [ ...AppLocalizations.localizationsDelegates, @@ -150,7 +168,9 @@ class _BusyMaxAppState extends ConsumerState { ), _OpenSettingsIntent: CallbackAction<_OpenSettingsIntent>( onInvoke: (intent) { - router.go('/settings'); + if (router.state.uri.path != '/settings') { + unawaited(router.push('/settings')); + } return null; }, ), @@ -185,6 +205,8 @@ class _BusyMaxAppState extends ConsumerState { agenda: l10n.viewAgenda, search: materialL10n.searchFieldLabel, create: l10n.create, + createEvent: l10n.createEventAtTime, + createTask: l10n.createTaskAtDate, refresh: l10n.refreshAll, menu: l10n.mainMenu, previous: materialL10n.previousPageTooltip, @@ -217,6 +239,7 @@ class _BusyMaxAppState extends ConsumerState { accentForegroundColor: colorScheme.onPrimary, popoverBackgroundColor: colors.popover, borderColor: colors.border, + sidebarBorderColor: colors.sidebarBorder, shadeColor: colors.shade, modalBarrierColor: modalBarrierColor, ), @@ -357,7 +380,7 @@ class _BusyMaxWindowCornerClip extends StatelessWidget { borderRadius: const BorderRadius.vertical( bottom: Radius.circular(BusyMaxRadius.window), ), - clipBehavior: Clip.antiAliasWithSaveLayer, + clipBehavior: Clip.antiAlias, child: ColoredBox( color: BusyMaxSurfaceColors.of(context).window, child: child, diff --git a/lib/src/app/busymax_design.dart b/lib/src/app/busymax_design.dart index 42e0ea6..47e1d76 100644 --- a/lib/src/app/busymax_design.dart +++ b/lib/src/app/busymax_design.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:ui' as ui; import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; @@ -34,9 +35,11 @@ abstract final class BusyMaxSizes { static const double detailsWidth = 700; static const double compactDetailsWidth = 700; static const double toolbarHeight = kYaruTitleBarHeight; - static const double pushButtonWidth = 136; - static const double pushButtonHeight = 36; - static const Size pushButtonSize = Size(pushButtonWidth, pushButtonHeight); + static const double pushButtonHeight = kYaruButtonHeight; + static final Size pushButtonSize = Size( + kPushButtonSize.width, + pushButtonHeight, + ); static const double sidebarRowHeight = 36; static const double taskRowMinHeight = 48; static const double iconSm = 16; @@ -55,12 +58,16 @@ abstract final class BusyMaxSizes { } abstract final class BusyMaxElevation { - static const double surface = 1; + static const double card = 2; static const double popover = 6; static const double tooltip = 10; static const double window = 12; } +abstract final class BusyMaxStroke { + static const double outline = 1; +} + abstract final class BusyMaxAlpha { static const double modalBarrier = 0.32; } @@ -187,21 +194,31 @@ class BusyMaxPopoverSurface extends StatelessWidget { @override Widget build(BuildContext context) { final arrowHeight = BusyMaxSizes.popoverArrowHeight; + final alignment = arrowAlignment.clamp(0.0, 1.0).toDouble(); + final clipper = _BusyMaxPopoverClipper( + side: arrowSide, + alignment: alignment, + ); return PhysicalShape( - clipper: _BusyMaxPopoverClipper( - side: arrowSide, - alignment: arrowAlignment.clamp(0.0, 1.0).toDouble(), - ), + clipper: clipper, color: color, elevation: BusyMaxElevation.tooltip, shadowColor: BusyMaxShadow.physicalColor(context), clipBehavior: Clip.antiAlias, - child: Padding( - padding: EdgeInsets.only( - top: arrowSide == BusyMaxPopoverArrowSide.top ? arrowHeight : 0, - bottom: arrowSide == BusyMaxPopoverArrowSide.bottom ? arrowHeight : 0, + child: CustomPaint( + foregroundPainter: _BusyMaxPopoverOutlinePainter( + clipper: clipper, + color: BusyMaxSurfaceColors.of(context).subtleBorder, + ), + child: Padding( + padding: EdgeInsets.only( + top: arrowSide == BusyMaxPopoverArrowSide.top ? arrowHeight : 0, + bottom: arrowSide == BusyMaxPopoverArrowSide.bottom + ? arrowHeight + : 0, + ), + child: Padding(padding: padding, child: child), ), - child: Padding(padding: padding, child: child), ), ); } @@ -232,21 +249,22 @@ class _BusyMaxPopoverClipper extends CustomClipper { .clamp(minArrowCenter, maxArrowCenter) .toDouble(); - final path = Path()..addRRect(body); + final bodyPath = Path()..addRRect(body); + final arrowPath = Path(); if (side == BusyMaxPopoverArrowSide.top) { - path + arrowPath ..moveTo(arrowCenter - arrowWidth / 2, bodyTop) ..lineTo(arrowCenter, 0) ..lineTo(arrowCenter + arrowWidth / 2, bodyTop) ..close(); } else { - path + arrowPath ..moveTo(arrowCenter - arrowWidth / 2, bodyBottom) ..lineTo(arrowCenter, size.height) ..lineTo(arrowCenter + arrowWidth / 2, bodyBottom) ..close(); } - return path; + return Path.combine(PathOperation.union, bodyPath, arrowPath); } @override @@ -255,6 +273,36 @@ class _BusyMaxPopoverClipper extends CustomClipper { } } +class _BusyMaxPopoverOutlinePainter extends CustomPainter { + const _BusyMaxPopoverOutlinePainter({ + required this.clipper, + required this.color, + }); + + final _BusyMaxPopoverClipper clipper; + final Color color; + + @override + void paint(Canvas canvas, Size size) { + canvas.drawPath( + clipper.getClip(size), + Paint() + ..color = color + ..style = PaintingStyle.stroke + // PhysicalShape clips its child to the same path. Drawing a double + // width leaves one semantic outline pixel inside that clip. + ..strokeWidth = BusyMaxStroke.outline * 2, + ); + } + + @override + bool shouldRepaint(covariant _BusyMaxPopoverOutlinePainter oldDelegate) { + return oldDelegate.color != color || + oldDelegate.clipper.side != clipper.side || + oldDelegate.clipper.alignment != clipper.alignment; + } +} + class BusyMaxCircularAction extends StatelessWidget { const BusyMaxCircularAction({ super.key, @@ -496,13 +544,11 @@ ButtonStyle busyMaxDropdownMenuItemStyle(BuildContext context) { } ButtonStyle busyMaxPushButtonStyle(ButtonStyle? style) { - return const ButtonStyle( - fixedSize: WidgetStatePropertyAll( + return ButtonStyle( + fixedSize: const WidgetStatePropertyAll( Size.fromHeight(BusyMaxSizes.pushButtonHeight), ), minimumSize: WidgetStatePropertyAll(BusyMaxSizes.pushButtonSize), - padding: WidgetStatePropertyAll(EdgeInsets.symmetric(horizontal: 12)), - tapTargetSize: MaterialTapTargetSize.shrinkWrap, ).merge(style); } @@ -519,36 +565,113 @@ ButtonStyle busyMaxHeaderPushButtonStyle(ButtonStyle? style) { ).merge(style); } -abstract final class BusyMaxPushButton { - static PushButton elevated({ - required Widget child, - required VoidCallback? onPressed, - VoidCallback? onLongPress, - ValueChanged? onHover, - ValueChanged? onFocusChange, - ButtonStyle? style, - FocusNode? focusNode, - bool autofocus = false, - Clip clipBehavior = Clip.none, - WidgetStatesController? statesController, - Key? key, - }) { - return PushButton.elevated( - key: key, - onPressed: onPressed, - onLongPress: onLongPress, - onHover: onHover, - onFocusChange: onFocusChange, - style: busyMaxPushButtonStyle(style), - focusNode: focusNode, - autofocus: autofocus, - clipBehavior: clipBehavior, - statesController: statesController, - child: child, +/// BusyMax's cross-platform fallback for a native desktop search entry. +/// +/// Linux header bars use `GtkSearchEntry`. Flutter-owned layouts delegate +/// geometry, icons, and interaction states to Yaru instead of restyling a raw +/// [TextField]. +class BusyMaxSearchField extends StatefulWidget { + const BusyMaxSearchField({ + super.key, + this.controller, + this.hintText, + this.autofocus = false, + this.focusRequest = 0, + this.onChanged, + this.onSubmitted, + this.onClear, + this.clearButtonSemanticLabel, + }); + + final TextEditingController? controller; + final String? hintText; + final bool autofocus; + + /// Increment this value to focus the Yaru-owned text entry again. + final int focusRequest; + + final ValueChanged? onChanged; + final ValueChanged? onSubmitted; + final VoidCallback? onClear; + final String? clearButtonSemanticLabel; + + @override + State createState() => _BusyMaxSearchFieldState(); +} + +class _BusyMaxSearchFieldState extends State { + final _focusScopeNode = FocusScopeNode( + debugLabel: 'BusyMaxSearchField scope', + ); + final _yaruKeyboardFocusNode = FocusNode( + debugLabel: 'BusyMaxSearchField keyboard listener', + skipTraversal: true, + ); + + @override + void initState() { + super.initState(); + if (widget.autofocus) { + _requestTextFocus(); + } + } + + @override + void didUpdateWidget(covariant BusyMaxSearchField oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.focusRequest != widget.focusRequest) { + _requestTextFocus(); + } + } + + @override + void dispose() { + _focusScopeNode.dispose(); + _yaruKeyboardFocusNode.dispose(); + super.dispose(); + } + + void _requestTextFocus() { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) { + return; + } + // Yaru's public focus node belongs to its keyboard listener. Keep that + // wrapper out of traversal and focus the first Yaru-owned control, + // which is the actual text entry. + for (final node in _focusScopeNode.traversalDescendants) { + if (node.canRequestFocus) { + node.requestFocus(); + return; + } + } + }); + } + + @override + Widget build(BuildContext context) { + return FocusScope( + node: _focusScopeNode, + child: YaruSearchField( + controller: widget.controller, + focusNode: _yaruKeyboardFocusNode, + hintText: widget.hintText, + autofocus: widget.autofocus, + onChanged: widget.onChanged, + onSubmitted: widget.onSubmitted, + onClear: widget.onClear, + clearIconSemanticLabel: + widget.clearButtonSemanticLabel ?? + MaterialLocalizations.of(context).clearButtonTooltip, + ), ); } +} - static PushButton filled({ +abstract final class BusyMaxPushButton { + /// A neutral desktop action. Yaru renders this with its standard filled + /// control surface and native interaction geometry. + static PushButton standard({ required Widget child, required VoidCallback? onPressed, VoidCallback? onLongPress, @@ -576,7 +699,9 @@ abstract final class BusyMaxPushButton { ); } - static PushButton outlined({ + /// A suggested action. Yaru reserves the accent-filled elevated role for + /// the single preferred action in a group. + static PushButton suggested({ required Widget child, required VoidCallback? onPressed, VoidCallback? onLongPress, @@ -589,7 +714,7 @@ abstract final class BusyMaxPushButton { WidgetStatesController? statesController, Key? key, }) { - return PushButton.outlined( + return PushButton.elevated( key: key, onPressed: onPressed, onLongPress: onLongPress, @@ -606,7 +731,7 @@ abstract final class BusyMaxPushButton { } abstract final class BusyMaxHeaderPushButton { - static PushButton filled({ + static PushButton standard({ required Widget child, required VoidCallback? onPressed, VoidCallback? onLongPress, @@ -634,7 +759,7 @@ abstract final class BusyMaxHeaderPushButton { ); } - static PushButton outlined({ + static PushButton suggested({ required Widget child, required VoidCallback? onPressed, VoidCallback? onLongPress, @@ -647,7 +772,7 @@ abstract final class BusyMaxHeaderPushButton { WidgetStatesController? statesController, Key? key, }) { - return PushButton.outlined( + return PushButton.elevated( key: key, onPressed: onPressed, onLongPress: onLongPress, @@ -675,11 +800,12 @@ Color busyMaxHoverBackground(BuildContext context) { ); } +Color busyMaxRowHoverColor(BuildContext context) { + return Theme.of(context).hoverColor; +} + Color busyMaxEditorRowHoverColor(BuildContext context) { - final surfaceColors = BusyMaxSurfaceColors.of(context); - return surfaceColors.foreground.withValues( - alpha: Theme.of(context).brightness == Brightness.dark ? 0.045 : 0.055, - ); + return busyMaxRowHoverColor(context); } Color busyMaxModalBarrierColor(BuildContext context) { @@ -815,7 +941,7 @@ class BusyMaxSurface extends StatelessWidget { final surfaceColors = BusyMaxSurfaceColors.of(context); return Material( color: filled ? color ?? surfaceColors.card : Colors.transparent, - elevation: filled ? BusyMaxElevation.surface : 0, + elevation: filled ? BusyMaxElevation.card : 0, shadowColor: BusyMaxShadow.physicalColor(context), surfaceTintColor: Colors.transparent, shape: RoundedRectangleBorder(borderRadius: borderRadius, side: side), @@ -847,6 +973,32 @@ class BusyMaxGroupedSurface extends StatelessWidget { } } +class BusyMaxSidebarSurface extends StatelessWidget { + const BusyMaxSidebarSurface({super.key, required this.child}); + + final Widget child; + + @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, + ), + ), + ), + child: child, + ), + ); + } +} + class _BusyMaxGroupedListSurface extends StatelessWidget { const _BusyMaxGroupedListSurface({ required this.filled, @@ -957,7 +1109,7 @@ class _BusyMaxActionRowState extends State { trailing: widget.trailing, enabled: widget.enabled, autofocus: widget.autofocus, - hoverColor: widget.hoverColor, + hoverColor: widget.hoverColor ?? busyMaxRowHoverColor(context), onTap: interactive ? _activate : null, ); @@ -1594,6 +1746,7 @@ class BusyMaxComboRow extends StatelessWidget { required this.labelFor, required this.onSelected, this.subtitle, + this.errorText, this.leading, this.enabled = true, this.tooltip, @@ -1609,6 +1762,7 @@ class BusyMaxComboRow extends StatelessWidget { final String Function(T value) labelFor; final ValueChanged onSelected; final String? subtitle; + final String? errorText; final Widget? leading; final bool enabled; final String? tooltip; @@ -1619,67 +1773,145 @@ class BusyMaxComboRow extends StatelessWidget { @override Widget build(BuildContext context) { - final row = YaruListTile.square( - leading: leading, - titleText: title, - subtitle: subtitle == null ? null : Text(subtitle!), - trailing: Row( - mainAxisSize: MainAxisSize.min, - children: [ - SizedBox( - width: width, - child: MenuButtonBuilder( - selected: selected, - values: values, - menuPosition: PopupMenuPosition.under, - decoration: busyMaxDropdownDecoration(), - style: busyMaxDropdownButtonStyle(context), - menuStyle: busyMaxDropdownMenuStyle(context, minWidth: width), - itemStyle: busyMaxDropdownMenuItemStyle(context), - itemBuilder: (context, value, _) => - menuItemBuilder?.call(context, value) ?? - Text(labelFor(value)), - onSelected: enabled ? onSelected : null, - child: - selectedBuilder?.call(context, selected) ?? - Text(labelFor(selected), overflow: TextOverflow.ellipsis), + return LayoutBuilder( + builder: (context, constraints) { + final hasError = errorText?.isNotEmpty ?? false; + final subtitleWidget = hasError + ? Semantics( + liveRegion: true, + child: Text( + errorText!, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context).colorScheme.error, + ), + ), + ) + : subtitle == null + ? null + : Text(subtitle!); + final textScale = MediaQuery.textScalerOf(context).scale(14) / 14; + final actionAllowance = trailingAction == null + ? 0.0 + : BusyMaxSizes.headerIconButton + BusyMaxSpacing.xs; + final stackControl = + !constraints.hasBoundedWidth || + constraints.maxWidth < 560 || + textScale > 1.2; + final availableWidth = constraints.hasBoundedWidth + ? constraints.maxWidth + : width + BusyMaxSpacing.md * 2 + actionAllowance; + final maximumInlineSelectorWidth = (availableWidth * 0.46) + .clamp(120.0, double.infinity) + .toDouble(); + final selectorWidth = stackControl + ? (availableWidth - BusyMaxSpacing.md * 2 - actionAllowance) + .clamp(120.0, double.infinity) + .toDouble() + : constraints.hasBoundedWidth + ? width.clamp(120.0, maximumInlineSelectorWidth).toDouble() + : width.clamp(120.0, double.infinity).toDouble(); + final selector = SizedBox( + width: selectorWidth, + child: MenuButtonBuilder( + selected: selected, + values: values, + menuPosition: PopupMenuPosition.under, + decoration: busyMaxDropdownDecoration(), + style: busyMaxDropdownButtonStyle(context), + menuStyle: busyMaxDropdownMenuStyle( + context, + minWidth: selectorWidth, ), + itemStyle: busyMaxDropdownMenuItemStyle(context), + itemBuilder: (context, value, _) => + menuItemBuilder?.call(context, value) ?? Text(labelFor(value)), + onSelected: enabled ? onSelected : null, + child: + selectedBuilder?.call(context, selected) ?? + Text(labelFor(selected), overflow: TextOverflow.ellipsis), ), - if (trailingAction != null) ...[ - const SizedBox(width: BusyMaxSpacing.xs), - trailingAction!, + ); + final trailing = Row( + mainAxisSize: MainAxisSize.min, + children: [ + selector, + if (trailingAction != null) ...[ + const SizedBox(width: BusyMaxSpacing.xs), + trailingAction!, + ], ], - ], - ), - enabled: enabled, - ); - - if (enabled) { - return row; - } + ); + final row = stackControl + ? Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + YaruListTile.square( + leading: leading, + titleText: title, + subtitle: subtitleWidget, + enabled: enabled, + ), + Padding( + padding: const EdgeInsets.fromLTRB( + BusyMaxSpacing.md, + 0, + BusyMaxSpacing.md, + BusyMaxSpacing.md, + ), + child: trailing, + ), + ], + ) + : YaruListTile.square( + leading: leading, + titleText: title, + subtitle: subtitleWidget, + trailing: trailing, + enabled: enabled, + ); + // YaruListTile deliberately expands its title inside a Row, so it + // requires a finite horizontal constraint. If dialog content is + // measured unbounded, use the compact stacked form and only reserve + // the selector's requested width plus the tile padding. + final boundedRow = constraints.hasBoundedWidth + ? row + : SizedBox(width: availableWidth, child: row); + final validatedRow = hasError + ? Semantics( + container: true, + validationResult: ui.SemanticsValidationResult.invalid, + child: boundedRow, + ) + : boundedRow; + + if (enabled) { + return validatedRow; + } - final disabledRow = Semantics( - container: true, - button: true, - enabled: false, - label: subtitle == null || subtitle!.isEmpty - ? title - : '$title, $subtitle', - value: labelFor(selected), - child: ExcludeSemantics( - child: Opacity( - opacity: 0.6, - child: ExcludeFocus(child: IgnorePointer(child: row)), - ), - ), + final disabledRow = Semantics( + container: true, + button: true, + enabled: false, + label: subtitle == null || subtitle!.isEmpty + ? title + : '$title, $subtitle', + value: labelFor(selected), + child: ExcludeSemantics( + child: Opacity( + opacity: 0.6, + child: ExcludeFocus(child: IgnorePointer(child: validatedRow)), + ), + ), + ); + return tooltip == null + ? disabledRow + : Tooltip( + message: tooltip!, + excludeFromSemantics: true, + child: disabledRow, + ); + }, ); - return tooltip == null - ? disabledRow - : Tooltip( - message: tooltip!, - excludeFromSemantics: true, - child: disabledRow, - ); } } @@ -1710,6 +1942,7 @@ class BusyMaxSwitchRow extends StatelessWidget { title: Text(title), subtitle: subtitle == null ? null : Text(subtitle!), shape: const RoundedRectangleBorder(), + hoverColor: busyMaxRowHoverColor(context), ); } } @@ -1735,7 +1968,11 @@ class BusyMaxMenuEntry { } typedef BusyMaxMenuTriggerBuilder = - Widget Function(BuildContext context, VoidCallback onPressed); + Widget Function( + BuildContext context, + VoidCallback? onPressed, + FocusNode focusNode, + ); class BusyMaxMenuButton extends StatefulWidget { const BusyMaxMenuButton({ @@ -1747,6 +1984,8 @@ class BusyMaxMenuButton extends StatefulWidget { this.minMenuWidth = 180, this.menuPosition = const Offset(0, BusyMaxSizes.headerIconButton), this.triggerBuilder, + this.controller, + this.enabled = true, }); final String tooltip; @@ -1756,25 +1995,49 @@ class BusyMaxMenuButton extends StatefulWidget { final double minMenuWidth; final Offset? menuPosition; final BusyMaxMenuTriggerBuilder? triggerBuilder; + final MenuController? controller; + final bool enabled; @override State> createState() => _BusyMaxMenuButtonState(); } class _BusyMaxMenuButtonState extends State> { - final _controller = MenuController(); + final _internalController = MenuController(); + final _triggerFocusNode = FocusNode(debugLabel: 'BusyMax menu trigger'); + + MenuController get _controller => widget.controller ?? _internalController; + + @override + void didUpdateWidget(covariant BusyMaxMenuButton oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.enabled && !widget.enabled && _controller.isOpen) { + _controller.close(); + } + } + + @override + void dispose() { + _triggerFocusNode.dispose(); + super.dispose(); + } @override Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; return MenuAnchor( controller: _controller, + childFocusNode: _triggerFocusNode, crossAxisUnconstrained: false, style: busyMaxDropdownMenuStyle(context, minWidth: widget.minMenuWidth), builder: (context, controller, child) { final triggerBuilder = widget.triggerBuilder; if (triggerBuilder != null) { - return triggerBuilder(context, () => _toggleMenu(controller)); + return triggerBuilder( + context, + widget.enabled ? () => _toggleMenu(controller) : null, + _triggerFocusNode, + ); } return YaruIconButton( tooltip: widget.tooltip, @@ -1786,7 +2049,8 @@ class _BusyMaxMenuButtonState extends State> { ), child: widget.icon, ), - onPressed: () => _toggleMenu(controller), + focusNode: _triggerFocusNode, + onPressed: widget.enabled ? () => _toggleMenu(controller) : null, style: busyMaxHeaderIconButtonStyle( foregroundColor: colorScheme.onSurfaceVariant, backgroundColor: busyMaxSubtleButtonBackground(context), @@ -1990,7 +2254,7 @@ class BusyMaxToolbarButton extends StatelessWidget { required this.label, required this.tooltip, required this.onPressed, - this.primary = false, + this.suggested = false, this.compact = false, }); @@ -1998,7 +2262,7 @@ class BusyMaxToolbarButton extends StatelessWidget { final String label; final String tooltip; final VoidCallback? onPressed; - final bool primary; + final bool suggested; final bool compact; @override @@ -2019,9 +2283,9 @@ class BusyMaxToolbarButton extends StatelessWidget { Text(label), ], ); - final button = primary - ? BusyMaxPushButton.filled(onPressed: onPressed, child: child) - : BusyMaxPushButton.outlined(onPressed: onPressed, child: child); + final button = suggested + ? BusyMaxPushButton.suggested(onPressed: onPressed, child: child) + : BusyMaxPushButton.standard(onPressed: onPressed, child: child); return Tooltip(message: tooltip, child: button); } } @@ -2057,7 +2321,7 @@ class BusyMaxEditorHeader extends StatelessWidget { ), child: Row( children: [ - BusyMaxHeaderPushButton.outlined( + BusyMaxHeaderPushButton.standard( onPressed: cancelEnabled ? onCancel : null, child: Text(cancelLabel, overflow: TextOverflow.ellipsis), ), @@ -2070,7 +2334,7 @@ class BusyMaxEditorHeader extends StatelessWidget { style: Theme.of(context).textTheme.titleMedium, ), ), - BusyMaxHeaderPushButton.filled( + BusyMaxHeaderPushButton.suggested( onPressed: onSave, child: saving ? const SizedBox.square( @@ -2098,68 +2362,56 @@ class BusyMaxTimeModeRow extends StatelessWidget { @override Widget build(BuildContext context) { final l10n = context.l10n; - return Padding( - padding: const EdgeInsets.all(BusyMaxSpacing.xs), - child: Row( - children: [ - Expanded( - child: _BusyMaxTimeModeButton( - label: l10n.allDay, - selected: allDay, - onPressed: () => onChanged(true), - ), - ), - const SizedBox(width: BusyMaxSpacing.xs), - Expanded( - child: _BusyMaxTimeModeButton( - label: l10n.timeSlot, - selected: !allDay, - onPressed: () => onChanged(false), - ), + final selector = ToggleButtons( + isSelected: [allDay, !allDay], + onPressed: (index) { + final value = index == 0; + if (value != allDay) { + onChanged(value); + } + }, + children: [ + for (final label in [l10n.allDay, l10n.timeSlot]) + Padding( + padding: const EdgeInsets.symmetric(horizontal: BusyMaxSpacing.md), + child: Text(label), ), - ], - ), + ], ); - } -} -class _BusyMaxTimeModeButton extends StatelessWidget { - const _BusyMaxTimeModeButton({ - required this.label, - required this.selected, - required this.onPressed, - }); - - final String label; - final bool selected; - final VoidCallback onPressed; - - @override - Widget build(BuildContext context) { - final colorScheme = Theme.of(context).colorScheme; - final surfaceColors = BusyMaxSurfaceColors.of(context); - final borderRadius = BorderRadius.circular(BusyMaxRadius.headerButton); - return Material( - color: selected ? surfaceColors.controlHover : Colors.transparent, - borderRadius: borderRadius, - child: InkWell( - borderRadius: borderRadius, - onTap: selected ? null : onPressed, - child: SizedBox( - height: BusyMaxSizes.pushButtonHeight, - child: Center( - child: Text( - label, - style: Theme.of(context).textTheme.labelLarge?.copyWith( - color: selected - ? colorScheme.onSurface - : colorScheme.onSurfaceVariant, - fontWeight: FontWeight.w600, + return LayoutBuilder( + builder: (context, constraints) { + final textScale = MediaQuery.textScalerOf(context).scale(14) / 14; + final stackSelector = + !constraints.hasBoundedWidth || + constraints.maxWidth < 480 || + textScale > 1.2; + final label = YaruListTile.square( + title: Text(l10n.timeMode), + subtitle: Text(l10n.timeModeDescription), + trailing: stackSelector ? null : selector, + ); + if (!stackSelector) { + return label; + } + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + label, + Padding( + padding: const EdgeInsetsDirectional.only( + start: BusyMaxSpacing.md, + end: BusyMaxSpacing.md, + bottom: BusyMaxSpacing.md, + ), + child: Align( + alignment: AlignmentDirectional.centerEnd, + child: selector, ), ), - ), - ), - ), + ], + ); + }, ); } } @@ -2190,35 +2442,41 @@ class BusyMaxModalEditorScaffold extends StatelessWidget { @override Widget build(BuildContext context) { - return Column( - mainAxisSize: MainAxisSize.min, - children: [ - BusyMaxEditorHeader( - title: title, - cancelLabel: cancelLabel, - saveLabel: saveLabel, - onCancel: onCancel, - onSave: onSave, - saving: saving, - cancelEnabled: cancelEnabled, - ), - const SizedBox(height: BusyMaxSpacing.headerInset), - Flexible( - child: SingleChildScrollView( - child: BusyMaxClamp( - maxWidth: contentMaxWidth, - margin: EdgeInsets.zero, - padding: const EdgeInsets.symmetric( - horizontal: BusyMaxSpacing.lg, - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: children, + return Semantics( + scopesRoute: true, + namesRoute: true, + explicitChildNodes: true, + label: title, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + BusyMaxEditorHeader( + title: title, + cancelLabel: cancelLabel, + saveLabel: saveLabel, + onCancel: onCancel, + onSave: onSave, + saving: saving, + cancelEnabled: cancelEnabled, + ), + const SizedBox(height: BusyMaxSpacing.headerInset), + Flexible( + child: SingleChildScrollView( + child: BusyMaxClamp( + maxWidth: contentMaxWidth, + margin: EdgeInsets.zero, + padding: const EdgeInsets.symmetric( + horizontal: BusyMaxSpacing.lg, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: children, + ), ), ), ), - ), - ], + ], + ), ); } } @@ -2302,8 +2560,14 @@ class BusyMaxModalEditorSurface extends StatelessWidget { child: Material( color: surfaceColors.dialog, surfaceTintColor: Colors.transparent, + elevation: BusyMaxElevation.window, + shadowColor: BusyMaxShadow.physicalColor(context), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(BusyMaxRadius.lg), + side: BorderSide( + color: surfaceColors.subtleBorder, + width: BusyMaxStroke.outline, + ), ), clipBehavior: Clip.antiAlias, child: child, @@ -2312,30 +2576,6 @@ class BusyMaxModalEditorSurface extends StatelessWidget { } } -class BusyMaxTooltipOnlyDisabled extends StatelessWidget { - const BusyMaxTooltipOnlyDisabled({ - super.key, - required this.enabled, - required this.tooltip, - required this.child, - }); - - final bool enabled; - final String tooltip; - final Widget child; - - @override - Widget build(BuildContext context) { - if (enabled) { - return child; - } - return Tooltip( - message: tooltip, - child: Opacity(opacity: 0.6, child: IgnorePointer(child: child)), - ); - } -} - class BusyMaxInlineBadge extends StatelessWidget { const BusyMaxInlineBadge({super.key, required this.label, this.tooltip}); @@ -2378,40 +2618,46 @@ class BusyMaxDialogShell extends StatelessWidget { @override Widget build(BuildContext context) { - return Dialog( - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(BusyMaxRadius.lg), - ), - child: ClipRRect( - borderRadius: BorderRadius.circular(BusyMaxRadius.lg), - child: ConstrainedBox( - constraints: BoxConstraints(maxWidth: maxWidth), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - YaruDialogTitleBar(title: Text(title), centerTitle: true), - Flexible( - child: SingleChildScrollView( - padding: const EdgeInsets.all(BusyMaxSpacing.lg), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: children, + return Semantics( + scopesRoute: true, + namesRoute: true, + explicitChildNodes: true, + label: title, + child: Dialog( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(BusyMaxRadius.lg), + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(BusyMaxRadius.lg), + child: ConstrainedBox( + constraints: BoxConstraints(maxWidth: maxWidth), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + YaruDialogTitleBar(title: Text(title), centerTitle: true), + Flexible( + child: SingleChildScrollView( + padding: const EdgeInsets.all(BusyMaxSpacing.lg), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: children, + ), ), ), - ), - if (actions.isNotEmpty) - Padding( - padding: const EdgeInsets.all(BusyMaxSpacing.lg), - child: OverflowBar( - alignment: MainAxisAlignment.end, - spacing: BusyMaxSpacing.sm, - overflowSpacing: BusyMaxSpacing.sm, - children: actions, + if (actions.isNotEmpty) + Padding( + padding: const EdgeInsets.all(BusyMaxSpacing.lg), + child: OverflowBar( + alignment: MainAxisAlignment.end, + spacing: BusyMaxSpacing.sm, + overflowSpacing: BusyMaxSpacing.sm, + children: actions, + ), ), - ), - ], + ], + ), ), ), ), @@ -2454,11 +2700,11 @@ class _BusyMaxPromptDialogState extends State { title: widget.title, maxWidth: 420, actions: [ - BusyMaxPushButton.outlined( + BusyMaxPushButton.standard( onPressed: () => Navigator.of(context).pop(), child: Text(context.l10n.cancel), ), - BusyMaxPushButton.filled( + BusyMaxPushButton.suggested( onPressed: () => Navigator.of(context).pop(_value), child: Text(widget.actionLabel), ), @@ -2501,13 +2747,13 @@ class BusyMaxConfirmDialog extends StatelessWidget { title: title, maxWidth: 460, actions: [ - BusyMaxPushButton.outlined( + BusyMaxPushButton.standard( onPressed: () => Navigator.of(context).pop(false), child: Text(context.l10n.cancel), ), - BusyMaxPushButton.filled( + BusyMaxPushButton.suggested( style: destructive - ? FilledButton.styleFrom( + ? ElevatedButton.styleFrom( backgroundColor: colorScheme.error, foregroundColor: colorScheme.onError, ) diff --git a/lib/src/app/busymax_dialogs.dart b/lib/src/app/busymax_dialogs.dart index 6e114f4..d1f5cb8 100644 --- a/lib/src/app/busymax_dialogs.dart +++ b/lib/src/app/busymax_dialogs.dart @@ -1,6 +1,8 @@ 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'; @@ -10,6 +12,22 @@ const _modalShortcuts = { BusyMaxShortcutActivators.settings: DoNothingAndStopPropagationIntent(), }; +/// Prevents application-level navigation shortcuts from escaping a modal +/// surface while preserving shortcuts owned by that surface's descendants. +/// +/// Use this for modal UI that is not presented by [showBusyMaxModalDialog], +/// such as anchored popovers and in-page editor overlays. +class BusyMaxModalShortcutBoundary extends StatelessWidget { + const BusyMaxModalShortcutBoundary({super.key, required this.child}); + + final Widget child; + + @override + Widget build(BuildContext context) { + return Shortcuts(shortcuts: _modalShortcuts, child: child); + } +} + final _modalDepths = Map.identity(); Future showBusyMaxModalDialog( @@ -19,10 +37,12 @@ Future showBusyMaxModalDialog( Color? barrierColor, bool barrierDismissible = true, }) async { + final effectiveHeaderBarService = + headerBarService ?? _headerBarServiceFrom(context); final previousFocus = FocusManager.instance.primaryFocus; - await acquireBusyMaxModalBarrier(headerBarService); + await acquireBusyMaxModalBarrier(effectiveHeaderBarService); if (!context.mounted) { - await releaseBusyMaxModalBarrier(headerBarService); + await releaseBusyMaxModalBarrier(effectiveHeaderBarService); return null; } @@ -33,10 +53,10 @@ Future showBusyMaxModalDialog( barrierDismissible: barrierDismissible, traversalEdgeBehavior: TraversalEdgeBehavior.closedLoop, builder: (dialogContext) => - Shortcuts(shortcuts: _modalShortcuts, child: builder(dialogContext)), + BusyMaxModalShortcutBoundary(child: builder(dialogContext)), ); } finally { - await releaseBusyMaxModalBarrier(headerBarService); + await releaseBusyMaxModalBarrier(effectiveHeaderBarService); if (previousFocus?.context != null && previousFocus!.canRequestFocus) { previousFocus.requestFocus(); } @@ -53,6 +73,7 @@ Future showBusyMaxModalEditorDialog( return showBusyMaxModalDialog( context, headerBarService: headerBarService, + barrierDismissible: false, builder: (dialogContext) { final reduceMotion = MediaQuery.disableAnimationsOf(dialogContext); return Dialog( @@ -88,6 +109,7 @@ Future showBusyMaxTextPrompt( context, headerBarService: headerBarService, barrierColor: barrierColor, + barrierDismissible: false, builder: (dialogContext) => BusyMaxPromptDialog( title: title, label: label, @@ -149,3 +171,16 @@ Future releaseBusyMaxModalBarrier(LinuxHeaderBarService? service) async { } _modalDepths[service] = depth - 1; } + +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_surface_colors.dart b/lib/src/app/busymax_surface_colors.dart index 25ba651..92332aa 100644 --- a/lib/src/app/busymax_surface_colors.dart +++ b/lib/src/app/busymax_surface_colors.dart @@ -194,7 +194,7 @@ BusyMaxSurfaceColors busyMaxFallbackSurfaceColors(Brightness brightness) { disabledControl: Color.fromRGBO(255, 255, 255, 0.06), border: Color.fromRGBO(0, 0, 6, 0.75), subtleBorder: Color.fromRGBO(255, 255, 255, 0.10), - sidebarBorder: Color.fromRGBO(0, 0, 6, 0.36), + sidebarBorder: Color.fromRGBO(255, 255, 255, 0.10), shade: Color.fromRGBO(0, 0, 6, 0.25), ), }; diff --git a/lib/src/app/busymax_yaru_theme.dart b/lib/src/app/busymax_yaru_theme.dart index cab84a4..d7a8d7f 100644 --- a/lib/src/app/busymax_yaru_theme.dart +++ b/lib/src/app/busymax_yaru_theme.dart @@ -1,5 +1,3 @@ -import 'dart:math' as math; - import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:yaru/theme.dart'; @@ -10,6 +8,8 @@ import 'busymax_surface_colors.dart'; export 'busymax_surface_colors.dart'; +const _minimumRaisedSurfaceContrast = 1.08; + abstract final class BusyMaxLinuxPalette { static const blueAccent = Color(0xFF3584E4); static const ubuntuBlueAccent = Color(0xFF0073E5); @@ -29,17 +29,9 @@ abstract final class BusyMaxLinuxPalette { static const ubuntuWartyBrownAccent = Color(0xFFB39169); static const red3 = Color(0xFFE01B24); static const red5 = Color(0xFFA51D2D); - static const light1 = Color(0xFFFFFFFF); static const light2 = Color(0xFFF6F5F4); - static const light3 = Color(0xFFDEDDDA); static const light4 = Color(0xFFC0BFBC); - static const light5 = Color(0xFF9A9996); - static const dark1 = Color(0xFF77767B); - static const dark2 = Color(0xFF5E5C64); - static const dark3 = Color(0xFF3D3846); - static const dark4 = Color(0xFF241F31); static const dark5 = Color(0xFF000000); - static const darkElevatedSurface = Color(0xFF383838); } class BusyMaxYaruTheme { @@ -51,19 +43,22 @@ class BusyMaxYaruTheme { String? gtkFontFamily, double? gtkFontSize, GtkThemeColors? gtkThemeColors, + bool highContrast = false, }) { final base = switch (brightness) { - Brightness.light => createYaruLightTheme( - primaryColor: BusyMaxLinuxPalette.light4, - ), + Brightness.light => createYaruLightTheme(primaryColor: accentColor), Brightness.dark => createYaruDarkTheme( - primaryColor: BusyMaxLinuxPalette.light2, + primaryColor: accentColor, + highContrast: highContrast, ), }; - final colors = _BusyMaxResolvedSurfaceColors( + final resolvedColors = _BusyMaxResolvedSurfaceColors( brightness, gtkThemeColors: gtkThemeColors, ).colors; + final colors = highContrast + ? _highContrastSurfaceColors(brightness) + : resolvedColors; final onAccent = contrastColor(accentColor); final accentContainer = Color.alphaBlend( accentColor.withValues( @@ -78,7 +73,9 @@ class BusyMaxYaruTheme { primaryContainer: accentContainer, onPrimaryContainer: contrastColor(accentContainer), secondary: accentColor, - error: brightness == Brightness.dark + error: highContrast + ? base.colorScheme.error + : brightness == Brightness.dark ? BusyMaxLinuxPalette.red3 : BusyMaxLinuxPalette.red5, surface: colors.view, @@ -93,9 +90,6 @@ class BusyMaxYaruTheme { outlineVariant: colors.subtleBorder, scrim: BusyMaxLinuxPalette.dark5, ); - final buttonShape = RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), - ); final inputBorder = OutlineInputBorder( borderSide: BorderSide(color: colors.border), borderRadius: BorderRadius.circular(6), @@ -152,44 +146,34 @@ class BusyMaxYaruTheme { fallback: textTheme.bodySmall, ), ); - final outlinedButtonStyle = _buttonStyle( + final outlinedButtonStyle = _semanticButtonStyle( base.outlinedButtonTheme.style, - shape: buttonShape, foreground: colors.foreground, - background: colors.control, + background: Colors.transparent, disabledForeground: colors.disabledForeground, - disabledBackground: colors.disabledControl, + disabledBackground: Colors.transparent, textStyle: _normalizeTextStyleProperty( base.outlinedButtonTheme.style?.textStyle, normalizer: normalizer, fallback: textTheme.labelLarge, ), - side: WidgetStateProperty.resolveWith((states) { - if (states.contains(WidgetState.focused)) { - return BorderSide(color: accentColor); - } - return BorderSide.none; - }), ); - final filledButtonStyle = _buttonStyle( + final filledButtonStyle = _semanticButtonStyle( base.filledButtonTheme.style, - shape: buttonShape, - foreground: onAccent, - background: accentColor, + foreground: colors.foreground, + background: colors.control, disabledForeground: colors.disabledForeground, disabledBackground: colors.disabledControl, - overlayColor: _onAccentOverlay(onAccent), textStyle: _normalizeTextStyleProperty( base.filledButtonTheme.style?.textStyle, normalizer: normalizer, fallback: textTheme.labelLarge, ), ); - final elevatedButtonStyle = _buttonStyle( + final elevatedButtonStyle = _semanticButtonStyle( base.elevatedButtonTheme.style, - shape: buttonShape, - foreground: colors.foreground, - background: colors.control, + foreground: onAccent, + background: accentColor, disabledForeground: colors.disabledForeground, disabledBackground: colors.disabledControl, textStyle: _normalizeTextStyleProperty( @@ -197,58 +181,32 @@ class BusyMaxYaruTheme { normalizer: normalizer, fallback: textTheme.labelLarge, ), - ).copyWith(elevation: const WidgetStatePropertyAll(0)); - final textButtonStyle = _buttonStyle( + ); + final textButtonStyle = _semanticButtonStyle( base.textButtonTheme.style, - shape: buttonShape, foreground: accentColor, background: Colors.transparent, disabledForeground: colors.disabledForeground, disabledBackground: Colors.transparent, - overlayColor: _accentOverlay(accentColor), textStyle: _normalizeTextStyleProperty( base.textButtonTheme.style?.textStyle, normalizer: normalizer, fallback: textTheme.labelLarge, ), ); - final segmentedButtonStyle = - _buttonStyle( - base.segmentedButtonTheme.style, - shape: buttonShape, - foreground: colors.foreground, - background: colors.control, - disabledForeground: colors.disabledForeground, - disabledBackground: colors.disabledControl, - overlayColor: _accentOverlay(accentColor), - textStyle: _normalizeTextStyleProperty( - base.segmentedButtonTheme.style?.textStyle, - normalizer: normalizer, - fallback: textTheme.labelLarge, - ), - side: WidgetStateProperty.resolveWith((states) { - if (states.contains(WidgetState.selected)) { - return BorderSide(color: accentColor); - } - return BorderSide(color: colors.border); - }), - ).copyWith( - foregroundColor: WidgetStateProperty.resolveWith((states) { - if (states.contains(WidgetState.disabled)) { - return colors.disabledForeground; - } - if (states.contains(WidgetState.selected)) { - return accentColor; - } - return colors.foreground; - }), - backgroundColor: WidgetStateProperty.resolveWith((states) { - if (states.contains(WidgetState.selected)) { - return accentContainer; - } - return colors.control; - }), - ); + final toggleButtonsTheme = base.toggleButtonsTheme.copyWith( + color: colors.foreground, + selectedColor: colors.foreground, + disabledColor: colors.disabledForeground, + fillColor: colors.controlActive, + borderColor: colors.border, + selectedBorderColor: colors.border, + disabledBorderColor: colors.disabledForeground, + hoverColor: colors.controlHover, + highlightColor: colors.controlActive, + splashColor: colors.controlHover, + focusColor: colors.controlActive, + ); return base.copyWith( brightness: brightness, @@ -264,11 +222,6 @@ class BusyMaxYaruTheme { colors, ], dividerColor: colors.subtleBorder, - visualDensity: VisualDensity.compact, - splashFactory: NoSplash.splashFactory, - focusColor: accentColor.withValues(alpha: 0.18), - hoverColor: colors.controlHover, - splashColor: accentColor.withValues(alpha: 0.12), appBarTheme: base.appBarTheme.copyWith( elevation: 0, scrolledUnderElevation: 0, @@ -325,14 +278,12 @@ class BusyMaxYaruTheme { elevatedButtonTheme: ElevatedButtonThemeData(style: elevatedButtonStyle), textButtonTheme: TextButtonThemeData(style: textButtonStyle), iconButtonTheme: IconButtonThemeData( - style: _buttonStyle( + style: _semanticButtonStyle( base.iconButtonTheme.style, - shape: buttonShape, - foreground: colors.mutedForeground, + foreground: colors.foreground, background: Colors.transparent, disabledForeground: colors.disabledForeground, disabledBackground: Colors.transparent, - overlayColor: _accentOverlay(accentColor), textStyle: _normalizeTextStyleProperty( base.iconButtonTheme.style?.textStyle, normalizer: normalizer, @@ -391,9 +342,7 @@ class BusyMaxYaruTheme { return colors.mutedForeground; }), ), - segmentedButtonTheme: SegmentedButtonThemeData( - style: segmentedButtonStyle, - ), + toggleButtonsTheme: toggleButtonsTheme, popupMenuTheme: base.popupMenuTheme.copyWith( color: colors.popover, surfaceTintColor: colors.popover, @@ -407,7 +356,12 @@ class BusyMaxYaruTheme { fallback: textTheme.bodyMedium, color: colors.foreground, ), - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + side: highContrast + ? BorderSide(color: colors.border) + : BorderSide.none, + ), ), chipTheme: base.chipTheme.copyWith( labelStyle: normalizer.apply( @@ -423,6 +377,7 @@ class BusyMaxYaruTheme { decoration: BoxDecoration( color: colors.popover, borderRadius: BorderRadius.circular(BusyMaxRadius.headerButton), + border: highContrast ? Border.all(color: colors.border) : null, boxShadow: BusyMaxShadow.tooltipShadows(colors.shade), ), padding: const EdgeInsets.symmetric( @@ -542,6 +497,43 @@ class BusyMaxYaruTheme { } } +BusyMaxSurfaceColors _highContrastSurfaceColors(Brightness brightness) { + final background = brightness == Brightness.dark + ? Colors.black + : Colors.white; + final foreground = contrastColor(background); + Color layer(double opacity) => + Color.alphaBlend(foreground.withValues(alpha: opacity), background); + + // High contrast is an accessibility contract, so it intentionally uses one + // coherent palette instead of retaining GTK surfaces that may have mixed + // luminance. Normal themes continue to preserve every valid GTK role. + return BusyMaxSurfaceColors( + window: background, + view: background, + sidebar: background, + secondarySidebar: background, + headerbar: background, + headerbarFlat: background, + card: background, + groupedSurface: background, + dialog: background, + popover: background, + control: layer(0.10), + controlHover: layer(0.18), + controlActive: layer(0.28), + activeToggle: layer(0.22), + foreground: foreground, + mutedForeground: foreground, + disabledForeground: layer(0.55), + disabledControl: layer(0.06), + border: foreground, + subtleBorder: foreground, + sidebarBorder: foreground, + shade: Colors.black, + ); +} + class _TextStyleNormalizer { _TextStyleNormalizer({String? gtkFontFamily, double? gtkFontSize}) : _gtkFontFamily = _validFontFamily(gtkFontFamily), @@ -620,94 +612,190 @@ class _BusyMaxResolvedSurfaceColors { return fallback; } - final window = - _runtimeSurfaceColor(runtime.window, brightness: brightness) ?? + // GTK surface colors may be translucent CSS layers. Resolve them against + // their semantic parent instead of rejecting valid native theme colors or + // trying to infer whether a theme's hue is aesthetically acceptable. + final sampledWindow = + _runtimeSurfaceColor(runtime.window, over: fallback.window) ?? fallback.window; - final runtimeSidebar = _runtimeSurfaceColor( - runtime.sidebar, - brightness: brightness, - ); - final runtimeSecondarySidebar = _runtimeSurfaceColor( - runtime.secondarySidebar, - brightness: brightness, + final sampledView = + _runtimeSurfaceColor(runtime.view, over: sampledWindow) ?? + fallback.view; + final sampledSidebar = + _runtimeSurfaceColor(runtime.sidebar, over: sampledWindow) ?? + _runtimeSurfaceColor(runtime.secondarySidebar, over: sampledWindow) ?? + _runtimeSurfaceColor(runtime.headerbar, over: sampledWindow) ?? + fallback.sidebar; + final sampledSecondarySidebar = + _runtimeSurfaceColor(runtime.secondarySidebar, over: sampledWindow) ?? + sampledSidebar; + final sampledHeaderbar = + _runtimeSurfaceColor(runtime.headerbar, over: sampledWindow) ?? + sampledWindow; + final sampledHeaderbarFlat = + _runtimeSurfaceColor(runtime.headerbarFlat, over: sampledView) ?? + sampledView; + final runtimeCard = _runtimeSurfaceColor(runtime.card, over: sampledView); + final sampledCard = runtimeCard ?? fallback.card; + final sampledDialog = + _runtimeSurfaceColor(runtime.dialog, over: sampledWindow) ?? + sampledCard; + final sampledPopover = + _runtimeSurfaceColor(runtime.popover, over: sampledWindow) ?? + sampledCard; + final sampledBackgrounds = [ + sampledWindow, + sampledView, + sampledSidebar, + sampledSecondarySidebar, + sampledHeaderbar, + sampledHeaderbarFlat, + sampledCard, + sampledDialog, + sampledPopover, + ]; + final foreground = + _runtimeReadableColor( + runtime.foreground, + backgrounds: sampledBackgrounds, + ) ?? + fallback.foreground; + + Color readableSurface(Color sampled, Color fallbackSurface) { + return _contrastRatio(foreground, sampled) >= 4.5 + ? sampled + : fallbackSurface; + } + + // BusyMax currently has one generic foreground role. Preserve each GTK + // surface independently when that role remains readable, and fall back + // only the conflicting role for mixed-luminance themes. + final window = readableSurface(sampledWindow, fallback.window); + final view = readableSurface(sampledView, fallback.view); + final sidebar = readableSurface(sampledSidebar, fallback.sidebar); + final secondarySidebar = readableSurface( + sampledSecondarySidebar, + fallback.secondarySidebar, ); - final runtimeView = _runtimeSurfaceColor( - runtime.view, - brightness: brightness, + final headerbar = readableSurface(sampledHeaderbar, fallback.headerbar); + final headerbarFlat = readableSurface( + sampledHeaderbarFlat, + fallback.headerbarFlat, ); - final runtimeHeaderbar = _runtimeSurfaceColor( - runtime.headerbar, + final card = readableSurface(sampledCard, fallback.card); + final dialog = readableSurface(sampledDialog, fallback.dialog); + final popover = readableSurface(sampledPopover, fallback.popover); + final groupedSurface = _resolvedGroupedSurface( + runtimeCard, brightness: brightness, + view: view, + foreground: foreground, + fallback: fallback.groupedSurface, ); - final runtimeHeaderbarFlat = _runtimeSurfaceColor( - runtime.headerbarFlat, + final sidebarBorder = _resolvedSidebarBorder( + runtime.sidebarBorder, brightness: brightness, + sidebar: sidebar, + fallback: fallback.sidebarBorder, ); - final runtimeCard = _runtimeSurfaceColor( - runtime.card, - brightness: brightness, + final readableBackgrounds = [ + window, + view, + sidebar, + secondarySidebar, + headerbar, + headerbarFlat, + card, + dialog, + popover, + ]; + final mutedForeground = _resolvedReadableColor( + runtime.mutedForeground, + fallback: fallback.mutedForeground, + guaranteed: foreground, + backgrounds: readableBackgrounds, + minContrast: 3, ); - final runtimePopover = _runtimeElevatedSurfaceColor( - runtime.popover, - brightness: brightness, + final disabledForeground = _resolvedReadableColor( + runtime.disabledForeground, + fallback: fallback.disabledForeground, + guaranteed: foreground, + backgrounds: readableBackgrounds, + minContrast: 1.5, ); - final view = runtimeView ?? fallback.view; - final sidebar = - _firstDistinctSemanticColor( - [runtimeSidebar, runtimeSecondarySidebar, runtimeHeaderbar], - from: [view, window], - ) ?? - _derivedSidebarColor(brightness, view); - final groupedSurface = - runtimePopover ?? - runtimeCard ?? - (brightness == Brightness.light ? view : fallback.groupedSurface); - final readableBackgrounds = [window, view, sidebar, groupedSurface]; return fallback.copyWith( window: window, view: view, sidebar: sidebar, - secondarySidebar: runtimeSecondarySidebar, - headerbar: runtimeHeaderbar, - headerbarFlat: runtimeHeaderbarFlat ?? view, - card: runtimeCard, + secondarySidebar: secondarySidebar, + headerbar: headerbar, + headerbarFlat: headerbarFlat, + card: card, groupedSurface: groupedSurface, - dialog: _runtimeSurfaceColor(runtime.dialog, brightness: brightness), - popover: runtimePopover, - control: _runtimeControlColor(runtime.control, brightness: brightness), - controlHover: _runtimeControlColor( - runtime.controlHover, - brightness: brightness, - ), - controlActive: _runtimeControlColor( - runtime.controlActive, - brightness: brightness, - ), + dialog: dialog, + popover: popover, + control: _runtimeColor(runtime.control), + controlHover: _runtimeColor(runtime.controlHover), + controlActive: _runtimeColor(runtime.controlActive), activeToggle: _runtimeColor(runtime.activeToggle), - foreground: _runtimeReadableColor( - runtime.foreground, - backgrounds: readableBackgrounds, - ), - mutedForeground: _runtimeReadableColor( - runtime.mutedForeground, - backgrounds: readableBackgrounds, - minContrast: 3.0, - ), - disabledForeground: _runtimeReadableColor( - runtime.disabledForeground, - backgrounds: readableBackgrounds, - minContrast: 1.5, - ), + foreground: foreground, + mutedForeground: mutedForeground, + disabledForeground: disabledForeground, disabledControl: _runtimeColor(runtime.disabledControl), border: _runtimeColor(runtime.border), subtleBorder: _runtimeColor(runtime.subtleBorder), - sidebarBorder: _runtimeColor(runtime.sidebarBorder), - shade: _runtimeShadeColor(runtime.shade), + sidebarBorder: sidebarBorder, + shade: _runtimeShadeColor(runtime.shade, over: popover), ); } } +Color _resolvedGroupedSurface( + Color? runtimeCard, { + required Brightness brightness, + required Color view, + required Color foreground, + required Color fallback, +}) { + if (runtimeCard == null || _contrastRatio(foreground, runtimeCard) < 4.5) { + return fallback; + } + if (brightness == Brightness.dark) { + // GTK 3 themes without a card role can return the underlying view color + // for an arbitrary `.card` sample. Dark grouped content needs a genuinely + // raised surface; otherwise its border and shadow disappear into the view. + final isRaised = + runtimeCard.computeLuminance() > view.computeLuminance() && + _contrastRatio(runtimeCard, view) >= _minimumRaisedSurfaceContrast; + if (!isRaised) { + return fallback; + } + } + return runtimeCard; +} + +Color _resolvedSidebarBorder( + Color? runtimeBorder, { + required Brightness brightness, + required Color sidebar, + required Color fallback, +}) { + final candidate = _runtimeColor(runtimeBorder); + if (candidate == null || brightness != Brightness.dark) { + return candidate ?? fallback; + } + final effective = candidate.a < 1 + ? Color.alphaBlend(candidate, sidebar) + : candidate; + // A dark separator sampled from GTK's generic `borders` token becomes a + // heavy inset edge on a dark sidebar. Retain native light separators and + // use the semantic fallback when the sample is visually recessed. + return effective.computeLuminance() < sidebar.computeLuminance() + ? fallback + : candidate; +} + Color? _runtimeColor(Color? color) { if (color == null || color.a <= 0) { return null; @@ -715,15 +803,13 @@ Color? _runtimeColor(Color? color) { return color; } -Color? _runtimeShadeColor(Color? color) { +Color? _runtimeShadeColor(Color? color, {required Color over}) { final runtime = _runtimeColor(color); if (runtime == null) { return null; } - if (runtime.computeLuminance() > 0.35) { - return null; - } - return runtime; + final shaded = Color.alphaBlend(runtime, over); + return shaded.computeLuminance() < over.computeLuminance() ? runtime : null; } Color? _runtimeReadableColor( @@ -743,6 +829,26 @@ Color? _runtimeReadableColor( return runtime; } +Color _resolvedReadableColor( + Color? runtime, { + required Color fallback, + required Color guaranteed, + required Iterable backgrounds, + required double minContrast, +}) { + return _runtimeReadableColor( + runtime, + backgrounds: backgrounds, + minContrast: minContrast, + ) ?? + _runtimeReadableColor( + fallback, + backgrounds: backgrounds, + minContrast: minContrast, + ) ?? + guaranteed; +} + double _contrastRatio(Color foreground, Color background) { final effectiveForeground = foreground.a < 1 ? Color.alphaBlend(foreground, background) @@ -758,122 +864,12 @@ double _contrastRatio(Color foreground, Color background) { return (lighter + 0.05) / (darker + 0.05); } -Color? _runtimeSurfaceColor(Color? color, {Brightness? brightness}) { +Color? _runtimeSurfaceColor(Color? color, {required Color over}) { final runtime = _runtimeColor(color); if (runtime == null) { return null; } - if (runtime.a < 0.98 || _isNearBlackSurface(runtime)) { - return null; - } - if (brightness == Brightness.dark && _isTintedDarkSurface(runtime)) { - return null; - } - return runtime; -} - -Color? _runtimeControlColor(Color? color, {required Brightness brightness}) { - final runtime = _runtimeColor(color); - if (runtime == null) { - return null; - } - if (_isChromaticControlColor(runtime)) { - return null; - } - if (brightness == Brightness.dark && - runtime.a >= 0.98 && - _isTintedDarkSurface(runtime)) { - return null; - } - return runtime; -} - -Color? _runtimeElevatedSurfaceColor( - Color? color, { - required Brightness brightness, -}) { - final runtime = _runtimeSurfaceColor(color, brightness: brightness); - if (runtime == null) { - return null; - } - if (brightness == Brightness.dark && _isTooDarkElevatedSurface(runtime)) { - return null; - } - return runtime; -} - -bool _isTooDarkElevatedSurface(Color color) { - final value = color.toARGB32(); - final red = (value >> 16) & 0xff; - final green = (value >> 8) & 0xff; - final blue = value & 0xff; - return red < 0x38 || green < 0x38 || blue < 0x38; -} - -bool _isNearBlackSurface(Color color) { - final value = color.toARGB32(); - final red = (value >> 16) & 0xff; - final green = (value >> 8) & 0xff; - final blue = value & 0xff; - return red <= 24 && green <= 24 && blue <= 24; -} - -bool _isTintedDarkSurface(Color color) { - return _isBlueDominantColor(color); -} - -bool _isBlueDominantColor(Color color) { - final value = color.toARGB32(); - final red = (value >> 16) & 0xff; - final green = (value >> 8) & 0xff; - final blue = value & 0xff; - final maxChannel = math.max(red, math.max(green, blue)); - final minChannel = math.min(red, math.min(green, blue)); - return blue == maxChannel && maxChannel - minChannel >= 10; -} - -bool _isChromaticControlColor(Color color) { - final value = color.toARGB32(); - final red = (value >> 16) & 0xff; - final green = (value >> 8) & 0xff; - final blue = value & 0xff; - final maxChannel = math.max(red, math.max(green, blue)); - final minChannel = math.min(red, math.min(green, blue)); - return maxChannel - minChannel >= 10; -} - -Color? _firstDistinctSemanticColor( - Iterable candidates, { - required Iterable from, -}) { - for (final candidate in candidates) { - if (candidate == null) { - continue; - } - final duplicatesExisting = from.any( - (existing) => _sameSemanticColor(candidate, existing), - ); - if (!duplicatesExisting) { - return candidate; - } - } - return null; -} - -Color _derivedSidebarColor(Brightness brightness, Color base) { - final overlay = brightness == Brightness.dark - ? Colors.white.withValues(alpha: 0.055) - : Colors.black.withValues(alpha: 0.045); - return Color.alphaBlend(overlay, base); -} - -bool _sameSemanticColor(Color left, Color right) { - final leftValue = left.toARGB32(); - final rightValue = right.toARGB32(); - return ((leftValue >> 24) & 0xff) == ((rightValue >> 24) & 0xff) && - ((leftValue >> 16) & 0xff) == ((rightValue >> 16) & 0xff) && - ((leftValue >> 8) & 0xff) == ((rightValue >> 8) & 0xff) && - (leftValue & 0xff) == (rightValue & 0xff); + return runtime.a < 1 ? Color.alphaBlend(runtime, over) : runtime; } String? _validFontFamily(String? family) { @@ -898,21 +894,18 @@ WidgetStateProperty _normalizeTextStyleProperty( }); } -ButtonStyle _buttonStyle( +/// Applies runtime semantic colors and typography without replacing Yaru's +/// geometry, focus treatment, hover/press overlays, or motion defaults. +ButtonStyle _semanticButtonStyle( ButtonStyle? base, { - required OutlinedBorder shape, required Color foreground, required Color background, required Color disabledForeground, required Color disabledBackground, - WidgetStateProperty? overlayColor, - WidgetStateProperty? side, WidgetStateProperty? textStyle, }) { return (base ?? const ButtonStyle()).copyWith( - visualDensity: const VisualDensity(horizontal: -1, vertical: -1), textStyle: textStyle, - shape: WidgetStatePropertyAll(shape), foregroundColor: WidgetStateProperty.resolveWith((states) { if (states.contains(WidgetState.disabled)) { return disabledForeground; @@ -925,53 +918,5 @@ ButtonStyle _buttonStyle( } return background; }), - overlayColor: overlayColor ?? _controlOverlay(foreground), - side: side ?? const WidgetStatePropertyAll(BorderSide.none), - elevation: const WidgetStatePropertyAll(0), ); } - -WidgetStateProperty _controlOverlay(Color foreground) { - return WidgetStateProperty.resolveWith((states) { - if (states.contains(WidgetState.pressed)) { - return foreground.withValues(alpha: 0.14); - } - if (states.contains(WidgetState.hovered)) { - return foreground.withValues(alpha: 0.08); - } - if (states.contains(WidgetState.focused)) { - return foreground.withValues(alpha: 0.10); - } - return null; - }); -} - -WidgetStateProperty _accentOverlay(Color accentColor) { - return WidgetStateProperty.resolveWith((states) { - if (states.contains(WidgetState.pressed)) { - return accentColor.withValues(alpha: 0.14); - } - if (states.contains(WidgetState.hovered)) { - return accentColor.withValues(alpha: 0.08); - } - if (states.contains(WidgetState.focused)) { - return accentColor.withValues(alpha: 0.12); - } - return null; - }); -} - -WidgetStateProperty _onAccentOverlay(Color accentForeground) { - return WidgetStateProperty.resolveWith((states) { - if (states.contains(WidgetState.pressed)) { - return accentForeground.withValues(alpha: 0.14); - } - if (states.contains(WidgetState.hovered)) { - return accentForeground.withValues(alpha: 0.08); - } - if (states.contains(WidgetState.focused)) { - return accentForeground.withValues(alpha: 0.12); - } - return null; - }); -} diff --git a/lib/src/features/auth/presentation/sign_in_screen.dart b/lib/src/features/auth/presentation/sign_in_screen.dart index 6581b20..831802d 100644 --- a/lib/src/features/auth/presentation/sign_in_screen.dart +++ b/lib/src/features/auth/presentation/sign_in_screen.dart @@ -225,8 +225,10 @@ class _SignInScreenState extends ConsumerState { title: title, viewMode: ref.read(appSettingsControllerProvider).scheduleViewMode, canRefresh: false, - canCreate: false, + canCreateEvent: false, + canCreateTask: false, searchActive: false, + searchQuery: '', canShowSidebar: false, sidebarVisible: false, navigationVisible: false, @@ -458,7 +460,7 @@ class _AccountsOnboardingStep extends StatelessWidget { ], if (isSigningIn) ...[ const SizedBox(height: BusyMaxSpacing.md), - BusyMaxPushButton.outlined( + BusyMaxPushButton.standard( onPressed: onCancelSignIn, child: Row( mainAxisAlignment: MainAxisAlignment.center, @@ -632,11 +634,13 @@ class _PreferencesOnboardingStep extends StatelessWidget { title: l10n.privacy, filled: true, children: [ - BusyMaxSwitchRow( - title: l10n.detailedNotifications, - value: settings.detailedNotifications, - onChanged: settingsController.setDetailedNotifications, + BusyMaxComboRow( + title: l10n.notificationDetailLevel, leading: const Icon(YaruIcons.eye), + values: NotificationDetailLevel.values, + selected: settings.notificationDetailLevel, + labelFor: (value) => _notificationDetailLabel(context, value), + onSelected: settingsController.setNotificationDetailLevel, ), BusyMaxSwitchRow( title: l10n.redactTaskContentInDiagnostics, @@ -746,12 +750,12 @@ class _OnboardingFooter extends StatelessWidget { ), child: Row( children: [ - BusyMaxPushButton.outlined( + BusyMaxPushButton.standard( onPressed: canGoBack ? onBack : null, child: Text(backLabel), ), const Spacer(), - BusyMaxPushButton.filled( + BusyMaxPushButton.suggested( onPressed: canContinue ? onContinue : null, child: Text(continueLabel), ), @@ -801,3 +805,14 @@ String _themeModeLabel( BusyMaxThemeModePreference.dark => l10n.themeDark, }; } + +String _notificationDetailLabel( + BuildContext context, + NotificationDetailLevel level, +) { + final l10n = context.l10n; + return switch (level) { + NotificationDetailLevel.private => l10n.notificationDetailPrivate, + NotificationDetailLevel.normal => l10n.notificationDetailNormal, + }; +} diff --git a/lib/src/features/calendar/data/calendar_repository.dart b/lib/src/features/calendar/data/calendar_repository.dart index 80ac05d..dc8509f 100644 --- a/lib/src/features/calendar/data/calendar_repository.dart +++ b/lib/src/features/calendar/data/calendar_repository.dart @@ -64,6 +64,74 @@ class CalendarSourceEntity { final String? colorId; final String? timeZone; final String? accessRole; + + CalendarSourceCapabilities get capabilities => + CalendarSourceCapabilities.fromSource(this); +} + +/// The event operations currently permitted by a calendar source. +/// +/// Keeping this policy with the source model gives every presentation surface +/// and mutation entry point the same answer. Visibility is deliberately not a +/// write capability: a hidden calendar can still own an event that is opened +/// from a notification or deep link. +class CalendarSourceCapabilities { + const CalendarSourceCapabilities({ + required this.canCreateEvents, + required this.canEditEvents, + required this.canDeleteEvents, + }); + + factory CalendarSourceCapabilities.fromSource(CalendarSourceEntity source) { + final writable = !source.readOnly && !source.isDeleted; + return CalendarSourceCapabilities( + canCreateEvents: writable, + canEditEvents: writable, + canDeleteEvents: writable, + ); + } + + static const unavailable = CalendarSourceCapabilities( + canCreateEvents: false, + canEditEvents: false, + canDeleteEvents: false, + ); + + final bool canCreateEvents; + final bool canEditEvents; + final bool canDeleteEvents; +} + +enum CalendarMutationOperation { + createEvent, + editEvent, + deleteEvent, + renameCalendar, + deleteCalendar, +} + +class CalendarMutationNotAllowed implements Exception { + const CalendarMutationNotAllowed({ + required this.operation, + required this.sourceId, + }); + + final CalendarMutationOperation operation; + final String sourceId; + + @override + String toString() { + return 'CalendarMutationNotAllowed(${operation.name}, source: $sourceId)'; + } +} + +List writableCalendarSources( + Iterable sources, +) { + return [ + for (final source in sources) + if (source.capabilities.canCreateEvents) source, + ]; } class CalendarRepository { @@ -89,7 +157,9 @@ class CalendarRepository { return Stream.value(const []); } final query = _database.select(_database.calendarSources) - ..where((row) => row.accountId.isIn(accountIds)) + ..where( + (row) => row.accountId.isIn(accountIds) & row.isDeleted.equals(false), + ) ..orderBy([ (row) => OrderingTerm.asc(row.accountId), (row) => OrderingTerm.asc(row.summary), @@ -139,6 +209,10 @@ class CalendarRepository { final source = await (_database.select( _database.calendarSources, )..where((row) => row.id.equals(sourceId))).getSingle(); + _requireWritableSource( + source, + operation: CalendarMutationOperation.renameCalendar, + ); final now = _now().millisecondsSinceEpoch; final nowUtc = DateTime.now().toUtc().toIso8601String(); await _database.transaction(() async { @@ -173,6 +247,10 @@ class CalendarRepository { final source = await (_database.select( _database.calendarSources, )..where((row) => row.id.equals(sourceId))).getSingle(); + _requireWritableSource( + source, + operation: CalendarMutationOperation.deleteCalendar, + ); final now = _now().millisecondsSinceEpoch; final nowUtc = DateTime.now().toUtc().toIso8601String(); await _database.transaction(() async { @@ -415,6 +493,17 @@ class CalendarRepository { final source = await (_database.select( _database.calendarSources, )..where((row) => row.id.equals(draft.sourceId))).getSingle(); + _requireWritableSource( + source, + operation: CalendarMutationOperation.createEvent, + ); + if (source.accountId != draft.accountId || + source.providerCalendarId != draft.providerCalendarId) { + throw CalendarMutationNotAllowed( + operation: CalendarMutationOperation.createEvent, + sourceId: source.id, + ); + } final now = _now().millisecondsSinceEpoch; final provider = TaskProviderParsing.fromStorageValue(source.provider); final localEventId = 'local:${const Uuid().v4()}'; @@ -521,6 +610,10 @@ class CalendarRepository { final existing = await (_database.select( _database.calendarEvents, )..where((row) => row.id.equals(eventId))).getSingle(); + _requireWritableSource( + source, + operation: CalendarMutationOperation.editEvent, + ); final sourceChanged = draft.accountId != existing.accountId || draft.sourceId != existing.calendarSourceId || @@ -669,6 +762,13 @@ class CalendarRepository { final existing = await (_database.select( _database.calendarEvents, )..where((row) => row.id.equals(eventId))).getSingle(); + final source = await (_database.select( + _database.calendarSources, + )..where((row) => row.id.equals(existing.calendarSourceId))).getSingle(); + _requireWritableSource( + source, + operation: CalendarMutationOperation.deleteEvent, + ); final now = _now().millisecondsSinceEpoch; await _database.transaction(() async { await (_database.update( @@ -851,6 +951,16 @@ class CalendarRepository { } } +void _requireWritableSource( + CalendarSource source, { + required CalendarMutationOperation operation, +}) { + if (!source.readOnly && !source.isDeleted) { + return; + } + throw CalendarMutationNotAllowed(operation: operation, sourceId: source.id); +} + String? _json(Object? value) => value == null ? null : jsonEncode(value); Map _eventRequest( diff --git a/lib/src/features/calendar/presentation/event_editor.dart b/lib/src/features/calendar/presentation/event_editor.dart index 6ac8c41..f9b46e4 100644 --- a/lib/src/features/calendar/presentation/event_editor.dart +++ b/lib/src/features/calendar/presentation/event_editor.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:yaru/yaru.dart'; @@ -32,6 +34,7 @@ Future showBusyMaxEventEditorDialog( initialDraft: initialDraft, sources: sources, categorySuggestionsByAccount: categorySuggestionsByAccount, + headerBarService: headerBarService, onCancel: () => Navigator.of(context).pop(), onSave: (draft) => Navigator.of(context).pop(EventEditorDialogResult.save(draft)), @@ -69,6 +72,7 @@ class EventEditor extends StatefulWidget { required this.onSave, this.onDelete, this.categorySuggestionsByAccount = const {}, + this.headerBarService, }); final EventEditorDraft initialDraft; @@ -77,6 +81,7 @@ class EventEditor extends StatefulWidget { final VoidCallback onCancel; final ValueChanged onSave; final ValueChanged? onDelete; + final LinuxHeaderBarService? headerBarService; @override State createState() => _EventEditorState(); @@ -90,6 +95,7 @@ class _EventEditorState extends State { String? _guestError; var _addingGuest = false; var _addingCategory = false; + var _confirmingCancel = false; @override void initState() { @@ -123,6 +129,9 @@ class _EventEditorState extends State { final canSave = dirty && _draft.canSave; return CallbackShortcuts( bindings: { + const SingleActivator(LogicalKeyboardKey.escape): () { + unawaited(_cancel()); + }, const SingleActivator(LogicalKeyboardKey.keyS, control: true): () { if (canSave) { widget.onSave(_draft); @@ -137,7 +146,7 @@ class _EventEditorState extends State { title: title, cancelLabel: l10n.cancel, saveLabel: l10n.save, - onCancel: widget.onCancel, + onCancel: () => unawaited(_cancel()), onSave: canSave ? () => widget.onSave(_draft) : null, children: [ BusyMaxGroupedList( @@ -314,6 +323,34 @@ class _EventEditorState extends State { ); } + Future _cancel() async { + if (_confirmingCancel) { + return; + } + if (_draft == widget.initialDraft) { + widget.onCancel(); + return; + } + + _confirmingCancel = true; + try { + final discard = await showBusyMaxConfirm( + context, + title: context.l10n.discardChanges, + message: context.l10n.discardChangesConfirmation, + confirmLabel: context.l10n.discard, + destructive: true, + barrierColor: Colors.transparent, + headerBarService: widget.headerBarService, + ); + if (discard && mounted) { + widget.onCancel(); + } + } finally { + _confirmingCancel = false; + } + } + KeyEventResult _handleEditorKeyEvent(FocusNode node, KeyEvent event) { if (event is! KeyDownEvent || !_canDeleteWithShortcut || diff --git a/lib/src/features/feedback/presentation/feedback_dialog.dart b/lib/src/features/feedback/presentation/feedback_dialog.dart index a81b92c..bcafd76 100644 --- a/lib/src/features/feedback/presentation/feedback_dialog.dart +++ b/lib/src/features/feedback/presentation/feedback_dialog.dart @@ -1,6 +1,8 @@ +import 'dart:async'; import 'dart:io'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:package_info_plus/package_info_plus.dart'; import 'package:uuid/uuid.dart'; import 'package:yaru/yaru.dart'; @@ -35,6 +37,7 @@ Future showBusyMaxFeedbackDialog( maxHeight: 760, builder: (dialogContext) => BusyMaxFeedbackDialog( submissionService: submissionService, + headerBarService: headerBarService, onCancel: () => Navigator.of(dialogContext).pop(), ), ); @@ -48,6 +51,7 @@ class BusyMaxFeedbackDialog extends StatefulWidget { this.metadataLoader, this.submissionIdGenerator, this.osVersionProvider, + this.headerBarService, }); final FeedbackSubmissionService submissionService; @@ -55,6 +59,7 @@ class BusyMaxFeedbackDialog extends StatefulWidget { final FeedbackAppMetadataLoader? metadataLoader; final FeedbackSubmissionIdGenerator? submissionIdGenerator; final FeedbackOsVersionProvider? osVersionProvider; + final LinuxHeaderBarService? headerBarService; @override State createState() => _BusyMaxFeedbackDialogState(); @@ -71,6 +76,7 @@ class _BusyMaxFeedbackDialogState extends State { var _includeTechnicalDetails = false; var _validationAttempted = false; var _submitting = false; + var _confirmingCancel = false; String? _statusMessage; var _statusIsError = false; @@ -105,149 +111,178 @@ class _BusyMaxFeedbackDialogState extends State { return PopScope( canPop: !_submitting, - child: BusyMaxModalEditorScaffold( - title: l10n.sendFeedback, - cancelLabel: l10n.cancel, - saveLabel: l10n.feedbackSubmit, - onCancel: widget.onCancel, - cancelEnabled: !_submitting, - onSave: _submitting ? null : _submit, - saving: _submitting, - children: [ - BusyMaxGroupedList( - filled: true, + child: CallbackShortcuts( + bindings: { + const SingleActivator(LogicalKeyboardKey.escape): () { + unawaited(_cancel()); + }, + }, + child: Focus( + autofocus: true, + child: BusyMaxModalEditorScaffold( + title: l10n.sendFeedback, + cancelLabel: l10n.cancel, + saveLabel: l10n.feedbackSubmit, + onCancel: () => unawaited(_cancel()), + cancelEnabled: !_submitting, + onSave: _submitting ? null : _submit, + saving: _submitting, children: [ - Padding( - padding: const EdgeInsets.all(BusyMaxSpacing.md), - child: InputDecorator( - decoration: InputDecoration( - labelText: l10n.feedbackCategory, + BusyMaxGroupedList( + filled: true, + children: [ + BusyMaxComboRow( + key: const Key('feedback-category'), + title: l10n.feedbackCategory, errorText: categoryInvalid ? l10n.feedbackCategoryRequired : null, + values: const [null, ...FeedbackCategory.values], + selected: _category, + labelFor: (category) => category == null + ? l10n.feedbackSelectCategory + : _categoryLabel(context, category), + enabled: !_submitting, + onSelected: (value) { + setState(() { + _category = value; + _draftChanged(); + }); + }, ), - child: DropdownButtonHideUnderline( - child: DropdownButton( - key: const Key('feedback-category'), - value: _category, - isExpanded: true, - hint: Text(l10n.feedbackSelectCategory), - items: [ - for (final category in FeedbackCategory.values) - DropdownMenuItem( - value: category, - child: Text(_categoryLabel(context, category)), - ), - ], - onChanged: _submitting - ? null - : (value) { - setState(() { - _category = value; - _draftChanged(); - }); - }, + YaruListTile.square( + title: TextField( + key: const Key('feedback-subject'), + controller: _subjectController, + enabled: !_submitting, + textInputAction: TextInputAction.next, + decoration: InputDecoration( + labelText: l10n.feedbackSubject, + errorText: subjectInvalid + ? l10n.feedbackSubjectLengthError + : null, + ), + onChanged: (_) => setState(_draftChanged), ), ), - ), - ), - YaruListTile.square( - title: TextField( - key: const Key('feedback-subject'), - controller: _subjectController, - enabled: !_submitting, - textInputAction: TextInputAction.next, - decoration: InputDecoration( - labelText: l10n.feedbackSubject, - errorText: subjectInvalid - ? l10n.feedbackSubjectLengthError - : null, + YaruListTile.square( + title: TextField( + key: const Key('feedback-message'), + controller: _messageController, + enabled: !_submitting, + minLines: 4, + maxLines: 8, + keyboardType: TextInputType.multiline, + decoration: InputDecoration( + labelText: l10n.feedbackDetailedMessage, + alignLabelWithHint: true, + errorText: messageInvalid + ? l10n.feedbackMessageLengthError + : null, + ), + onChanged: (_) => setState(_draftChanged), + ), ), - onChanged: (_) => setState(_draftChanged), - ), + YaruListTile.square( + title: TextField( + key: const Key('feedback-reply-email'), + controller: _replyEmailController, + enabled: !_submitting, + keyboardType: TextInputType.emailAddress, + textInputAction: TextInputAction.done, + decoration: InputDecoration( + labelText: l10n.feedbackReplyEmail, + errorText: replyEmailInvalid + ? l10n.feedbackInvalidEmail + : null, + ), + onChanged: (_) => setState(_draftChanged), + onSubmitted: (_) { + if (!_submitting) { + _submit(); + } + }, + ), + ), + ], ), - YaruListTile.square( - title: TextField( - key: const Key('feedback-message'), - controller: _messageController, - enabled: !_submitting, - minLines: 4, - maxLines: 8, - keyboardType: TextInputType.multiline, - decoration: InputDecoration( - labelText: l10n.feedbackDetailedMessage, - alignLabelWithHint: true, - errorText: messageInvalid - ? l10n.feedbackMessageLengthError - : null, + 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(), ), - onChanged: (_) => setState(_draftChanged), - ), + ], ), - YaruListTile.square( - title: TextField( - key: const Key('feedback-reply-email'), - controller: _replyEmailController, - enabled: !_submitting, - keyboardType: TextInputType.emailAddress, - textInputAction: TextInputAction.done, - decoration: InputDecoration( - labelText: l10n.feedbackReplyEmail, - errorText: replyEmailInvalid - ? l10n.feedbackInvalidEmail - : null, + if (_statusMessage case final status?) ...[ + const SizedBox(height: BusyMaxSpacing.md), + Semantics( + liveRegion: true, + child: Text( + status, + key: const Key('feedback-status'), + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: _statusIsError + ? Theme.of(context).colorScheme.error + : Theme.of(context).colorScheme.primary, + ), ), - onChanged: (_) => setState(_draftChanged), - onSubmitted: (_) { - if (!_submitting) { - _submit(); - } - }, ), - ), - ], - ), - BusyMaxGroupedList( - filled: true, - children: [ - CheckboxListTile( - 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), - controlAffinity: ListTileControlAffinity.leading, - ), + ], + const SizedBox(height: BusyMaxSpacing.lg), ], ), - if (_statusMessage case final status?) ...[ - const SizedBox(height: BusyMaxSpacing.md), - Semantics( - liveRegion: true, - child: Text( - status, - key: const Key('feedback-status'), - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: _statusIsError - ? Theme.of(context).colorScheme.error - : Theme.of(context).colorScheme.primary, - ), - ), - ), - ], - const SizedBox(height: BusyMaxSpacing.lg), - ], + ), ), ); } + Future _cancel() async { + if (_submitting || _confirmingCancel) { + return; + } + final hasDraft = + _category != null || + _subjectController.text.trim().isNotEmpty || + _messageController.text.trim().isNotEmpty || + _replyEmailController.text.trim().isNotEmpty || + _includeTechnicalDetails; + if (!hasDraft) { + widget.onCancel(); + return; + } + + _confirmingCancel = true; + try { + final discard = await showBusyMaxConfirm( + context, + title: context.l10n.discardChanges, + message: context.l10n.discardChangesConfirmation, + confirmLabel: context.l10n.discard, + destructive: true, + barrierColor: Colors.transparent, + headerBarService: widget.headerBarService, + ); + if (discard && mounted) { + widget.onCancel(); + } + } finally { + _confirmingCancel = false; + } + } + Future _submit() async { if (_submitting) { return; diff --git a/lib/src/features/notifications/desktop_notification_service.dart b/lib/src/features/notifications/desktop_notification_service.dart index ce11d7a..6db401a 100644 --- a/lib/src/features/notifications/desktop_notification_service.dart +++ b/lib/src/features/notifications/desktop_notification_service.dart @@ -84,7 +84,7 @@ class DesktopNotificationService { return; } - final body = _settings.detailedNotifications + final body = _showsNotificationDetails ? _strings.syncFailureBody(redactForLog(message)) : _strings.syncFailureBody(_strings.detailsHidden); final now = _now(); @@ -106,7 +106,7 @@ class DesktopNotificationService { if (!_settings.notifyConflicts || _isQuietHours()) { return; } - final body = _settings.detailedNotifications + final body = _showsNotificationDetails ? _strings.conflictBody(redactForLog(summary)) : _strings.conflictBody(_strings.detailsHidden); await _safeNotify( @@ -167,10 +167,10 @@ class DesktopNotificationService { ); } - bool get _usesPrivateReminderText { - return !_settings.detailedNotifications && - _settings.notificationDetailLevel == NotificationDetailLevel.private; - } + bool get _showsNotificationDetails => + _settings.notificationDetailLevel == NotificationDetailLevel.normal; + + bool get _usesPrivateReminderText => !_showsNotificationDetails; Future _safeNotify( String summary, diff --git a/lib/src/features/schedule/application/compact_agenda_data.dart b/lib/src/features/schedule/application/compact_agenda_data.dart index 5238f4d..f79ba24 100644 --- a/lib/src/features/schedule/application/compact_agenda_data.dart +++ b/lib/src/features/schedule/application/compact_agenda_data.dart @@ -84,6 +84,8 @@ Future loadCompactAgendaDataFromRepositories( hasSignedInAccounts: hasSignedInAccounts, hasSources: hasSources, generatedAt: now, + canCreateEvents: false, + canCreateTasks: false, ); } @@ -114,7 +116,7 @@ Future loadCompactAgendaDataFromRepositories( ); final hasSources = visibility.visibleCalendarSourceIds.isNotEmpty || - visibility.visibleTaskListIds.isNotEmpty; + visibility.visibleTaskListKeys.isNotEmpty; if (!hasSources) { return empty(hasSignedInAccounts: true, hasSources: false); } @@ -125,7 +127,7 @@ Future loadCompactAgendaDataFromRepositories( filters: ScheduleFilters( accountIds: accountIds, sourceIds: visibility.visibleCalendarSourceIds, - taskListIds: visibility.visibleTaskListIds, + taskListKeys: visibility.visibleTaskListKeys, sourceFilterActive: true, taskListFilterActive: true, includeCalendarEvents: true, @@ -139,7 +141,7 @@ Future loadCompactAgendaDataFromRepositories( limit: query.overdueLimit, filters: ScheduleFilters( accountIds: accountIds, - taskListIds: visibility.visibleTaskListIds, + taskListKeys: visibility.visibleTaskListKeys, taskListFilterActive: true, includeTasks: true, showCompletedTasks: false, @@ -149,7 +151,7 @@ Future loadCompactAgendaDataFromRepositories( limit: query.noDateLimit, filters: ScheduleFilters( accountIds: accountIds, - taskListIds: visibility.visibleTaskListIds, + taskListKeys: visibility.visibleTaskListKeys, taskListFilterActive: true, includeTasks: true, showCompletedTasks: false, @@ -183,6 +185,10 @@ Future loadCompactAgendaDataFromRepositories( hasSignedInAccounts: true, hasSources: true, generatedAt: now, + canCreateEvents: calendarSources.any( + (source) => source.capabilities.canCreateEvents, + ), + canCreateTasks: visibility.visibleTaskListKeys.isNotEmpty, ); } @@ -196,6 +202,8 @@ class CompactAgendaData { required this.hasSignedInAccounts, required this.hasSources, required this.generatedAt, + this.canCreateEvents = false, + this.canCreateTasks = false, }); final DateTime today; @@ -206,6 +214,8 @@ class CompactAgendaData { final bool hasSignedInAccounts; final bool hasSources; final DateTime generatedAt; + final bool canCreateEvents; + final bool canCreateTasks; } class CompactAgendaQuery { diff --git a/lib/src/features/schedule/application/compact_agenda_snapshot.dart b/lib/src/features/schedule/application/compact_agenda_snapshot.dart index b451235..4b26fcd 100644 --- a/lib/src/features/schedule/application/compact_agenda_snapshot.dart +++ b/lib/src/features/schedule/application/compact_agenda_snapshot.dart @@ -39,6 +39,8 @@ Map encodeCompactAgendaData(CompactAgendaData data) { 'hasSignedInAccounts': data.hasSignedInAccounts, 'hasSources': data.hasSources, 'generatedAt': data.generatedAt.toIso8601String(), + 'canCreateEvents': data.canCreateEvents, + 'canCreateTasks': data.canCreateTasks, }; } @@ -56,6 +58,8 @@ CompactAgendaData decodeCompactAgendaData(Object? raw) { hasSignedInAccounts: _boolValue(map, 'hasSignedInAccounts'), hasSources: _boolValue(map, 'hasSources'), generatedAt: _requiredDateTime(map, 'generatedAt'), + canCreateEvents: _boolValue(map, 'canCreateEvents'), + canCreateTasks: _boolValue(map, 'canCreateTasks'), ); } @@ -100,6 +104,8 @@ Map encodeScheduleItem(ScheduleItem item) { 'attendees': event.attendees, 'colorHex': event.colorHex, 'reminderMinutesBeforeStart': event.reminderMinutesBeforeStart, + 'canEdit': event.capabilities.canEdit, + 'canDelete': event.capabilities.canDelete, }; } @@ -175,6 +181,10 @@ ScheduleItem decodeScheduleItem(Object? raw) { sourceName: common.sourceName, accountDisplayName: common.accountDisplayName, accountEmail: common.accountEmail, + capabilities: ScheduleItemCapabilities( + canEdit: _boolValue(map, 'canEdit'), + canDelete: _boolValue(map, 'canDelete'), + ), ); } diff --git a/lib/src/features/schedule/presentation/compact_agenda_app.dart b/lib/src/features/schedule/presentation/compact_agenda_app.dart index f53a056..bf3431d 100644 --- a/lib/src/features/schedule/presentation/compact_agenda_app.dart +++ b/lib/src/features/schedule/presentation/compact_agenda_app.dart @@ -304,6 +304,24 @@ class _BusyMaxCompactAgendaAppState gtkFontSize: gtkFont?.size, gtkThemeColors: gtkThemeColors, ), + highContrastTheme: buildBusyMaxTheme( + brightness: Brightness.light, + accentColor: accentColor, + family: settings.themeFamily, + gtkFontFamily: gtkFont?.family, + gtkFontSize: gtkFont?.size, + gtkThemeColors: gtkThemeColors, + highContrast: true, + ), + highContrastDarkTheme: buildBusyMaxTheme( + brightness: Brightness.dark, + accentColor: accentColor, + family: settings.themeFamily, + gtkFontFamily: gtkFont?.family, + gtkFontSize: gtkFont?.size, + gtkThemeColors: gtkThemeColors, + highContrast: true, + ), themeMode: settings.themeMode, localizationsDelegates: const [ ...AppLocalizations.localizationsDelegates, diff --git a/lib/src/features/schedule/presentation/compact_agenda_panel.dart b/lib/src/features/schedule/presentation/compact_agenda_panel.dart index e1bd597..5f47b47 100644 --- a/lib/src/features/schedule/presentation/compact_agenda_panel.dart +++ b/lib/src/features/schedule/presentation/compact_agenda_panel.dart @@ -93,18 +93,20 @@ class _CompactAgendaPanelState extends ConsumerState { : watchedData; return Shortcuts( shortcuts: const { - SingleActivator(LogicalKeyboardKey.escape): _HideIntent(), + SingleActivator(LogicalKeyboardKey.escape): + _DismissCompactAgendaIntent(), SingleActivator(LogicalKeyboardKey.keyR, control: true): _RefreshIntent(), }, child: Actions( actions: { - _HideIntent: CallbackAction<_HideIntent>( - onInvoke: (_) { - unawaited(_hide()); - return null; - }, - ), + _DismissCompactAgendaIntent: + CallbackAction<_DismissCompactAgendaIntent>( + onInvoke: (_) { + _dismissCompactAgenda(); + return null; + }, + ), _RefreshIntent: CallbackAction<_RefreshIntent>( onInvoke: (_) { unawaited(_refresh()); @@ -175,8 +177,12 @@ class _CompactAgendaPanelState extends ConsumerState { ), ), _CompactAgendaBottomBar( - onNewEvent: _newEvent, - onNewTask: _newTask, + onNewEvent: data.valueOrNull?.canCreateEvents == true + ? _newEvent + : null, + onNewTask: data.valueOrNull?.canCreateTasks == true + ? _newTask + : null, ), ], ); @@ -188,7 +194,7 @@ class _CompactAgendaPanelState extends ConsumerState { ), child: ClipRRect( borderRadius: BorderRadius.circular(BusyMaxRadius.window), - clipBehavior: Clip.antiAliasWithSaveLayer, + clipBehavior: Clip.antiAlias, child: DecoratedBox( decoration: BoxDecoration( color: colors.card, @@ -383,6 +389,9 @@ class _CompactAgendaPanelState extends ConsumerState { } Future _newEvent() async { + if (_lastAgendaData?.canCreateEvents != true) { + return; + } setState(() { _creatingEvent = true; _creatingTask = false; @@ -441,6 +450,9 @@ class _CompactAgendaPanelState extends ConsumerState { } void _openEventEditor(CalendarScheduleItem item) { + if (!item.capabilities.canEdit) { + return; + } setState(() { _editingEventDraft = _eventDraftFromItem(item); _creatingEvent = false; @@ -504,6 +516,22 @@ class _CompactAgendaPanelState extends ConsumerState { await windowManager.hide(); } + void _dismissCompactAgenda() { + if (_editingTask != null) { + _closeTaskEditor(); + return; + } + if (_creatingEvent || _editingEventDraft != null) { + _closeEventEditor(); + return; + } + if (_creatingTask) { + _closeNewTaskEditor(); + return; + } + unawaited(_hide()); + } + Future _openItem( BuildContext anchorContext, ScheduleItem item, [ @@ -527,7 +555,9 @@ class _CompactAgendaPanelState extends ConsumerState { case ScheduleItemDetailsAction.export: await _exportItem(item); case ScheduleItemDetailsAction.edit: - if (item is TaskScheduleItem) { + if (!item.capabilities.canEdit) { + return; + } else if (item is TaskScheduleItem) { _openTaskEditor(item); } else if (item is CalendarScheduleItem) { _openEventEditor(item); @@ -536,7 +566,9 @@ class _CompactAgendaPanelState extends ConsumerState { await windowManager.hide(); } case ScheduleItemDetailsAction.delete: - await _deleteItem(item); + if (item.capabilities.canDelete) { + await _deleteItem(item); + } } } @@ -560,6 +592,9 @@ class _CompactAgendaPanelState extends ConsumerState { } Future _deleteItem(ScheduleItem item) async { + if (!item.capabilities.canDelete) { + return; + } final confirmed = await showBusyMaxConfirm( context, title: item is CalendarScheduleItem @@ -708,15 +743,14 @@ DateTime _defaultNewEventStart() { } List _editableSources( - List sources, { - required String? currentSourceId, -}) { + List sources, +) { final visibleEditable = [ for (final source in sources) if (!source.isDeleted && !source.hidden && source.selected && - (!source.readOnly || source.id == currentSourceId)) + source.capabilities.canCreateEvents) source, ]; if (visibleEditable.isNotEmpty) { @@ -726,7 +760,7 @@ List _editableSources( for (final source in sources) if (!source.isDeleted && !source.hidden && - (!source.readOnly || source.id == currentSourceId)) + source.capabilities.canCreateEvents) source, ]; } @@ -959,7 +993,6 @@ class _CompactAgendaEventEditorViewState } final sources = _editableSources( snapshot.data ?? const [], - currentSourceId: widget.initialDraft?.sourceId, ); if (sources.isEmpty) { return _CompactAgendaMessageState( @@ -1192,14 +1225,14 @@ class _CompactAgendaMessageState extends StatelessWidget { runSpacing: BusyMaxSpacing.sm, children: [ if (primaryLabel != null) - BusyMaxPushButton.filled( + BusyMaxPushButton.suggested( onPressed: onPrimary == null ? null : () => unawaited(onPrimary!()), child: Text(primaryLabel!), ), if (secondaryLabel != null) - BusyMaxPushButton.outlined( + BusyMaxPushButton.standard( onPressed: onSecondary == null ? null : () => unawaited(onSecondary!()), @@ -1345,7 +1378,12 @@ class _CompactAgendaRowMarker extends StatelessWidget { @override Widget build(BuildContext context) { final isTask = item.kind == ScheduleItemKind.task; - final color = BusyMaxSurfaceColors.of(context).mutedForeground; + final color = isTask + ? BusyMaxSurfaceColors.of(context).mutedForeground + : ScheduleProjection.colorForItem( + item, + Theme.of(context).colorScheme.brightness, + ); final icon = isTask ? YaruIcons.task_list : YaruIcons.calendar; return Icon(icon, size: BusyMaxSizes.iconSm, color: color); } @@ -1433,8 +1471,8 @@ class _CompactAgendaBottomBar extends StatelessWidget { required this.onNewTask, }); - final Future Function() onNewEvent; - final Future Function() onNewTask; + final Future Function()? onNewEvent; + final Future Function()? onNewTask; @override Widget build(BuildContext context) { @@ -1445,15 +1483,19 @@ class _CompactAgendaBottomBar extends StatelessWidget { child: Row( children: [ Expanded( - child: BusyMaxPushButton.filled( - onPressed: () => unawaited(onNewEvent()), + child: BusyMaxPushButton.standard( + onPressed: onNewEvent == null + ? null + : () => unawaited(onNewEvent!()), child: Text(context.l10n.newEvent), ), ), const SizedBox(width: BusyMaxSpacing.sm), Expanded( - child: BusyMaxPushButton.filled( - onPressed: () => unawaited(onNewTask()), + child: BusyMaxPushButton.standard( + onPressed: onNewTask == null + ? null + : () => unawaited(onNewTask!()), child: Text(context.l10n.compactAgendaNewTask), ), ), @@ -1497,8 +1539,8 @@ class _CompactAgendaScrollShadow extends StatelessWidget { } } -class _HideIntent extends Intent { - const _HideIntent(); +class _DismissCompactAgendaIntent extends Intent { + const _DismissCompactAgendaIntent(); } class _RefreshIntent extends Intent { diff --git a/lib/src/features/schedule/presentation/schedule_agenda_view.dart b/lib/src/features/schedule/presentation/schedule_agenda_view.dart index 1fee7de..7f9782c 100644 --- a/lib/src/features/schedule/presentation/schedule_agenda_view.dart +++ b/lib/src/features/schedule/presentation/schedule_agenda_view.dart @@ -239,7 +239,12 @@ class _AgendaItemMarker extends StatelessWidget { @override Widget build(BuildContext context) { final isTask = item.kind == ScheduleItemKind.task; - final color = BusyMaxSurfaceColors.of(context).mutedForeground; + final color = isTask + ? BusyMaxSurfaceColors.of(context).mutedForeground + : ScheduleProjection.colorForItem( + item, + Theme.of(context).colorScheme.brightness, + ); final icon = isTask ? YaruIcons.task_list : YaruIcons.calendar; return Icon(icon, size: BusyMaxSizes.iconSm, color: color); } diff --git a/lib/src/features/schedule/presentation/schedule_anchored_popover.dart b/lib/src/features/schedule/presentation/schedule_anchored_popover.dart new file mode 100644 index 0000000..d650c5f --- /dev/null +++ b/lib/src/features/schedule/presentation/schedule_anchored_popover.dart @@ -0,0 +1,382 @@ +import 'dart:async'; +import 'dart:math' as math; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +import '../../../app/busymax_design.dart'; +import '../../../app/busymax_dialogs.dart'; + +typedef ScheduleAnchoredPopoverBuilder = + Widget Function( + BuildContext context, + BusyMaxPopoverArrowSide arrowSide, + double arrowAlignment, + ); + +/// Coordinates anchored popovers with schedule-owned native header actions. +class ScheduleAnchoredPopoverController { + _SchedulePopoverRegistration? _active; + + bool get isOpen => _active != null; + + Future dismiss() { + return _active?.dismiss() ?? Future.value(); + } + + void _attach(_SchedulePopoverRegistration registration) { + _active = registration; + } + + void _detach(Object token) { + if (_active?.token == token) { + _active = null; + } + } +} + +class ScheduleAnchoredPopoverScope extends InheritedWidget { + const ScheduleAnchoredPopoverScope({ + super.key, + required this.controller, + required super.child, + }); + + final ScheduleAnchoredPopoverController controller; + + static ScheduleAnchoredPopoverController? maybeControllerOf( + BuildContext context, + ) { + return context + .dependOnInheritedWidgetOfExactType() + ?.controller; + } + + @override + bool updateShouldNotify(ScheduleAnchoredPopoverScope oldWidget) { + return oldWidget.controller != controller; + } +} + +class _SchedulePopoverRegistration { + const _SchedulePopoverRegistration({ + required this.token, + required this.dismiss, + }); + + final Object token; + final Future Function() dismiss; +} + +/// Presents a keyboard-modal desktop popover anchored to a widget or pointer. +/// +/// The route owns focus while it is visible, restores the previous focus on +/// dismissal, and constrains its child to the real space above or below the +/// anchor. Callers remain responsible only for their popover content. +Future showScheduleAnchoredPopover({ + required BuildContext context, + required BuildContext anchorContext, + required ScheduleAnchoredPopoverBuilder builder, + required String semanticLabel, + Offset? anchorPoint, + double preferredWidth = 420, + double minimumWidth = 280, + double preferredMinimumHeight = 240, +}) async { + final previousFocus = FocusManager.instance.primaryFocus; + final anchorRect = anchorPoint == null + ? scheduleGlobalRectFor(anchorContext) + : Rect.fromCenter(center: anchorPoint, width: 1, height: 1); + final disableAnimations = + MediaQuery.maybeOf(context)?.disableAnimations ?? false; + final controller = + ScheduleAnchoredPopoverScope.maybeControllerOf(anchorContext) ?? + ScheduleAnchoredPopoverScope.maybeControllerOf(context); + final navigator = Navigator.of(context, rootNavigator: true); + final route = RawDialogRoute( + barrierDismissible: true, + barrierLabel: MaterialLocalizations.of(context).modalBarrierDismissLabel, + barrierColor: Colors.transparent, + transitionDuration: disableAnimations + ? Duration.zero + : const Duration(milliseconds: 120), + traversalEdgeBehavior: TraversalEdgeBehavior.closedLoop, + directionalTraversalEdgeBehavior: TraversalEdgeBehavior.stop, + pageBuilder: (context, animation, secondaryAnimation) { + return _ScheduleAnchoredPopoverRoute( + anchorRect: anchorRect, + preferredWidth: preferredWidth, + minimumWidth: minimumWidth, + preferredMinimumHeight: preferredMinimumHeight, + semanticLabel: semanticLabel, + builder: builder, + ); + }, + transitionBuilder: (context, animation, secondaryAnimation, child) { + if (disableAnimations) { + return child; + } + final curved = CurvedAnimation( + parent: animation, + curve: Curves.easeOutCubic, + ); + return FadeTransition( + opacity: curved, + child: ScaleTransition( + scale: Tween(begin: 0.98, end: 1).animate(curved), + child: child, + ), + ); + }, + ); + final routeResult = navigator.push(route); + final token = Object(); + final finished = Completer(); + controller?._attach( + _SchedulePopoverRegistration( + token: token, + dismiss: () async { + if (route.isCurrent) { + navigator.pop(); + } + await finished.future; + }, + ), + ); + try { + return await routeResult; + } finally { + if (previousFocus?.context?.mounted ?? false) { + previousFocus!.requestFocus(); + } + controller?._detach(token); + if (!finished.isCompleted) { + finished.complete(); + } + } +} + +Rect? scheduleGlobalRectFor(BuildContext context) { + final renderObject = context.findRenderObject(); + if (renderObject is! RenderBox || !renderObject.hasSize) { + return null; + } + return renderObject.localToGlobal(Offset.zero) & renderObject.size; +} + +class _ScheduleAnchoredPopoverRoute extends StatelessWidget { + const _ScheduleAnchoredPopoverRoute({ + required this.anchorRect, + required this.preferredWidth, + required this.minimumWidth, + required this.preferredMinimumHeight, + required this.builder, + required this.semanticLabel, + }); + + final Rect? anchorRect; + final double preferredWidth; + final double minimumWidth; + final double preferredMinimumHeight; + final ScheduleAnchoredPopoverBuilder builder; + final String semanticLabel; + + @override + Widget build(BuildContext context) { + return Material( + color: Colors.transparent, + child: SafeArea( + child: LayoutBuilder( + builder: (context, constraints) { + final layout = _SchedulePopoverLayout.resolve( + anchor: anchorRect, + viewport: constraints.biggest, + textDirection: Directionality.of(context), + preferredWidth: preferredWidth, + minimumWidth: minimumWidth, + preferredMinimumHeight: preferredMinimumHeight, + ); + return BusyMaxModalShortcutBoundary( + child: Shortcuts( + shortcuts: const { + SingleActivator(LogicalKeyboardKey.escape): DismissIntent(), + }, + child: Actions( + actions: { + DismissIntent: CallbackAction( + onInvoke: (_) { + Navigator.of(context).pop(); + return null; + }, + ), + }, + child: FocusTraversalGroup( + policy: WidgetOrderTraversalPolicy(), + child: Focus( + autofocus: true, + child: Semantics( + scopesRoute: true, + namesRoute: true, + label: semanticLabel, + explicitChildNodes: true, + child: BlockSemantics( + child: Stack( + children: [ + Positioned.fill( + child: GestureDetector( + behavior: HitTestBehavior.translucent, + onTap: () => Navigator.of(context).pop(), + ), + ), + Positioned.fill( + child: CustomSingleChildLayout( + delegate: _SchedulePopoverPositionDelegate( + layout, + ), + child: builder( + context, + layout.arrowSide, + layout.arrowAlignment, + ), + ), + ), + ], + ), + ), + ), + ), + ), + ), + ), + ); + }, + ), + ), + ); + } +} + +class _SchedulePopoverLayout { + const _SchedulePopoverLayout({ + required this.anchor, + required this.left, + required this.width, + required this.maximumHeight, + required this.arrowSide, + required this.arrowAlignment, + }); + + factory _SchedulePopoverLayout.resolve({ + required Rect? anchor, + required Size viewport, + required TextDirection textDirection, + required double preferredWidth, + required double minimumWidth, + required double preferredMinimumHeight, + }) { + const margin = BusyMaxSpacing.md; + const gap = BusyMaxSpacing.xs; + final availableWidth = math.max(0.0, viewport.width - margin * 2); + final width = availableWidth < minimumWidth + ? availableWidth + : math.min(preferredWidth, availableWidth); + final maximumLeft = math.max(margin, viewport.width - width - margin); + if (anchor == null) { + return _SchedulePopoverLayout( + anchor: null, + left: ((viewport.width - width) / 2) + .clamp(margin, maximumLeft) + .toDouble(), + width: width, + maximumHeight: math.max(0, viewport.height - margin * 2), + arrowSide: BusyMaxPopoverArrowSide.top, + arrowAlignment: 0.5, + ); + } + + final preferredLeft = textDirection == TextDirection.rtl + ? anchor.right - width + : anchor.left; + final left = preferredLeft.clamp(margin, maximumLeft).toDouble(); + final spaceAbove = math.max(0.0, anchor.top - gap - margin); + final spaceBelow = math.max( + 0.0, + viewport.height - anchor.bottom - gap - margin, + ); + final showBelow = + spaceBelow >= math.min(preferredMinimumHeight, spaceAbove) || + spaceBelow >= spaceAbove; + final arrowAlignment = width <= 0 + ? 0.5 + : ((anchor.center.dx - left) / width).clamp(0.08, 0.92).toDouble(); + return _SchedulePopoverLayout( + anchor: anchor, + left: left, + width: width, + maximumHeight: showBelow ? spaceBelow : spaceAbove, + arrowSide: showBelow + ? BusyMaxPopoverArrowSide.top + : BusyMaxPopoverArrowSide.bottom, + arrowAlignment: arrowAlignment, + ); + } + + final Rect? anchor; + final double left; + final double width; + final double maximumHeight; + final BusyMaxPopoverArrowSide arrowSide; + final double arrowAlignment; +} + +class _SchedulePopoverPositionDelegate extends SingleChildLayoutDelegate { + const _SchedulePopoverPositionDelegate(this.layout); + + final _SchedulePopoverLayout layout; + + @override + BoxConstraints getConstraintsForChild(BoxConstraints constraints) { + return BoxConstraints( + minWidth: layout.width, + maxWidth: layout.width, + maxHeight: layout.maximumHeight, + ); + } + + @override + Offset getPositionForChild(Size size, Size childSize) { + const margin = BusyMaxSpacing.md; + const gap = BusyMaxSpacing.xs; + final maximumTop = math.max( + margin, + size.height - childSize.height - margin, + ); + final anchor = layout.anchor; + if (anchor == null) { + return Offset( + layout.left, + ((size.height - childSize.height) / 2) + .clamp(margin, maximumTop) + .toDouble(), + ); + } + final preferredTop = switch (layout.arrowSide) { + BusyMaxPopoverArrowSide.top => anchor.bottom + gap, + BusyMaxPopoverArrowSide.bottom => anchor.top - childSize.height - gap, + }; + return Offset( + layout.left, + preferredTop.clamp(margin, maximumTop).toDouble(), + ); + } + + @override + bool shouldRelayout(covariant _SchedulePopoverPositionDelegate oldDelegate) { + return layout.anchor != oldDelegate.layout.anchor || + layout.left != oldDelegate.layout.left || + layout.width != oldDelegate.layout.width || + layout.maximumHeight != oldDelegate.layout.maximumHeight || + layout.arrowSide != oldDelegate.layout.arrowSide || + layout.arrowAlignment != oldDelegate.layout.arrowAlignment; + } +} diff --git a/lib/src/features/schedule/presentation/schedule_create_menu.dart b/lib/src/features/schedule/presentation/schedule_create_menu.dart index aefee9e..3f34897 100644 --- a/lib/src/features/schedule/presentation/schedule_create_menu.dart +++ b/lib/src/features/schedule/presentation/schedule_create_menu.dart @@ -9,6 +9,8 @@ enum ScheduleCreateChoice { event, task } Future showScheduleCreateMenu({ required BuildContext context, + bool canCreateEvent = true, + bool canCreateTask = true, LinuxHeaderBarService? headerBarService, }) { return showBusyMaxModalDialog( @@ -29,17 +31,21 @@ Future showScheduleCreateMenu({ style: Theme.of(dialogContext).textTheme.titleMedium, ), const SizedBox(height: BusyMaxSpacing.md), - BusyMaxPushButton.outlined( - onPressed: () => Navigator.of( - dialogContext, - ).pop(ScheduleCreateChoice.event), + BusyMaxPushButton.standard( + onPressed: canCreateEvent + ? () => Navigator.of( + dialogContext, + ).pop(ScheduleCreateChoice.event) + : null, child: Text(dialogContext.l10n.createEventAtTime), ), const SizedBox(height: BusyMaxSpacing.sm), - BusyMaxPushButton.outlined( - onPressed: () => Navigator.of( - dialogContext, - ).pop(ScheduleCreateChoice.task), + BusyMaxPushButton.standard( + onPressed: canCreateTask + ? () => Navigator.of( + dialogContext, + ).pop(ScheduleCreateChoice.task) + : null, child: Text(dialogContext.l10n.createTaskAtDate), ), ], diff --git a/lib/src/features/schedule/presentation/schedule_empty_states.dart b/lib/src/features/schedule/presentation/schedule_empty_states.dart index b3e03e7..6c43b4f 100644 --- a/lib/src/features/schedule/presentation/schedule_empty_states.dart +++ b/lib/src/features/schedule/presentation/schedule_empty_states.dart @@ -57,12 +57,12 @@ class ScheduleNoSourcesState extends StatelessWidget { ? context.l10n.scheduleNoSourcesDescription : context.l10n.scheduleSignInDescription, actions: [ - BusyMaxPushButton.filled( + BusyMaxPushButton.suggested( onPressed: onOpenSettings, child: Text(context.l10n.settings), ), if (onRefresh != null) - BusyMaxPushButton.outlined( + BusyMaxPushButton.standard( onPressed: onRefresh, child: Text(context.l10n.trayAgendaRefresh), ), @@ -82,7 +82,7 @@ class ScheduleUnavailableState extends StatelessWidget { icon: Icons.sync_problem_outlined, title: context.l10n.scheduleUnavailable, actions: [ - BusyMaxPushButton.filled( + BusyMaxPushButton.suggested( onPressed: onRetry, child: Text(context.l10n.retry), ), @@ -98,8 +98,8 @@ class ScheduleEmptyState extends StatelessWidget { required this.onNewTask, }); - final VoidCallback onNewEvent; - final VoidCallback onNewTask; + final VoidCallback? onNewEvent; + final VoidCallback? onNewTask; @override Widget build(BuildContext context) { @@ -107,15 +107,30 @@ class ScheduleEmptyState extends StatelessWidget { icon: Icons.event_available, title: context.l10n.noEventsOrTasks, actions: [ - BusyMaxPushButton.outlined( - onPressed: onNewEvent, - child: Text(context.l10n.newEvent), - ), - BusyMaxPushButton.outlined( - onPressed: onNewTask, - child: Text(context.l10n.newTask), - ), + if (onNewEvent != null) + BusyMaxPushButton.standard( + onPressed: onNewEvent, + child: Text(context.l10n.newEvent), + ), + if (onNewTask != null) + BusyMaxPushButton.standard( + onPressed: onNewTask, + child: Text(context.l10n.newTask), + ), ], ); } } + +class ScheduleSearchEmptyState extends StatelessWidget { + const ScheduleSearchEmptyState({super.key}); + + @override + Widget build(BuildContext context) { + return BusyMaxEmptyState( + icon: Icons.search_off_outlined, + title: context.l10n.scheduleNoSearchResults, + message: context.l10n.scheduleNoSearchResultsDescription, + ); + } +} diff --git a/lib/src/features/schedule/presentation/schedule_event_block.dart b/lib/src/features/schedule/presentation/schedule_event_block.dart index 87398c0..b803e25 100644 --- a/lib/src/features/schedule/presentation/schedule_event_block.dart +++ b/lib/src/features/schedule/presentation/schedule_event_block.dart @@ -52,6 +52,10 @@ class _ScheduleEventBlockState extends State { final tooltipDetails = _tooltipDetails(context); final interactive = widget.onTap != null; final focusBorder = BorderSide(color: colorScheme.primary, width: 2); + final sourceAccent = ScheduleProjection.colorForItem( + widget.item, + colorScheme.brightness, + ); return Semantics( container: true, @@ -112,7 +116,7 @@ class _ScheduleEventBlockState extends State { left: BorderSide( color: _showFocusHighlight ? colorScheme.primary - : surfaceColors.subtleBorder, + : sourceAccent, width: 4, ), top: _showFocusHighlight ? focusBorder : BorderSide.none, diff --git a/lib/src/features/schedule/presentation/schedule_item_details_popover.dart b/lib/src/features/schedule/presentation/schedule_item_details_popover.dart index a0e8625..4f048a7 100644 --- a/lib/src/features/schedule/presentation/schedule_item_details_popover.dart +++ b/lib/src/features/schedule/presentation/schedule_item_details_popover.dart @@ -1,5 +1,3 @@ -import 'dart:math' as math; - import 'package:flutter/material.dart'; import 'package:intl/intl.dart' hide TextDirection; import 'package:yaru/yaru.dart'; @@ -10,6 +8,7 @@ import '../../../calendar_providers/calendar_description.dart'; import '../../../l10n/l10n.dart'; import '../../../schedule/schedule_item.dart'; import '../../../schedule/schedule_projection.dart'; +import 'schedule_anchored_popover.dart'; import 'schedule_event_block.dart'; enum ScheduleItemDetailsAction { export, edit, delete } @@ -20,170 +19,92 @@ Future showScheduleItemDetailsPopover({ required ScheduleItem item, Offset? anchorPoint, }) { - final anchorRect = anchorPoint == null - ? _globalRectFor(anchorContext) - : _globalRectForPoint(anchorPoint); - return showGeneralDialog( + return showScheduleAnchoredPopover( context: context, - barrierDismissible: true, - barrierLabel: MaterialLocalizations.of(context).modalBarrierDismissLabel, - barrierColor: Colors.transparent, - transitionDuration: const Duration(milliseconds: 120), - pageBuilder: (context, animation, secondaryAnimation) { - return _ScheduleItemDetailsPopover(anchorRect: anchorRect, item: item); - }, - transitionBuilder: (context, animation, secondaryAnimation, child) { - final curved = CurvedAnimation( - parent: animation, - curve: Curves.easeOutCubic, - ); - return FadeTransition( - opacity: curved, - child: ScaleTransition( - scale: Tween(begin: 0.98, end: 1).animate(curved), - child: child, - ), + anchorContext: anchorContext, + anchorPoint: anchorPoint, + semanticLabel: item.title, + builder: (context, arrowSide, arrowAlignment) { + return _ScheduleItemDetailsPopoverCard( + item: item, + arrowSide: arrowSide, + arrowAlignment: arrowAlignment, ); }, ); } -class _ScheduleItemDetailsPopover extends StatelessWidget { - const _ScheduleItemDetailsPopover({ - required this.anchorRect, - required this.item, - }); - - final Rect? anchorRect; - final ScheduleItem item; - - @override - Widget build(BuildContext context) { - final surfaceColors = BusyMaxSurfaceColors.of(context); - final colorScheme = Theme.of(context).colorScheme; - final itemColor = ScheduleProjection.colorForItem( - item, - colorScheme.brightness, - ); - - return Material( - color: Colors.transparent, - child: SafeArea( - child: LayoutBuilder( - builder: (context, constraints) { - final layout = _popoverLayout( - anchorRect, - constraints.biggest, - textDirection: Directionality.of(context), - ); - return Stack( - children: [ - Positioned.fill( - child: GestureDetector( - behavior: HitTestBehavior.translucent, - onTap: () => Navigator.of(context).pop(), - ), - ), - Positioned.fill( - child: CustomSingleChildLayout( - delegate: _PopoverPositionDelegate(layout), - child: ConstrainedBox( - constraints: BoxConstraints( - minWidth: layout.width, - maxWidth: layout.width, - ), - child: _ScheduleItemDetailsPopoverCard( - item: item, - itemColor: itemColor, - surfaceColors: surfaceColors, - arrowSide: layout.arrowSide, - arrowAlignment: layout.arrowAlignment, - ), - ), - ), - ), - ], - ); - }, - ), - ), - ); - } -} - class _ScheduleItemDetailsPopoverCard extends StatelessWidget { const _ScheduleItemDetailsPopoverCard({ required this.item, - required this.itemColor, - required this.surfaceColors, required this.arrowSide, required this.arrowAlignment, }); final ScheduleItem item; - final Color itemColor; - final BusyMaxSurfaceColors surfaceColors; final BusyMaxPopoverArrowSide arrowSide; final double arrowAlignment; @override Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; + final surfaceColors = BusyMaxSurfaceColors.of(context); + final itemColor = ScheduleProjection.colorForItem( + item, + colorScheme.brightness, + ); return Material( color: Colors.transparent, - child: ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 420), - child: BusyMaxPopoverSurface( - color: surfaceColors.popover, - arrowSide: arrowSide, - arrowAlignment: arrowAlignment, - child: Padding( - padding: const EdgeInsets.all(BusyMaxSpacing.lg), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Container( - width: BusyMaxSizes.iconSm, - height: BusyMaxSizes.iconSm, - margin: const EdgeInsets.only(top: BusyMaxSpacing.xs), - decoration: BoxDecoration( - color: itemColor, - shape: BoxShape.circle, - ), + child: BusyMaxPopoverSurface( + color: surfaceColors.popover, + arrowSide: arrowSide, + arrowAlignment: arrowAlignment, + child: SingleChildScrollView( + padding: const EdgeInsets.all(BusyMaxSpacing.lg), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + width: BusyMaxSizes.iconSm, + height: BusyMaxSizes.iconSm, + margin: const EdgeInsets.only(top: BusyMaxSpacing.xs), + decoration: BoxDecoration( + color: itemColor, + shape: BoxShape.circle, ), - const SizedBox(width: BusyMaxSpacing.sm), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - Text( - item.title, - maxLines: 3, - overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.titleMedium, - ), - const SizedBox(height: BusyMaxSpacing.xxs), - Text( - _kindLabel(context, item), - style: Theme.of(context).textTheme.bodySmall - ?.copyWith(color: colorScheme.onSurfaceVariant), - ), - ], - ), + ), + const SizedBox(width: BusyMaxSpacing.sm), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + item.title, + maxLines: 3, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: BusyMaxSpacing.xxs), + Text( + _kindLabel(context, item), + style: Theme.of(context).textTheme.bodySmall + ?.copyWith(color: colorScheme.onSurfaceVariant), + ), + ], ), - const SizedBox(width: BusyMaxSpacing.sm), - _PopoverActions(item: item), - ], - ), - const SizedBox(height: BusyMaxSpacing.lg), - _ScheduleItemDetails(item: item), - ], - ), + ), + const SizedBox(width: BusyMaxSpacing.sm), + _PopoverActions(item: item), + ], + ), + const SizedBox(height: BusyMaxSpacing.lg), + _ScheduleItemDetails(item: item), + ], ), ), ), @@ -198,23 +119,21 @@ class _PopoverActions extends StatelessWidget { @override Widget build(BuildContext context) { - return Row( - mainAxisSize: MainAxisSize.min, - children: [ - BusyMaxCircularAction( - icon: Icons.download_outlined, - tooltip: context.l10n.export, - onPressed: () => - Navigator.of(context).pop(ScheduleItemDetailsAction.export), - ), - const SizedBox(width: BusyMaxSpacing.xs), + final actions = [ + BusyMaxCircularAction( + icon: Icons.download_outlined, + tooltip: context.l10n.export, + onPressed: () => + Navigator.of(context).pop(ScheduleItemDetailsAction.export), + ), + if (item.capabilities.canEdit) BusyMaxCircularAction( icon: Icons.edit_outlined, tooltip: _editLabel(context, item), onPressed: () => Navigator.of(context).pop(ScheduleItemDetailsAction.edit), ), - const SizedBox(width: BusyMaxSpacing.xs), + if (item.capabilities.canDelete) BusyMaxCircularAction( icon: Icons.delete_outline, tooltip: context.l10n.delete, @@ -222,138 +141,24 @@ class _PopoverActions extends StatelessWidget { onPressed: () => Navigator.of(context).pop(ScheduleItemDetailsAction.delete), ), - const SizedBox(width: BusyMaxSpacing.xs), - BusyMaxCircularAction( - icon: Icons.close, - tooltip: MaterialLocalizations.of(context).closeButtonTooltip, - onPressed: () => Navigator.of(context).pop(), - ), + BusyMaxCircularAction( + icon: Icons.close, + tooltip: MaterialLocalizations.of(context).closeButtonTooltip, + onPressed: () => Navigator.of(context).pop(), + ), + ]; + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + for (var index = 0; index < actions.length; index += 1) ...[ + if (index > 0) const SizedBox(width: BusyMaxSpacing.xs), + actions[index], + ], ], ); } } -Rect? _globalRectFor(BuildContext context) { - final renderObject = context.findRenderObject(); - if (renderObject is! RenderBox || !renderObject.hasSize) { - return null; - } - return renderObject.localToGlobal(Offset.zero) & renderObject.size; -} - -Rect _globalRectForPoint(Offset point) { - return Rect.fromCenter(center: point, width: 1, height: 1); -} - -_PopoverLayout _popoverLayout( - Rect? anchor, - Size viewport, { - required TextDirection textDirection, -}) { - const margin = BusyMaxSpacing.md; - const gap = BusyMaxSpacing.xs; - const preferredWidth = 420.0; - const minWidth = 280.0; - const estimatedHeight = 310.0; - - final availableWidth = math.max(0.0, viewport.width - margin * 2); - final width = availableWidth < minWidth - ? availableWidth - : math.min(preferredWidth, availableWidth); - final maxLeft = math.max(margin, viewport.width - width - margin); - - if (anchor == null) { - return _PopoverLayout( - anchor: null, - left: ((viewport.width - width) / 2).clamp(margin, maxLeft).toDouble(), - width: width, - arrowSide: BusyMaxPopoverArrowSide.top, - arrowAlignment: 0.5, - ); - } - - final preferredLeft = textDirection == TextDirection.rtl - ? anchor.right - width - : anchor.left; - final left = preferredLeft.clamp(margin, maxLeft).toDouble(); - final below = anchor.bottom + gap; - final showBelow = below + estimatedHeight <= viewport.height - margin; - final arrowAlignment = ((anchor.center.dx - left) / width) - .clamp(0.08, 0.92) - .toDouble(); - - return _PopoverLayout( - anchor: anchor, - left: left, - width: width, - arrowSide: showBelow - ? BusyMaxPopoverArrowSide.top - : BusyMaxPopoverArrowSide.bottom, - arrowAlignment: arrowAlignment, - ); -} - -class _PopoverLayout { - const _PopoverLayout({ - required this.anchor, - required this.left, - required this.width, - required this.arrowSide, - required this.arrowAlignment, - }); - - final Rect? anchor; - final double left; - final double width; - final BusyMaxPopoverArrowSide arrowSide; - final double arrowAlignment; -} - -class _PopoverPositionDelegate extends SingleChildLayoutDelegate { - const _PopoverPositionDelegate(this.layout); - - final _PopoverLayout layout; - - @override - BoxConstraints getConstraintsForChild(BoxConstraints constraints) { - const margin = BusyMaxSpacing.md; - return BoxConstraints( - minWidth: layout.width, - maxWidth: layout.width, - maxHeight: math.max(0, constraints.maxHeight - margin * 2), - ); - } - - @override - Offset getPositionForChild(Size size, Size childSize) { - const margin = BusyMaxSpacing.md; - const gap = BusyMaxSpacing.xs; - final maxTop = math.max(margin, size.height - childSize.height - margin); - final anchor = layout.anchor; - - if (anchor == null) { - final centered = (size.height - childSize.height) / 2; - return Offset(layout.left, centered.clamp(margin, maxTop).toDouble()); - } - - final preferredTop = switch (layout.arrowSide) { - BusyMaxPopoverArrowSide.top => anchor.bottom + gap, - BusyMaxPopoverArrowSide.bottom => anchor.top - childSize.height - gap, - }; - return Offset(layout.left, preferredTop.clamp(margin, maxTop).toDouble()); - } - - @override - bool shouldRelayout(covariant _PopoverPositionDelegate oldDelegate) { - final old = oldDelegate.layout; - return layout.anchor != old.anchor || - layout.left != old.left || - layout.width != old.width || - layout.arrowSide != old.arrowSide || - layout.arrowAlignment != old.arrowAlignment; - } -} - class _ScheduleItemDetails extends StatelessWidget { const _ScheduleItemDetails({required this.item}); @@ -483,7 +288,7 @@ List _eventDetails(BuildContext context, CalendarScheduleItem item) { icon: Icons.notifications_outlined, text: '${context.l10n.reminder}: ' - '${item.reminderMinutesBeforeStart.map(_reminderBeforeLabel).join(', ')}', + '${item.reminderMinutesBeforeStart.map((minutes) => _reminderBeforeLabel(context, minutes)).join(', ')}', ), if (item.categories.isNotEmpty) _ScheduleDetailRow( @@ -526,16 +331,17 @@ List _taskDetails(BuildContext context, TaskScheduleItem item) { ]; } -String _reminderBeforeLabel(int minutes) { +String _reminderBeforeLabel(BuildContext context, int minutes) { return switch (minutes) { - 0 => 'At start', - 1 => '1 minute before', - < 60 => '$minutes minutes before', - 60 => '1 hour before', - < 1440 when minutes % 60 == 0 => '${minutes ~/ 60} hours before', - 1440 => '1 day before', - > 1440 when minutes % 1440 == 0 => '${minutes ~/ 1440} days before', - _ => '$minutes minutes before', + 0 => context.l10n.reminderAtStart, + < 60 => context.l10n.reminderMinutesBefore(minutes), + < 1440 when minutes % 60 == 0 => context.l10n.reminderHoursBefore( + minutes ~/ 60, + ), + >= 1440 when minutes % 1440 == 0 => context.l10n.reminderDaysBefore( + minutes ~/ 1440, + ), + _ => context.l10n.reminderMinutesBefore(minutes), }; } diff --git a/lib/src/features/schedule/presentation/schedule_month_view.dart b/lib/src/features/schedule/presentation/schedule_month_view.dart index 4b417ab..859f7f9 100644 --- a/lib/src/features/schedule/presentation/schedule_month_view.dart +++ b/lib/src/features/schedule/presentation/schedule_month_view.dart @@ -252,20 +252,34 @@ class _MonthDayCell extends StatelessWidget { if (showOverflow) Align( alignment: AlignmentDirectional.centerStart, - child: TextButton( - style: TextButton.styleFrom( - padding: const EdgeInsets.symmetric(horizontal: 6), - minimumSize: const Size(0, moreHeight), - tapTargetSize: MaterialTapTargetSize.shrinkWrap, - ), - onPressed: () => showScheduleMorePopover( - context: context, - day: day, - items: items, - onItemSelected: onItemSelected, - onTaskCompletionChanged: onTaskCompletionChanged, + child: Builder( + builder: (anchorContext) => TextButton( + style: TextButton.styleFrom( + padding: const EdgeInsets.symmetric(horizontal: 6), + minimumSize: const Size(0, moreHeight), + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), + onPressed: () async { + final selection = await showScheduleMorePopover( + context: context, + anchorContext: anchorContext, + day: day, + items: items, + onTaskCompletionChanged: onTaskCompletionChanged, + ); + if (selection == null || + !context.mounted || + !anchorContext.mounted) { + return; + } + onItemSelected( + anchorContext, + selection.item, + selection.anchorPoint, + ); + }, + child: Text(context.l10n.moreItems(overflow)), ), - child: Text(context.l10n.moreItems(overflow)), ), ), ], diff --git a/lib/src/features/schedule/presentation/schedule_more_popover.dart b/lib/src/features/schedule/presentation/schedule_more_popover.dart index 039bbeb..812668b 100644 --- a/lib/src/features/schedule/presentation/schedule_more_popover.dart +++ b/lib/src/features/schedule/presentation/schedule_more_popover.dart @@ -2,64 +2,89 @@ import 'package:flutter/material.dart'; import 'package:intl/intl.dart'; import '../../../app/busymax_design.dart'; +import '../../../app/busymax_yaru_theme.dart'; import '../../../schedule/schedule_item.dart'; +import 'schedule_anchored_popover.dart'; import 'schedule_item_chip.dart'; -import 'schedule_item_selection.dart'; -Future showScheduleMorePopover({ +class ScheduleMorePopoverSelection { + const ScheduleMorePopoverSelection({ + required this.item, + required this.anchorPoint, + }); + + final ScheduleItem item; + final Offset? anchorPoint; +} + +Future showScheduleMorePopover({ required BuildContext context, + required BuildContext anchorContext, required DateTime day, required List items, - required ScheduleItemSelectionCallback onItemSelected, required void Function(TaskScheduleItem item, bool completed) onTaskCompletionChanged, -}) { +}) async { final locale = Localizations.localeOf(context).toLanguageTag(); - return showDialog( + final dayLabel = DateFormat.yMMMMEEEEd(locale).format(day); + final fallbackAnchorPoint = scheduleGlobalRectFor(anchorContext)?.center; + return showScheduleAnchoredPopover( context: context, - builder: (context) { - return Dialog( - child: ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 420, maxHeight: 520), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Padding( - padding: const EdgeInsets.all(BusyMaxSpacing.lg), - child: Text( - DateFormat.yMMMMEEEEd(locale).format(day), - style: Theme.of(context).textTheme.titleMedium, - ), + anchorContext: anchorContext, + semanticLabel: dayLabel, + preferredMinimumHeight: 200, + builder: (context, arrowSide, arrowAlignment) { + return BusyMaxPopoverSurface( + color: BusyMaxSurfaceColors.of(context).popover, + arrowSide: arrowSide, + arrowAlignment: arrowAlignment, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Padding( + padding: const EdgeInsets.all(BusyMaxSpacing.lg), + child: Text( + dayLabel, + style: Theme.of(context).textTheme.titleMedium, ), - const Divider(height: 1), - Flexible( - child: ListView.separated( - shrinkWrap: true, - padding: const EdgeInsets.all(BusyMaxSpacing.md), - itemCount: items.length, - separatorBuilder: (_, _) => - const SizedBox(height: BusyMaxSpacing.sm), - itemBuilder: (context, index) { - final item = items[index]; - return ScheduleItemChip( - item: item, - height: 34, - compact: false, - onTap: (context, [globalPosition]) { - Navigator.of(context).pop(); - onItemSelected(context, item, globalPosition); - }, - onTaskCompletionChanged: item is TaskScheduleItem - ? (completed) => - onTaskCompletionChanged(item, completed) - : null, - ); - }, - ), + ), + const Divider(height: 1), + Flexible( + child: ListView.separated( + shrinkWrap: true, + padding: const EdgeInsets.all(BusyMaxSpacing.md), + itemCount: items.length, + separatorBuilder: (_, _) => + const SizedBox(height: BusyMaxSpacing.sm), + itemBuilder: (context, index) { + final item = items[index]; + return ScheduleItemChip( + item: item, + height: 34, + compact: false, + onTap: (itemAnchorContext, [globalPosition]) { + Navigator.of(context).pop( + ScheduleMorePopoverSelection( + item: item, + anchorPoint: + globalPosition ?? + scheduleGlobalRectFor( + itemAnchorContext, + )?.center ?? + fallbackAnchorPoint, + ), + ); + }, + onTaskCompletionChanged: item is TaskScheduleItem + ? (completed) => + onTaskCompletionChanged(item, completed) + : null, + ); + }, ), - ], - ), + ), + ], ), ); }, diff --git a/lib/src/features/schedule/presentation/schedule_sidebar.dart b/lib/src/features/schedule/presentation/schedule_sidebar.dart index 4f2a689..61a37d0 100644 --- a/lib/src/features/schedule/presentation/schedule_sidebar.dart +++ b/lib/src/features/schedule/presentation/schedule_sidebar.dart @@ -43,8 +43,7 @@ class ScheduleSidebar extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final accounts = ref.watch(accountsStreamProvider).valueOrNull ?? const []; - return Material( - color: BusyMaxSurfaceColors.of(context).sidebar, + return BusyMaxSidebarSurface( child: Column( children: [ MiniCalendar( diff --git a/lib/src/features/schedule/presentation/schedule_toolbar.dart b/lib/src/features/schedule/presentation/schedule_toolbar.dart index d0f91a0..225634d 100644 --- a/lib/src/features/schedule/presentation/schedule_toolbar.dart +++ b/lib/src/features/schedule/presentation/schedule_toolbar.dart @@ -9,6 +9,8 @@ import '../../../schedule/schedule_view_mode.dart'; enum ScheduleToolbarMenuAction { refresh, settings, keyboardShortcuts, about } +enum _ScheduleCreateAction { event, task } + class ScheduleToolbar extends StatelessWidget { const ScheduleToolbar({ super.key, @@ -19,8 +21,10 @@ class ScheduleToolbar extends StatelessWidget { required this.onPrevious, required this.onNext, required this.onModeChanged, - required this.canCreate, - required this.onCreate, + required this.canCreateEvent, + required this.canCreateTask, + required this.onCreateEvent, + required this.onCreateTask, required this.onRefresh, this.canRefresh = true, this.canShowSidebar = false, @@ -28,6 +32,7 @@ class ScheduleToolbar extends StatelessWidget { this.onToggleSidebar, this.onSearch, this.onMenuSelected, + this.createMenuController, }); final ScheduleViewMode mode; @@ -37,8 +42,10 @@ class ScheduleToolbar extends StatelessWidget { final VoidCallback onPrevious; final VoidCallback onNext; final ValueChanged onModeChanged; - final bool canCreate; - final VoidCallback onCreate; + final bool canCreateEvent; + final bool canCreateTask; + final VoidCallback onCreateEvent; + final VoidCallback onCreateTask; final VoidCallback onRefresh; final bool canRefresh; final bool canShowSidebar; @@ -46,6 +53,7 @@ class ScheduleToolbar extends StatelessWidget { final VoidCallback? onToggleSidebar; final VoidCallback? onSearch; final ValueChanged? onMenuSelected; + final MenuController? createMenuController; @override Widget build(BuildContext context) { @@ -68,7 +76,7 @@ class ScheduleToolbar extends StatelessWidget { ), onPressed: onToggleSidebar, ), - BusyMaxPushButton.outlined( + BusyMaxPushButton.standard( onPressed: onToday, child: Text(context.l10n.today), ), @@ -116,10 +124,31 @@ class ScheduleToolbar extends StatelessWidget { icon: const Icon(YaruIcons.search), onPressed: onSearch, ), - YaruIconButton( + BusyMaxMenuButton<_ScheduleCreateAction>( tooltip: context.l10n.create, icon: const Icon(YaruIcons.plus), - onPressed: canCreate ? onCreate : null, + controller: createMenuController, + enabled: canCreateEvent || canCreateTask, + entries: [ + BusyMaxMenuEntry( + value: _ScheduleCreateAction.event, + label: context.l10n.createEventAtTime, + enabled: canCreateEvent, + ), + BusyMaxMenuEntry( + value: _ScheduleCreateAction.task, + label: context.l10n.createTaskAtDate, + enabled: canCreateTask, + ), + ], + onSelected: (value) { + switch (value) { + case _ScheduleCreateAction.event: + onCreateEvent(); + case _ScheduleCreateAction.task: + onCreateTask(); + } + }, ), if (!compact) YaruIconButton( diff --git a/lib/src/features/schedule/presentation/schedule_workspace.dart b/lib/src/features/schedule/presentation/schedule_workspace.dart index eb78e61..825f75f 100644 --- a/lib/src/features/schedule/presentation/schedule_workspace.dart +++ b/lib/src/features/schedule/presentation/schedule_workspace.dart @@ -6,7 +6,6 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter/services.dart'; import 'package:go_router/go_router.dart'; import 'package:intl/intl.dart'; -import 'package:yaru/yaru.dart'; import '../../../app/app_bootstrap.dart'; import '../../../app/busymax_about_dialog.dart'; @@ -38,6 +37,7 @@ import '../../tasks/data/tasks_repository.dart'; import '../../tasks/presentation/new_task_dialog.dart'; import '../../tasks/presentation/task_details_pane.dart'; import 'schedule_agenda_view.dart'; +import 'schedule_anchored_popover.dart'; import 'schedule_create_menu.dart'; import 'schedule_day_week_view.dart'; import 'schedule_empty_states.dart'; @@ -49,10 +49,110 @@ import 'schedule_sidebar.dart'; import 'schedule_toolbar.dart'; import 'schedule_year_view.dart'; +enum _ScheduleShortcut { + search, + create, + dismissSearch, + previous, + next, + newEvent, + newTask, + today, + day, + week, + month, + year, + agenda, +} + +class _ScheduleShortcutIntent extends Intent { + const _ScheduleShortcutIntent(this.command); + + final _ScheduleShortcut command; +} + +const _scheduleShortcuts = { + BusyMaxShortcutActivators.search: _ScheduleShortcutIntent( + _ScheduleShortcut.search, + ), + BusyMaxShortcutActivators.create: _ScheduleShortcutIntent( + _ScheduleShortcut.create, + ), + BusyMaxShortcutActivators.dismiss: _ScheduleShortcutIntent( + _ScheduleShortcut.dismissSearch, + ), + SingleActivator(LogicalKeyboardKey.arrowLeft, shift: true): + _ScheduleShortcutIntent(_ScheduleShortcut.previous), + SingleActivator(LogicalKeyboardKey.arrowRight, shift: true): + _ScheduleShortcutIntent(_ScheduleShortcut.next), + SingleActivator(LogicalKeyboardKey.keyE): _ScheduleShortcutIntent( + _ScheduleShortcut.newEvent, + ), + SingleActivator(LogicalKeyboardKey.keyT): _ScheduleShortcutIntent( + _ScheduleShortcut.newTask, + ), + SingleActivator(LogicalKeyboardKey.keyT, shift: true): + _ScheduleShortcutIntent(_ScheduleShortcut.today), + SingleActivator(LogicalKeyboardKey.digit1): _ScheduleShortcutIntent( + _ScheduleShortcut.day, + ), + SingleActivator(LogicalKeyboardKey.numpad1): _ScheduleShortcutIntent( + _ScheduleShortcut.day, + ), + SingleActivator(LogicalKeyboardKey.keyD): _ScheduleShortcutIntent( + _ScheduleShortcut.day, + ), + SingleActivator(LogicalKeyboardKey.digit2): _ScheduleShortcutIntent( + _ScheduleShortcut.week, + ), + SingleActivator(LogicalKeyboardKey.numpad2): _ScheduleShortcutIntent( + _ScheduleShortcut.week, + ), + SingleActivator(LogicalKeyboardKey.keyW): _ScheduleShortcutIntent( + _ScheduleShortcut.week, + ), + SingleActivator(LogicalKeyboardKey.digit3): _ScheduleShortcutIntent( + _ScheduleShortcut.month, + ), + SingleActivator(LogicalKeyboardKey.numpad3): _ScheduleShortcutIntent( + _ScheduleShortcut.month, + ), + SingleActivator(LogicalKeyboardKey.keyM): _ScheduleShortcutIntent( + _ScheduleShortcut.month, + ), + SingleActivator(LogicalKeyboardKey.digit4): _ScheduleShortcutIntent( + _ScheduleShortcut.year, + ), + SingleActivator(LogicalKeyboardKey.numpad4): _ScheduleShortcutIntent( + _ScheduleShortcut.year, + ), + SingleActivator(LogicalKeyboardKey.keyY): _ScheduleShortcutIntent( + _ScheduleShortcut.year, + ), + SingleActivator(LogicalKeyboardKey.digit0): _ScheduleShortcutIntent( + _ScheduleShortcut.agenda, + ), + SingleActivator(LogicalKeyboardKey.numpad0): _ScheduleShortcutIntent( + _ScheduleShortcut.agenda, + ), + SingleActivator(LogicalKeyboardKey.keyA): _ScheduleShortcutIntent( + _ScheduleShortcut.agenda, + ), +}; + class ScheduleWorkspace extends ConsumerStatefulWidget { - const ScheduleWorkspace({super.key, this.initialScope = ScheduleScope.all}); + const ScheduleWorkspace({ + super.key, + this.initialScope = ScheduleScope.all, + this.initialTaskAccountId, + this.initialTaskListId, + this.initialTaskId, + }); final ScheduleScope initialScope; + final String? initialTaskAccountId; + final String? initialTaskListId; + final String? initialTaskId; @override ConsumerState createState() => _ScheduleWorkspaceState(); @@ -70,18 +170,22 @@ class _ScheduleWorkspaceState extends ConsumerState { _TaskDetailsTarget? _taskDetailsTarget; late final LinuxHeaderBarSession _headerBarSession; StreamSubscription? _headerBarActions; + StreamSubscription? _headerBarSearchEvents; var _headerBarReady = false; var _nativeHeaderBarAvailable = false; var _sidebarCollapsed = false; var _searchActive = false; var _searchQuery = ''; final _searchController = TextEditingController(); - final _searchFocusNode = FocusNode(); + var _fallbackSearchFocusRequest = 0; var _latestCanShowSidebar = false; var _latestAccounts = const []; - var _latestVisibleSources = const []; + var _latestWritableSources = const []; + var _latestVisibleTaskLists = const []; + var _latestCanCreateTask = false; var _latestItems = const []; final _itemAnchorContexts = {}; + final _createMenuController = MenuController(); ScheduleWorkspaceCommand? _pendingAnchoredCommand; List _pendingAnchoredSources = const []; @@ -89,23 +193,32 @@ class _ScheduleWorkspaceState extends ConsumerState { var _agendaOverdueTaskLimit = _agendaInitialTaskBucketLimit; var _agendaNoDateTaskLimit = _agendaInitialTaskBucketLimit; ScheduleViewMode? _lastSettingsMode; + var _initialTaskHandled = false; + var _initialTaskOpening = false; + var _initialTaskWatchGeneration = 0; + StreamSubscription? _initialTaskTargetSubscription; + var _taskDetailsDirty = false; + final _anchoredPopoverController = ScheduleAnchoredPopoverController(); + var _handlingModalHeaderAction = false; @override void initState() { super.initState(); _scope = widget.initialScope; _applyInitialScope(); - HardwareKeyboard.instance.addHandler(_handleScheduleShortcutEvent); _headerBarSession = ref.read(linuxHeaderBarServiceProvider).claimSession(); _headerBarActions = _headerBarSession.actions.listen( _handleHeaderBarAction, ); + _headerBarSearchEvents = _headerBarSession.searchEvents.listen( + _handleHeaderBarSearchEvent, + ); unawaited(_initializeHeaderBar()); + _scheduleInitialTaskWatch(); } @override void dispose() { - HardwareKeyboard.instance.removeHandler(_handleScheduleShortcutEvent); _headerBarSession.dispose(); if (_taskDetailsTarget != null) { unawaited( @@ -113,8 +226,9 @@ class _ScheduleWorkspaceState extends ConsumerState { ); } unawaited(_headerBarActions?.cancel()); + unawaited(_headerBarSearchEvents?.cancel()); + unawaited(_initialTaskTargetSubscription?.cancel()); _searchController.dispose(); - _searchFocusNode.dispose(); super.dispose(); } @@ -125,6 +239,13 @@ class _ScheduleWorkspaceState extends ConsumerState { _scope = widget.initialScope; _applyInitialScope(); } + if (oldWidget.initialTaskAccountId != widget.initialTaskAccountId || + oldWidget.initialTaskListId != widget.initialTaskListId || + oldWidget.initialTaskId != widget.initialTaskId) { + _initialTaskHandled = false; + _initialTaskOpening = false; + _scheduleInitialTaskWatch(); + } } @override @@ -144,272 +265,349 @@ class _ScheduleWorkspaceState extends ConsumerState { .watch(calendarRepositoryProvider) .watchSourcesForAccounts(accountIds); - return StreamBuilder>( - stream: sourcesStream, - builder: (context, sourcesSnapshot) { - final sourcesLoading = - sourcesSnapshot.connectionState == ConnectionState.waiting && - !sourcesSnapshot.hasData; - final sourcesUnavailable = - sourcesSnapshot.hasError && !sourcesSnapshot.hasData; - final sources = sourcesSnapshot.data ?? const []; - return FutureBuilder>( - future: _taskListsForAccounts(accounts), - builder: (context, listsSnapshot) { - final taskListsLoading = - listsSnapshot.connectionState == ConnectionState.waiting && - !listsSnapshot.hasData; - final taskListsUnavailable = - listsSnapshot.hasError && !listsSnapshot.hasData; - final taskLists = listsSnapshot.data ?? const []; - final visibility = ScheduleSourceVisibility.fromSources( - calendarSources: sources, - taskLists: taskLists, - settings: settings, - ); - final firstWeekday = _firstWeekday(context); - final visibleSources = sources - .where( - (source) => - visibility.visibleCalendarSourceIds.contains(source.id), - ) - .toList(); - _latestAccounts = accounts; - _latestVisibleSources = visibleSources; - - return FutureBuilder<_ScheduleItemsResult>( - future: _scheduleItems( - repository: ref.watch(scheduleRepositoryProvider), - range: range, - searchHasQuery: searchHasQuery, - accountIds: accountIds.toSet(), - sourceIds: visibility.visibleCalendarSourceIds, - taskListIds: visibility.visibleTaskListIds, - ), - builder: (context, snapshot) { - final itemsLoading = - snapshot.connectionState == ConnectionState.waiting && - !snapshot.hasData; - final scheduleLoading = - accountsLoading || - sourcesLoading || - taskListsLoading || - itemsLoading; - final scheduleUnavailable = - accountsUnavailable || - sourcesUnavailable || - taskListsUnavailable || - (snapshot.hasError && !snapshot.hasData); - final scopedItems = ScheduleProjection.filterByScope( - snapshot.data?.items ?? const [], - _scope, + return _scheduleShortcutScope( + ScheduleAnchoredPopoverScope( + controller: _anchoredPopoverController, + child: StreamBuilder>( + stream: sourcesStream, + builder: (context, sourcesSnapshot) { + final sourcesLoading = + sourcesSnapshot.connectionState == ConnectionState.waiting && + !sourcesSnapshot.hasData; + final sourcesUnavailable = + sourcesSnapshot.hasError && !sourcesSnapshot.hasData; + final sources = + sourcesSnapshot.data ?? const []; + return FutureBuilder>( + future: _taskListsForAccounts(accounts), + builder: (context, listsSnapshot) { + final taskListsLoading = + listsSnapshot.connectionState == ConnectionState.waiting && + !listsSnapshot.hasData; + final taskListsUnavailable = + listsSnapshot.hasError && !listsSnapshot.hasData; + final taskLists = + listsSnapshot.data ?? const []; + final visibility = ScheduleSourceVisibility.fromSources( + calendarSources: sources, + taskLists: taskLists, + settings: settings, ); - final items = - !searchHasQuery && _mode == ScheduleViewMode.agenda - ? _agendaItems(scopedItems, range) - : scopedItems; - _latestItems = items; - final miniCalendarItemsFuture = ref - .watch(scheduleRepositoryProvider) - .listItems( - range: ScheduleRange.month( - _selectedDate, - firstWeekday: firstWeekday, + final firstWeekday = _firstWeekday(context); + final visibleSources = sources + .where( + (source) => visibility.visibleCalendarSourceIds.contains( + source.id, ), - filters: ScheduleFilters( - accountIds: accountIds.toSet(), - sourceIds: visibility.visibleCalendarSourceIds, - taskListIds: visibility.visibleTaskListIds, - sourceFilterActive: true, - taskListFilterActive: true, - includeCalendarEvents: _scope != ScheduleScope.tasks, - includeTasks: _scope != ScheduleScope.events, - showCompletedTasks: true, - showNoDateTasks: false, - ), - ); - final displayRange = searchHasQuery - ? _rangeForSearchResults(items, range) - : range; - final displayMode = searchHasQuery - ? ScheduleViewMode.agenda - : _mode; - _consumePendingCommand(visibleSources, accounts); - final showFallbackHeader = _showFlutterHeaderFallback; - final canShowFallbackSidebar = BusyMaxLayoutRules.showSidebar( - MediaQuery.sizeOf(context).width, - ); - final main = Column( - children: [ - if (showFallbackHeader) ...[ - ScheduleToolbar( - mode: _mode, - range: range, - selectedDate: _selectedDate, - onToday: _goToToday, - onPrevious: _previous, - onNext: _next, - onModeChanged: _setMode, - canCreate: - accounts.isNotEmpty || visibleSources.isNotEmpty, - onCreate: _openCreateAtSelectedDate, - onRefresh: () => unawaited(_refreshAll()), - canRefresh: accounts.isNotEmpty, - canShowSidebar: canShowFallbackSidebar, - sidebarVisible: - canShowFallbackSidebar && !_sidebarCollapsed, - onToggleSidebar: () => _handleHeaderBarAction( - BusyMaxHeaderBarAction.sidebarToggle, + ) + .toList(); + final writableSources = writableCalendarSources(visibleSources); + final routedTaskListKey = _initialTaskListKey; + final visibleTaskListKeys = routedTaskListKey == null + ? visibility.visibleTaskListKeys + : {routedTaskListKey}; + final visibleTaskLists = taskLists + .where( + (list) => visibleTaskListKeys.contains( + ScheduleTaskListKey( + accountId: list.accountId, + taskListId: list.id, ), - onSearch: () => _handleHeaderBarAction( - BusyMaxHeaderBarAction.search, - ), - onMenuSelected: _handleFallbackToolbarMenu, - ), - const Divider(height: 1), - ], - if (_searchActive) ...[ - _ScheduleSearchField( - controller: _searchController, - focusNode: _searchFocusNode, - onChanged: (value) => - setState(() => _searchQuery = value), - onClose: _closeSearch, ), - const Divider(height: 1), - ], - Expanded( - child: _ScheduleBody( - isLoading: scheduleLoading, - isUnavailable: scheduleUnavailable, - mode: displayMode, - range: displayRange, - selectedDate: searchHasQuery - ? displayRange.start - : _selectedDate, - firstWeekday: _firstWeekday(context), - dayStartMinute: settings.scheduleDayStartMinute, - dayEndMinute: settings.scheduleDayEndMinute, - hasAnySources: - visibility.hasCalendarSources || - visibility.hasTaskLists, - hasAccounts: accounts.isNotEmpty, - items: items, - onOpenSettings: () => context.go('/settings'), - onRetry: _retrySchedule, - onRefresh: accounts.isEmpty - ? null - : () => unawaited(_refreshAll()), - onDaySelected: _setDate, - onYearDaySelected: _openDay, - onMonthSelected: _setMonth, - onEmptySlot: (start) => unawaited( - _openCreateChoice(accounts, visibleSources, start), - ), - onCreateAtDay: (day) => unawaited( - _openCreateChoice( - accounts, - visibleSources, - DateTime(day.year, day.month, day.day, 9), + ) + .toList(); + final canCreateTask = visibleTaskLists.isNotEmpty; + _latestAccounts = accounts; + _latestWritableSources = writableSources; + _latestVisibleTaskLists = visibleTaskLists; + _latestCanCreateTask = canCreateTask; + + return FutureBuilder<_ScheduleItemsResult>( + future: _scheduleItems( + repository: ref.watch(scheduleRepositoryProvider), + range: range, + searchHasQuery: searchHasQuery, + accountIds: accountIds.toSet(), + sourceIds: visibility.visibleCalendarSourceIds, + taskListKeys: visibleTaskListKeys, + ), + builder: (context, snapshot) { + final itemsLoading = + snapshot.connectionState == ConnectionState.waiting && + !snapshot.hasData; + final scheduleLoading = + accountsLoading || + sourcesLoading || + taskListsLoading || + itemsLoading; + final scheduleUnavailable = + accountsUnavailable || + sourcesUnavailable || + taskListsUnavailable || + (snapshot.hasError && !snapshot.hasData); + final scopedItems = ScheduleProjection.filterByScope( + snapshot.data?.items ?? const [], + _scope, + ); + final items = + !searchHasQuery && _mode == ScheduleViewMode.agenda + ? _agendaItems(scopedItems, range) + : scopedItems; + _latestItems = items; + final miniCalendarItemsFuture = ref + .watch(scheduleRepositoryProvider) + .listItems( + range: ScheduleRange.month( + _selectedDate, + firstWeekday: firstWeekday, ), - ), - onNewEvent: () => unawaited( - _openNewEvent(visibleSources, _selectedDate), - ), - onNewTask: () => unawaited(_openNewTask(accounts)), - onPrevious: _previous, - onNext: _next, - onAgendaLoadMore: - !searchHasQuery && _mode == ScheduleViewMode.agenda - ? _loadMoreAgendaDays - : null, - hasMoreAgendaOverdueTasks: - !searchHasQuery && - _mode == ScheduleViewMode.agenda && - (snapshot.data?.hasMoreOverdueTasks ?? false), - hasMoreAgendaNoDateTasks: - !searchHasQuery && - _mode == ScheduleViewMode.agenda && - (snapshot.data?.hasMoreNoDateTasks ?? false), - onAgendaLoadMoreOverdue: - !searchHasQuery && _mode == ScheduleViewMode.agenda - ? _loadMoreAgendaOverdueTasks - : null, - onAgendaLoadMoreNoDate: - !searchHasQuery && _mode == ScheduleViewMode.agenda - ? _loadMoreAgendaNoDateTasks - : null, - onItemSelected: (context, item, [globalPosition]) => - unawaited( - _openItem( + filters: ScheduleFilters( + accountIds: accountIds.toSet(), + sourceIds: visibility.visibleCalendarSourceIds, + taskListKeys: visibleTaskListKeys, + sourceFilterActive: true, + taskListFilterActive: true, + includeCalendarEvents: + _scope != ScheduleScope.tasks, + includeTasks: _scope != ScheduleScope.events, + showCompletedTasks: true, + showNoDateTasks: false, + ), + ); + final displayRange = searchHasQuery + ? _rangeForSearchResults(items, range) + : range; + final displayMode = searchHasQuery + ? ScheduleViewMode.agenda + : _mode; + _consumePendingCommand(visibleSources, accounts); + final showFallbackHeader = _showFlutterHeaderFallback; + final canShowFallbackSidebar = + BusyMaxLayoutRules.showSidebar( + MediaQuery.sizeOf(context).width, + ); + final main = Column( + children: [ + if (showFallbackHeader) ...[ + 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: canShowFallbackSidebar, + sidebarVisible: + canShowFallbackSidebar && !_sidebarCollapsed, + onToggleSidebar: () => _handleHeaderBarAction( + BusyMaxHeaderBarAction.sidebarToggle, + ), + onSearch: () => _handleHeaderBarAction( + BusyMaxHeaderBarAction.search, + ), + onMenuSelected: _handleFallbackToolbarMenu, + ), + const Divider(height: 1), + ], + if (_searchActive && _showFlutterHeaderFallback) ...[ + Padding( + padding: const EdgeInsets.symmetric( + horizontal: BusyMaxSpacing.md, + vertical: BusyMaxSpacing.sm, + ), + child: BusyMaxSearchField( + controller: _searchController, + autofocus: true, + focusRequest: _fallbackSearchFocusRequest, + hintText: MaterialLocalizations.of( context, - item, + ).searchFieldLabel, + onChanged: _setSearchQuery, + onClear: _clearSearchQuery, + ), + ), + const Divider(height: 1), + ], + Expanded( + child: _ScheduleBody( + isLoading: scheduleLoading, + isUnavailable: scheduleUnavailable, + mode: displayMode, + range: displayRange, + selectedDate: searchHasQuery + ? 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, + onEmptySlot: (start) => unawaited( + _openCreateChoice( + accounts, visibleSources, - globalPosition: globalPosition, + start, + canCreateTask: canCreateTask, ), ), - onItemAnchorAvailable: _handleItemAnchorAvailable, - onTaskCompletionChanged: _setTaskCompleted, - ), - ), - ], - ); - return Scaffold( - body: LayoutBuilder( - builder: (context, constraints) { - final showSidebar = BusyMaxLayoutRules.showSidebar( - constraints.maxWidth, - ); - _updateHeaderBarState( - context, - range: range, - accounts: accounts, - visibleSources: visibleSources, - showSidebar: showSidebar, - ); - final body = !showSidebar || _sidebarCollapsed - ? main - : Row( - children: [ - SizedBox( - width: BusyMaxSizes.sidebarWidth, - child: FutureBuilder>( - future: miniCalendarItemsFuture, - builder: (context, miniSnapshot) { - final miniCalendarItems = - ScheduleProjection.filterByScope( - miniSnapshot.data ?? - const [], - _scope, - ); - return ScheduleSidebar( - selectedDate: _selectedDate, - firstWeekday: firstWeekday, - items: miniCalendarItems, - onDateSelected: _openDay, - onMonthSelected: _setMonth, - onYearSelected: _setYear, - onWeekSelected: _setWeek, - ); - }, + onCreateAtDay: (day) => unawaited( + _openCreateChoice( + accounts, + visibleSources, + DateTime(day.year, day.month, day.day, 9), + canCreateTask: canCreateTask, + ), + ), + onNewEvent: () => unawaited( + _openNewEvent(visibleSources, _selectedDate), + ), + onNewTask: () => unawaited(_openNewTask(accounts)), + onPrevious: _previous, + onNext: _next, + onAgendaLoadMore: + !searchHasQuery && + _mode == ScheduleViewMode.agenda + ? _loadMoreAgendaDays + : null, + hasMoreAgendaOverdueTasks: + !searchHasQuery && + _mode == ScheduleViewMode.agenda && + (snapshot.data?.hasMoreOverdueTasks ?? false), + hasMoreAgendaNoDateTasks: + !searchHasQuery && + _mode == ScheduleViewMode.agenda && + (snapshot.data?.hasMoreNoDateTasks ?? false), + onAgendaLoadMoreOverdue: + !searchHasQuery && + _mode == ScheduleViewMode.agenda + ? _loadMoreAgendaOverdueTasks + : null, + onAgendaLoadMoreNoDate: + !searchHasQuery && + _mode == ScheduleViewMode.agenda + ? _loadMoreAgendaNoDateTasks + : null, + onItemSelected: (context, item, [globalPosition]) => + unawaited( + _openItem( + context, + item, + visibleSources, + globalPosition: globalPosition, ), ), - Expanded(child: main), - ], - ); - return _ScheduleTaskDetailsOverlay( - target: _taskDetailsTarget, - onClose: _closeTaskDetails, - child: body, - ); - }, - ), + onItemAnchorAvailable: _handleItemAnchorAvailable, + onTaskCompletionChanged: _setTaskCompleted, + canCreateEvent: writableSources.isNotEmpty, + canCreateTask: canCreateTask, + searchActive: searchHasQuery, + ), + ), + ], + ); + return Scaffold( + body: LayoutBuilder( + builder: (context, constraints) { + final showSidebar = BusyMaxLayoutRules.showSidebar( + constraints.maxWidth, + ); + _updateHeaderBarState( + context, + range: range, + accounts: accounts, + canCreateEvent: writableSources.isNotEmpty, + canCreateTask: canCreateTask, + showSidebar: showSidebar, + ); + final body = !showSidebar || _sidebarCollapsed + ? main + : Row( + children: [ + SizedBox( + width: BusyMaxSizes.sidebarWidth, + child: FutureBuilder>( + future: miniCalendarItemsFuture, + builder: (context, miniSnapshot) { + final miniCalendarItems = + ScheduleProjection.filterByScope( + miniSnapshot.data ?? + const [], + _scope, + ); + return ScheduleSidebar( + selectedDate: _selectedDate, + firstWeekday: firstWeekday, + items: miniCalendarItems, + onDateSelected: _openDay, + onMonthSelected: _setMonth, + onYearSelected: _setYear, + onWeekSelected: _setWeek, + ); + }, + ), + ), + Expanded(child: main), + ], + ); + return _ScheduleTaskDetailsOverlay( + target: _taskDetailsTarget, + onClose: () => + unawaited(_requestCloseTaskDetails()), + onDirtyChanged: (dirty) { + _taskDetailsDirty = dirty; + }, + child: body, + ); + }, + ), + ); + }, ); }, ); }, - ); - }, + ), + ), + ); + } + + Widget _scheduleShortcutScope(Widget child) { + return Shortcuts( + shortcuts: _scheduleShortcuts, + child: Actions( + actions: {_ScheduleShortcutIntent: _ScheduleShortcutAction(this)}, + child: Focus(autofocus: true, child: child), + ), ); } @@ -447,7 +645,8 @@ class _ScheduleWorkspaceState extends ConsumerState { BuildContext context, { required ScheduleRange range, required List accounts, - required List visibleSources, + required bool canCreateEvent, + required bool canCreateTask, required bool showSidebar, }) { _latestCanShowSidebar = showSidebar; @@ -461,13 +660,14 @@ class _ScheduleWorkspaceState extends ConsumerState { _selectedDate, ); final sidebarVisible = showSidebar && !_sidebarCollapsed; - final canCreate = accounts.isNotEmpty || visibleSources.isNotEmpty; final headerBarState = BusyMaxHeaderBarState( title: titleRange, viewMode: _mode, canRefresh: accounts.isNotEmpty, - canCreate: canCreate, + canCreateEvent: canCreateEvent, + canCreateTask: canCreateTask, searchActive: _searchActive, + searchQuery: _searchQuery, canShowSidebar: showSidebar, sidebarVisible: sidebarVisible, navigationVisible: _mode != ScheduleViewMode.agenda, @@ -499,6 +699,67 @@ class _ScheduleWorkspaceState extends ConsumerState { if (!_headerBarSession.isCurrent) { return; } + 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(): + // Native focus is presentation state. The event is still exposed by + // the route-owned bridge so callers can observe focus without making + // it part of the durable header state. + return; + case BusyMaxHeaderBarSearchCleared(): + _clearSearchQuery(); + case BusyMaxHeaderBarSearchEscapePressed(): + if (_searchActive) { + _closeSearch(); + } + } + } + + Future _dismissModalThenHandleHeaderAction( + BusyMaxHeaderBarAction action, + ) async { + if (_handlingModalHeaderAction) { + return; + } + _handlingModalHeaderAction = true; + try { + if (_taskDetailsTarget != null) { + if (!await _confirmDiscardTaskDetails()) { + return; + } + _closeTaskDetails(); + await WidgetsBinding.instance.endOfFrame; + } else if (_anchoredPopoverController.isOpen) { + await _anchoredPopoverController.dismiss(); + } else { + return; + } + if (!mounted || + !_headerBarSession.isCurrent || + !_canHandleRouteShortcut()) { + return; + } + _dispatchHeaderBarAction(action); + } finally { + _handlingModalHeaderAction = false; + } + } + + void _dispatchHeaderBarAction(BusyMaxHeaderBarAction action) { switch (action) { case BusyMaxHeaderBarAction.back: case BusyMaxHeaderBarAction.continueSetup: @@ -537,12 +798,24 @@ class _ScheduleWorkspaceState extends ConsumerState { setState(() => _searchActive = true); _focusSearch(); } - case BusyMaxHeaderBarAction.create: - _openCreateAtSelectedDate(); + case BusyMaxHeaderBarAction.createEvent: + if (_latestWritableSources.isEmpty) { + return; + } + unawaited( + _openNewEvent(_latestWritableSources, _defaultSelectedDateStart()), + ); + case BusyMaxHeaderBarAction.createTask: + if (!_latestCanCreateTask) { + return; + } + unawaited( + _openNewTask(_latestAccounts, due: _day(_defaultSelectedDateStart())), + ); case BusyMaxHeaderBarAction.refresh: unawaited(_refreshAll()); case BusyMaxHeaderBarAction.settings: - context.go('/settings'); + unawaited(context.push('/settings')); case BusyMaxHeaderBarAction.keyboardShortcuts: unawaited( showBusyMaxKeyboardShortcutsDialog( @@ -616,7 +889,7 @@ class _ScheduleWorkspaceState extends ConsumerState { required bool searchHasQuery, required Set accountIds, required Set sourceIds, - required Set taskListIds, + required Set taskListKeys, }) async { final currentItems = repository.listItems( range: range, @@ -624,7 +897,7 @@ class _ScheduleWorkspaceState extends ConsumerState { query: _searchQuery, accountIds: accountIds, sourceIds: sourceIds, - taskListIds: taskListIds, + taskListKeys: taskListKeys, sourceFilterActive: true, taskListFilterActive: true, includeCalendarEvents: _scope != ScheduleScope.tasks, @@ -642,7 +915,7 @@ class _ScheduleWorkspaceState extends ConsumerState { limit: _agendaOverdueTaskLimit, filters: ScheduleFilters( accountIds: accountIds, - taskListIds: taskListIds, + taskListKeys: taskListKeys, taskListFilterActive: true, includeTasks: _scope != ScheduleScope.events, showCompletedTasks: false, @@ -652,7 +925,7 @@ class _ScheduleWorkspaceState extends ConsumerState { limit: _agendaNoDateTaskLimit, filters: ScheduleFilters( accountIds: accountIds, - taskListIds: taskListIds, + taskListKeys: taskListKeys, taskListFilterActive: true, includeTasks: _scope != ScheduleScope.events, showCompletedTasks: true, @@ -850,20 +1123,45 @@ class _ScheduleWorkspaceState extends ConsumerState { } void _focusSearch() { - WidgetsBinding.instance.addPostFrameCallback((_) { - if (mounted) { - _searchFocusNode.requestFocus(); + 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); + } + } + + void _setSearchQuery(String value) { + if (_searchQuery == value && _searchController.text == value) { + return; + } + setState(() { + _searchQuery = value; + if (_searchController.text != value) { + _searchController.value = TextEditingValue( + text: value, + selection: TextSelection.collapsed(offset: value.length), + ); } }); } + void _clearSearchQuery() { + _setSearchQuery(''); + } + void _closeSearch() { setState(() { _searchActive = false; _searchQuery = ''; _searchController.clear(); }); - _searchFocusNode.unfocus(); + if (!_nativeHeaderBarAvailable) { + FocusManager.instance.primaryFocus?.unfocus(); + } } void _previous() { @@ -920,94 +1218,64 @@ class _ScheduleWorkspaceState extends ConsumerState { }); } - bool _handleScheduleShortcutEvent(KeyEvent event) { - if (event is! KeyDownEvent || !_canHandleRouteShortcut()) { - return false; - } - final keyboard = HardwareKeyboard.instance; - if (BusyMaxShortcutActivators.search.accepts(event, keyboard)) { - if (!_searchActive) { - setState(() => _searchActive = true); - } - _focusSearch(); - return true; - } - if (BusyMaxShortcutActivators.create.accepts(event, keyboard)) { - _openCreateAtSelectedDate(); - return true; - } - if (_searchActive && - BusyMaxShortcutActivators.dismiss.accepts(event, keyboard)) { - _closeSearch(); - return true; - } - if (!_canHandleScheduleShortcut()) { - return false; - } - if (keyboard.isControlPressed || - keyboard.isAltPressed || - keyboard.isMetaPressed) { + bool _isScheduleShortcutEnabled(_ScheduleShortcut command) { + if (!_canHandleRouteShortcut()) { return false; } + return switch (command) { + _ScheduleShortcut.search => true, + _ScheduleShortcut.create => + _latestWritableSources.isNotEmpty || _latestCanCreateTask, + _ScheduleShortcut.dismissSearch => _searchActive, + _ScheduleShortcut.newEvent => + _canHandleScheduleShortcut() && _latestWritableSources.isNotEmpty, + _ScheduleShortcut.newTask => + _canHandleScheduleShortcut() && _latestCanCreateTask, + _ScheduleShortcut.today || + _ScheduleShortcut.day || + _ScheduleShortcut.week || + _ScheduleShortcut.month || + _ScheduleShortcut.year || + _ScheduleShortcut.agenda => _canHandleScheduleShortcut(), + _ScheduleShortcut.previous || _ScheduleShortcut.next => + _canHandleScheduleShortcut() && _mode != ScheduleViewMode.agenda, + }; + } - switch (event.logicalKey) { - case LogicalKeyboardKey.arrowRight: - if (!keyboard.isShiftPressed || _mode == ScheduleViewMode.agenda) { - return false; - } - _next(); - return true; - case LogicalKeyboardKey.arrowLeft: - if (!keyboard.isShiftPressed || _mode == ScheduleViewMode.agenda) { - return false; + void _invokeScheduleShortcut(_ScheduleShortcut command) { + switch (command) { + case _ScheduleShortcut.search: + if (!_searchActive) { + setState(() => _searchActive = true); } + _focusSearch(); + case _ScheduleShortcut.create: + _openCreateAtSelectedDate(); + case _ScheduleShortcut.dismissSearch: + _closeSearch(); + case _ScheduleShortcut.previous: _previous(); - return true; - case LogicalKeyboardKey.keyE: - if (_latestVisibleSources.isEmpty) { - return false; - } + case _ScheduleShortcut.next: + _next(); + case _ScheduleShortcut.newEvent: unawaited( - _openNewEvent(_latestVisibleSources, _defaultSelectedDateStart()), + _openNewEvent(_latestWritableSources, _defaultSelectedDateStart()), ); - return true; - case LogicalKeyboardKey.keyT: - if (!keyboard.isShiftPressed) { - if (_latestAccounts.isEmpty) { - return false; - } - unawaited(_openNewTask(_latestAccounts, due: _day(_selectedDate))); - return true; - } + case _ScheduleShortcut.newTask: + unawaited(_openNewTask(_latestAccounts, due: _day(_selectedDate))); + case _ScheduleShortcut.today: _goToToday(); - return true; - case LogicalKeyboardKey.digit1: - case LogicalKeyboardKey.numpad1: - case LogicalKeyboardKey.keyD: + case _ScheduleShortcut.day: _setMode(ScheduleViewMode.day); - return true; - case LogicalKeyboardKey.digit2: - case LogicalKeyboardKey.numpad2: - case LogicalKeyboardKey.keyW: + case _ScheduleShortcut.week: _setMode(ScheduleViewMode.week); - return true; - case LogicalKeyboardKey.digit3: - case LogicalKeyboardKey.numpad3: - case LogicalKeyboardKey.keyM: + case _ScheduleShortcut.month: _setMode(ScheduleViewMode.month); - return true; - case LogicalKeyboardKey.digit4: - case LogicalKeyboardKey.numpad4: - case LogicalKeyboardKey.keyY: + case _ScheduleShortcut.year: _setMode(ScheduleViewMode.year); - return true; - case LogicalKeyboardKey.digit0: - case LogicalKeyboardKey.numpad0: - case LogicalKeyboardKey.keyA: + case _ScheduleShortcut.agenda: _setMode(ScheduleViewMode.agenda); - return true; } - return false; } DateTime _defaultSelectedDateStart() { @@ -1078,10 +1346,17 @@ class _ScheduleWorkspaceState extends ConsumerState { Future _openCreateChoice( List accounts, List sources, - DateTime start, - ) async { + DateTime start, { + required bool canCreateTask, + }) async { + final writableSources = writableCalendarSources(sources); + if (writableSources.isEmpty && !canCreateTask) { + return; + } final choice = await showScheduleCreateMenu( context: context, + canCreateEvent: writableSources.isNotEmpty, + canCreateTask: canCreateTask, headerBarService: ref.read(linuxHeaderBarServiceProvider), ); if (!mounted || choice == null) { @@ -1089,30 +1364,31 @@ class _ScheduleWorkspaceState extends ConsumerState { } switch (choice) { case ScheduleCreateChoice.event: - unawaited(_openNewEvent(sources, start)); + unawaited(_openNewEvent(writableSources, start)); case ScheduleCreateChoice.task: await _openNewTask(accounts, due: _day(start)); } } void _openCreateAtSelectedDate() { - unawaited( - _openCreateChoice( - _latestAccounts, - _latestVisibleSources, - _defaultSelectedDateStart(), - ), - ); + if (_nativeHeaderBarAvailable && _headerBarSession.isCurrent) { + unawaited(_headerBarSession.showCreateMenu()); + return; + } + if (_showFlutterHeaderFallback) { + _createMenuController.open(); + } } Future _openNewEvent( List sources, DateTime start, ) async { - if (sources.isEmpty) { + final writableSources = writableCalendarSources(sources); + if (writableSources.isEmpty) { return; } - final source = sources.first; + final source = writableSources.first; await _openEventEditor( EventEditorDraft.newEvent( accountId: source.accountId, @@ -1121,7 +1397,7 @@ class _ScheduleWorkspaceState extends ConsumerState { start: start, end: start.add(const Duration(hours: 1)), ), - sources, + writableSources, ); } @@ -1132,7 +1408,7 @@ class _ScheduleWorkspaceState extends ConsumerState { Offset? globalPosition, }) async { final action = await showScheduleItemDetailsPopover( - context: context, + context: anchorContext, anchorContext: anchorContext, item: item, anchorPoint: globalPosition, @@ -1144,13 +1420,20 @@ class _ScheduleWorkspaceState extends ConsumerState { case ScheduleItemDetailsAction.export: await _exportItem(item); case ScheduleItemDetailsAction.edit: - _editItem(item, sources); + if (item.capabilities.canEdit) { + _editItem(item, sources); + } case ScheduleItemDetailsAction.delete: - await _deleteItem(item); + if (item.capabilities.canDelete) { + await _deleteItem(item); + } } } void _editItem(ScheduleItem item, List sources) { + if (!item.capabilities.canEdit) { + return; + } if (item is CalendarScheduleItem) { unawaited( _openEventEditor( @@ -1214,26 +1497,282 @@ class _ScheduleWorkspaceState extends ConsumerState { } void _openTaskDetails(TaskScheduleItem item) { + unawaited( + _openTaskDetailsTarget( + _TaskDetailsTarget( + accountId: item.accountId, + taskListId: item.sourceId, + taskId: item.id, + ), + ), + ); + } + + Future _openTaskDetailsTarget( + _TaskDetailsTarget target, { + bool Function()? isRequestCurrent, + }) async { + if (_taskDetailsTarget == target) { + return true; + } + final replacingOpenTarget = _taskDetailsTarget != null; + if (replacingOpenTarget && !await _confirmDiscardTaskDetails()) { + return false; + } + if (!mounted || !(isRequestCurrent?.call() ?? true)) { + return false; + } setState(() { - _taskDetailsTarget = _TaskDetailsTarget( - accountId: item.accountId, - taskListId: item.sourceId, - taskId: item.id, - ); + _taskDetailsTarget = target; + _taskDetailsDirty = false; }); + if (replacingOpenTarget) { + return true; + } unawaited( acquireBusyMaxModalBarrier(ref.read(linuxHeaderBarServiceProvider)), ); + return true; + } + + ScheduleTaskListKey? get _initialTaskListKey { + final accountId = widget.initialTaskAccountId?.trim(); + final taskListId = widget.initialTaskListId?.trim(); + if (accountId == null || + accountId.isEmpty || + taskListId == null || + taskListId.isEmpty) { + return null; + } + return ScheduleTaskListKey(accountId: accountId, taskListId: taskListId); + } + + ScheduleTaskTarget? get _initialTaskRouteTarget { + final listKey = _initialTaskListKey; + final taskId = widget.initialTaskId?.trim(); + if (listKey == null || taskId == null || taskId.isEmpty) { + return null; + } + return ScheduleTaskTarget( + accountId: listKey.accountId, + taskListId: listKey.taskListId, + taskId: taskId, + ); + } + + void _scheduleInitialTaskWatch() { + final generation = ++_initialTaskWatchGeneration; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted && generation == _initialTaskWatchGeneration) { + unawaited(_startInitialTaskWatch(generation)); + } + }); + } + + Future _startInitialTaskWatch(int generation) async { + final previousSubscription = _initialTaskTargetSubscription; + _initialTaskTargetSubscription = null; + if (!mounted || generation != _initialTaskWatchGeneration) { + await previousSubscription?.cancel(); + return; + } + final request = _initialTaskRouteTarget; + if (request == null) { + await _reconcileTaskDetailsWithListRoute(generation); + await previousSubscription?.cancel(); + return; + } + if (!await _reconcileTaskDetailsWithTaskRoute(request, generation)) { + await previousSubscription?.cancel(); + return; + } + await previousSubscription?.cancel(); + if (!mounted || generation != _initialTaskWatchGeneration) { + return; + } + _initialTaskTargetSubscription = ref + .read(scheduleRepositoryProvider) + .watchTaskTarget( + accountId: request.accountId, + taskListId: request.taskListId, + taskId: request.taskId, + ) + .listen((target) { + if (target == null) { + unawaited(_handleInitialTaskUnavailable(request, generation)); + return; + } + unawaited(_handleInitialTaskTarget(target, generation)); + }); + } + + Future _reconcileTaskDetailsWithTaskRoute( + ScheduleTaskTarget request, + int generation, + ) async { + final visibleTarget = _taskDetailsTarget; + if (visibleTarget == null || visibleTarget.scheduleTarget == request) { + return true; + } + if (!await _confirmDiscardTaskDetails()) { + if (mounted && + generation == _initialTaskWatchGeneration && + _initialTaskRouteTarget == request) { + _goToTaskRoute(visibleTarget); + } + return false; + } + if (!mounted || + generation != _initialTaskWatchGeneration || + _initialTaskRouteTarget != request || + _taskDetailsTarget != visibleTarget) { + return false; + } + _closeTaskDetails(); + return true; + } + + Future _reconcileTaskDetailsWithListRoute(int generation) async { + final visibleTarget = _taskDetailsTarget; + if (visibleTarget == null) { + return; + } + if (!await _confirmDiscardTaskDetails()) { + if (mounted && + generation == _initialTaskWatchGeneration && + _initialTaskRouteTarget == null) { + _goToTaskRoute(visibleTarget); + } + return; + } + if (!mounted || + generation != _initialTaskWatchGeneration || + _initialTaskRouteTarget != null || + _taskDetailsTarget != visibleTarget) { + return; + } + _closeTaskDetails(); + } + + Future _handleInitialTaskUnavailable( + ScheduleTaskTarget request, + int generation, + ) async { + final visibleTarget = _taskDetailsTarget; + if (!mounted || + generation != _initialTaskWatchGeneration || + _initialTaskRouteTarget != request || + visibleTarget?.scheduleTarget != request) { + return; + } + if (!await _confirmDiscardTaskDetails()) { + return; + } + if (!mounted || + generation != _initialTaskWatchGeneration || + _initialTaskRouteTarget != request || + _taskDetailsTarget != visibleTarget) { + return; + } + _closeTaskDetails(); + } + + Future _handleInitialTaskTarget( + ScheduleTaskTarget target, + int generation, + ) async { + if (!mounted || + generation != _initialTaskWatchGeneration || + _initialTaskHandled || + _initialTaskOpening || + _initialTaskRouteTarget != target) { + return; + } + _initialTaskOpening = true; + final detailsTarget = _TaskDetailsTarget( + accountId: target.accountId, + taskListId: target.taskListId, + taskId: target.taskId, + ); + final opened = await _openTaskDetailsTarget( + detailsTarget, + isRequestCurrent: () => + mounted && + generation == _initialTaskWatchGeneration && + _initialTaskRouteTarget == target, + ); + if (!mounted || generation != _initialTaskWatchGeneration) { + return; + } + _initialTaskOpening = false; + if (opened) { + _initialTaskHandled = true; + return; + } + + // A declined discard keeps the editor and URL as one atomic state. + final visibleTarget = _taskDetailsTarget; + if (visibleTarget != null && _initialTaskRouteTarget == target) { + _goToTaskRoute(visibleTarget); + } + } + + Future _requestCloseTaskDetails() async { + if (!await _confirmDiscardTaskDetails()) { + return; + } + _closeTaskDetails(); + } + + Future _confirmDiscardTaskDetails() async { + if (!_taskDetailsDirty) { + return true; + } + return showBusyMaxConfirm( + context, + title: context.l10n.discardChanges, + message: context.l10n.discardChangesConfirmation, + confirmLabel: context.l10n.discard, + destructive: true, + barrierColor: Colors.transparent, + headerBarService: ref.read(linuxHeaderBarServiceProvider), + ); } void _closeTaskDetails() { - if (_taskDetailsTarget == null) { + final target = _taskDetailsTarget; + if (target == null) { return; } - setState(() => _taskDetailsTarget = null); + setState(() { + _taskDetailsTarget = null; + _taskDetailsDirty = false; + }); unawaited( releaseBusyMaxModalBarrier(ref.read(linuxHeaderBarServiceProvider)), ); + if (_initialTaskRouteTarget == target.scheduleTarget) { + _goToTaskListRoute(target); + } + } + + void _goToTaskListRoute(_TaskDetailsTarget target) { + GoRouter.maybeOf(context)?.go( + _taskRouteLocation( + accountId: target.accountId, + taskListId: target.taskListId, + ), + ); + } + + void _goToTaskRoute(_TaskDetailsTarget target) { + GoRouter.maybeOf(context)?.go( + _taskRouteLocation( + accountId: target.accountId, + taskListId: target.taskListId, + taskId: target.taskId, + ), + ); } Future _saveEvent(EventEditorDraft draft) async { @@ -1269,10 +1808,14 @@ class _ScheduleWorkspaceState extends ConsumerState { EventEditorDraft draft, List sources, ) async { + final editableSources = writableCalendarSources(sources); + if (editableSources.every((source) => source.id != draft.sourceId)) { + return; + } final result = await showBusyMaxEventEditorDialog( context, initialDraft: draft, - sources: sources, + sources: editableSources, categorySuggestionsByAccount: _categorySuggestionsByAccount(), headerBarService: ref.read(linuxHeaderBarServiceProvider), ); @@ -1301,6 +1844,9 @@ class _ScheduleWorkspaceState extends ConsumerState { } Future _deleteItem(ScheduleItem item) async { + if (!item.capabilities.canDelete) { + return; + } final confirmed = await showBusyMaxConfirm( context, title: item is CalendarScheduleItem @@ -1337,12 +1883,41 @@ class _ScheduleWorkspaceState extends ConsumerState { if (accounts.isEmpty) { return; } + TaskListEntity? initialList; + final routedListKey = _initialTaskListKey; + if (routedListKey != null) { + for (final list in _latestVisibleTaskLists) { + if (list.accountId == routedListKey.accountId && + list.id == routedListKey.taskListId) { + initialList = list; + break; + } + } + if (initialList == null && + accounts.any((account) => account.id == routedListKey.accountId)) { + final routedAccountLists = await ref + .read( + taskListsRepositoryForAccountProvider(routedListKey.accountId), + ) + .listTaskLists(); + if (!mounted) { + return; + } + for (final list in routedAccountLists) { + if (list.id == routedListKey.taskListId) { + initialList = list; + break; + } + } + } + } final draft = await showBusyMaxNewTaskDialog( context, ref: ref, accounts: accounts, - initialAccountId: ref.read(activeAccountProvider), - initialListId: null, + initialAccountId: + initialList?.accountId ?? ref.read(activeAccountProvider), + initialListId: initialList?.id, initialDueUtc: due, headerBarService: ref.read(linuxHeaderBarServiceProvider), ); @@ -1561,46 +2136,6 @@ T? _findCommandItem( return null; } -class _ScheduleSearchField extends StatelessWidget { - const _ScheduleSearchField({ - required this.controller, - required this.focusNode, - required this.onChanged, - required this.onClose, - }); - - final TextEditingController controller; - final FocusNode focusNode; - final ValueChanged onChanged; - final VoidCallback onClose; - - @override - Widget build(BuildContext context) { - return Padding( - padding: const EdgeInsets.symmetric( - horizontal: BusyMaxSpacing.md, - vertical: BusyMaxSpacing.sm, - ), - child: TextField( - controller: controller, - focusNode: focusNode, - autofocus: true, - onChanged: onChanged, - textInputAction: TextInputAction.search, - decoration: InputDecoration( - prefixIcon: const Icon(YaruIcons.search), - suffixIcon: YaruIconButton( - tooltip: MaterialLocalizations.of(context).closeButtonTooltip, - icon: const Icon(YaruIcons.window_close), - onPressed: onClose, - ), - hintText: MaterialLocalizations.of(context).searchFieldLabel, - ), - ), - ); - } -} - class _ScheduleItemsResult { const _ScheduleItemsResult({ required this.items, @@ -1646,6 +2181,9 @@ class _ScheduleBody extends StatelessWidget { required this.onItemSelected, required this.onItemAnchorAvailable, required this.onTaskCompletionChanged, + required this.canCreateEvent, + required this.canCreateTask, + required this.searchActive, }); final bool isLoading; @@ -1680,6 +2218,9 @@ class _ScheduleBody extends StatelessWidget { final ScheduleItemAnchorCallback onItemAnchorAvailable; final void Function(TaskScheduleItem item, bool completed) onTaskCompletionChanged; + final bool canCreateEvent; + final bool canCreateTask; + final bool searchActive; @override Widget build(BuildContext context) { @@ -1696,6 +2237,15 @@ class _ScheduleBody extends StatelessWidget { onRefresh: onRefresh, ); } + if (items.isEmpty && searchActive) { + 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( range: range, @@ -1794,39 +2344,126 @@ class _HorizontalSchedulePager extends StatelessWidget { } } -class _ScheduleTaskDetailsOverlay extends StatelessWidget { +class _ScheduleTaskDetailsOverlay extends StatefulWidget { const _ScheduleTaskDetailsOverlay({ required this.child, required this.target, required this.onClose, + required this.onDirtyChanged, }); final Widget child; final _TaskDetailsTarget? target; final VoidCallback onClose; + final ValueChanged onDirtyChanged; + + @override + State<_ScheduleTaskDetailsOverlay> createState() => + _ScheduleTaskDetailsOverlayState(); +} + +class _ScheduleTaskDetailsOverlayState + extends State<_ScheduleTaskDetailsOverlay> { + final _modalFocusNode = FocusNode(debugLabel: 'scheduleTaskDetails'); + FocusNode? _previousFocus; + + @override + void initState() { + super.initState(); + if (widget.target != null) { + _previousFocus = FocusManager.instance.primaryFocus; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + _modalFocusNode.requestFocus(); + } + }); + } + } + + @override + void didUpdateWidget(covariant _ScheduleTaskDetailsOverlay oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.target == null && widget.target != null) { + _previousFocus = FocusManager.instance.primaryFocus; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + _modalFocusNode.requestFocus(); + } + }); + } else if (oldWidget.target != null && widget.target == null) { + final previousFocus = _previousFocus; + _previousFocus = null; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted && (previousFocus?.context?.mounted ?? false)) { + previousFocus!.requestFocus(); + } + }); + } + } + + @override + void dispose() { + _modalFocusNode.dispose(); + super.dispose(); + } @override Widget build(BuildContext context) { - final target = this.target; + final target = widget.target; if (target == null) { - return child; + return widget.child; } return Stack( children: [ - child, + ExcludeFocus(child: ExcludeSemantics(child: widget.child)), ModalBarrier( color: busyMaxModalBarrierColor(context), dismissible: false, ), - Center( - child: BusyMaxModalEditorSurface( - maxWidth: BusyMaxSizes.compactDetailsWidth, - maxHeight: 760, - child: TaskDetailsPane( - accountId: target.accountId, - taskListId: target.taskListId, - taskId: target.taskId, - onClose: onClose, + 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; + }, + ), + }, + 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, + dialogBarrierColor: Colors.transparent, + ), + ), + ), + ), + ), + ), + ), ), ), ), @@ -1845,6 +2482,56 @@ class _TaskDetailsTarget { final String accountId; final String taskListId; final String taskId; + + ScheduleTaskTarget get scheduleTarget => ScheduleTaskTarget( + accountId: accountId, + taskListId: taskListId, + taskId: taskId, + ); + + @override + bool operator ==(Object other) { + return other is _TaskDetailsTarget && + other.accountId == accountId && + other.taskListId == taskListId && + other.taskId == taskId; + } + + @override + int get hashCode => Object.hash(accountId, taskListId, taskId); +} + +String _taskRouteLocation({ + required String accountId, + required String taskListId, + String? taskId, +}) { + return Uri( + pathSegments: [ + '', + 'tasks', + accountId, + taskListId, + if (taskId != null) taskId, + ], + ).toString(); +} + +class _ScheduleShortcutAction extends ContextAction<_ScheduleShortcutIntent> { + _ScheduleShortcutAction(this.state); + + final _ScheduleWorkspaceState state; + + @override + bool isEnabled(_ScheduleShortcutIntent intent, [BuildContext? context]) { + return state._isScheduleShortcutEnabled(intent.command); + } + + @override + Object? invoke(_ScheduleShortcutIntent intent, [BuildContext? context]) { + state._invokeScheduleShortcut(intent.command); + return null; + } } int _firstWeekday(BuildContext context) { diff --git a/lib/src/features/settings/presentation/settings_screen.dart b/lib/src/features/settings/presentation/settings_screen.dart index b70122b..e23b55a 100644 --- a/lib/src/features/settings/presentation/settings_screen.dart +++ b/lib/src/features/settings/presentation/settings_screen.dart @@ -21,6 +21,7 @@ import '../../accounts/data/accounts_repository.dart'; import '../../auth/data/auth_repository.dart'; import '../../diagnostics/presentation/diagnostics_screen.dart'; import '../../sync/sync_auth_error.dart'; +import '../../tasks/presentation/desktop_date_time_fields.dart'; import '../../tasks/presentation/tasks_selection_state.dart'; class SettingsScreen extends ConsumerStatefulWidget { @@ -57,6 +58,14 @@ class _SettingsScreenState extends ConsumerState { super.dispose(); } + @override + void didUpdateWidget(covariant SettingsScreen oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.initialPage != widget.initialPage) { + _page = widget.initialPage; + } + } + @override Widget build(BuildContext context) { final selectedAccount = ref.watch(selectedAccountProvider); @@ -121,21 +130,25 @@ class _SettingsScreenState extends ConsumerState { ? null : () => _fullSync(context, ref, accounts), ), - BusyMaxSwitchRow( - title: l10n.runInBackgroundWhenClosed, - value: settings.runInBackgroundWhenClosed, - onChanged: settingsController.setRunInBackgroundWhenClosed, - leading: const Icon(YaruIcons.window), - ), BusyMaxSwitchRow( title: l10n.showTrayIcon, value: settings.showTrayIcon, onChanged: settingsController.setShowTrayIcon, leading: const Icon(YaruIcons.pin), ), + BusyMaxSwitchRow( + title: l10n.runInBackgroundWhenClosed, + subtitle: settings.showTrayIcon ? null : l10n.requiresTrayIcon, + value: settings.runInBackgroundWhenClosed, + enabled: settings.showTrayIcon, + onChanged: settingsController.setRunInBackgroundWhenClosed, + leading: const Icon(YaruIcons.window), + ), BusyMaxSwitchRow( title: l10n.startMinimizedToTray, + subtitle: settings.showTrayIcon ? null : l10n.requiresTrayIcon, value: settings.startMinimizedToTray, + enabled: settings.showTrayIcon, onChanged: settingsController.setStartMinimizedToTray, leading: const Icon(YaruIcons.window_minimize), ), @@ -198,10 +211,33 @@ class _SettingsScreenState extends ConsumerState { ), BusyMaxSwitchRow( title: l10n.quietHours, + subtitle: l10n.quietHoursDescription, value: settings.quietHoursEnabled, onChanged: settingsController.setQuietHoursEnabled, leading: const Icon(YaruIcons.clear_night), ), + DesktopTimeValueRow( + label: l10n.quietHoursStart, + time: settings.quietHoursStart, + enabled: settings.quietHoursEnabled, + allowEmpty: false, + onChanged: (time) { + if (time != null) { + unawaited(settingsController.setQuietHoursStart(time)); + } + }, + ), + DesktopTimeValueRow( + label: l10n.quietHoursEnd, + time: settings.quietHoursEnd, + enabled: settings.quietHoursEnabled, + allowEmpty: false, + onChanged: (time) { + if (time != null) { + unawaited(settingsController.setQuietHoursEnd(time)); + } + }, + ), ], ), SettingsPage.privacy => BusyMaxGroupedList( @@ -214,12 +250,6 @@ class _SettingsScreenState extends ConsumerState { onChanged: settingsController.setRedactTaskContentInDiagnostics, leading: const Icon(YaruIcons.shield_warning), ), - BusyMaxSwitchRow( - title: l10n.detailedNotifications, - value: settings.detailedNotifications, - onChanged: settingsController.setDetailedNotifications, - leading: const Icon(YaruIcons.eye), - ), ], ), SettingsPage.diagnostics => const DiagnosticsPanel(scrollable: false), @@ -253,7 +283,7 @@ class _SettingsScreenState extends ConsumerState { ), child: _SettingsPageSelector( selected: _page, - onSelected: (page) => setState(() => _page = page), + onSelected: _selectPage, ), ), Expanded( @@ -275,7 +305,7 @@ class _SettingsScreenState extends ConsumerState { width: BusyMaxSizes.sidebarWidth, child: _SettingsSidebar( selected: _page, - onSelected: (page) => setState(() => _page = page), + onSelected: _selectPage, ), ), Expanded(child: content), @@ -325,7 +355,7 @@ class _SettingsScreenState extends ConsumerState { return; } if (action == BusyMaxHeaderBarAction.settings) { - setState(() => _page = SettingsPage.accounts); + _selectPage(SettingsPage.accounts); return; } if (action == BusyMaxHeaderBarAction.keyboardShortcuts) { @@ -351,9 +381,40 @@ class _SettingsScreenState extends ConsumerState { } void _goBack() { + if (context.canPop()) { + context.pop(); + return; + } context.go('/schedule'); } + void _selectPage(SettingsPage page) { + if (_page != page) { + setState(() => _page = page); + } + final router = GoRouter.maybeOf(context); + final uri = router?.state.uri; + if (router == null || uri == null || uri.path != '/settings') { + return; + } + final routePage = uri.queryParameters['page']; + if (routePage == settingsPageRouteValue(page)) { + return; + } + unawaited( + router.replace( + uri + .replace( + queryParameters: { + ...uri.queryParameters, + 'page': settingsPageRouteValue(page), + }, + ) + .toString(), + ), + ); + } + void _updateSettingsHeaderBar( BuildContext context, String title, { @@ -373,8 +434,10 @@ class _SettingsScreenState extends ConsumerState { title: title, viewMode: settings.scheduleViewMode, canRefresh: false, - canCreate: false, + canCreateEvent: false, + canCreateTask: false, searchActive: false, + searchQuery: '', canShowSidebar: showSidebar, sidebarVisible: showSidebar, navigationVisible: false, @@ -543,21 +606,37 @@ class _SettingsSidebar extends StatelessWidget { @override Widget build(BuildContext context) { - return Material( - color: BusyMaxSurfaceColors.of(context).sidebar, - child: ListView( - padding: const EdgeInsets.symmetric( - horizontal: BusyMaxSpacing.xs, - vertical: BusyMaxSpacing.md, + final sidebarColor = BusyMaxSurfaceColors.of(context).sidebar; + return BusyMaxSidebarSurface( + child: YaruNavigationPageTheme( + data: YaruNavigationPageThemeData( + sideBarColor: sidebarColor, + railPadding: const EdgeInsets.symmetric( + horizontal: BusyMaxSpacing.xs, + vertical: BusyMaxSpacing.md, + ), + ), + child: YaruNavigationRail( + length: SettingsPage.values.length, + selectedIndex: SettingsPage.values.indexOf(selected), + onDestinationSelected: (index) => + onSelected(SettingsPage.values[index]), + itemBuilder: (context, index, isSelected) { + final page = SettingsPage.values[index]; + return Semantics( + key: ValueKey('settings-navigation-${page.name}'), + container: true, + selected: isSelected, + child: YaruNavigationRailItem( + style: YaruNavigationRailStyle.labelledExtended, + width: BusyMaxSizes.sidebarWidth - 2 * BusyMaxSpacing.xs, + extendedSelectedIndicator: true, + icon: Icon(_settingsPageIcon(page)), + label: Text(_settingsPageLabel(context, page)), + ), + ); + }, ), - children: [ - for (final page in SettingsPage.values) - _SettingsSidebarItem( - page: page, - selected: selected == page, - onTap: () => onSelected(page), - ), - ], ), ); } @@ -591,9 +670,10 @@ class _SettingsPageSelector extends StatelessWidget { ), ], onSelected: onSelected, - triggerBuilder: (context, onPressed) { - return BusyMaxPushButton.outlined( + triggerBuilder: (context, onPressed, focusNode) { + return BusyMaxPushButton.standard( onPressed: onPressed, + focusNode: focusNode, child: Row( children: [ Icon(_settingsPageIcon(selected)), @@ -615,65 +695,6 @@ class _SettingsPageSelector extends StatelessWidget { } } -class _SettingsSidebarItem extends StatelessWidget { - const _SettingsSidebarItem({ - required this.page, - required this.selected, - required this.onTap, - }); - - final SettingsPage page; - final bool selected; - final VoidCallback onTap; - - @override - Widget build(BuildContext context) { - final colorScheme = Theme.of(context).colorScheme; - return Padding( - padding: const EdgeInsets.symmetric(vertical: 2), - child: Material( - color: selected - ? colorScheme.onSurface.withValues(alpha: 0.08) - : Colors.transparent, - borderRadius: BorderRadius.circular(BusyMaxRadius.headerButton), - child: InkWell( - borderRadius: BorderRadius.circular(BusyMaxRadius.headerButton), - onTap: onTap, - child: SizedBox( - height: 42, - child: Row( - children: [ - const SizedBox(width: BusyMaxSpacing.md), - Icon( - _settingsPageIcon(page), - size: BusyMaxSizes.iconSm, - color: selected - ? colorScheme.onSurface - : colorScheme.onSurfaceVariant, - ), - const SizedBox(width: BusyMaxSpacing.sm), - Expanded( - child: Text( - _settingsPageLabel(context, page), - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.labelLarge?.copyWith( - color: selected - ? colorScheme.onSurface - : colorScheme.onSurfaceVariant, - ), - ), - ), - const SizedBox(width: BusyMaxSpacing.md), - ], - ), - ), - ), - ), - ); - } -} - class _SettingsFallbackHeader extends StatelessWidget { const _SettingsFallbackHeader({required this.title, required this.onBack}); diff --git a/lib/src/features/task_lists/presentation/task_lists_sidebar.dart b/lib/src/features/task_lists/presentation/task_lists_sidebar.dart index 75c8f24..b90325b 100644 --- a/lib/src/features/task_lists/presentation/task_lists_sidebar.dart +++ b/lib/src/features/task_lists/presentation/task_lists_sidebar.dart @@ -7,7 +7,6 @@ import 'package:go_router/go_router.dart'; import 'package:yaru/yaru.dart'; import '../../../app/app_bootstrap.dart'; -import '../../../app/busymax_yaru_theme.dart'; import '../../../app/busymax_design.dart'; import '../../../app/busymax_dialogs.dart'; import '../../../features/accounts/data/accounts_repository.dart'; @@ -30,8 +29,7 @@ class TaskListsSidebar extends ConsumerWidget { return SizedBox( width: BusyMaxSizes.sidebarWidth, - child: Material( - color: BusyMaxSurfaceColors.of(context).sidebar, + child: BusyMaxSidebarSurface( child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ @@ -102,7 +100,7 @@ class TaskListsSidebar extends ConsumerWidget { child: _SidebarFooterButton( icon: YaruIcons.settings, label: l10n.settings, - onTap: () => context.go('/settings'), + onTap: () => unawaited(context.push('/settings')), ), ), ], diff --git a/lib/src/features/tasks/presentation/desktop_date_time_fields.dart b/lib/src/features/tasks/presentation/desktop_date_time_fields.dart index 50d6734..7c90875 100644 --- a/lib/src/features/tasks/presentation/desktop_date_time_fields.dart +++ b/lib/src/features/tasks/presentation/desktop_date_time_fields.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:intl/intl.dart'; import 'package:busymax/src/app/busymax_design.dart'; +import 'package:busymax/src/app/busymax_dialogs.dart'; import 'package:busymax/src/l10n/l10n.dart'; import 'package:yaru/yaru.dart'; @@ -226,7 +227,10 @@ class _DesktopDateFieldState extends State { width: 190, child: widget.enabled ? dateEntry - : Opacity(opacity: 0.6, child: IgnorePointer(child: dateEntry)), + : Opacity( + opacity: 0.6, + child: ExcludeFocus(child: IgnorePointer(child: dateEntry)), + ), ), YaruIconButton( tooltip: widget.label, @@ -242,6 +246,9 @@ class _DesktopDateFieldState extends State { } Future _pickNativeDate(BuildContext context) async { + if (!widget.enabled) { + return; + } if (!widget.useNativePicker) { final fallbackPicked = await showBusyMaxDateValueDialog( context, @@ -304,10 +311,9 @@ Future showBusyMaxDateValueDialog( required String label, required String? initialDate, }) { - return showDialog( - context: context, - barrierColor: Colors.transparent, - builder: (context) { + return showBusyMaxModalDialog( + context, + builder: (dialogContext) { return _DesktopDateValueDialog(label: label, initialDate: initialDate); }, ); @@ -344,11 +350,11 @@ class _DesktopDateValueDialogState extends State<_DesktopDateValueDialog> { title: widget.label, maxWidth: 360, actions: [ - BusyMaxPushButton.outlined( + BusyMaxPushButton.standard( onPressed: () => Navigator.of(context).pop(), child: Text(context.l10n.cancel), ), - BusyMaxPushButton.filled( + BusyMaxPushButton.suggested( onPressed: _selected == null ? null : _submit, child: Text(MaterialLocalizations.of(context).okButtonLabel), ), @@ -443,9 +449,9 @@ class DesktopTimeValueRow extends StatelessWidget { if (!enabled) { return; } - await showDialog( - context: context, - builder: (context) { + await showBusyMaxModalDialog( + context, + builder: (dialogContext) { return _DesktopTimeValueDialog( label: label, time: time, @@ -522,11 +528,11 @@ class _DesktopTimeValueDialogState extends State<_DesktopTimeValueDialog> { title: widget.label, maxWidth: 360, actions: [ - BusyMaxPushButton.outlined( + BusyMaxPushButton.standard( onPressed: () => Navigator.of(context).pop(), child: Text(context.l10n.cancel), ), - BusyMaxPushButton.filled( + BusyMaxPushButton.suggested( onPressed: !_invalid && (widget.allowEmpty || _selected != null) ? _submit : null, @@ -613,7 +619,10 @@ class _DesktopTimeFieldState extends State { width: 168, child: widget.enabled ? timeEntry - : Opacity(opacity: 0.6, child: IgnorePointer(child: timeEntry)), + : Opacity( + opacity: 0.6, + child: ExcludeFocus(child: IgnorePointer(child: timeEntry)), + ), ), enabled: widget.enabled, ); diff --git a/lib/src/features/tasks/presentation/task_filters.dart b/lib/src/features/tasks/presentation/task_filters.dart index ad2be04..6a766bc 100644 --- a/lib/src/features/tasks/presentation/task_filters.dart +++ b/lib/src/features/tasks/presentation/task_filters.dart @@ -1,6 +1,5 @@ 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'; @@ -90,11 +89,10 @@ class _TaskFiltersBarState extends ConsumerState { return Row( children: [ Expanded( - child: YaruSearchField( + child: BusyMaxSearchField( controller: _searchController, autofocus: false, hintText: l10n.searchTasks, - clearIconSemanticLabel: l10n.cancel, onClear: () { notifier.state = filter.copyWith(searchQuery: ''); }, diff --git a/lib/src/features/tasks/presentation/task_tree_view.dart b/lib/src/features/tasks/presentation/task_tree_view.dart index 2272da3..1610575 100644 --- a/lib/src/features/tasks/presentation/task_tree_view.dart +++ b/lib/src/features/tasks/presentation/task_tree_view.dart @@ -78,7 +78,7 @@ class TaskTreeView extends ConsumerWidget { icon: YaruIcons.plus, label: l10n.newTask, tooltip: l10n.newTask, - primary: true, + suggested: true, onPressed: onCreateTask, ), if (onRefreshAll != null) @@ -144,7 +144,7 @@ class TaskTreeView extends ConsumerWidget { icon: YaruIcons.plus, label: l10n.newTask, tooltip: l10n.newTask, - primary: true, + suggested: true, onPressed: onCreateTask, ), ], diff --git a/lib/src/features/tasks/presentation/tasks_workspace.dart b/lib/src/features/tasks/presentation/tasks_workspace.dart index 89beb8e..334a776 100644 --- a/lib/src/features/tasks/presentation/tasks_workspace.dart +++ b/lib/src/features/tasks/presentation/tasks_workspace.dart @@ -328,7 +328,7 @@ class _TasksToolbar extends ConsumerWidget { actions: [ BusyMaxToolbarButton( compact: compactActions, - primary: true, + suggested: true, tooltip: l10n.newTask, label: l10n.newTask, icon: YaruIcons.plus, diff --git a/lib/src/platform/gtk_font_service.dart b/lib/src/platform/gtk_font_service.dart index de3d63d..2e1e276 100644 --- a/lib/src/platform/gtk_font_service.dart +++ b/lib/src/platform/gtk_font_service.dart @@ -47,6 +47,8 @@ class GtkFontService { return _parseFontSettings(raw); } on MissingPluginException { return null; + } on PlatformException { + return null; } } @@ -78,6 +80,22 @@ class GtkThemeService { final MethodChannel _methodChannel; final EventChannel _themeColorsEventsChannel; + Future setPreferDark(bool? preferDark) async { + if (preferDark == null) { + return; + } + try { + await _methodChannel.invokeMethod( + 'setGtkThemePreference', + preferDark, + ); + } on MissingPluginException { + // Non-Linux and lightweight test hosts do not expose GTK settings. + } on PlatformException { + // A theme preference is advisory; fall back to the current GTK palette. + } + } + Future getGtkThemeColors() async { try { final raw = await _methodChannel.invokeMapMethod( @@ -89,6 +107,8 @@ class GtkThemeService { return _parseThemeColors(raw); } on MissingPluginException { return null; + } on PlatformException { + return null; } } @@ -106,8 +126,15 @@ class GtkThemeService { } } +final initialGtkFontSettingsProvider = Provider( + (ref) => null, +); + final gtkFontSettingsProvider = StreamProvider((ref) { - return const GtkFontService().watchGtkFont(); + return _seededGtkSettingsStream( + ref.watch(initialGtkFontSettingsProvider), + const GtkFontService().watchGtkFont(), + ); }); @immutable @@ -219,15 +246,33 @@ class GtkThemeColors { ]); } +final initialGtkThemeColorsProvider = Provider((ref) => null); + final gtkThemeColorsProvider = StreamProvider((ref) { - return const GtkThemeService().watchGtkThemeColors(); + return _seededGtkSettingsStream( + ref.watch(initialGtkThemeColorsProvider), + const GtkThemeService().watchGtkThemeColors(), + ); }); +Stream _seededGtkSettingsStream(T? initial, Stream updates) async* { + var previous = initial; + yield initial; + await for (final update in updates) { + if (update == previous) { + continue; + } + previous = update; + yield update; + } +} + GtkFontSettings? _parseFontSettings(Object? value) { if (value is! Map) { return null; } - final family = (value['family'] as String?)?.trim(); + final rawFamily = value['family']; + final family = rawFamily is String ? rawFamily.trim() : null; if (family == null || family.isEmpty) { return null; } @@ -243,8 +288,11 @@ GtkThemeColors? _parseThemeColors(Object? value) { if (value is! Map) { return null; } - final window = _parseColor(value['window'] as String?); - final brightness = switch ((value['brightness'] as String?)?.trim()) { + final window = _parseColor(value['window']); + final rawBrightness = value['brightness']; + final brightness = switch (rawBrightness is String + ? rawBrightness.trim() + : null) { 'dark' => Brightness.dark, 'light' => Brightness.light, _ => @@ -260,33 +308,36 @@ GtkThemeColors? _parseThemeColors(Object? value) { return GtkThemeColors( brightness: brightness, window: window, - view: _parseColor(value['view'] as String?), - sidebar: _parseColor(value['sidebar'] as String?), - secondarySidebar: _parseColor(value['secondarySidebar'] as String?), - headerbar: _parseColor(value['headerbar'] as String?), - headerbarFlat: _parseColor(value['headerbarFlat'] as String?), - card: _parseColor(value['card'] as String?), - dialog: _parseColor(value['dialog'] as String?), - popover: _parseColor(value['popover'] as String?), - control: _parseColor(value['control'] as String?), - controlHover: _parseColor(value['controlHover'] as String?), - controlActive: _parseColor(value['controlActive'] as String?), - accent: _parseColor(value['accent'] as String?), - activeToggle: _parseColor(value['activeToggle'] as String?), - foreground: _parseColor(value['foreground'] as String?), - mutedForeground: _parseColor(value['mutedForeground'] as String?), - disabledForeground: _parseColor(value['disabledForeground'] as String?), - disabledControl: _parseColor(value['disabledControl'] as String?), - border: _parseColor(value['border'] as String?), - subtleBorder: _parseColor(value['subtleBorder'] as String?), - sidebarBorder: _parseColor(value['sidebarBorder'] as String?), - shade: _parseColor(value['shade'] as String?), + view: _parseColor(value['view']), + sidebar: _parseColor(value['sidebar']), + secondarySidebar: _parseColor(value['secondarySidebar']), + headerbar: _parseColor(value['headerbar']), + headerbarFlat: _parseColor(value['headerbarFlat']), + card: _parseColor(value['card']), + dialog: _parseColor(value['dialog']), + popover: _parseColor(value['popover']), + control: _parseColor(value['control']), + controlHover: _parseColor(value['controlHover']), + controlActive: _parseColor(value['controlActive']), + accent: _parseColor(value['accent']), + activeToggle: _parseColor(value['activeToggle']), + foreground: _parseColor(value['foreground']), + mutedForeground: _parseColor(value['mutedForeground']), + disabledForeground: _parseColor(value['disabledForeground']), + disabledControl: _parseColor(value['disabledControl']), + border: _parseColor(value['border']), + subtleBorder: _parseColor(value['subtleBorder']), + sidebarBorder: _parseColor(value['sidebarBorder']), + shade: _parseColor(value['shade']), ); } -Color? _parseColor(String? value) { - final raw = value?.trim(); - if (raw == null || raw.isEmpty || !raw.startsWith('#')) { +Color? _parseColor(Object? value) { + if (value is! String) { + return null; + } + final raw = value.trim(); + if (raw.isEmpty || !raw.startsWith('#')) { return null; } final hex = raw.substring(1); diff --git a/lib/src/platform/linux_header_bar_provider.dart b/lib/src/platform/linux_header_bar_provider.dart new file mode 100644 index 0000000..52e9b44 --- /dev/null +++ b/lib/src/platform/linux_header_bar_provider.dart @@ -0,0 +1,14 @@ +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 index 0f8a48e..bb1c73e 100644 --- a/lib/src/platform/linux_header_bar_service.dart +++ b/lib/src/platform/linux_header_bar_service.dart @@ -19,13 +19,45 @@ enum BusyMaxHeaderBarAction { viewModeYear, viewModeAgenda, search, - create, + createEvent, + createTask, refresh, settings, keyboardShortcuts, 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({ @@ -37,6 +69,8 @@ class BusyMaxHeaderBarLabels { required this.agenda, required this.search, required this.create, + required this.createEvent, + required this.createTask, required this.refresh, required this.menu, required this.previous, @@ -56,6 +90,8 @@ class BusyMaxHeaderBarLabels { final String agenda; final String search; final String create; + final String createEvent; + final String createTask; final String refresh; final String menu; final String previous; @@ -76,6 +112,8 @@ class BusyMaxHeaderBarLabels { 'agenda': agenda, 'search': search, 'create': create, + 'createEvent': createEvent, + 'createTask': createTask, 'refresh': refresh, 'menu': menu, 'previous': previous, @@ -100,6 +138,8 @@ class BusyMaxHeaderBarLabels { agenda == other.agenda && search == other.search && create == other.create && + createEvent == other.createEvent && + createTask == other.createTask && refresh == other.refresh && menu == other.menu && previous == other.previous && @@ -121,6 +161,8 @@ class BusyMaxHeaderBarLabels { agenda, search, create, + createEvent, + createTask, refresh, menu, previous, @@ -150,6 +192,7 @@ class BusyMaxHeaderBarTheme { required this.accentForegroundColor, required this.popoverBackgroundColor, required this.borderColor, + required this.sidebarBorderColor, required this.shadeColor, required this.modalBarrierColor, }); @@ -168,6 +211,7 @@ class BusyMaxHeaderBarTheme { final Color accentForegroundColor; final Color popoverBackgroundColor; final Color borderColor; + final Color sidebarBorderColor; final Color shadeColor; final Color modalBarrierColor; @@ -187,6 +231,7 @@ class BusyMaxHeaderBarTheme { 'accentForegroundColor': busyMaxCssColor(accentForegroundColor), 'popoverBackgroundColor': busyMaxCssColor(popoverBackgroundColor), 'borderColor': busyMaxCssColor(borderColor), + 'sidebarBorderColor': busyMaxCssColor(sidebarBorderColor), 'shadeColor': busyMaxCssColor(shadeColor), 'modalBarrierColor': busyMaxCssColor(modalBarrierColor), }; @@ -210,6 +255,7 @@ class BusyMaxHeaderBarTheme { other.accentForegroundColor == accentForegroundColor && other.popoverBackgroundColor == popoverBackgroundColor && other.borderColor == borderColor && + other.sidebarBorderColor == sidebarBorderColor && other.shadeColor == shadeColor && other.modalBarrierColor == modalBarrierColor; } @@ -230,6 +276,7 @@ class BusyMaxHeaderBarTheme { accentForegroundColor, popoverBackgroundColor, borderColor, + sidebarBorderColor, shadeColor, modalBarrierColor, ); @@ -245,8 +292,10 @@ class BusyMaxHeaderBarState { required this.title, required this.viewMode, required this.canRefresh, - required this.canCreate, + required this.canCreateEvent, + required this.canCreateTask, required this.searchActive, + required this.searchQuery, required this.canShowSidebar, required this.sidebarVisible, required this.navigationVisible, @@ -254,13 +303,17 @@ class BusyMaxHeaderBarState { required this.backVisible, }); - static const int schemaVersion = 1; + static const int schemaVersion = 3; final String title; final ScheduleViewMode viewMode; final bool canRefresh; - final bool canCreate; + 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. /// @@ -278,8 +331,10 @@ class BusyMaxHeaderBarState { 'title': title, 'viewMode': viewMode.name, 'canRefresh': canRefresh, - 'canCreate': canCreate, + 'canCreateEvent': canCreateEvent, + 'canCreateTask': canCreateTask, 'searchActive': searchActive, + 'searchQuery': searchQuery, 'canShowSidebar': canShowSidebar, 'sidebarVisible': sidebarVisible, 'navigationVisible': navigationVisible, @@ -292,8 +347,10 @@ class BusyMaxHeaderBarState { String? title, ScheduleViewMode? viewMode, bool? canRefresh, - bool? canCreate, + bool? canCreateEvent, + bool? canCreateTask, bool? searchActive, + String? searchQuery, bool? canShowSidebar, bool? sidebarVisible, bool? navigationVisible, @@ -304,8 +361,10 @@ class BusyMaxHeaderBarState { title: title ?? this.title, viewMode: viewMode ?? this.viewMode, canRefresh: canRefresh ?? this.canRefresh, - canCreate: canCreate ?? this.canCreate, + 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, @@ -322,8 +381,10 @@ class BusyMaxHeaderBarState { title == other.title && viewMode == other.viewMode && canRefresh == other.canRefresh && - canCreate == other.canCreate && + canCreateEvent == other.canCreateEvent && + canCreateTask == other.canCreateTask && searchActive == other.searchActive && + searchQuery == other.searchQuery && canShowSidebar == other.canShowSidebar && sidebarVisible == other.sidebarVisible && navigationVisible == other.navigationVisible && @@ -336,8 +397,10 @@ class BusyMaxHeaderBarState { title, viewMode, canRefresh, - canCreate, + canCreateEvent, + canCreateTask, searchActive, + searchQuery, canShowSidebar, sidebarVisible, navigationVisible, @@ -409,6 +472,10 @@ class LinuxHeaderBarService { if (!_disposed) { _available = false; } + } on PlatformException { + if (!_disposed) { + _available = false; + } } } @@ -520,6 +587,11 @@ class LinuxHeaderBarService { @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); @@ -535,9 +607,51 @@ class LinuxHeaderBarService { 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; + } + + 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, @@ -552,7 +666,8 @@ class LinuxHeaderBarService { 'viewModeYear' => BusyMaxHeaderBarAction.viewModeYear, 'viewModeAgenda' => BusyMaxHeaderBarAction.viewModeAgenda, 'search' => BusyMaxHeaderBarAction.search, - 'create' => BusyMaxHeaderBarAction.create, + 'createEvent' => BusyMaxHeaderBarAction.createEvent, + 'createTask' => BusyMaxHeaderBarAction.createTask, 'refresh' => BusyMaxHeaderBarAction.refresh, 'settings' => BusyMaxHeaderBarAction.settings, 'keyboardShortcuts' => BusyMaxHeaderBarAction.keyboardShortcuts, @@ -585,6 +700,8 @@ class LinuxHeaderBarSession { final LinuxHeaderBarService _service; final _actions = StreamController.broadcast(); + final _searchEvents = + StreamController.broadcast(); bool _disposed = false; BusyMaxHeaderBarState? _state; int _stateRevision = 0; @@ -597,6 +714,8 @@ class LinuxHeaderBarSession { Stream get actions => _actions.stream; + Stream get searchEvents => _searchEvents.stream; + Future initialize() => _service.initialize(); Future updateState( @@ -615,6 +734,33 @@ class LinuxHeaderBarSession { 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); + } + Future setOnboardingControls({ required bool visible, required bool canGoBack, @@ -680,6 +826,12 @@ class LinuxHeaderBarSession { } } + void _dispatchSearchEvent(BusyMaxHeaderBarSearchEvent event) { + if (isCurrent && !_searchEvents.isClosed) { + _searchEvents.add(event); + } + } + void dispose() { if (_disposed) { return; @@ -687,6 +839,7 @@ class LinuxHeaderBarSession { _disposed = true; _service._releaseSession(this); unawaited(_actions.close()); + unawaited(_searchEvents.close()); } void _disposeFromService() { @@ -695,6 +848,7 @@ class LinuxHeaderBarSession { } _disposed = true; unawaited(_actions.close()); + unawaited(_searchEvents.close()); } } diff --git a/lib/src/schedule/schedule_filters.dart b/lib/src/schedule/schedule_filters.dart index dd698bf..415399b 100644 --- a/lib/src/schedule/schedule_filters.dart +++ b/lib/src/schedule/schedule_filters.dart @@ -1,8 +1,28 @@ +class ScheduleTaskListKey { + const ScheduleTaskListKey({ + required this.accountId, + required this.taskListId, + }); + + final String accountId; + final String taskListId; + + @override + bool operator ==(Object other) { + return other is ScheduleTaskListKey && + other.accountId == accountId && + other.taskListId == taskListId; + } + + @override + int get hashCode => Object.hash(accountId, taskListId); +} + class ScheduleFilters { const ScheduleFilters({ this.accountIds = const {}, this.sourceIds = const {}, - this.taskListIds = const {}, + this.taskListKeys = const {}, this.sourceFilterActive = false, this.taskListFilterActive = false, this.includeCalendarEvents = true, @@ -14,7 +34,7 @@ class ScheduleFilters { final Set accountIds; final Set sourceIds; - final Set taskListIds; + final Set taskListKeys; final bool sourceFilterActive; final bool taskListFilterActive; final bool includeCalendarEvents; diff --git a/lib/src/schedule/schedule_item.dart b/lib/src/schedule/schedule_item.dart index 2d3ff48..ca39923 100644 --- a/lib/src/schedule/schedule_item.dart +++ b/lib/src/schedule/schedule_item.dart @@ -2,6 +2,25 @@ import '../task_providers/task_provider.dart'; enum ScheduleItemKind { calendarEvent, task, localReminder } +class ScheduleItemCapabilities { + const ScheduleItemCapabilities({ + required this.canEdit, + required this.canDelete, + }); + + static const editable = ScheduleItemCapabilities( + canEdit: true, + canDelete: true, + ); + static const readOnly = ScheduleItemCapabilities( + canEdit: false, + canDelete: false, + ); + + final bool canEdit; + final bool canDelete; +} + sealed class ScheduleItem { String get id; String get accountId; @@ -16,6 +35,7 @@ sealed class ScheduleItem { bool get allDay; List get categories; ScheduleItemKind get kind; + ScheduleItemCapabilities get capabilities; } class CalendarScheduleItem implements ScheduleItem { @@ -46,6 +66,7 @@ class CalendarScheduleItem implements ScheduleItem { this.sourceName, this.accountDisplayName, this.accountEmail, + this.capabilities = ScheduleItemCapabilities.editable, }); @override @@ -90,6 +111,8 @@ class CalendarScheduleItem implements ScheduleItem { final String? accountDisplayName; @override final String? accountEmail; + @override + final ScheduleItemCapabilities capabilities; @override ScheduleItemKind get kind => ScheduleItemKind.calendarEvent; @@ -112,6 +135,7 @@ class TaskScheduleItem implements ScheduleItem { this.sourceName, this.accountDisplayName, this.accountEmail, + this.capabilities = ScheduleItemCapabilities.editable, }); @override @@ -141,6 +165,8 @@ class TaskScheduleItem implements ScheduleItem { final String? accountDisplayName; @override final String? accountEmail; + @override + final ScheduleItemCapabilities capabilities; @override ScheduleItemKind get kind => ScheduleItemKind.task; diff --git a/lib/src/schedule/schedule_projection.dart b/lib/src/schedule/schedule_projection.dart index de26253..6837b00 100644 --- a/lib/src/schedule/schedule_projection.dart +++ b/lib/src/schedule/schedule_projection.dart @@ -90,6 +90,10 @@ class ScheduleProjection { } static Color colorForItem(ScheduleItem item, Brightness brightness) { + if (item is CalendarScheduleItem) { + return _colorFromHex(item.colorHex) ?? + deterministicSourceColor(item.sourceId, brightness); + } if (item is TaskScheduleItem && item.completed) { return brightness == Brightness.dark ? const Color(0xff949494) @@ -130,6 +134,18 @@ class ScheduleProjection { } } +Color? _colorFromHex(String? value) { + if (value == null) { + return null; + } + final normalized = value.trim().replaceFirst('#', ''); + if (normalized.length != 6) { + return null; + } + final parsed = int.tryParse(normalized, radix: 16); + return parsed == null ? null : Color(0xff000000 | parsed); +} + String? _cleanLabel(String? label) { final value = label?.trim(); if (value == null || value.isEmpty) { diff --git a/lib/src/schedule/schedule_repository.dart b/lib/src/schedule/schedule_repository.dart index aeac0a1..82b0ae4 100644 --- a/lib/src/schedule/schedule_repository.dart +++ b/lib/src/schedule/schedule_repository.dart @@ -18,6 +18,70 @@ class ScheduleRepository { final AppDatabase _database; + Future findTaskTarget({ + required String accountId, + required String taskListId, + required String taskId, + }) async { + return watchTaskTarget( + accountId: accountId, + taskListId: taskListId, + taskId: taskId, + ).first; + } + + /// Watches one live, visible task identified by its full database key. + /// + /// A stream is used for deep links so a route can remain pending while an + /// initial sync inserts the requested task, without polling or retry loops. + Stream watchTaskTarget({ + required String accountId, + required String taskListId, + required String taskId, + }) { + final query = + _database.select(_database.tasks).join([ + innerJoin( + _database.taskLists, + _database.taskLists.accountId.equalsExp( + _database.tasks.accountId, + ) & + _database.taskLists.id.equalsExp(_database.tasks.taskListId), + ), + innerJoin( + _database.accounts, + _database.accounts.id.equalsExp(_database.tasks.accountId), + ), + ]) + ..where(_database.tasks.accountId.equals(accountId)) + ..where(_database.tasks.taskListId.equals(taskListId)) + ..where(_database.tasks.id.equals(taskId)) + ..where(_database.tasks.pendingDelete.equals(false)) + ..where(_database.tasks.serverMissing.equals(false)) + ..where( + _database.tasks.deleted.isNull() | + _database.tasks.deleted.equals(false), + ) + ..where( + _database.tasks.hidden.isNull() | + _database.tasks.hidden.equals(false), + ) + ..where(_database.taskLists.pendingDelete.equals(false)) + ..where(_database.taskLists.serverMissing.equals(false)) + ..where(_database.accounts.authState.equals('signed_in')); + return query.watchSingleOrNull().map((row) { + if (row == null) { + return null; + } + final task = row.readTable(_database.tasks); + return ScheduleTaskTarget( + accountId: task.accountId, + taskListId: task.taskListId, + taskId: task.id, + ); + }); + } + Future> listItems({ required ScheduleRange range, ScheduleFilters filters = const ScheduleFilters(), @@ -209,6 +273,9 @@ class ScheduleRepository { sourceName: source?.summary, accountDisplayName: accountDisplayNames[event.accountId], accountEmail: accountEmails[event.accountId], + capabilities: source != null && !source.readOnly && !source.isDeleted + ? ScheduleItemCapabilities.editable + : ScheduleItemCapabilities.readOnly, ), ); } @@ -224,7 +291,7 @@ class ScheduleRepository { Map accountDisplayNames, Map accountEmails, ) async { - if (filters.taskListFilterActive && filters.taskListIds.isEmpty) { + if (filters.taskListFilterActive && filters.taskListKeys.isEmpty) { return const []; } final query = @@ -253,7 +320,7 @@ class ScheduleRepository { _database.taskLists.serverMissing.equals(false), ); if (filters.taskListFilterActive) { - query.where(_database.tasks.taskListId.isIn(filters.taskListIds)); + query.where(_taskListFilter(filters.taskListKeys)); } if (!filters.showCompletedTasks) { query.where(_taskIncomplete()); @@ -294,7 +361,7 @@ class ScheduleRepository { required bool Function(TaskScheduleItem item) itemFilter, }) async { if (!filters.includeTasks || - (filters.taskListFilterActive && filters.taskListIds.isEmpty)) { + (filters.taskListFilterActive && filters.taskListKeys.isEmpty)) { return const ScheduleTaskBucketPage(items: [], hasMore: false); } @@ -332,7 +399,7 @@ class ScheduleRepository { ..where(databaseFilter) ..limit(effectiveLimit + 1); if (filters.taskListFilterActive) { - query.where(_database.tasks.taskListId.isIn(filters.taskListIds)); + query.where(_taskListFilter(filters.taskListKeys)); } if (!filters.showCompletedTasks) { query.where(_taskIncomplete()); @@ -415,6 +482,17 @@ class ScheduleRepository { _database.tasks.status.equals('completed').not(); } + Expression _taskListFilter(Set taskListKeys) { + Expression matches = const Constant(false); + for (final key in taskListKeys) { + matches = + matches | + (_database.tasks.accountId.equals(key.accountId) & + _database.tasks.taskListId.equals(key.taskListId)); + } + return matches; + } + Expression _taskNoDate() { return _database.tasks.dueUtc.isNull() & _database.tasks.microsoftStartDateTime.isNull() & @@ -437,6 +515,29 @@ class ScheduleRepository { } } +class ScheduleTaskTarget { + const ScheduleTaskTarget({ + required this.accountId, + required this.taskListId, + required this.taskId, + }); + + final String accountId; + final String taskListId; + final String taskId; + + @override + bool operator ==(Object other) { + return other is ScheduleTaskTarget && + other.accountId == accountId && + other.taskListId == taskListId && + other.taskId == taskId; + } + + @override + int get hashCode => Object.hash(accountId, taskListId, taskId); +} + class ScheduleTaskBucketPage { const ScheduleTaskBucketPage({required this.items, required this.hasMore}); diff --git a/lib/src/schedule/schedule_source_visibility.dart b/lib/src/schedule/schedule_source_visibility.dart index f3d0c1f..da6792d 100644 --- a/lib/src/schedule/schedule_source_visibility.dart +++ b/lib/src/schedule/schedule_source_visibility.dart @@ -1,11 +1,12 @@ import '../app/app_settings.dart'; import '../features/calendar/data/calendar_repository.dart'; import '../features/task_lists/data/task_lists_repository.dart'; +import 'schedule_filters.dart'; class ScheduleSourceVisibility { const ScheduleSourceVisibility({ required this.visibleCalendarSourceIds, - required this.visibleTaskListIds, + required this.visibleTaskListKeys, required this.hasCalendarSources, required this.hasTaskLists, }); @@ -20,11 +21,11 @@ class ScheduleSourceVisibility { for (final source in calendarSources) if (source.selected && !source.hidden && !source.isDeleted) source.id, }, - visibleTaskListIds: { + visibleTaskListKeys: { for (final list in taskLists) if (!list.pendingDelete && settings.isTaskListVisibleInSchedule(list.accountId, list.id)) - list.id, + ScheduleTaskListKey(accountId: list.accountId, taskListId: list.id), }, hasCalendarSources: calendarSources.any((source) => !source.isDeleted), hasTaskLists: taskLists.any((list) => !list.pendingDelete), @@ -32,7 +33,7 @@ class ScheduleSourceVisibility { } final Set visibleCalendarSourceIds; - final Set visibleTaskListIds; + final Set visibleTaskListKeys; final bool hasCalendarSources; final bool hasTaskLists; } diff --git a/linux/runner/my_application.cc b/linux/runner/my_application.cc index 2fe7dd3..fb6e365 100644 --- a/linux/runner/my_application.cc +++ b/linux/runner/my_application.cc @@ -26,6 +26,7 @@ constexpr char kGtkThemeColorsEventChannel[] = "io.busystack.busymax/gtk_theme_colors"; constexpr char kCompactAgendaWindowChannel[] = "io.busystack.busymax/compact_agenda_window"; +constexpr gint64 kHeaderBarStateSchemaVersion = 3; constexpr gint kHeaderButtonHeight = 34; constexpr gint kHeaderButtonRadius = 8; constexpr gint kHeaderButtonHorizontalPadding = 8; @@ -41,6 +42,8 @@ constexpr gint kHeaderMainContentStartInset = kHeaderSidebarContentInset; constexpr gint kHeaderTooltipVerticalPadding = 5; constexpr gint kHeaderTooltipHorizontalPadding = 8; constexpr gint kHeaderWindowRadius = 8; +constexpr gint kMainWindowDefaultWidth = 1280; +constexpr gint kMainWindowDefaultHeight = 720; constexpr gint kCompactAgendaPanelWidth = 420; constexpr gint kCompactAgendaPanelHeight = 680; constexpr gint kCompactAgendaWindowShadowMargin = 32; @@ -77,6 +80,7 @@ struct _MyApplication { 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_muted_foreground_color; gchar* header_bar_disabled_foreground_color; @@ -100,6 +104,7 @@ struct _MyApplication { GtkWidget* header_start_box; GtkWidget* header_title_balance_spacer; GtkWidget* header_title_box; + GtkWidget* header_title_stack; GtkWidget* onboarding_back_slot; GtkWidget* onboarding_back_button; GtkWidget* onboarding_continue_slot; @@ -113,6 +118,7 @@ struct _MyApplication { GtkWidget* about_item; GtkWidget* header_view_box; GtkWidget* header_title_label; + GtkWidget* search_entry; GtkWidget* back_button; GtkWidget* sidebar_collapsed_toggle_button; GtkWidget* today_button; @@ -128,10 +134,15 @@ struct _MyApplication { GtkWidget* view_mode_agenda_item; GtkWidget* search_button; GtkWidget* create_button; + GtkWidget* create_menu; + GtkWidget* create_event_item; + GtkWidget* create_task_item; GtkWidget* refresh_button; gchar* header_view_mode; + 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; @@ -167,150 +178,17 @@ static GdkPixbuf* load_application_icon() { return load_application_icon_at_size(256); } -static gboolean gtk_theme_exists_in_data_dir(const gchar* data_dir, - const gchar* theme_name) { - if (data_dir == nullptr || theme_name == nullptr || theme_name[0] == '\0') { - return FALSE; - } - g_autofree gchar* css_path = - g_build_filename(data_dir, "themes", theme_name, "gtk-3.0", "gtk.css", - nullptr); - return g_file_test(css_path, G_FILE_TEST_IS_REGULAR); -} - -static gboolean gtk_theme_exists(const gchar* theme_name) { - if (gtk_theme_exists_in_data_dir(g_get_user_data_dir(), theme_name)) { - return TRUE; - } - const gchar* const* data_dirs = g_get_system_data_dirs(); - for (gint i = 0; data_dirs != nullptr && data_dirs[i] != nullptr; ++i) { - if (gtk_theme_exists_in_data_dir(data_dirs[i], theme_name)) { - return TRUE; - } - } - return FALSE; -} - -static gboolean icon_theme_exists_in_data_dir(const gchar* data_dir, - const gchar* theme_name) { - if (data_dir == nullptr || theme_name == nullptr || theme_name[0] == '\0') { - return FALSE; - } - g_autofree gchar* index_path = - g_build_filename(data_dir, "icons", theme_name, "index.theme", nullptr); - return g_file_test(index_path, G_FILE_TEST_IS_REGULAR); -} - -static gboolean icon_theme_exists(const gchar* theme_name) { - if (icon_theme_exists_in_data_dir(g_get_user_data_dir(), theme_name)) { - return TRUE; - } - const gchar* const* data_dirs = g_get_system_data_dirs(); - for (gint i = 0; data_dirs != nullptr && data_dirs[i] != nullptr; ++i) { - if (icon_theme_exists_in_data_dir(data_dirs[i], theme_name)) { - return TRUE; - } - } - return FALSE; -} - -static gboolean is_yaru_theme_name(const gchar* theme_name) { - return theme_name != nullptr && g_str_has_prefix(theme_name, "Yaru") && - (theme_name[4] == '\0' || theme_name[4] == '-'); -} - -static gchar* yaru_theme_name_for_preference( - const gchar* theme_name, - gboolean prefer_dark, - gboolean (*theme_exists)(const gchar*)) { - if (!is_yaru_theme_name(theme_name)) { - return nullptr; - } - const gboolean is_dark = g_str_has_suffix(theme_name, "-dark"); - if (prefer_dark == is_dark) { - return theme_exists(theme_name) ? g_strdup(theme_name) : nullptr; - } - if (prefer_dark) { - g_autofree gchar* dark_name = g_strdup_printf("%s-dark", theme_name); - return theme_exists(dark_name) ? g_strdup(dark_name) : nullptr; - } - - const gsize suffix_length = strlen("-dark"); - const gsize theme_length = strlen(theme_name); - if (theme_length <= suffix_length) { - return nullptr; - } - g_autofree gchar* light_name = - g_strndup(theme_name, theme_length - suffix_length); - return theme_exists(light_name) ? g_strdup(light_name) : nullptr; -} - -static gchar* available_gtk_theme_for_preference(const gchar* current_theme, - gboolean prefer_dark) { - g_autofree gchar* current_variant = - yaru_theme_name_for_preference(current_theme, prefer_dark, - gtk_theme_exists); - if (current_variant != nullptr) { - return g_strdup(current_variant); - } - - const gchar* primary = prefer_dark ? "Yaru-dark" : "Yaru"; - if (gtk_theme_exists(primary)) { - return g_strdup(primary); - } - const gchar* secondary = prefer_dark ? "Adwaita-dark" : "Adwaita"; - return gtk_theme_exists(secondary) ? g_strdup(secondary) : nullptr; -} - -static gchar* available_icon_theme_for_preference(const gchar* current_theme, - gboolean prefer_dark) { - g_autofree gchar* current_variant = - yaru_theme_name_for_preference(current_theme, prefer_dark, - icon_theme_exists); - if (current_variant != nullptr) { - return g_strdup(current_variant); - } - - const gchar* primary = prefer_dark ? "Yaru-dark" : "Yaru"; - if (icon_theme_exists(primary)) { - return g_strdup(primary); - } - return icon_theme_exists("Adwaita") ? g_strdup("Adwaita") : nullptr; -} - static void set_gtk_theme_preference(gboolean prefer_dark) { GtkSettings* settings = gtk_settings_get_default(); if (settings == nullptr) { return; } + // Express the app preference through GTK. Do not replace the user's GTK or + // icon theme: GTK remains responsible for resolving the installed theme's + // light/dark presentation and its native assets. g_object_set(settings, "gtk-application-prefer-dark-theme", prefer_dark, nullptr); - - g_autofree gchar* theme_name = nullptr; - g_object_get(settings, "gtk-theme-name", &theme_name, nullptr); - g_autofree gchar* theme_for_preference = - available_gtk_theme_for_preference(theme_name, prefer_dark); - if (theme_for_preference != nullptr) { - if (g_strcmp0(theme_name, theme_for_preference) != 0) { - g_object_set(settings, "gtk-theme-name", theme_for_preference, nullptr); - } - } - - g_autofree gchar* icon_theme_name = nullptr; - g_object_get(settings, "gtk-icon-theme-name", &icon_theme_name, nullptr); - g_autofree gchar* icon_theme_for_preference = - available_icon_theme_for_preference(icon_theme_name, prefer_dark); - if (icon_theme_for_preference != nullptr) { - if (g_strcmp0(icon_theme_name, icon_theme_for_preference) != 0) { - g_object_set(settings, "gtk-icon-theme-name", icon_theme_for_preference, - nullptr); - GtkIconTheme* icon_theme = gtk_icon_theme_get_default(); - if (icon_theme != nullptr) { - gtk_icon_theme_set_custom_theme(icon_theme, icon_theme_for_preference); - } - } - } } static const gchar* fl_lookup_string_arg(FlValue* args, const gchar* key) { @@ -351,6 +229,20 @@ static gboolean fl_lookup_optional_bool_arg(FlValue* args, return TRUE; } +static gboolean fl_lookup_int_arg(FlValue* args, + const gchar* key, + gint64* value_out) { + if (args == nullptr || fl_value_get_type(args) != FL_VALUE_TYPE_MAP) { + return FALSE; + } + FlValue* value = fl_value_lookup_string(args, key); + if (value == nullptr || fl_value_get_type(value) != FL_VALUE_TYPE_INT) { + return FALSE; + } + *value_out = fl_value_get_int(value); + return TRUE; +} + static gboolean parse_date(const gchar* value, guint* year, guint* month, @@ -609,6 +501,8 @@ static void refresh_header_bar_css(MyApplication* self) { 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, "rgba(255,255,255,0.10)"); const gchar* foreground_color = css_color_or( self->header_bar_foreground_color, "rgba(255,255,255,0.86)"); const gchar* foreground_disabled_color = @@ -676,6 +570,7 @@ static void refresh_header_bar_css(MyApplication* self) { "background-color: %s;" "background-image: none;" "border: none;" + "border-right: 1px solid %s;" "box-shadow: none;" "border-top-left-radius: %dpx;" "border-top-right-radius: 0;" @@ -837,6 +732,11 @@ static void refresh_header_bar_css(MyApplication* self) { "}" "popover.busymax-header-popover " "button.busymax-header-popover-row:focus {" + "background-color: transparent;" + "box-shadow: none;" + "}" + "popover.busymax-header-popover " + "button.busymax-header-popover-row.busymax-keyboard-focus:focus {" "background-color: %s;" "box-shadow: inset 0 0 0 2px %s;" "}" @@ -854,6 +754,15 @@ static void refresh_header_bar_css(MyApplication* self) { "button.busymax-header-popover-row image {" "color: %s;" "}" + "popover.busymax-header-popover " + "button.busymax-header-popover-row:disabled," + "popover.busymax-header-popover " + "button.busymax-header-popover-row:disabled label," + "popover.busymax-header-popover " + "button.busymax-header-popover-row:disabled image {" + "color: %s;" + "background-color: transparent;" + "}" "tooltip," "tooltip.background {" "margin: 0;" @@ -877,7 +786,8 @@ static void refresh_header_bar_css(MyApplication* self) { window_css_background_color, shade_color, kHeaderWindowRadius, background_color, kHeaderWindowRadius, kHeaderWindowRadius, header_bar_left_radius, kHeaderWindowRadius, sidebar_background_color, - kHeaderWindowRadius, foreground_color, foreground_color, + sidebar_border_color, kHeaderWindowRadius, foreground_color, + foreground_color, background_color, modal_barrier_color, modal_barrier_color, sidebar_background_color, modal_barrier_color, modal_barrier_color, background_color, modal_barrier_color, modal_barrier_color, @@ -893,7 +803,7 @@ static void refresh_header_bar_css(MyApplication* self) { border_color, shade_color, kHeaderButtonHeight, kHeaderButtonHorizontalPadding, kHeaderButtonRadius, control_hover_color, control_hover_color, accent_color, foreground_color, - muted_foreground_color, + muted_foreground_color, foreground_disabled_color, kHeaderButtonRadius, shade_color, kHeaderTooltipVerticalPadding, kHeaderTooltipHorizontalPadding, kHeaderButtonRadius); @@ -940,6 +850,8 @@ static void set_header_bar_theme(MyApplication* self, FlValue* args) { 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_muted_foreground_color, @@ -1008,6 +920,122 @@ static void invoke_header_bar_action(MyApplication* self, nullptr, nullptr, nullptr); } +static void invoke_header_bar_string_action(MyApplication* self, + const gchar* action, + const gchar* value) { + if (self->header_bar_channel == nullptr || action == nullptr) { + return; + } + 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); +} + +static void invoke_header_bar_bool_action(MyApplication* self, + const gchar* action, + gboolean value) { + if (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); +} + +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) { + const gchar* normalized_query = query == nullptr ? "" : query; + const gboolean echoes_last_native_query = + g_strcmp0(self->header_search_query, normalized_query) == 0; + cache_header_search_query(self, normalized_query); + if (self->search_entry == nullptr || !GTK_IS_ENTRY(self->search_entry)) { + return; + } + const gchar* current_query = + gtk_entry_get_text(GTK_ENTRY(self->search_entry)); + if (g_strcmp0(current_query, normalized_query) == 0) { + return; + } + if (echoes_last_native_query && self->header_search_active && + gtk_widget_has_focus(self->search_entry)) { + // Dart mirrors native query events into its route state. Do not let that + // asynchronous echo overwrite newer text that the user has already typed. + return; + } + + 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_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) { + invoke_header_bar_bool_action(MY_APPLICATION(user_data), + "searchFocusChanged", TRUE); + return FALSE; +} + +static gboolean header_search_entry_focus_out_cb(GtkWidget*, + GdkEventFocus*, + gpointer user_data) { + invoke_header_bar_bool_action(MY_APPLICATION(user_data), + "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_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_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); @@ -1164,6 +1192,18 @@ static void set_widget_sensitive(GtkWidget* widget, gboolean sensitive) { } } +static void set_header_create_capabilities(MyApplication* self, + gboolean can_create_event, + gboolean can_create_task) { + set_widget_sensitive(self->create_event_item, can_create_event); + set_widget_sensitive(self->create_task_item, 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); @@ -1312,6 +1352,70 @@ static void header_view_mode_item_clicked_cb(GtkWidget* widget, invoke_header_bar_action(self, header_view_mode_action(mode)); } +static void set_header_popover_row_keyboard_focus(GtkWidget* row, + gboolean visible) { + if (row == nullptr || !GTK_IS_WIDGET(row)) { + return; + } + GtkStyleContext* context = gtk_widget_get_style_context(row); + if (visible) { + gtk_style_context_add_class(context, "busymax-keyboard-focus"); + } else { + gtk_style_context_remove_class(context, "busymax-keyboard-focus"); + } +} + +static gboolean header_popover_row_focus_in_cb(GtkWidget* row, + GdkEventFocus* event, + gpointer user_data) { + MyApplication* self = MY_APPLICATION(user_data); + const gboolean focus_visible = + self->main_window != nullptr && GTK_IS_WINDOW(self->main_window) && + gtk_window_get_focus_visible(self->main_window); + set_header_popover_row_keyboard_focus(row, focus_visible); + return FALSE; +} + +static gboolean header_popover_row_focus_out_cb(GtkWidget* row, + GdkEventFocus* event, + gpointer user_data) { + set_header_popover_row_keyboard_focus(row, FALSE); + return FALSE; +} + +static gboolean header_popover_row_key_press_cb(GtkWidget* row, + GdkEventKey* event, + gpointer user_data) { + set_header_popover_row_keyboard_focus(row, TRUE); + return FALSE; +} + +static gboolean header_popover_row_button_press_cb(GtkWidget* row, + GdkEventButton* event, + gpointer user_data) { + set_header_popover_row_keyboard_focus(row, FALSE); + return FALSE; +} + +static void configure_header_popover_row(MyApplication* self, + GtkWidget* row) { + gtk_button_set_relief(GTK_BUTTON(row), GTK_RELIEF_NONE); + gtk_widget_set_halign(row, GTK_ALIGN_FILL); + gtk_widget_set_hexpand(row, TRUE); + gtk_style_context_add_class(gtk_widget_get_style_context(row), + GTK_STYLE_CLASS_FLAT); + gtk_style_context_add_class(gtk_widget_get_style_context(row), + "busymax-header-popover-row"); + g_signal_connect(row, "focus-in-event", + G_CALLBACK(header_popover_row_focus_in_cb), self); + g_signal_connect(row, "focus-out-event", + G_CALLBACK(header_popover_row_focus_out_cb), self); + g_signal_connect(row, "key-press-event", + G_CALLBACK(header_popover_row_key_press_cb), self); + g_signal_connect(row, "button-press-event", + G_CALLBACK(header_popover_row_button_press_cb), self); +} + static GtkWidget* create_header_view_mode_item(MyApplication* self, const gchar* mode, const gchar* fallback_label) { @@ -1326,13 +1430,7 @@ static GtkWidget* create_header_view_mode_item(MyApplication* self, gtk_box_pack_start(GTK_BOX(box), label, TRUE, TRUE, 0); gtk_container_add(GTK_CONTAINER(item), box); gtk_widget_set_opacity(check, 0.0); - gtk_button_set_relief(GTK_BUTTON(item), GTK_RELIEF_NONE); - gtk_widget_set_halign(item, GTK_ALIGN_FILL); - gtk_widget_set_hexpand(item, TRUE); - gtk_style_context_add_class(gtk_widget_get_style_context(item), - GTK_STYLE_CLASS_FLAT); - gtk_style_context_add_class(gtk_widget_get_style_context(item), - "busymax-header-popover-row"); + configure_header_popover_row(self, item); g_object_set_data(G_OBJECT(item), "busymax-header-label-widget", label); g_object_set_data(G_OBJECT(item), "busymax-header-check-widget", check); g_object_set_data_full(G_OBJECT(item), "busymax-header-label", @@ -1344,46 +1442,44 @@ static GtkWidget* create_header_view_mode_item(MyApplication* self, return item; } -static void header_settings_item_clicked_cb(GtkWidget* widget, - gpointer user_data) { +static void header_popover_action_item_clicked_cb(GtkWidget* widget, + gpointer user_data) { MyApplication* self = MY_APPLICATION(user_data); if (self->suppress_header_bar_actions) { return; } const gchar* action = static_cast( - g_object_get_data(G_OBJECT(widget), "busymax-settings-action")); - close_header_menu_button(self->settings_menu_button); + g_object_get_data(G_OBJECT(widget), "busymax-header-action")); + GtkWidget* popover = gtk_widget_get_ancestor(widget, GTK_TYPE_POPOVER); + if (popover != nullptr && GTK_IS_POPOVER(popover)) { + gtk_popover_popdown(GTK_POPOVER(popover)); + } focus_flutter_view(self); invoke_header_bar_action(self, action); } -static GtkWidget* create_header_settings_item(MyApplication* self, - const gchar* action, - const gchar* fallback_label) { +static GtkWidget* create_header_popover_action_item( + MyApplication* self, + const gchar* action, + const gchar* fallback_label) { GtkWidget* item = gtk_button_new(); GtkWidget* label = gtk_label_new(fallback_label); gtk_label_set_xalign(GTK_LABEL(label), 0.0); gtk_widget_set_hexpand(label, TRUE); gtk_container_add(GTK_CONTAINER(item), label); - gtk_button_set_relief(GTK_BUTTON(item), GTK_RELIEF_NONE); - gtk_widget_set_halign(item, GTK_ALIGN_FILL); - gtk_widget_set_hexpand(item, TRUE); - gtk_style_context_add_class(gtk_widget_get_style_context(item), - GTK_STYLE_CLASS_FLAT); - gtk_style_context_add_class(gtk_widget_get_style_context(item), - "busymax-header-popover-row"); + configure_header_popover_row(self, item); g_object_set_data(G_OBJECT(item), "busymax-header-label-widget", label); g_object_set_data_full(G_OBJECT(item), "busymax-header-label", g_strdup(fallback_label), g_free); - g_object_set_data_full(G_OBJECT(item), "busymax-settings-action", + g_object_set_data_full(G_OBJECT(item), "busymax-header-action", g_strdup(action), g_free); g_signal_connect(item, "clicked", - G_CALLBACK(header_settings_item_clicked_cb), self); + G_CALLBACK(header_popover_action_item_clicked_cb), self); return item; } -static void set_header_settings_item_label(GtkWidget* item, - const gchar* label) { +static void set_header_popover_action_item_label(GtkWidget* item, + const gchar* label) { if (item == nullptr || label == nullptr) { return; } @@ -1419,6 +1515,54 @@ static void set_header_title(MyApplication* self, const gchar* title) { } } +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; + set_header_search_query(self, query); + 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; + + 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; @@ -1457,6 +1601,9 @@ static void update_header_title_box_geometry(MyApplication* self) { static void update_header_control_visibility(MyApplication* self) { const gboolean schedule_controls_visible = self->header_schedule_controls_visible; + if (!schedule_controls_visible) { + 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); @@ -1480,6 +1627,9 @@ static void update_header_control_visibility(MyApplication* self) { 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); } @@ -1546,18 +1696,34 @@ static void set_header_bar_state(MyApplication* self, FlValue* args) { 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); } - if (fl_lookup_optional_bool_arg(args, "canCreate", &value)) { - set_widget_sensitive(self->create_button, value); - } - if (fl_lookup_optional_bool_arg(args, "searchActive", &value)) { - set_toggle_button_active(self, self->search_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); @@ -1578,6 +1744,7 @@ static void set_header_bar_state(MyApplication* self, FlValue* args) { 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) { @@ -1595,6 +1762,8 @@ static void set_header_localized_labels(MyApplication* self, FlValue* args) { 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"); @@ -1610,16 +1779,22 @@ static void set_header_localized_labels(MyApplication* self, FlValue* args) { set_header_view_mode_labels(self, day, week, month, year, agenda); set_widget_tooltip(self->back_button, back); set_widget_tooltip(self->search_button, search); + 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); + set_header_popover_action_item_label(self->create_event_item, create_event); + set_header_popover_action_item_label(self->create_task_item, create_task); set_widget_tooltip(self->settings_menu_button, menu); set_widget_tooltip(self->refresh_button, refresh); set_widget_tooltip(self->previous_button, previous); set_widget_tooltip(self->next_button, next); set_widget_tooltip(self->sidebar_collapsed_toggle_button, sidebar); - set_header_settings_item_label(self->settings_item, settings); - set_header_settings_item_label(self->keyboard_shortcuts_item, - keyboard_shortcuts); - set_header_settings_item_label(self->about_item, about_busymax); + set_header_popover_action_item_label(self->settings_item, settings); + set_header_popover_action_item_label(self->keyboard_shortcuts_item, + keyboard_shortcuts); + set_header_popover_action_item_label(self->about_item, about_busymax); } static GtkWidget* create_busymax_header_bar(MyApplication* self) { @@ -1672,14 +1847,14 @@ static GtkWidget* create_busymax_header_bar(MyApplication* self) { GtkWidget* settings_menu_box = create_header_popover_box(self->settings_menu); track_widget_pointer(&self->settings_item, - create_header_settings_item(self, "settings", - "Settings")); + create_header_popover_action_item(self, "settings", + "Settings")); track_widget_pointer(&self->keyboard_shortcuts_item, - create_header_settings_item(self, "keyboardShortcuts", - "Keyboard Shortcuts")); + create_header_popover_action_item( + self, "keyboardShortcuts", "Keyboard Shortcuts")); track_widget_pointer(&self->about_item, - create_header_settings_item(self, "aboutBusyMax", - "About BusyMax")); + create_header_popover_action_item( + self, "aboutBusyMax", "About BusyMax")); gtk_box_pack_start(GTK_BOX(settings_menu_box), self->settings_item, FALSE, FALSE, 0); gtk_box_pack_start(GTK_BOX(settings_menu_box), @@ -1770,6 +1945,11 @@ static GtkWidget* create_busymax_header_bar(MyApplication* self) { kHeaderButtonSpacing)); gtk_widget_set_halign(self->header_title_box, GTK_ALIGN_CENTER); 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_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)); @@ -1796,6 +1976,29 @@ static GtkWidget* create_busymax_header_bar(MyApplication* self) { 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_entry_set_placeholder_text(GTK_ENTRY(self->search_entry), ""); + 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, @@ -1816,7 +2019,7 @@ static GtkWidget* create_busymax_header_bar(MyApplication* self) { 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_label, TRUE, TRUE, 0); + 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); @@ -1881,9 +2084,33 @@ static GtkWidget* create_busymax_header_bar(MyApplication* self) { FALSE, FALSE, 0); gtk_box_pack_start(GTK_BOX(end_box), self->header_view_box, FALSE, FALSE, 0); - track_widget_pointer(&self->create_button, - create_header_icon_button("list-add-symbolic", "")); - connect_header_bar_action(self, self->create_button, "create"); + track_widget_pointer(&self->create_menu, create_header_popup_window(self)); + GtkWidget* create_menu_box = create_header_popover_box(self->create_menu); + track_widget_pointer( + &self->create_event_item, + create_header_popover_action_item(self, "createEvent", "Event")); + track_widget_pointer( + &self->create_task_item, + create_header_popover_action_item(self, "createTask", "Task")); + gtk_box_pack_start(GTK_BOX(create_menu_box), self->create_event_item, FALSE, + FALSE, 0); + gtk_box_pack_start(GTK_BOX(create_menu_box), self->create_task_item, FALSE, + FALSE, 0); + show_header_popover_content(create_menu_box); + + track_widget_pointer(&self->create_button, gtk_menu_button_new()); + gtk_button_set_relief(GTK_BUTTON(self->create_button), GTK_RELIEF_NONE); + gtk_button_set_image( + GTK_BUTTON(self->create_button), + gtk_image_new_from_icon_name("list-add-symbolic", GTK_ICON_SIZE_MENU)); + gtk_menu_button_set_use_popover(GTK_MENU_BUTTON(self->create_button), TRUE); + gtk_menu_button_set_popover(GTK_MENU_BUTTON(self->create_button), + self->create_menu); + gtk_style_context_add_class(gtk_widget_get_style_context(self->create_button), + GTK_STYLE_CLASS_FLAT); + gtk_style_context_add_class(gtk_widget_get_style_context(self->create_button), + "busymax-header-button"); + make_header_icon_button_square(self->create_button); gtk_box_pack_start(GTK_BOX(end_box), self->create_button, FALSE, FALSE, 0); track_widget_pointer(&self->refresh_button, @@ -1902,6 +2129,28 @@ static GtkWidget* create_busymax_header_bar(MyApplication* self) { return self->titlebar_box; } +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; + } + + gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(self->create_button), TRUE); + GtkWidget* first_item = + self->create_event_item != nullptr && + gtk_widget_get_sensitive(self->create_event_item) + ? self->create_event_item + : self->create_task_item; + if (first_item != nullptr && GTK_IS_WIDGET(first_item)) { + gtk_widget_grab_focus(first_item); + } + return TRUE; +} + static void header_bar_method_call_cb(FlMethodChannel* channel, FlMethodCall* method_call, gpointer user_data) { @@ -1923,8 +2172,11 @@ static void header_bar_method_call_cb(FlMethodChannel* channel, set_widget_sensitive(self->refresh_button, fl_method_bool_arg(args)); respond_success(method_call); } else if (strcmp(method, "setCanCreate") == 0) { - set_widget_sensitive(self->create_button, fl_method_bool_arg(args)); + 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); @@ -1932,8 +2184,11 @@ static void header_bar_method_call_cb(FlMethodChannel* channel, set_header_sidebar_width(self, fl_method_double_arg(args, 300)); respond_success(method_call); } else if (strcmp(method, "setSearchActive") == 0) { - set_toggle_button_active(self, self->search_button, fl_method_bool_arg(args)); + set_header_search_state(self, fl_method_bool_arg(args), + self->header_search_query); respond_success(method_call); + } 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); @@ -2227,12 +2482,16 @@ static void gtk_settings_method_call_cb(FlMethodChannel* channel, FlMethodCall* method_call, gpointer user_data) { const gchar* method = fl_method_call_get_name(method_call); + FlValue* args = fl_method_call_get_args(method_call); if (strcmp(method, "getGtkFont") == 0) { g_autoptr(FlValue) result = get_gtk_font_settings(); fl_method_call_respond_success(method_call, result, nullptr); } else if (strcmp(method, "getGtkThemeColors") == 0) { g_autoptr(FlValue) result = get_gtk_theme_colors(); 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 { fl_method_call_respond_not_implemented(method_call, nullptr); } @@ -3132,7 +3391,8 @@ static void my_application_activate(GApplication* application) { if (application_icon == nullptr) { gtk_window_set_icon_name(window, APPLICATION_ID); } - gtk_window_set_default_size(window, 1280, 720); + gtk_window_set_default_size(window, kMainWindowDefaultWidth, + kMainWindowDefaultHeight); g_signal_connect(window, "delete-event", G_CALLBACK(window_delete_event_cb), self); @@ -3228,6 +3488,7 @@ static void my_application_dispose(GObject* object) { clear_widget_pointer(&self->header_start_box); clear_widget_pointer(&self->header_title_balance_spacer); 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); @@ -3241,6 +3502,7 @@ static void my_application_dispose(GObject* object) { clear_widget_pointer(&self->about_item); 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); @@ -3256,10 +3518,14 @@ static void my_application_dispose(GObject* object) { clear_widget_pointer(&self->view_mode_agenda_item); clear_widget_pointer(&self->search_button); clear_widget_pointer(&self->create_button); + clear_widget_pointer(&self->create_menu); + clear_widget_pointer(&self->create_event_item); + clear_widget_pointer(&self->create_task_item); 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_muted_foreground_color, g_free); g_clear_pointer(&self->header_bar_disabled_foreground_color, g_free); @@ -3273,6 +3539,7 @@ static void my_application_dispose(GObject* object) { g_clear_pointer(&self->header_bar_shade_color, g_free); g_clear_pointer(&self->header_bar_modal_barrier_color, g_free); g_clear_pointer(&self->header_view_mode, g_free); + g_clear_pointer(&self->header_search_query, g_free); g_clear_pointer(&self->dart_entrypoint_arguments, g_strfreev); G_OBJECT_CLASS(my_application_parent_class)->dispose(object); } @@ -3311,6 +3578,7 @@ static void my_application_init(MyApplication* self) { 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_muted_foreground_color = nullptr; self->header_bar_disabled_foreground_color = nullptr; @@ -3334,6 +3602,7 @@ static void my_application_init(MyApplication* self) { self->header_start_box = nullptr; self->header_title_balance_spacer = 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; @@ -3347,6 +3616,7 @@ static void my_application_init(MyApplication* self) { self->about_item = 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; @@ -3362,8 +3632,13 @@ static void my_application_init(MyApplication* self) { self->view_mode_agenda_item = nullptr; self->search_button = nullptr; self->create_button = nullptr; + self->create_menu = nullptr; + self->create_event_item = nullptr; + self->create_task_item = nullptr; self->refresh_button = nullptr; self->header_view_mode = nullptr; + self->header_search_query = g_strdup(""); + self->header_search_active = FALSE; self->header_navigation_visible = TRUE; } diff --git a/test/app/app_settings_test.dart b/test/app/app_settings_test.dart new file mode 100644 index 0000000..28827b0 --- /dev/null +++ b/test/app/app_settings_test.dart @@ -0,0 +1,176 @@ +import 'package:busymax/src/app/app_settings.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test( + 'notification detail level is the only persisted runtime setting', + () async { + final store = _MemorySettingsStore(); + final controller = AppSettingsController(store); + addTearDown(controller.dispose); + await controller.ready; + + await controller.setNotificationDetailLevel( + NotificationDetailLevel.private, + ); + + expect( + controller.state.notificationDetailLevel, + NotificationDetailLevel.private, + ); + expect(store.value['notificationDetailLevel'], 'private'); + expect(store.value, isNot(contains('detailedNotifications'))); + }, + ); + + test( + 'legacy notification privacy data migrates without overriding new data', + () { + final currentFormatWins = AppSettings.fromJson(const { + 'detailedNotifications': true, + 'notificationDetailLevel': 'private', + }); + final legacyDetailed = AppSettings.fromJson(const { + 'detailedNotifications': true, + }); + final legacyPrivate = AppSettings.fromJson(const { + 'detailedNotifications': false, + }); + + expect( + currentFormatWins.notificationDetailLevel, + NotificationDetailLevel.private, + ); + expect( + legacyDetailed.notificationDetailLevel, + NotificationDetailLevel.normal, + ); + expect( + legacyPrivate.notificationDetailLevel, + NotificationDetailLevel.private, + ); + }, + ); + + test('quiet hours persist only normalized, distinct times', () async { + final store = _MemorySettingsStore(); + final first = AppSettingsController(store); + addTearDown(first.dispose); + await first.ready; + + await first.setQuietHoursStart('21:5'); + await first.setQuietHoursEnd('6:45'); + expect(first.state.quietHoursStart, '21:05'); + expect(first.state.quietHoursEnd, '06:45'); + + await first.setQuietHoursStart('invalid'); + await first.setQuietHoursEnd('21:05'); + expect(first.state.quietHoursStart, '21:05'); + expect(first.state.quietHoursEnd, '06:45'); + + final second = AppSettingsController(store); + addTearDown(second.dispose); + await second.ready; + expect(second.state.quietHoursStart, '21:05'); + expect(second.state.quietHoursEnd, '06:45'); + }); + + test('invalid persisted quiet-hour ranges fall back safely', () { + final invalidFormat = AppSettings.fromJson(const { + 'quietHoursStart': '25:00', + 'quietHoursEnd': 'not-a-time', + }); + final equalRange = AppSettings.fromJson(const { + 'quietHoursStart': '08:00', + 'quietHoursEnd': '08:00', + }); + + expect(invalidFormat.quietHoursStart, '22:00'); + expect(invalidFormat.quietHoursEnd, '07:00'); + expect(equalRange.quietHoursStart, '22:00'); + expect(equalRange.quietHoursEnd, '07:00'); + }); + + test('tray-dependent preferences remain internally consistent', () async { + final controller = AppSettingsController(_MemorySettingsStore()); + addTearDown(controller.dispose); + await controller.ready; + + await controller.setShowTrayIcon(false); + expect(controller.state.showTrayIcon, isFalse); + expect(controller.state.runInBackgroundWhenClosed, isFalse); + expect(controller.state.startMinimizedToTray, isFalse); + + await controller.setRunInBackgroundWhenClosed(true); + expect(controller.state.showTrayIcon, isTrue); + expect(controller.state.runInBackgroundWhenClosed, isTrue); + + await controller.setShowTrayIcon(false); + await controller.setStartMinimizedToTray(true); + expect(controller.state.showTrayIcon, isTrue); + expect(controller.state.startMinimizedToTray, isTrue); + }); + + test( + 'legacy background preferences retain a recoverable tray entry point', + () { + final migrated = AppSettings.fromJson(const { + 'showTrayIcon': false, + 'runInBackgroundWhenClosed': true, + }); + + expect(migrated.showTrayIcon, isTrue); + expect(migrated.runInBackgroundWhenClosed, isTrue); + }, + ); + + test( + 'preloaded settings are available before the first provider frame', + () async { + final store = _MemorySettingsStore() + ..value = {'themeModePreference': 'dark'}; + final initialSettings = await loadInitialAppSettings(store); + final controller = AppSettingsController( + store, + initialSettings: initialSettings, + ); + addTearDown(controller.dispose); + + expect( + controller.state.themeModePreference, + BusyMaxThemeModePreference.dark, + ); + await controller.ready; + expect( + controller.state.themeModePreference, + BusyMaxThemeModePreference.dark, + ); + }, + ); + + test('preloading malformed settings falls back to defaults', () async { + final settings = await loadInitialAppSettings(_ThrowingSettingsStore()); + + expect(settings.themeModePreference, BusyMaxThemeModePreference.system); + }); +} + +class _MemorySettingsStore implements LocalSettingsStore { + Map value = {}; + + @override + Future> load() async => value; + + @override + Future save(Map json) async { + value = Map.from(json); + } +} + +class _ThrowingSettingsStore implements LocalSettingsStore { + @override + Future> load() => Future.error(const FormatException()); + + @override + Future save(Map json) async {} +} diff --git a/test/app/busymax_dialogs_test.dart b/test/app/busymax_dialogs_test.dart index 9cf303e..35a23be 100644 --- a/test/app/busymax_dialogs_test.dart +++ b/test/app/busymax_dialogs_test.dart @@ -1,7 +1,10 @@ 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/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'; @@ -135,4 +138,188 @@ void main() { expect(barrierCalls, hasLength(2)); expect(barrierCalls.last.arguments, isFalse); }); + + 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(); + }, + ), + ), + ), + ); + + final result = showBusyMaxConfirm( + hostContext, + title: 'Remove item?', + message: 'This action cannot be undone.', + confirmLabel: 'Remove', + ); + await tester.pumpAndSettle(); + + expect(calls.first.method, 'initialize'); + expect( + calls + .where((call) => call.method == 'setModalBarrierVisible') + .single + .arguments, + isTrue, + ); + + await tester.tap(find.text('Cancel')); + await tester.pumpAndSettle(); + + expect(await result, isFalse); + final barrierCalls = calls + .where((call) => call.method == 'setModalBarrierVisible') + .toList(); + expect(barrierCalls.last.arguments, isFalse); + }); + + testWidgets('editor dialog requires an explicit cancel action', ( + tester, + ) async { + late BuildContext hostContext; + await tester.pumpWidget( + localizedTestApp( + child: Builder( + builder: (context) { + hostContext = context; + return const SizedBox(); + }, + ), + ), + ); + + final result = showBusyMaxModalEditorDialog( + hostContext, + builder: (dialogContext) => SizedBox( + width: 320, + height: 200, + child: Center( + child: TextButton( + onPressed: () => Navigator.of(dialogContext).pop('cancelled'), + child: const Text('Cancel editor'), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + await tester.tapAt(const Offset(2, 2)); + await tester.pumpAndSettle(); + expect(find.text('Cancel editor'), findsOneWidget); + + await tester.sendKeyEvent(LogicalKeyboardKey.escape); + await tester.pumpAndSettle(); + expect(find.text('Cancel editor'), findsOneWidget); + + await tester.tap(find.text('Cancel editor')); + await tester.pumpAndSettle(); + expect(await result, 'cancelled'); + }); + + testWidgets('text prompt preserves input until an explicit action', ( + tester, + ) async { + late BuildContext hostContext; + await tester.pumpWidget( + localizedTestApp( + child: Builder( + builder: (context) { + hostContext = context; + return const SizedBox(); + }, + ), + ), + ); + + final result = showBusyMaxTextPrompt( + hostContext, + title: 'Rename item', + label: 'Name', + actionLabel: 'Rename', + initialValue: 'Draft name', + ); + await tester.pumpAndSettle(); + + await tester.enterText(find.byType(TextField), 'Edited name'); + await tester.tapAt(const Offset(2, 2)); + await tester.sendKeyEvent(LogicalKeyboardKey.escape); + await tester.pumpAndSettle(); + + expect(find.text('Rename item'), findsOneWidget); + expect(find.text('Edited name'), findsOneWidget); + + await tester.tap(find.text('Cancel')); + await tester.pumpAndSettle(); + expect(await result, isNull); + }); + + testWidgets('modal shortcut boundary blocks application navigation', ( + tester, + ) async { + var applicationNavigationCount = 0; + await tester.pumpWidget( + localizedTestApp( + child: Shortcuts( + shortcuts: const { + BusyMaxShortcutActivators.settings: _ApplicationNavigationIntent(), + BusyMaxShortcutActivators.keyboardShortcuts: + _ApplicationNavigationIntent(), + }, + child: Actions( + actions: { + _ApplicationNavigationIntent: + CallbackAction<_ApplicationNavigationIntent>( + onInvoke: (_) { + applicationNavigationCount += 1; + return null; + }, + ), + }, + child: const BusyMaxModalShortcutBoundary( + child: Material(child: TextField(autofocus: true)), + ), + ), + ), + ), + ); + await tester.pump(); + + for (final key in [LogicalKeyboardKey.comma, LogicalKeyboardKey.slash]) { + await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft); + await tester.sendKeyEvent(key); + await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft); + } + + expect(applicationNavigationCount, 0); + }); +} + +class _ApplicationNavigationIntent extends Intent { + const _ApplicationNavigationIntent(); } diff --git a/test/app/busymax_grouped_surface_test.dart b/test/app/busymax_grouped_surface_test.dart index 8c8d214..3c4dfec 100644 --- a/test/app/busymax_grouped_surface_test.dart +++ b/test/app/busymax_grouped_surface_test.dart @@ -1,3 +1,6 @@ +import 'dart:io'; +import 'dart:ui' as ui; + import 'package:busymax/src/app/busymax_design.dart'; import 'package:busymax/src/app/busymax_yaru_theme.dart'; import 'package:flutter/material.dart'; @@ -5,6 +8,8 @@ import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:yaru/yaru.dart'; +import '../test_localized_app.dart'; + void main() { for (final brightness in Brightness.values) { testWidgets( @@ -46,7 +51,8 @@ void main() { ), ), ); - expect(materialSurface.elevation, BusyMaxElevation.surface); + expect(BusyMaxElevation.card, 2); + expect(materialSurface.elevation, BusyMaxElevation.card); expect(materialSurface.shadowColor, theme.colorScheme.shadow); final shape = materialSurface.shape! as RoundedRectangleBorder; expect(shape.side.color, colors.subtleBorder); @@ -69,6 +75,120 @@ void main() { ); } + for (final brightness in Brightness.values) { + testWidgets('rows use the subtle Yaru $brightness hover role', ( + tester, + ) async { + final theme = BusyMaxYaruTheme.build( + brightness: brightness, + accentColor: const Color(0xFF3584E4), + ); + final yaruBase = brightness == Brightness.light + ? createYaruLightTheme(primaryColor: BusyMaxLinuxPalette.light4) + : createYaruDarkTheme(primaryColor: BusyMaxLinuxPalette.light2); + + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: Scaffold( + body: Column( + children: [ + BusyMaxActionRow(title: 'Calendar', onTap: () {}), + const BusyMaxSwitchRow( + title: 'Notifications', + value: true, + onChanged: _ignoreBool, + ), + ], + ), + ), + ), + ); + + expect(theme.hoverColor, yaruBase.hoverColor); + expect( + theme.hoverColor, + isNot(theme.extension()!.controlHover), + ); + final actionTile = tester.widget( + find.descendant( + of: find.byType(BusyMaxActionRow), + matching: find.byType(YaruListTile), + ), + ); + final switchTile = tester.widget( + find.descendant( + of: find.byType(BusyMaxSwitchRow), + matching: find.byType(YaruListTile), + ), + ); + expect(actionTile.hoverColor, theme.hoverColor); + expect(switchTile.hoverColor, theme.hoverColor); + expect( + busyMaxEditorRowHoverColor( + tester.element(find.byType(BusyMaxActionRow)), + ), + theme.hoverColor, + ); + }); + } + + testWidgets('sidebar surface draws the semantic directional end boundary', ( + tester, + ) async { + await tester.pumpWidget( + _testApp( + const BusyMaxSidebarSurface( + child: SizedBox(width: BusyMaxSizes.sidebarWidth, height: 200), + ), + ), + ); + final context = tester.element(find.byType(BusyMaxSidebarSurface)); + final colors = BusyMaxSurfaceColors.of(context); + final material = tester.widget( + find.descendant( + of: find.byType(BusyMaxSidebarSurface), + matching: find.byWidgetPredicate( + (widget) => widget is Material && widget.color == colors.sidebar, + ), + ), + ); + final decorated = tester.widget( + find.descendant( + of: find.byType(BusyMaxSidebarSurface), + matching: find.byWidgetPredicate( + (widget) => + widget is DecoratedBox && + widget.decoration is BoxDecoration && + (widget.decoration as BoxDecoration).border is BorderDirectional, + ), + ), + ); + final border = (decorated.decoration as BoxDecoration).border!; + + expect(material.color, colors.sidebar); + expect(decorated.position, DecorationPosition.foreground); + expect(border, isA()); + expect((border as BorderDirectional).end.color, colors.sidebarBorder); + expect(border.end.width, BusyMaxStroke.outline); + }); + + test('all primary sidebars reuse the shared boundary surface', () { + for (final path in [ + 'lib/src/features/schedule/presentation/schedule_sidebar.dart', + 'lib/src/features/task_lists/presentation/task_lists_sidebar.dart', + 'lib/src/features/settings/presentation/settings_screen.dart', + ]) { + final source = File(path).readAsStringSync(); + expect(source, contains('BusyMaxSidebarSurface('), reason: path); + expect( + source, + isNot(contains('color: BusyMaxSurfaceColors.of(context).sidebar')), + reason: path, + ); + } + }); + testWidgets('action row distinguishes keyboard and pointer activation', ( tester, ) async { @@ -191,6 +311,62 @@ void main() { expect(disabledSemantics.properties.value, 'Personal'); }); + testWidgets('combo row accepts unbounded horizontal constraints', ( + tester, + ) async { + await tester.pumpWidget( + _testApp( + UnconstrainedBox( + child: BusyMaxComboRow( + title: 'Calendar', + values: const ['Personal', 'Work'], + selected: 'Personal', + labelFor: (value) => value, + onSelected: (_) {}, + ), + ), + ), + ); + + expect(find.byType(OutlinedButton), findsOneWidget); + expect(tester.getSize(find.byType(OutlinedButton)).width, 220); + expect(tester.takeException(), isNull); + }); + + testWidgets('combo row exposes validation errors accessibly', (tester) async { + await tester.pumpWidget( + _testApp( + BusyMaxComboRow( + title: 'Category', + errorText: 'Choose a category', + values: const ['None', 'Problem'], + selected: 'None', + labelFor: (value) => value, + onSelected: (_) {}, + ), + ), + ); + + final errorText = tester.widget(find.text('Choose a category')); + final errorContext = tester.element(find.text('Choose a category')); + expect(errorText.style?.color, Theme.of(errorContext).colorScheme.error); + expect( + find.byWidgetPredicate( + (widget) => widget is Semantics && widget.properties.liveRegion == true, + ), + findsOneWidget, + ); + expect( + find.byWidgetPredicate( + (widget) => + widget is Semantics && + widget.properties.validationResult == + ui.SemanticsValidationResult.invalid, + ), + findsOneWidget, + ); + }); + testWidgets('switch row exposes one merged toggle interaction', ( tester, ) async { @@ -224,6 +400,225 @@ void main() { expect(values, [isFalse, isFalse]); semanticsHandle.dispose(); }); + + for (final brightness in Brightness.values) { + testWidgets('floating surfaces use semantic $brightness separation', ( + tester, + ) async { + final theme = BusyMaxYaruTheme.build( + brightness: brightness, + accentColor: const Color(0xFF3584E4), + ); + final colors = theme.extension()!; + + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: Scaffold( + body: Column( + children: [ + BusyMaxModalEditorSurface( + child: const SizedBox(width: 240, height: 120), + ), + BusyMaxPopoverSurface( + color: colors.popover, + child: const SizedBox(width: 180, height: 80), + ), + ], + ), + ), + ), + ); + + final modalMaterial = tester.widget( + find.descendant( + of: find.byType(BusyMaxModalEditorSurface), + matching: find.byWidgetPredicate( + (widget) => widget is Material && widget.color == colors.dialog, + ), + ), + ); + final modalShape = modalMaterial.shape! as RoundedRectangleBorder; + expect(modalMaterial.elevation, BusyMaxElevation.window); + expect(modalMaterial.shadowColor, theme.colorScheme.shadow); + expect(modalShape.side.color, colors.subtleBorder); + expect(modalShape.side.width, BusyMaxStroke.outline); + + final physicalShape = tester.widget( + find.descendant( + of: find.byType(BusyMaxPopoverSurface), + matching: find.byType(PhysicalShape), + ), + ); + expect(physicalShape.elevation, BusyMaxElevation.tooltip); + expect(physicalShape.shadowColor, theme.colorScheme.shadow); + final outlinePaint = tester.widget( + find.descendant( + of: find.byType(BusyMaxPopoverSurface), + matching: find.byWidgetPredicate( + (widget) => + widget is CustomPaint && widget.foregroundPainter != null, + ), + ), + ); + expect(outlinePaint.foregroundPainter, isNotNull); + expect(tester.takeException(), isNull); + }); + } + + testWidgets('combo row stacks its selector for large text', (tester) async { + await tester.pumpWidget( + MaterialApp( + theme: BusyMaxYaruTheme.build( + brightness: Brightness.light, + accentColor: const Color(0xFF3584E4), + ), + home: MediaQuery( + data: const MediaQueryData(textScaler: TextScaler.linear(1.4)), + child: Scaffold( + body: SizedBox( + width: 760, + child: BusyMaxComboRow( + title: 'Calendar account with a long label', + values: const ['Personal calendar', 'Work calendar'], + selected: 'Personal calendar', + labelFor: (value) => value, + onSelected: (_) {}, + ), + ), + ), + ), + ), + ); + + final titleRect = tester.getRect( + find.text('Calendar account with a long label'), + ); + final triggerRect = tester.getRect(find.byType(OutlinedButton)); + expect(triggerRect.top, greaterThanOrEqualTo(titleRect.bottom)); + expect(tester.takeException(), isNull); + }); + + testWidgets('time mode uses a labeled row and neutral Yaru toggle group', ( + tester, + ) async { + const accentColor = Color(0xFF3584E4); + final changes = []; + await tester.pumpWidget( + localizedTestApp( + child: Theme( + data: BusyMaxYaruTheme.build( + brightness: Brightness.light, + accentColor: accentColor, + ), + child: Scaffold( + body: BusyMaxTimeModeRow(allDay: true, onChanged: changes.add), + ), + ), + ), + ); + + expect(find.text('Time'), findsOneWidget); + expect(find.text('Use dates only or set specific times.'), findsOneWidget); + expect(find.byType(YaruListTile), findsOneWidget); + + final control = tester.widget(find.byType(ToggleButtons)); + expect(control.isSelected, [isTrue, isFalse]); + final theme = Theme.of(tester.element(find.byType(ToggleButtons))); + final colors = theme.extension()!; + expect(theme.toggleButtonsTheme.fillColor, colors.controlActive); + expect(theme.toggleButtonsTheme.fillColor, isNot(accentColor)); + expect(theme.toggleButtonsTheme.selectedColor, colors.foreground); + expect(theme.toggleButtonsTheme.selectedBorderColor, colors.border); + expect( + theme.toggleButtonsTheme.borderRadius, + BorderRadius.circular(BusyMaxRadius.sm), + ); + + final titleRect = tester.getRect(find.text('Time')); + final descriptionRect = tester.getRect( + find.text('Use dates only or set specific times.'), + ); + final controlRect = tester.getRect(find.byType(ToggleButtons)); + expect(descriptionRect.top, greaterThan(titleRect.top)); + expect(controlRect.left, greaterThan(titleRect.right)); + + await tester.tap(find.text('Time slot')); + await tester.pump(); + expect(changes, [isFalse]); + }); + + testWidgets('time mode stacks cleanly when its form section is narrow', ( + tester, + ) async { + await tester.pumpWidget( + localizedTestApp( + child: Theme( + data: BusyMaxYaruTheme.build( + brightness: Brightness.light, + accentColor: const Color(0xFF3584E4), + ), + child: const Scaffold( + body: Center( + child: SizedBox( + width: 420, + child: BusyMaxTimeModeRow(allDay: true, onChanged: _ignoreBool), + ), + ), + ), + ), + ), + ); + + final descriptionRect = tester.getRect( + find.text('Use dates only or set specific times.'), + ); + final controlRect = tester.getRect(find.byType(ToggleButtons)); + expect(controlRect.top, greaterThanOrEqualTo(descriptionRect.bottom)); + expect(tester.takeException(), isNull); + }); + + testWidgets('custom dialogs announce their title as route semantics', ( + tester, + ) async { + Semantics routeSemantics(String label) { + return tester.widget( + find.byWidgetPredicate( + (widget) => widget is Semantics && widget.properties.label == label, + ), + ); + } + + await tester.pumpWidget( + _testApp( + BusyMaxModalEditorScaffold( + title: 'Edit event', + cancelLabel: 'Cancel', + saveLabel: 'Save', + onCancel: () {}, + onSave: null, + children: const [Text('Editor content')], + ), + ), + ); + final editorSemantics = routeSemantics('Edit event'); + expect(editorSemantics.properties.scopesRoute, isTrue); + expect(editorSemantics.properties.namesRoute, isTrue); + expect(editorSemantics.explicitChildNodes, isTrue); + + await tester.pumpWidget( + _testApp( + const BusyMaxDialogShell( + title: 'Confirm action', + children: [Text('Dialog content')], + ), + ), + ); + final dialogSemantics = routeSemantics('Confirm action'); + expect(dialogSemantics.properties.scopesRoute, isTrue); + expect(dialogSemantics.properties.namesRoute, isTrue); + expect(dialogSemantics.explicitChildNodes, isTrue); + }); } void _ignoreBool(bool value) {} diff --git a/test/app/busymax_search_field_test.dart b/test/app/busymax_search_field_test.dart new file mode 100644 index 0000000..46e5d2c --- /dev/null +++ b/test/app/busymax_search_field_test.dart @@ -0,0 +1,67 @@ +import 'dart:io'; + +import 'package:busymax/src/app/busymax_design.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:yaru/yaru.dart'; + +void main() { + testWidgets('BusyMax search delegates visuals and interaction to Yaru', ( + tester, + ) async { + final controller = TextEditingController(); + addTearDown(controller.dispose); + final changes = []; + var clearCount = 0; + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: BusyMaxSearchField( + controller: controller, + hintText: 'Search', + onChanged: changes.add, + onClear: () => clearCount += 1, + ), + ), + ), + ); + + expect(find.byType(YaruSearchField), findsOneWidget); + final context = tester.element(find.byType(BusyMaxSearchField)); + final field = tester.widget(find.byType(YaruSearchField)); + expect(field.style, YaruSearchFieldStyle.filled); + expect(field.height, kYaruTitleBarItemHeight); + expect( + field.clearIconSemanticLabel, + MaterialLocalizations.of(context).clearButtonTooltip, + ); + + await tester.enterText(find.byType(TextField), 'planning'); + await tester.pump(); + expect(changes, contains('planning')); + + await tester.tap(find.byIcon(YaruIcons.edit_clear)); + await tester.pump(); + expect(controller.text, isEmpty); + expect(clearCount, 1); + }); + + test('search consumers use only the shared Yaru adapter', () { + final design = File('lib/src/app/busymax_design.dart').readAsStringSync(); + final schedule = File( + 'lib/src/features/schedule/presentation/schedule_workspace.dart', + ).readAsStringSync(); + final taskFilters = File( + 'lib/src/features/tasks/presentation/task_filters.dart', + ).readAsStringSync(); + + expect(design, contains('class BusyMaxSearchField')); + expect(RegExp(r'YaruSearchField\(').allMatches(design), hasLength(1)); + expect(schedule, contains('BusyMaxSearchField(')); + expect(taskFilters, contains('BusyMaxSearchField(')); + expect(schedule, isNot(contains('class _ScheduleSearchField'))); + expect(schedule, isNot(contains('YaruSearchField('))); + expect(taskFilters, isNot(contains('YaruSearchField('))); + }); +} diff --git a/test/app/high_contrast_theme_test.dart b/test/app/high_contrast_theme_test.dart new file mode 100644 index 0000000..4ea599f --- /dev/null +++ b/test/app/high_contrast_theme_test.dart @@ -0,0 +1,126 @@ +import 'package:busymax/src/app/app_theme.dart'; +import 'package:busymax/src/app/busymax_surface_colors.dart'; +import 'package:busymax/src/platform/gtk_font_service.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:yaru/theme.dart'; + +const _testAccent = Color(0xFF3584E4); + +void main() { + test('high-contrast themes use Yaru high-contrast component semantics', () { + final light = buildBusyMaxTheme( + brightness: Brightness.light, + accentColor: _testAccent, + highContrast: true, + ); + final dark = buildBusyMaxTheme( + brightness: Brightness.dark, + accentColor: _testAccent, + highContrast: true, + ); + + expect(light.colorScheme.primary, Colors.black); + expect(light.colorScheme.isHighContrast, isTrue); + expect(dark.colorScheme.primary, Colors.white); + expect(dark.colorScheme.isHighContrast, isTrue); + + final lightSurfaces = light.extension()!; + final darkSurfaces = dark.extension()!; + for (final surface in [ + lightSurfaces.window, + lightSurfaces.view, + lightSurfaces.sidebar, + lightSurfaces.dialog, + lightSurfaces.popover, + ]) { + expect(surface, Colors.white); + } + for (final surface in [ + darkSurfaces.window, + darkSurfaces.view, + darkSurfaces.sidebar, + darkSurfaces.dialog, + darkSurfaces.popover, + ]) { + expect(surface, Colors.black); + } + + for (final theme in [light, dark]) { + final surfaces = theme.extension()!; + expect(surfaces.mutedForeground, surfaces.foreground); + expect(surfaces.disabledForeground, isNot(surfaces.foreground)); + expect(surfaces.border, surfaces.foreground); + expect(surfaces.subtleBorder, surfaces.foreground); + expect(surfaces.sidebarBorder, surfaces.foreground); + expect(theme.colorScheme.outline, surfaces.foreground); + expect(theme.colorScheme.outlineVariant, surfaces.foreground); + expect( + _contrastRatio(theme.colorScheme.error, surfaces.view), + greaterThanOrEqualTo(4.5), + ); + + final outlinedSide = theme.outlinedButtonTheme.style!.side!.resolve({}); + expect(outlinedSide, isNot(BorderSide.none)); + expect(outlinedSide!.color, surfaces.border); + + final elevatedShape = + theme.elevatedButtonTheme.style!.shape!.resolve({}) + as RoundedRectangleBorder; + expect(elevatedShape.side, isNot(BorderSide.none)); + expect(elevatedShape.side.color, surfaces.border); + + final popupShape = theme.popupMenuTheme.shape! as RoundedRectangleBorder; + expect(popupShape.side.color, surfaces.border); + + final tooltipDecoration = theme.tooltipTheme.decoration! as BoxDecoration; + expect(tooltipDecoration.border, isNotNull); + } + }); + + test('high contrast does not retain mixed-luminance GTK surfaces', () { + const gtkColors = GtkThemeColors( + brightness: Brightness.light, + window: Colors.white, + view: Colors.white, + sidebar: Colors.black, + dialog: Colors.black, + popover: Colors.black, + ); + final theme = buildBusyMaxTheme( + brightness: Brightness.light, + accentColor: _testAccent, + gtkThemeColors: gtkColors, + highContrast: true, + ); + final surfaces = theme.extension()!; + + expect(surfaces.sidebar, Colors.white); + expect(surfaces.dialog, Colors.white); + expect(surfaces.popover, Colors.white); + expect(surfaces.foreground, Colors.black); + }); + + test('standard themes retain the requested system accent', () { + const accent = Color(0xFF3584E4); + final theme = buildBusyMaxTheme( + brightness: Brightness.light, + accentColor: accent, + ); + + expect(theme.colorScheme.primary, accent); + expect(theme.colorScheme.isHighContrast, isFalse); + }); +} + +double _contrastRatio(Color first, Color second) { + final firstLuminance = first.computeLuminance(); + final secondLuminance = second.computeLuminance(); + final lighter = firstLuminance > secondLuminance + ? firstLuminance + : secondLuminance; + final darker = firstLuminance > secondLuminance + ? secondLuminance + : firstLuminance; + return (lighter + 0.05) / (darker + 0.05); +} diff --git a/test/app/native_ui_audit_test.dart b/test/app/native_ui_audit_test.dart index 5b26e5e..9dca2bc 100644 --- a/test/app/native_ui_audit_test.dart +++ b/test/app/native_ui_audit_test.dart @@ -118,20 +118,16 @@ void main() { expect(scheduleAgenda, contains('BusyMaxActionRow')); expect(scheduleAgenda, isNot(contains('scheduleAgendaRowBackground'))); expect(scheduleAgenda, isNot(contains('surfaceColor:'))); - expect( - scheduleAgenda, - isNot(contains('ScheduleProjection.colorForItem')), - ); + expect(scheduleAgenda, contains('ScheduleProjection.colorForItem')); + expect(scheduleAgenda, contains('leading: _AgendaItemMarker')); expect(scheduleAgenda, isNot(contains('class _AgendaDayHeader'))); expect(scheduleAgenda, isNot(contains('class _AgendaPlainHeader'))); expect(compactAgenda, contains('BusyMaxGroupedList')); expect(compactAgenda, contains('BusyMaxActionRow')); expect(compactAgenda, isNot(contains('scheduleAgendaRowBackground'))); - expect( - compactAgenda, - isNot(contains('ScheduleProjection.colorForItem')), - ); + expect(compactAgenda, contains('ScheduleProjection.colorForItem')); + expect(compactAgenda, contains('leading: _CompactAgendaRowMarker')); expect(dateTimeFields, contains('YaruDateTimeEntry')); expect(dateTimeFields, contains('_BusyMaxTimeTextEntry')); @@ -169,6 +165,14 @@ void main() { expect(source, isNot(contains("label: 'Quit BusyMax'"))); }); + test('main calendar window starts at the intended desktop size', () { + final source = File('linux/runner/my_application.cc').readAsStringSync(); + + expect(source, contains('kMainWindowDefaultWidth = 1280')); + expect(source, contains('kMainWindowDefaultHeight = 720')); + expect(source, contains('gtk_window_set_default_size')); + }); + test('snap uses portal-backed secret storage without keyring plug', () { final snapcraft = File('snap/snapcraft.yaml').readAsStringSync(); final bootstrap = File( @@ -407,11 +411,11 @@ void main() { expect(source, contains('header_brand_label')); expect(source, contains('settings_menu_button')); expect(source, contains('settings_menu')); - expect(source, contains('create_header_settings_item(self, "settings"')); expect( source, - contains('create_header_settings_item(self, "aboutBusyMax"'), + contains('create_header_popover_action_item(self, "settings"'), ); + expect(source, contains('self, "aboutBusyMax", "About BusyMax"')); expect(source, isNot(contains('settingsAccounts'))); expect(source, isNot(contains('settingsDiagnostics'))); expect(source, contains('gtk_label_new(kApplicationDisplayName)')); @@ -620,6 +624,10 @@ void main() { ), ); expect(source, contains('button.busymax-header-popover-row:focus')); + expect(source, contains('busymax-keyboard-focus:focus')); + expect(source, contains('gtk_window_get_focus_visible')); + expect(source, contains('configure_header_popover_row(self, item)')); + expect(source, isNot(contains('gtk_widget_set_can_focus(item, FALSE)'))); expect(source, contains('"object-select-symbolic"')); expect(source, contains('gtk_widget_set_opacity(check_widget')); expect(source, isNot(contains('gtk_model_button_new()'))); @@ -660,6 +668,32 @@ void main() { expect(source, contains('setLocalizedLabels')); expect(source, contains('setSidebarWidth')); 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('gtk_search_entry_new()')); + expect(source, contains('gtk_stack_add_named')); + 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, isNot(contains('busymax-search-entry'))); + expect(source, contains('set_header_create_capabilities')); + expect(source, contains('strcmp(method, "showCreateMenu") == 0')); expect(source, contains('setModalBarrierVisible')); expect(source, contains('busymax-modal-barrier')); expect(source, contains('gtk_widget_set_sensitive(self->titlebar_box')); @@ -673,6 +707,8 @@ void main() { expect(source, contains('header_bar_control_hover_color')); expect(source, contains('header_bar_popover_background_color')); expect(source, contains('header_bar_border_color')); + expect(source, contains('header_bar_sidebar_border_color')); + expect(source, contains('border-right: 1px solid %s;')); expect(source, contains('header_bar_shade_color')); expect(source, contains('header_bar_accent_color')); expect(source, contains('header_bar_accent_foreground_color')); @@ -784,12 +820,14 @@ void main() { expect(source, contains('list-add-symbolic')); expect( source, - contains( - 'connect_header_bar_action(self, self->create_button, "create")', - ), + contains('create_header_popover_action_item(self, "createEvent"'), ); + expect(source, contains('self, "createTask", "Task"')); + expect(source, contains('gtk_menu_button_set_popover')); + expect(source, contains('button.busymax-header-popover-row:disabled')); + expect(source, isNot(contains('self->create_button, "create"'))); expect(source, contains('open-menu-symbolic')); - expect(source, contains('create_header_settings_item')); + expect(source, contains('create_header_popover_action_item')); expect( source, contains('button.busymax-header-button.busymax-sidebar-toggle:checked'), @@ -950,17 +988,21 @@ void main() { source, contains('"gtk-application-prefer-dark-theme", prefer_dark'), ); - expect(source, contains('yaru_theme_name_for_preference')); - expect(source, contains('available_gtk_theme_for_preference')); - expect(source, contains('available_icon_theme_for_preference')); - expect(source, contains('g_str_has_suffix(theme_name, "-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('"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'] as String?)"), - ); + expect(gtkFontService, contains("accent: _parseColor(value['accent'])")); expect( app, contains( @@ -973,7 +1015,6 @@ void main() { 'ubuntuAccentColor ?? gtkThemeColors?.accent ?? systemColor.accent', ), ); - expect(source, contains('gtk_icon_theme_set_custom_theme')); expect(source, contains('fl_lookup_optional_bool_arg')); expect( source, diff --git a/test/app/theme_localization_test.dart b/test/app/theme_localization_test.dart index 7a37d3f..a4429b2 100644 --- a/test/app/theme_localization_test.dart +++ b/test/app/theme_localization_test.dart @@ -34,11 +34,13 @@ void main() { final selected = {WidgetState.selected}; final lightSurfaceColors = light.extension()!; + final yaruBase = createYaruLightTheme(primaryColor: _testAccentColor); expect(light.colorScheme.primary, _testAccentColor); expect(dark.colorScheme.primary, _testAccentColor); expect(light.primaryColor, _testAccentColor); - expect(light.visualDensity, VisualDensity.compact); + expect(light.visualDensity, yaruBase.visualDensity); + expect(light.splashFactory, yaruBase.splashFactory); expect(light.scaffoldBackgroundColor, isNot(_testAccentColor)); expect(light.colorScheme.surface, alternate.colorScheme.surface); expect( @@ -64,19 +66,42 @@ void main() { ); expect( light.filledButtonTheme.style?.backgroundColor?.resolve({}), - _testAccentColor, + lightSurfaceColors.control, ); expect( - light.segmentedButtonTheme.style?.foregroundColor?.resolve(selected), + light.elevatedButtonTheme.style?.backgroundColor?.resolve({}), _testAccentColor, ); + expect(light.toggleButtonsTheme.color, lightSurfaceColors.foreground); expect( - light.segmentedButtonTheme.style?.side?.resolve(selected)?.color, - _testAccentColor, + light.toggleButtonsTheme.selectedColor, + lightSurfaceColors.foreground, ); expect( - light.segmentedButtonTheme.style?.backgroundColor?.resolve(selected), - light.colorScheme.primaryContainer, + light.toggleButtonsTheme.fillColor, + lightSurfaceColors.controlActive, + ); + expect(light.toggleButtonsTheme.fillColor, isNot(_testAccentColor)); + expect(light.toggleButtonsTheme.borderColor, lightSurfaceColors.border); + expect( + light.toggleButtonsTheme.selectedBorderColor, + lightSurfaceColors.border, + ); + expect( + light.toggleButtonsTheme.borderRadius, + BorderRadius.circular(BusyMaxRadius.sm), + ); + expect( + light.toggleButtonsTheme.highlightColor, + lightSurfaceColors.controlActive, + ); + expect( + light.toggleButtonsTheme.splashColor, + lightSurfaceColors.controlHover, + ); + expect( + light.toggleButtonsTheme.focusColor, + lightSurfaceColors.controlActive, ); expect(light.floatingActionButtonTheme.backgroundColor, _testAccentColor); expect(light.progressIndicatorTheme.color, _testAccentColor); @@ -89,7 +114,7 @@ void main() { final outlinedShape = light.outlinedButtonTheme.style?.shape?.resolve({}) as RoundedRectangleBorder; - expect(outlinedShape.borderRadius, BorderRadius.circular(12)); + expect(outlinedShape.borderRadius, BorderRadius.circular(BusyMaxRadius.sm)); final pushButtonStyle = busyMaxPushButtonStyle(null); expect( @@ -101,19 +126,50 @@ void main() { const Size.fromHeight(BusyMaxSizes.pushButtonHeight), ); expect( - pushButtonStyle.padding?.resolve({}), - const EdgeInsets.symmetric(horizontal: 12), + light.outlinedButtonTheme.style?.side?.resolve({}), + yaruBase.outlinedButtonTheme.style?.side?.resolve({}), ); + for (final pair in [ + (light.outlinedButtonTheme.style, yaruBase.outlinedButtonTheme.style), + (light.filledButtonTheme.style, yaruBase.filledButtonTheme.style), + (light.elevatedButtonTheme.style, yaruBase.elevatedButtonTheme.style), + ]) { + expect(pair.$1?.shape?.resolve({}), pair.$2?.shape?.resolve({})); + for (final states in [ + {WidgetState.hovered}, + {WidgetState.focused}, + {WidgetState.pressed}, + ]) { + expect( + pair.$1?.overlayColor?.resolve(states), + pair.$2?.overlayColor?.resolve(states), + ); + } + } + }); - expect(light.outlinedButtonTheme.style?.side?.resolve({}), BorderSide.none); - expect( - light.outlinedButtonTheme.style?.side?.resolve({WidgetState.disabled}), - BorderSide.none, + test('shared push buttons expose semantic Yaru roles', () { + final standard = BusyMaxPushButton.standard( + onPressed: () {}, + child: const Text('Standard'), ); - expect( - light.outlinedButtonTheme.style?.side?.resolve({WidgetState.focused}), - BorderSide(color: _testAccentColor), + final suggested = BusyMaxPushButton.suggested( + onPressed: () {}, + child: const Text('Suggested'), + ); + final headerStandard = BusyMaxHeaderPushButton.standard( + onPressed: () {}, + child: const Text('Cancel'), ); + final headerSuggested = BusyMaxHeaderPushButton.suggested( + onPressed: () {}, + child: const Text('Save'), + ); + + expect(standard, isA()); + expect(suggested, isA()); + expect(headerStandard, isA()); + expect(headerSuggested, isA()); }); test('BusyMax Yaru theme exposes semantic fallback surfaces', () { @@ -137,6 +193,7 @@ void main() { expect(darkColors.groupedSurface, const Color(0xFF383838)); expect(darkColors.dialog, const Color(0xFF222226)); expect(darkColors.popover, const Color(0xFF383838)); + expect(darkColors.sidebarBorder, const Color.fromRGBO(255, 255, 255, 0.10)); expect(darkColors.view, isNot(const Color(0xFF3E3E3E))); expect(light.scaffoldBackgroundColor, lightColors.window); expect(dark.scaffoldBackgroundColor, darkColors.window); @@ -414,7 +471,6 @@ void main() { (theme.filledButtonTheme.style, base.filledButtonTheme.style), (theme.elevatedButtonTheme.style, base.elevatedButtonTheme.style), (theme.textButtonTheme.style, base.textButtonTheme.style), - (theme.segmentedButtonTheme.style, base.segmentedButtonTheme.style), ]) { _expectComponentStyleUsesTypography( pair.$1?.textStyle?.resolve(buttonStates), @@ -502,7 +558,7 @@ void main() { expect(theme.popupMenuTheme.color, gtkColors.popover); final colors = theme.extension()!; expect(colors.sidebar, gtkColors.sidebar); - expect(colors.groupedSurface, gtkColors.popover); + expect(colors.groupedSurface, gtkColors.card); }); test('BusyMax theme ignores light GTK runtime shade samples', () { @@ -520,13 +576,16 @@ void main() { final colors = theme.extension()!; expect(colors.shade, busyMaxFallbackSurfaceColors(Brightness.light).shade); - expect(colors.groupedSurface, gtkColors.view); + expect( + colors.groupedSurface, + busyMaxFallbackSurfaceColors(Brightness.light).groupedSurface, + ); expect(theme.shadowColor, theme.colorScheme.shadow); expect(theme.popupMenuTheme.shadowColor, theme.colorScheme.shadow); expect(theme.popupMenuTheme.shadowColor, isNot(colors.shade)); }); - test('BusyMax theme ignores too-dark GTK popover samples', () { + test('BusyMax theme preserves dark GTK popover samples', () { const gtkColors = GtkThemeColors( brightness: Brightness.dark, window: Color(0xFF242424), @@ -541,12 +600,77 @@ void main() { final colors = theme.extension()!; - expect(theme.popupMenuTheme.color, const Color(0xFF383838)); - expect(colors.popover, const Color(0xFF383838)); - expect(colors.groupedSurface, const Color(0xFF383838)); + expect(theme.popupMenuTheme.color, gtkColors.popover); + expect(colors.popover, gtkColors.popover); + expect( + colors.groupedSurface, + busyMaxFallbackSurfaceColors(Brightness.dark).groupedSurface, + ); + }); + + test('BusyMax grouped surfaces ignore unreadable GTK card samples', () { + const gtkColors = GtkThemeColors( + brightness: Brightness.light, + window: Color(0xFFFFFFFF), + view: Color(0xFFFFFFFF), + card: Color(0xFF101010), + popover: Color(0xFFEEEEEE), + ); + final colors = _buildBusyMaxTheme( + brightness: Brightness.light, + gtkThemeColors: gtkColors, + ).extension()!; + + expect( + colors.groupedSurface, + busyMaxFallbackSurfaceColors(Brightness.light).groupedSurface, + ); + expect(colors.groupedSurface, isNot(gtkColors.card)); + expect(colors.groupedSurface, isNot(gtkColors.popover)); }); - test('BusyMax theme ignores blue purple GTK dark surface samples', () { + test( + 'BusyMax dark grouped surfaces reject flat or recessed card samples', + () { + for (final card in const [Color(0xFF242424), Color(0xFF101010)]) { + final colors = _buildBusyMaxTheme( + brightness: Brightness.dark, + gtkThemeColors: GtkThemeColors( + brightness: Brightness.dark, + window: const Color(0xFF202020), + view: const Color(0xFF242424), + card: card, + ), + ).extension()!; + + expect( + colors.groupedSurface, + busyMaxFallbackSurfaceColors(Brightness.dark).groupedSurface, + ); + } + }, + ); + + test('BusyMax dark sidebar rejects recessed boundary samples', () { + const gtkColors = GtkThemeColors( + brightness: Brightness.dark, + window: Color(0xFF202020), + view: Color(0xFF242424), + sidebar: Color(0xFF303030), + sidebarBorder: Color.fromRGBO(0, 0, 0, 0.36), + ); + final colors = _buildBusyMaxTheme( + brightness: Brightness.dark, + gtkThemeColors: gtkColors, + ).extension()!; + + expect( + colors.sidebarBorder, + busyMaxFallbackSurfaceColors(Brightness.dark).sidebarBorder, + ); + }); + + test('BusyMax theme preserves chromatic GTK dark surface samples', () { const gtkColors = GtkThemeColors( brightness: Brightness.dark, window: Color(0xFF241F31), @@ -563,9 +687,9 @@ void main() { ); final colors = theme.extension()!; - expect(theme.scaffoldBackgroundColor, const Color(0xFF1D1D20)); - expect(theme.colorScheme.surface, const Color(0xFF1D1D20)); - expect(theme.colorScheme.surfaceContainer, const Color(0xFF222226)); + expect(theme.scaffoldBackgroundColor, gtkColors.window); + expect(theme.colorScheme.surface, gtkColors.view); + expect(theme.colorScheme.surfaceContainer, gtkColors.card); expect( theme.colorScheme.surfaceContainerHigh, const Color.fromRGBO(255, 255, 255, 0.10), @@ -574,15 +698,15 @@ void main() { theme.colorScheme.surfaceContainerHighest, const Color.fromRGBO(255, 255, 255, 0.14), ); - expect(theme.dialogTheme.backgroundColor, const Color(0xFF222226)); - expect(colors.sidebar, isNot(const Color(0xFF3D3846))); + expect(theme.dialogTheme.backgroundColor, gtkColors.dialog); + expect(colors.sidebar, gtkColors.sidebar); expect(colors.control, const Color.fromRGBO(255, 255, 255, 0.10)); expect(colors.controlHover, const Color.fromRGBO(255, 255, 255, 0.14)); - expect(colors.popover, const Color(0xFF383838)); - expect(colors.groupedSurface, const Color(0xFF383838)); + expect(colors.popover, gtkColors.popover); + expect(colors.groupedSurface, gtkColors.card); }); - test('BusyMax theme ignores GTK accent control samples', () { + test('BusyMax theme preserves chromatic GTK control samples', () { const gtkColors = GtkThemeColors( brightness: Brightness.dark, control: Color(0x22004A99), @@ -594,16 +718,14 @@ void main() { gtkThemeColors: gtkColors, ); final colors = theme.extension()!; - final fallback = busyMaxFallbackSurfaceColors(Brightness.dark); - - expect(colors.control, fallback.control); - expect(colors.controlHover, fallback.controlHover); - expect(colors.controlActive, fallback.controlActive); - expect(theme.colorScheme.surfaceContainerHigh, fallback.control); - expect(theme.colorScheme.surfaceContainerHighest, fallback.controlHover); + expect(colors.control, gtkColors.control); + expect(colors.controlHover, gtkColors.controlHover); + expect(colors.controlActive, gtkColors.controlActive); + expect(theme.colorScheme.surfaceContainerHigh, gtkColors.control); + expect(theme.colorScheme.surfaceContainerHighest, gtkColors.controlHover); }); - test('BusyMax theme derives sidebar from collapsed GTK colors', () { + test('BusyMax theme preserves a flat GTK surface hierarchy', () { const gtkColors = GtkThemeColors( brightness: Brightness.dark, window: Color(0xFF3E3E3E), @@ -617,17 +739,10 @@ void main() { expect(theme.scaffoldBackgroundColor, gtkColors.window); expect(theme.colorScheme.surface, gtkColors.view); - expect( - theme.extension()?.sidebar, - isNot(const Color(0xFF2E2E32)), - ); - expect( - theme.extension()?.sidebar, - isNot(theme.scaffoldBackgroundColor), - ); + expect(theme.extension()?.sidebar, gtkColors.sidebar); }); - test('BusyMax theme uses native headerbar as sidebar candidate', () { + test('BusyMax theme keeps GTK semantic roles independent', () { const gtkColors = GtkThemeColors( brightness: Brightness.dark, window: Color(0xFF242424), @@ -641,13 +756,12 @@ void main() { ); expect(theme.colorScheme.surface, gtkColors.view); - expect( - theme.extension()?.sidebar, - gtkColors.headerbar, - ); + final colors = theme.extension()!; + expect(colors.sidebar, gtkColors.sidebar); + expect(colors.headerbar, gtkColors.headerbar); }); - test('BusyMax theme ignores black GTK surface samples', () { + test('BusyMax theme preserves black GTK surface samples', () { const gtkColors = GtkThemeColors( brightness: Brightness.dark, window: Color(0xFF000000), @@ -661,13 +775,13 @@ void main() { ); final colors = theme.extension()!; - expect(theme.scaffoldBackgroundColor, const Color(0xFF1D1D20)); + expect(theme.scaffoldBackgroundColor, gtkColors.window); expect(theme.colorScheme.surface, gtkColors.view); - expect(colors.sidebar, isNot(const Color(0xFF000000))); - expect(colors.sidebar, isNot(const Color(0xFF2E2E32))); + expect(colors.sidebar, gtkColors.sidebar); + expect(colors.headerbar, gtkColors.headerbar); }); - test('BusyMax theme ignores near-black GTK surface samples', () { + test('BusyMax theme preserves near-black GTK surface samples', () { const gtkColors = GtkThemeColors( brightness: Brightness.dark, window: Color(0xFF101010), @@ -681,13 +795,13 @@ void main() { ); final colors = theme.extension()!; - expect(theme.scaffoldBackgroundColor, const Color(0xFF1D1D20)); - expect(theme.colorScheme.surface, const Color(0xFF1D1D20)); - expect(colors.sidebar, isNot(const Color(0xFF101010))); - expect(colors.sidebar, isNot(theme.colorScheme.surface)); + expect(theme.scaffoldBackgroundColor, gtkColors.window); + expect(theme.colorScheme.surface, gtkColors.view); + expect(colors.sidebar, gtkColors.sidebar); + expect(colors.headerbar, gtkColors.headerbar); }); - test('BusyMax theme ignores translucent GTK surface samples', () { + test('BusyMax theme composites translucent GTK surface layers', () { const gtkColors = GtkThemeColors( brightness: Brightness.dark, window: Color(0x33000000), @@ -700,11 +814,16 @@ void main() { gtkThemeColors: gtkColors, ); final colors = theme.extension()!; + final window = Color.alphaBlend( + gtkColors.window!, + busyMaxFallbackSurfaceColors(Brightness.dark).window, + ); + final view = Color.alphaBlend(gtkColors.view!, window); - expect(theme.scaffoldBackgroundColor, const Color(0xFF1D1D20)); - expect(theme.colorScheme.surface, const Color(0xFF1D1D20)); - expect(colors.sidebar, isNot(const Color(0x33000000))); - expect(colors.sidebar, isNot(theme.colorScheme.surface)); + expect(theme.scaffoldBackgroundColor, window); + expect(theme.colorScheme.surface, view); + expect(colors.sidebar, Color.alphaBlend(gtkColors.sidebar!, window)); + expect(colors.headerbar, Color.alphaBlend(gtkColors.headerbar!, window)); }); test('BusyMax theme rejects unreadable GTK foreground samples', () { @@ -728,6 +847,38 @@ void main() { expect(theme.colorScheme.onSurfaceVariant, colors.mutedForeground); }); + test('BusyMax theme falls back only mixed-luminance GTK surface roles', () { + const gtkColors = GtkThemeColors( + brightness: Brightness.light, + window: Color(0xFFFFFFFF), + view: Color(0xFFFFFFFF), + sidebar: Color(0xFF101010), + secondarySidebar: Color(0xFF111111), + headerbar: Color(0xFF121212), + headerbarFlat: Color(0xFF131313), + card: Color(0xFFFFFFFF), + dialog: Color(0xFF141414), + popover: Color(0xFF151515), + ); + final theme = _buildBusyMaxTheme( + brightness: Brightness.light, + gtkThemeColors: gtkColors, + ); + final colors = theme.extension()!; + final fallback = busyMaxFallbackSurfaceColors(Brightness.light); + + expect(colors.window, gtkColors.window); + expect(colors.view, gtkColors.view); + expect(colors.card, gtkColors.card); + expect(colors.sidebar, fallback.sidebar); + expect(colors.secondarySidebar, fallback.secondarySidebar); + expect(colors.headerbar, fallback.headerbar); + expect(colors.headerbarFlat, fallback.headerbarFlat); + expect(colors.dialog, fallback.dialog); + expect(colors.popover, fallback.popover); + expect(colors.foreground, fallback.foreground); + }); + test('BusyMax theme ignores GTK runtime colors for other brightness', () { const gtkColors = GtkThemeColors( brightness: Brightness.dark, @@ -842,27 +993,6 @@ void main() { expect(store.json['scheduleViewMode'], 'month'); }); - test('notification detail settings stay synchronized', () async { - final store = _MemorySettingsStore(); - final settings = AppSettingsController(store); - await Future.delayed(Duration.zero); - - await settings.setNotificationDetailLevel(NotificationDetailLevel.private); - expect(settings.state.detailedNotifications, isFalse); - - await settings.setDetailedNotifications(true); - expect( - settings.state.notificationDetailLevel, - NotificationDetailLevel.normal, - ); - - final loaded = AppSettings.fromJson({ - 'detailedNotifications': true, - 'notificationDetailLevel': 'private', - }); - expect(loaded.notificationDetailLevel, NotificationDetailLevel.normal); - }); - test('native headerbar receives semantic surface colors', () { final source = File('lib/src/app/busymax_app.dart').readAsStringSync(); @@ -893,7 +1023,8 @@ void main() { expect(source, contains('ClipRRect(')); expect(source, contains('bottom: Radius.circular(BusyMaxRadius.window)')); - expect(source, contains('clipBehavior: Clip.antiAliasWithSaveLayer')); + expect(source, contains('clipBehavior: Clip.antiAlias')); + expect(source, isNot(contains('Clip.antiAliasWithSaveLayer'))); expect( source, contains('color: BusyMaxSurfaceColors.of(context).window'), diff --git a/test/features/auth/presentation/auth_routing_test.dart b/test/features/auth/presentation/auth_routing_test.dart index c214ff0..d8ed34b 100644 --- a/test/features/auth/presentation/auth_routing_test.dart +++ b/test/features/auth/presentation/auth_routing_test.dart @@ -3,10 +3,10 @@ import 'dart:io'; import 'package:drift/drift.dart'; import 'package:drift/native.dart'; +import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:flutter/widgets.dart'; import 'package:go_router/go_router.dart'; import 'package:busymax/src/app/app_bootstrap.dart'; import 'package:busymax/src/app/busymax_app.dart'; @@ -23,6 +23,7 @@ import 'package:busymax/src/google_tasks/oauth/oauth_models.dart'; import 'package:busymax/src/google_tasks/oauth/oauth_service.dart'; import 'package:busymax/src/google_tasks/oauth/oauth_token_store.dart'; import 'package:busymax/src/platform/linux_header_bar_service.dart'; +import 'package:busymax/src/schedule/schedule_scope.dart'; import 'package:busymax/src/task_providers/task_provider.dart'; void main() { @@ -132,6 +133,8 @@ void main() { await tester.pumpAndSettle(); expect(find.text('Choose system settings'), findsOneWidget); + expect(find.text('Notification detail level'), findsOneWidget); + expect(find.text('Detailed notification text'), findsNothing); await tester.tap(find.text('Finish setup')); await tester.pumpAndSettle(); @@ -157,6 +160,86 @@ void main() { expect(find.byType(ScheduleWorkspace), findsOneWidget); expect(find.byType(TasksWorkspace), findsNothing); + + final accountId = (await database.select(database.accounts).getSingle()).id; + await database.taskListsDao.upsertTaskList( + TaskListsCompanion.insert( + accountId: accountId, + id: 'list-1', + title: 'Route list', + rawJson: '{}', + createdLocalAtUtc: '2026-06-04T00:00:00.000Z', + updatedLocalAtUtc: '2026-06-04T00:00:00.000Z', + ), + ); + await database.tasksDao.upsertTask( + TasksCompanion.insert( + accountId: accountId, + taskListId: 'list-1', + id: 'task-1', + title: 'Route task', + status: const Value('needsAction'), + rawJson: '{}', + createdLocalAtUtc: '2026-06-04T00:00:00.000Z', + updatedLocalAtUtc: '2026-06-04T00:00:00.000Z', + ), + ); + final router = GoRouter.of(tester.element(find.byType(ScheduleWorkspace))); + router.go( + Uri( + pathSegments: ['', 'tasks', accountId, 'list-1', 'task-1'], + ).toString(), + ); + await tester.pumpAndSettle(); + + final deepLinkedWorkspace = tester.widget( + find.byType(ScheduleWorkspace), + ); + expect(deepLinkedWorkspace.initialScope, ScheduleScope.tasks); + expect(deepLinkedWorkspace.initialTaskAccountId, accountId); + expect(deepLinkedWorkspace.initialTaskListId, 'list-1'); + expect(deepLinkedWorkspace.initialTaskId, 'task-1'); + expect(find.text('Edit Task'), findsOneWidget); + + final routedWorkspaceState = tester.state(find.byType(ScheduleWorkspace)); + final titleField = find.byType(TextField).first; + await tester.enterText(titleField, 'Unsaved route title'); + await tester.pump(); + router.go( + Uri( + pathSegments: ['', 'tasks', accountId, 'list-1', 'missing-task'], + ).toString(), + ); + await tester.pump(); + expect( + tester.state(find.byType(ScheduleWorkspace)), + same(routedWorkspaceState), + ); + await tester.pumpAndSettle(); + + expect(find.text('Discard changes?'), findsOneWidget); + await tester.tap(find.text('Cancel').last); + await tester.pumpAndSettle(); + expect(router.routeInformationProvider.value.uri.pathSegments, [ + 'tasks', + accountId, + 'list-1', + 'task-1', + ]); + expect(find.text('Edit Task'), findsOneWidget); + + router.go(Uri(pathSegments: ['', 'tasks', accountId, 'list-1']).toString()); + await tester.pumpAndSettle(); + expect(find.text('Discard changes?'), findsOneWidget); + await tester.tap(find.text('Discard')); + await tester.pumpAndSettle(); + + expect(router.routeInformationProvider.value.uri.pathSegments, [ + 'tasks', + accountId, + 'list-1', + ]); + expect(find.text('Edit Task'), findsNothing); await _disposeApp(tester); }); diff --git a/test/features/calendar/data/calendar_repository_test.dart b/test/features/calendar/data/calendar_repository_test.dart index 8ebd7f9..41fa6f8 100644 --- a/test/features/calendar/data/calendar_repository_test.dart +++ b/test/features/calendar/data/calendar_repository_test.dart @@ -99,6 +99,21 @@ void main() { expect(source.isDeleted, isTrue); }); + test('source stream removes locally tombstoned calendars', () async { + await _upsertSource(repository); + expect( + await repository.watchSourcesForAccounts(const ['google:g']).first, + hasLength(1), + ); + + await repository.deleteLocalSource(_sourceId); + + expect( + await repository.watchSourcesForAccounts(const ['google:g']).first, + isEmpty, + ); + }); + test('provider hidden state can return to visible', () async { await repository.upsertSource( accountId: 'google:g', @@ -143,6 +158,87 @@ void main() { expect(await database.select(database.notificationSchedule).get(), isEmpty); expect(schedulerCalls, 1); }); + + test( + 'read-only source rejects event creation before local mutation', + () async { + await repository.upsertSource( + accountId: 'google:g', + source: const CalendarSourceDto( + provider: TaskProvider.google, + providerCalendarId: 'calendar-1', + summary: 'Shared calendar', + readOnly: true, + ), + ); + + await expectLater( + repository.createLocalEvent(_newEventDraft()), + throwsA( + isA().having( + (error) => error.operation, + 'operation', + CalendarMutationOperation.createEvent, + ), + ), + ); + + expect(await database.select(database.calendarEvents).get(), isEmpty); + expect(await database.select(database.pendingOps).get(), isEmpty); + }, + ); + + test('read-only source rejects event edits and deletes', () async { + await _upsertSource(repository); + await repository.createLocalEvent(_newEventDraft()); + final event = await database.select(database.calendarEvents).getSingle(); + await (database.update(database.calendarSources) + ..where((row) => row.id.equals(_sourceId))) + .write(const CalendarSourcesCompanion(readOnly: Value(true))); + final pendingBefore = await database.select(database.pendingOps).get(); + + await expectLater( + repository.updateLocalEvent( + EventEditorDraft.existing( + eventId: event.id, + accountId: event.accountId, + sourceId: event.calendarSourceId, + providerCalendarId: event.providerCalendarId, + title: 'Updated title', + allDay: event.allDay, + start: DateTime.utc(2026, 6, 8, 9), + end: DateTime.utc(2026, 6, 8, 10), + ), + ), + throwsA( + isA().having( + (error) => error.operation, + 'operation', + CalendarMutationOperation.editEvent, + ), + ), + ); + await expectLater( + repository.deleteLocalEvent(event.id), + throwsA( + isA().having( + (error) => error.operation, + 'operation', + CalendarMutationOperation.deleteEvent, + ), + ), + ); + + final unchanged = await database + .select(database.calendarEvents) + .getSingle(); + expect(unchanged.title, ''); + expect(unchanged.isDeleted, isFalse); + expect( + await database.select(database.pendingOps).get(), + hasLength(pendingBefore.length), + ); + }); } const _sourceId = 'google:g|google|calendar-1'; @@ -158,6 +254,16 @@ Future _upsertSource(CalendarRepository repository) { ); } +EventEditorDraft _newEventDraft() { + return EventEditorDraft.newEvent( + accountId: 'google:g', + sourceId: _sourceId, + providerCalendarId: 'calendar-1', + start: DateTime.utc(2026, 6, 8, 9), + end: DateTime.utc(2026, 6, 8, 10), + ); +} + Future _seedScheduledEvent( CalendarRepository repository, AppDatabase database, diff --git a/test/features/calendar/presentation/event_editor_test.dart b/test/features/calendar/presentation/event_editor_test.dart index 2e36f99..32a549a 100644 --- a/test/features/calendar/presentation/event_editor_test.dart +++ b/test/features/calendar/presentation/event_editor_test.dart @@ -92,8 +92,8 @@ void main() { expect(find.text('Start time'), findsNothing); expect(find.text('End Time'), findsNothing); - expect(_plainTextFinder('All Day'), findsOneWidget); - expect(_plainTextFinder('Time Slot'), findsOneWidget); + expect(_plainTextFinder('All day'), findsOneWidget); + expect(_plainTextFinder('Time slot'), findsOneWidget); expect(find.text('No conference'), findsNothing); expect(find.text('Delete Event'), findsNothing); }); @@ -124,7 +124,7 @@ void main() { ), ); - await tester.tap(_plainTextFinder('All Day')); + await tester.tap(_plainTextFinder('All day')); await tester.pump(); await tester.tap(_headerButtonFinder('Save')); @@ -1013,6 +1013,42 @@ void main() { }, ); + testWidgets('Escape routes dirty event cancellation through confirmation', ( + tester, + ) async { + var cancelled = false; + 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: () => cancelled = true, + onSave: (_) {}, + ), + ), + ), + ); + + await tester.enterText(find.byType(TextFormField).first, 'Changed event'); + _focusEditorShortcuts(tester); + await tester.sendKeyEvent(LogicalKeyboardKey.escape); + await tester.pumpAndSettle(); + + expect(cancelled, isFalse); + expect(find.text('Discard changes?'), findsOneWidget); + + await tester.tap(find.text('Discard')); + await tester.pumpAndSettle(); + expect(cancelled, isTrue); + }); + test('event editor is opened through the shared BusyMax dialog route', () { final editor = File( 'lib/src/features/calendar/presentation/event_editor.dart', @@ -1045,8 +1081,8 @@ void main() { expect(design, contains('class BusyMaxModalEditorScaffold')); expect(design, contains('BusyMaxEditorHeader(')); expect(design, contains('SingleChildScrollView')); - expect(design, contains('BusyMaxHeaderPushButton.outlined')); - expect(design, contains('BusyMaxHeaderPushButton.filled')); + expect(design, contains('BusyMaxHeaderPushButton.standard')); + expect(design, contains('BusyMaxHeaderPushButton.suggested')); expect( design, contains('EdgeInsets.symmetric(horizontal: BusyMaxSpacing.xl)'), @@ -1056,22 +1092,18 @@ void main() { expect(editor, isNot(contains('BusyMaxDialogCloseButton'))); }); - test( - 'editor row hover uses subtle foreground overlay, not selected color', - () { - final design = File('lib/src/app/busymax_design.dart').readAsStringSync(); - final hoverStart = design.indexOf('Color busyMaxEditorRowHoverColor'); - final hoverEnd = design.indexOf('Color busyMaxPanelBorder'); - final hoverSource = design.substring(hoverStart, hoverEnd); - - expect(hoverSource, contains('surfaceColors.foreground.withValues')); - expect(hoverSource, contains('Brightness.dark ? 0.045 : 0.055')); - expect(hoverSource, isNot(contains('.controlHover'))); - expect(hoverSource, isNot(contains('Theme.of(context).hoverColor'))); - expect(hoverSource, isNot(contains('primaryContainer'))); - expect(hoverSource, isNot(contains('colorScheme.primary'))); - }, - ); + test('editor rows reuse the shared Yaru hover role, not a control fill', () { + final design = File('lib/src/app/busymax_design.dart').readAsStringSync(); + final hoverStart = design.indexOf('Color busyMaxRowHoverColor'); + final hoverEnd = design.indexOf('Color busyMaxPanelBorder'); + final hoverSource = design.substring(hoverStart, hoverEnd); + + expect(hoverSource, contains('return Theme.of(context).hoverColor')); + expect(hoverSource, contains('return busyMaxRowHoverColor(context)')); + expect(hoverSource, isNot(contains('.controlHover'))); + expect(hoverSource, isNot(contains('primaryContainer'))); + expect(hoverSource, isNot(contains('colorScheme.primary'))); + }); test('event editor text fields do not render duplicate section labels', () { final editor = File( diff --git a/test/features/feedback/presentation/feedback_dialog_test.dart b/test/features/feedback/presentation/feedback_dialog_test.dart index 7aa5870..8bb6422 100644 --- a/test/features/feedback/presentation/feedback_dialog_test.dart +++ b/test/features/feedback/presentation/feedback_dialog_test.dart @@ -6,6 +6,7 @@ import 'package:busymax/src/features/feedback/presentation/feedback_dialog.dart' import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:yaru/yaru.dart'; import '../../../test_localized_app.dart'; @@ -146,6 +147,27 @@ void main() { await tester.pumpAndSettle(); }); + testWidgets('Escape confirms before discarding a feedback draft', ( + tester, + ) async { + final service = _FakeFeedbackService((_) async { + return const FeedbackReceipt(id: 'unexpected'); + }); + var cancelCount = 0; + await _pumpDialog(tester, service, onCancel: () => cancelCount += 1); + await _enterValidRequiredFields(tester); + + await tester.sendKeyEvent(LogicalKeyboardKey.escape); + await tester.pumpAndSettle(); + + expect(cancelCount, 0); + expect(find.text('Discard changes?'), findsOneWidget); + + await tester.tap(find.text('Discard')); + await tester.pumpAndSettle(); + expect(cancelCount, 1); + }); + testWidgets('clears the form and shows the server reference on success', ( tester, ) async { @@ -155,7 +177,7 @@ void main() { await _pumpDialog(tester, service); await _enterValidRequiredFields(tester); - final checkbox = tester.widget( + final checkbox = tester.widget( find.byKey(const Key('feedback-technical-details')), ); expect(checkbox.value, isFalse); @@ -352,7 +374,12 @@ Future _pumpDialog( } Future _enterValidRequiredFields(WidgetTester tester) async { - await tester.tap(find.byKey(const Key('feedback-category'))); + await tester.tap( + find.descendant( + of: find.byKey(const Key('feedback-category')), + matching: find.byType(OutlinedButton), + ), + ); await tester.pumpAndSettle(); await tester.tap(find.text('Problem or bug').last); await tester.pumpAndSettle(); diff --git a/test/features/notifications/desktop_notification_service_test.dart b/test/features/notifications/desktop_notification_service_test.dart index 35fa515..e944566 100644 --- a/test/features/notifications/desktop_notification_service_test.dart +++ b/test/features/notifications/desktop_notification_service_test.dart @@ -21,7 +21,9 @@ void main() { final backend = _FakeNotificationBackend(); final service = DesktopNotificationService( backend: backend, - settings: AppSettings.defaults().copyWith(detailedNotifications: true), + settings: AppSettings.defaults().copyWith( + notificationDetailLevel: NotificationDetailLevel.normal, + ), ); await service.notifySyncFailure( @@ -126,23 +128,51 @@ void main() { }); test( - 'detailed notification switch overrides private reminder text', + 'private detail level also hides diagnostic notification text', () async { final backend = _FakeNotificationBackend(); final service = DesktopNotificationService( backend: backend, settings: AppSettings.defaults().copyWith( - detailedNotifications: true, notificationDetailLevel: NotificationDetailLevel.private, ), ); - await service.notifyEventReminder('Doctor', 'Clinic'); + await service.notifySyncFailure('Private server response'); - expect(backend.notifications.single.summary, 'Doctor'); - expect(backend.notifications.single.body, 'Clinic'); + expect(backend.notifications.single.body, isNot(contains('Private'))); + expect( + backend.notifications.single.body, + contains('Details are hidden by privacy settings.'), + ); }, ); + + test('configured overnight quiet hours use an end-exclusive range', () async { + final settings = AppSettings.defaults().copyWith( + quietHoursEnabled: true, + quietHoursStart: '22:00', + quietHoursEnd: '07:00', + ); + final quietBackend = _FakeNotificationBackend(); + final quietService = DesktopNotificationService( + backend: quietBackend, + settings: settings, + now: () => DateTime(2026, 1, 1, 23, 30), + ); + final awakeBackend = _FakeNotificationBackend(); + final awakeService = DesktopNotificationService( + backend: awakeBackend, + settings: settings, + now: () => DateTime(2026, 1, 2, 7), + ); + + await quietService.notifySyncFailure('Offline'); + await awakeService.notifySyncFailure('Offline'); + + expect(quietBackend.notifications, isEmpty); + expect(awakeBackend.notifications, hasLength(1)); + }); } class _FakeNotificationBackend implements DesktopNotificationBackend { diff --git a/test/features/schedule/presentation/compact_agenda_panel_test.dart b/test/features/schedule/presentation/compact_agenda_panel_test.dart index 1c55573..4c31cfd 100644 --- a/test/features/schedule/presentation/compact_agenda_panel_test.dart +++ b/test/features/schedule/presentation/compact_agenda_panel_test.dart @@ -8,6 +8,7 @@ import 'package:busymax/src/schedule/schedule_range.dart'; import 'package:busymax/src/task_providers/task_provider.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:yaru/yaru.dart'; @@ -322,6 +323,64 @@ void main() { expect(find.text('Work'), findsWidgets); }); + testWidgets('Escape closes an editor before hiding compact agenda', ( + tester, + ) async { + var hideCalls = 0; + await tester.pumpWidget( + _testPanel( + data: _data(today, canCreateEvents: true), + onHide: () async => hideCalls += 1, + ), + ); + + await tester.tap(find.text('New event')); + await tester.pump(); + expect(find.text('Agenda'), findsNothing); + + await tester.sendKeyEvent(LogicalKeyboardKey.escape); + await tester.pumpAndSettle(); + + expect(hideCalls, 0); + expect(find.text('Agenda'), findsOneWidget); + }); + + testWidgets('equal create actions use neutral standard buttons', ( + tester, + ) async { + await tester.pumpWidget( + _testPanel( + data: _data(today, canCreateEvents: true, canCreateTasks: true), + ), + ); + + final standardButtons = find.byWidgetPredicate( + (widget) => widget is FilledButton, + description: 'standard filled button', + ); + final suggestedButtons = find.byWidgetPredicate( + (widget) => widget is ElevatedButton, + description: 'suggested elevated button', + ); + + expect( + find.ancestor(of: find.text('New event'), matching: standardButtons), + findsOneWidget, + ); + expect( + find.ancestor(of: find.text('New task'), matching: standardButtons), + findsOneWidget, + ); + expect( + find.ancestor(of: find.text('New event'), matching: suggestedButtons), + findsNothing, + ); + expect( + find.ancestor(of: find.text('New task'), matching: suggestedButtons), + findsNothing, + ); + }); + testWidgets('loading state renders progress and skeleton rows', ( tester, ) async { @@ -348,6 +407,7 @@ Widget _testPanel({ Size size = const Size(420, 680), Future Function(ScheduleItem item)? onOpenItem, CompactAgendaTaskCompletionCallback? onTaskCompletionChanged, + Future Function()? onHide, }) { return ProviderScope( child: localizedTestApp( @@ -360,7 +420,7 @@ Widget _testPanel({ onOpenBusyMax: () async {}, onNewTask: () async {}, onRefresh: () async {}, - onHide: () async {}, + onHide: onHide ?? () async {}, onOpenItem: onOpenItem, onTaskCompletionChanged: onTaskCompletionChanged, ), @@ -377,6 +437,8 @@ AsyncValue _data( bool hasMoreNoDateTasks = false, bool hasSignedInAccounts = true, bool hasSources = true, + bool canCreateEvents = false, + bool canCreateTasks = false, }) { return AsyncData( CompactAgendaData( @@ -391,6 +453,8 @@ AsyncValue _data( hasSignedInAccounts: hasSignedInAccounts, hasSources: hasSources, generatedAt: today, + canCreateEvents: canCreateEvents, + canCreateTasks: canCreateTasks, ), ); } diff --git a/test/features/schedule/presentation/schedule_create_menu_test.dart b/test/features/schedule/presentation/schedule_create_menu_test.dart index 1dbd39f..d913c6b 100644 --- a/test/features/schedule/presentation/schedule_create_menu_test.dart +++ b/test/features/schedule/presentation/schedule_create_menu_test.dart @@ -66,4 +66,35 @@ void main() { expect(barrierCalls.first.arguments, isTrue); expect(barrierCalls.last.arguments, isFalse); }); + + testWidgets('create chooser disables unavailable creation kinds', ( + tester, + ) async { + late BuildContext hostContext; + await tester.pumpWidget( + localizedTestApp( + child: Builder( + builder: (context) { + hostContext = context; + return const SizedBox(); + }, + ), + ), + ); + + final result = showScheduleCreateMenu( + context: hostContext, + canCreateEvent: false, + canCreateTask: true, + ); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Event')); + await tester.pump(); + expect(find.text('Create'), findsOneWidget); + + await tester.tap(find.text('Task')); + await tester.pumpAndSettle(); + expect(await result, ScheduleCreateChoice.task); + }); } diff --git a/test/features/schedule/presentation/schedule_toolbar_test.dart b/test/features/schedule/presentation/schedule_toolbar_test.dart index c3854f9..882d2c6 100644 --- a/test/features/schedule/presentation/schedule_toolbar_test.dart +++ b/test/features/schedule/presentation/schedule_toolbar_test.dart @@ -2,7 +2,9 @@ import 'package:busymax/src/features/schedule/presentation/schedule_toolbar.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/services.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:yaru/yaru.dart'; import '../../../test_localized_app.dart'; @@ -12,6 +14,8 @@ void main() { ) async { var sidebarToggles = 0; var searches = 0; + var events = 0; + var tasks = 0; ScheduleViewMode? selectedMode; ScheduleToolbarMenuAction? selectedMenuAction; @@ -28,8 +32,10 @@ void main() { onPrevious: () {}, onNext: () {}, onModeChanged: (value) => selectedMode = value, - canCreate: true, - onCreate: () {}, + canCreateEvent: true, + canCreateTask: true, + onCreateEvent: () => events++, + onCreateTask: () => tasks++, onRefresh: () {}, canShowSidebar: true, sidebarVisible: true, @@ -47,6 +53,15 @@ void main() { expect(sidebarToggles, 1); expect(searches, 1); + await tester.tap(find.byTooltip('Create')); + await tester.pumpAndSettle(); + expect(find.text('Event'), findsOneWidget); + expect(find.text('Task'), findsOneWidget); + await tester.tap(find.text('Event')); + await tester.pumpAndSettle(); + expect(events, 1); + expect(tasks, 0); + await tester.tap(find.byTooltip('Week')); await tester.pumpAndSettle(); await tester.tap(find.text('Month')); @@ -79,8 +94,10 @@ void main() { onPrevious: () {}, onNext: () {}, onModeChanged: (_) {}, - canCreate: true, - onCreate: () {}, + canCreateEvent: true, + canCreateTask: true, + onCreateEvent: () {}, + onCreateTask: () {}, onRefresh: () => refreshes++, onMenuSelected: (value) { selectedMenuAction = value; @@ -103,4 +120,161 @@ void main() { expect(selectedMenuAction, ScheduleToolbarMenuAction.refresh); expect(refreshes, 1); }); + + testWidgets('create menu keeps equal choices neutral and capability-aware', ( + tester, + ) async { + var events = 0; + var tasks = 0; + + await tester.pumpWidget( + localizedTestApp( + child: Scaffold( + body: SizedBox( + width: 1000, + child: ScheduleToolbar( + mode: ScheduleViewMode.week, + range: ScheduleRange.week(DateTime(2026, 7, 22)), + selectedDate: DateTime(2026, 7, 22), + onToday: () {}, + onPrevious: () {}, + onNext: () {}, + onModeChanged: (_) {}, + canCreateEvent: false, + canCreateTask: true, + onCreateEvent: () => events++, + onCreateTask: () => tasks++, + onRefresh: () {}, + ), + ), + ), + ), + ); + + await tester.tap(find.byTooltip('Create')); + await tester.pumpAndSettle(); + + final eventButton = tester.widget( + find.ancestor( + of: find.text('Event'), + matching: find.byType(MenuItemButton), + ), + ); + final taskButton = tester.widget( + find.ancestor( + of: find.text('Task'), + matching: find.byType(MenuItemButton), + ), + ); + expect(eventButton.onPressed, isNull); + expect(taskButton.onPressed, isNotNull); + expect( + eventButton.style?.backgroundColor?.resolve({}), + taskButton.style?.backgroundColor?.resolve({}), + ); + expect(eventButton.style?.backgroundColor?.resolve({}), Colors.transparent); + expect(taskButton.style?.backgroundColor?.resolve({}), Colors.transparent); + + await tester.tap(find.text('Task')); + await tester.pumpAndSettle(); + expect(events, 0); + expect(tasks, 1); + }); + + testWidgets('external controller opens the fallback create menu', ( + tester, + ) async { + final controller = MenuController(); + + await tester.pumpWidget( + localizedTestApp( + child: Scaffold( + body: SizedBox( + width: 1000, + child: ScheduleToolbar( + mode: ScheduleViewMode.week, + range: ScheduleRange.week(DateTime(2026, 7, 22)), + selectedDate: DateTime(2026, 7, 22), + onToday: () {}, + onPrevious: () {}, + onNext: () {}, + onModeChanged: (_) {}, + canCreateEvent: true, + canCreateTask: true, + onCreateEvent: () {}, + onCreateTask: () {}, + onRefresh: () {}, + createMenuController: controller, + ), + ), + ), + ), + ); + + controller.open(); + await tester.pumpAndSettle(); + + expect(find.text('Event'), findsOneWidget); + expect(find.text('Task'), findsOneWidget); + + final trigger = tester.widget( + find.ancestor( + of: find.byTooltip('Create'), + matching: find.byType(YaruIconButton), + ), + ); + final anchor = tester.widget( + find.ancestor( + of: find.byTooltip('Create'), + matching: find.byType(MenuAnchor), + ), + ); + expect(trigger.focusNode, isNotNull); + expect(anchor.childFocusNode, same(trigger.focusNode)); + + await tester.sendKeyEvent(LogicalKeyboardKey.escape); + await tester.pumpAndSettle(); + expect(find.text('Event'), findsNothing); + expect(find.text('Task'), findsNothing); + }); + + testWidgets('create trigger disables when no creation kind is available', ( + tester, + ) async { + await tester.pumpWidget( + localizedTestApp( + child: Scaffold( + body: SizedBox( + width: 1000, + child: ScheduleToolbar( + mode: ScheduleViewMode.week, + range: ScheduleRange.week(DateTime(2026, 7, 22)), + selectedDate: DateTime(2026, 7, 22), + onToday: () {}, + onPrevious: () {}, + onNext: () {}, + onModeChanged: (_) {}, + canCreateEvent: false, + canCreateTask: false, + onCreateEvent: () {}, + onCreateTask: () {}, + onRefresh: () {}, + ), + ), + ), + ), + ); + + final trigger = tester.widget( + find.ancestor( + of: find.byTooltip('Create'), + matching: find.byType(YaruIconButton), + ), + ); + expect(trigger.onPressed, isNull); + await tester.tap(find.byTooltip('Create')); + await tester.pumpAndSettle(); + expect(find.text('Event'), findsNothing); + expect(find.text('Task'), findsNothing); + }); } diff --git a/test/features/schedule/presentation/schedule_views_test.dart b/test/features/schedule/presentation/schedule_views_test.dart index 1f1c90b..e0502c6 100644 --- a/test/features/schedule/presentation/schedule_views_test.dart +++ b/test/features/schedule/presentation/schedule_views_test.dart @@ -2,6 +2,7 @@ import 'dart:io'; import 'package:busymax/src/app/busymax_design.dart'; import 'package:busymax/src/features/schedule/presentation/schedule_agenda_view.dart'; +import 'package:busymax/src/features/schedule/presentation/schedule_anchored_popover.dart'; import 'package:busymax/src/features/schedule/presentation/schedule_day_week_view.dart'; import 'package:busymax/src/features/schedule/presentation/schedule_event_block.dart'; import 'package:busymax/src/features/schedule/presentation/schedule_item_chip.dart'; @@ -398,6 +399,59 @@ void main() { expect(tester.takeException(), isNull); }); + testWidgets('month More waits for dismissal and returns a stable anchor', ( + tester, + ) async { + final selectedDate = DateTime(2026, 1, 15); + BuildContext? selectedAnchor; + ScheduleItem? selectedItem; + + await tester.pumpWidget( + localizedTestApp( + child: Scaffold( + body: SizedBox( + width: 700, + height: 720, + child: ScheduleMonthView( + range: ScheduleRange.month(selectedDate), + selectedDate: selectedDate, + firstWeekday: DateTime.monday, + items: _manyAllDayItemsFor(selectedDate), + onDaySelected: (_) {}, + onCreateAtDay: (_) {}, + onItemSelected: (anchor, item, [_]) { + selectedAnchor = anchor; + selectedItem = item; + }, + onTaskCompletionChanged: (_, _) {}, + ), + ), + ), + ), + ); + + final moreButton = find.ancestor( + of: find.textContaining('more'), + matching: find.byType(TextButton), + ); + expect(moreButton, findsOneWidget); + tester.widget(moreButton).onPressed!(); + await tester.pumpAndSettle(); + expect(find.byType(BusyMaxPopoverSurface), findsOneWidget); + expect(find.byType(ListView), findsOneWidget); + final popoverChips = find.descendant( + of: find.byType(ListView), + matching: find.byType(ScheduleItemChip), + ); + expect(popoverChips, findsWidgets); + await tester.tap(popoverChips.at(1)); + await tester.pumpAndSettle(); + + expect(selectedItem?.id, 'all-day-task:1'); + expect(selectedAnchor, isNotNull); + expect(selectedAnchor!.mounted, isTrue); + }); + testWidgets('calendar schedule chip invokes calendar item tap', ( tester, ) async { @@ -494,6 +548,15 @@ void main() { ); final popoverSurface = tester.widget(popoverSurfaceFinder); final popoverContext = tester.element(popoverSurfaceFinder); + final popoverRoute = ModalRoute.of(popoverContext)!; + expect( + popoverRoute.traversalEdgeBehavior, + TraversalEdgeBehavior.closedLoop, + ); + expect( + popoverRoute.directionalTraversalEdgeBehavior, + TraversalEdgeBehavior.stop, + ); expect( popoverSurface.shadowColor, Theme.of(popoverContext).colorScheme.shadow, @@ -512,6 +575,49 @@ void main() { expect(await action, ScheduleItemDetailsAction.export); }); + testWidgets('direct details popover registers for native-header dismissal', ( + tester, + ) async { + final selectedDate = DateTime(2026, 1, 15); + final event = _itemsFor( + selectedDate, + ).whereType().first; + final controller = ScheduleAnchoredPopoverController(); + + await tester.pumpWidget( + localizedTestApp( + child: ScheduleAnchoredPopoverScope( + controller: controller, + child: Scaffold( + body: Builder( + builder: (anchorContext) => TextButton( + onPressed: () { + showScheduleItemDetailsPopover( + context: anchorContext, + anchorContext: anchorContext, + item: event, + ); + }, + child: const Text('Open coordinated details'), + ), + ), + ), + ), + ), + ); + + await tester.tap(find.text('Open coordinated details')); + await tester.pumpAndSettle(); + expect(controller.isOpen, isTrue); + + final dismissal = controller.dismiss(); + await tester.pumpAndSettle(); + await dismissal; + + expect(controller.isOpen, isFalse); + expect(find.byIcon(Icons.close), findsNothing); + }); + testWidgets('schedule item details popover delete button returns delete', ( tester, ) async { @@ -550,6 +656,105 @@ void main() { expect(await action, ScheduleItemDetailsAction.delete); }); + testWidgets('read-only event details omit mutation actions', (tester) async { + final event = CalendarScheduleItem( + id: 'event:read-only', + accountId: 'google:g', + provider: TaskProvider.google, + sourceId: 'calendar:shared', + providerCalendarId: 'shared', + title: 'Shared calendar event', + allDay: false, + start: DateTime(2026, 1, 15, 9), + end: DateTime(2026, 1, 15, 10), + capabilities: ScheduleItemCapabilities.readOnly, + ); + + await tester.pumpWidget( + localizedTestApp( + child: Scaffold( + body: Builder( + builder: (context) => TextButton( + onPressed: () => showScheduleItemDetailsPopover( + context: context, + anchorContext: context, + item: event, + ), + child: const Text('Open details'), + ), + ), + ), + ), + ); + + await tester.tap(find.text('Open details')); + await tester.pumpAndSettle(); + + expect(find.byIcon(Icons.download_outlined), findsOneWidget); + expect(find.byIcon(Icons.close), findsOneWidget); + expect(find.byIcon(Icons.edit_outlined), findsNothing); + expect(find.byIcon(Icons.delete_outline), findsNothing); + }); + + testWidgets('details popover constrains long content without animation', ( + tester, + ) async { + tester.view.devicePixelRatio = 1; + tester.view.physicalSize = const Size(480, 300); + addTearDown(tester.view.resetDevicePixelRatio); + addTearDown(tester.view.resetPhysicalSize); + final event = CalendarScheduleItem( + id: 'event:long', + accountId: 'google:g', + provider: TaskProvider.google, + sourceId: 'calendar:primary', + providerCalendarId: 'primary', + title: 'A detailed event with a deliberately long title for sizing', + allDay: false, + start: DateTime(2026, 1, 15, 9), + end: DateTime(2026, 1, 15, 10), + location: 'A long location that still belongs inside the popover', + description: List.filled(20, 'Detailed agenda notes').join(' '), + categories: List.generate(20, (index) => 'Category $index'), + ); + + await tester.pumpWidget( + localizedTestApp( + child: MediaQuery( + data: const MediaQueryData( + size: Size(480, 300), + disableAnimations: true, + ), + child: Scaffold( + body: Builder( + builder: (context) => TextButton( + onPressed: () => showScheduleItemDetailsPopover( + context: context, + anchorContext: context, + item: event, + ), + child: const Text('Open details'), + ), + ), + ), + ), + ), + ); + + await tester.tap(find.text('Open details')); + await tester.pump(); + + final popover = find.byWidgetPredicate( + (widget) => + widget is PhysicalShape && + widget.elevation == BusyMaxElevation.tooltip, + ); + expect(popover, findsOneWidget); + expect(tester.getSize(popover).height, lessThanOrEqualTo(276)); + expect(find.byType(SingleChildScrollView), findsOneWidget); + expect(tester.takeException(), isNull); + }); + testWidgets('schedule item details popover shows categories', (tester) async { final selectedDate = DateTime(2026, 1, 15); final task = TaskScheduleItem( @@ -640,6 +845,49 @@ void main() { expect(find.text('Categories: Blue category, Work'), findsOneWidget); }); + testWidgets('event reminder details use locale-aware labels', (tester) async { + final event = CalendarScheduleItem( + id: 'event:localized-reminders', + accountId: 'google:g', + provider: TaskProvider.google, + sourceId: 'calendar:primary', + providerCalendarId: 'primary', + title: 'Termin', + allDay: false, + start: DateTime(2026, 1, 15, 9), + end: DateTime(2026, 1, 15, 10), + reminderMinutesBeforeStart: const [0, 60, 1440], + ); + + await tester.pumpWidget( + localizedTestApp( + locale: const Locale('de'), + child: Scaffold( + body: Builder( + builder: (context) => TextButton( + onPressed: () => showScheduleItemDetailsPopover( + context: context, + anchorContext: context, + item: event, + ), + child: const Text('Öffnen'), + ), + ), + ), + ), + ); + + await tester.tap(find.text('Öffnen')); + await tester.pumpAndSettle(); + + expect( + find.text( + 'Erinnerung: Zum Startzeitpunkt, 1 Stunde vorher, 1 Tag vorher', + ), + findsOneWidget, + ); + }); + testWidgets('schedule task details popover shows reminder', (tester) async { final selectedDate = DateTime(2026, 1, 15); final task = TaskScheduleItem( @@ -1190,7 +1438,7 @@ void main() { expect(source, contains('context.l10n.allTasksRefreshed')); expect(source, contains('setScheduleViewMode(mode)')); expect(source, contains('settings.scheduleViewMode')); - expect(source, isNot(contains('ScheduleEmptyState'))); + expect(source, contains('ScheduleEmptyState')); expect(source, isNot(contains('BusyMaxHeaderBarAction.newItem'))); expect(source, isNot(contains('BusyMaxHeaderBarAction.openMenu'))); }); @@ -1200,10 +1448,12 @@ void main() { 'lib/src/features/schedule/presentation/schedule_workspace.dart', ).readAsStringSync(); - expect(source, contains('HardwareKeyboard.instance.addHandler')); + expect(source, isNot(contains('HardwareKeyboard.instance.addHandler'))); + expect(source, contains('return Shortcuts(')); + expect(source, contains('_ScheduleShortcutAction(this)')); expect(source, contains('route != null && !route.isCurrent')); - expect(source, contains('BusyMaxShortcutActivators.search.accepts')); - expect(source, contains('BusyMaxShortcutActivators.create.accepts')); + expect(source, contains('BusyMaxShortcutActivators.search:')); + expect(source, contains('BusyMaxShortcutActivators.create:')); expect(source, contains('LogicalKeyboardKey.arrowRight')); expect(source, contains('_next();')); expect(source, contains('LogicalKeyboardKey.arrowLeft')); @@ -1214,10 +1464,13 @@ void main() { expect(source, isNot(contains('LogicalKeyboardKey.keyP'))); expect(source, contains('LogicalKeyboardKey.keyE')); expect(source, isNot(contains('LogicalKeyboardKey.keyC'))); - expect(source, contains('_openNewEvent(_latestVisibleSources')); + expect(source, contains('_openNewEvent(_latestWritableSources')); expect(source, contains('LogicalKeyboardKey.keyT')); expect(source, contains('_openNewTask(_latestAccounts')); - expect(source, contains('keyboard.isShiftPressed')); + expect( + source, + contains('SingleActivator(LogicalKeyboardKey.keyT, shift: true)'), + ); expect(source, contains('_goToToday();')); expect(source, contains('LogicalKeyboardKey.digit1')); expect(source, contains('LogicalKeyboardKey.keyD')); @@ -1266,6 +1519,24 @@ void main() { expect(source, contains('reminders: _eventRemindersForEdit(')); }); + test('month overflow uses the shared anchored popover route', () { + final month = File( + 'lib/src/features/schedule/presentation/schedule_month_view.dart', + ).readAsStringSync(); + final more = File( + 'lib/src/features/schedule/presentation/schedule_more_popover.dart', + ).readAsStringSync(); + + expect(month, contains('anchorContext: anchorContext')); + expect( + more, + contains('showScheduleAnchoredPopover('), + ); + expect(more, contains('BusyMaxPopoverSurface(')); + expect(more, isNot(contains('showDialog('))); + expect(more, isNot(contains('Dialog('))); + }); + test('schedule item details actions use shared button styling', () { final popover = File( 'lib/src/features/schedule/presentation/schedule_item_details_popover.dart', @@ -1303,12 +1574,7 @@ void main() { contains('final searchHasQuery = _searchQuery.trim().isNotEmpty'), ); expect(workspace, contains('_rangeForSearchResults(items, range)')); - expect( - workspace, - contains( - 'searchHasQuery\n ? ScheduleViewMode.agenda', - ), - ); + expect(workspace, contains('? ScheduleViewMode.agenda')); expect( repository, contains('final searching = filters.query.trim().isNotEmpty'), @@ -1334,8 +1600,8 @@ void main() { expect(compactAgenda, contains('BusyMaxGroupedList(')); expect(agenda, isNot(contains('surfaceColor:'))); expect(compactAgenda, isNot(contains('surfaceColor:'))); - expect(agenda, isNot(contains('ScheduleProjection.colorForItem'))); - expect(compactAgenda, isNot(contains('ScheduleProjection.colorForItem'))); + expect(agenda, contains('ScheduleProjection.colorForItem')); + expect(compactAgenda, contains('ScheduleProjection.colorForItem')); expect( agenda, contains('BusyMaxSurfaceColors.of(context).mutedForeground'), @@ -1772,7 +2038,7 @@ void main() { expect(sidebar, isNot(contains('_SourceVisibilityIndicator'))); }); - test('schedule create action lives in the headerbar before refresh', () { + test('schedule Create uses a native popover before refresh', () { final workspace = File( 'lib/src/features/schedule/presentation/schedule_workspace.dart', ).readAsStringSync(); @@ -1789,23 +2055,24 @@ void main() { expect(workspace, isNot(contains('floatingActionButtonLocation'))); expect(workspace, isNot(contains('FloatingActionButton('))); - expect(workspace, contains('BusyMaxHeaderBarAction.create')); + expect(workspace, contains('BusyMaxHeaderBarAction.createEvent')); + expect(workspace, contains('BusyMaxHeaderBarAction.createTask')); expect(workspace, contains('void _openCreateAtSelectedDate()')); - expect(headerService, contains('BusyMaxHeaderBarAction.create')); expect( headerService, - contains("'create' => BusyMaxHeaderBarAction.create"), + isNot(contains("'create' => BusyMaxHeaderBarAction.create")), ); expect( headerBar, - contains('create_header_icon_button("list-add-symbolic"'), + contains('gtk_image_new_from_icon_name("list-add-symbolic"'), ); expect( headerBar, - contains( - 'connect_header_bar_action(self, self->create_button, "create")', - ), + contains('create_header_popover_action_item(self, "createEvent"'), ); + expect(headerBar, contains('self, "createTask", "Task"')); + 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', @@ -1868,7 +2135,7 @@ void main() { expect(taskChip, isNot(contains('if (!compact)'))); }); - test('schedule item chips use neutral surfaces instead of blue tints', () { + test('schedule item chips keep neutral surfaces with source accents', () { final eventBlock = File( 'lib/src/features/schedule/presentation/schedule_event_block.dart', ).readAsStringSync(); @@ -1880,7 +2147,7 @@ void main() { ).readAsStringSync(); expect(eventBlock, contains('color: surfaceColors.control')); - expect(eventBlock, contains('surfaceColors.subtleBorder')); + expect(eventBlock, contains('sourceAccent')); expect(taskChip, contains('color: surfaceColors.control')); expect(taskChip, contains('color: surfaceColors.subtleBorder')); expect(taskChip, contains('YaruCheckbox(')); @@ -1889,7 +2156,8 @@ void main() { expect(taskChip, isNot(contains('YaruCheckboxTheme'))); expect(eventBlock, isNot(contains('Color.alphaBlend('))); expect(taskChip, isNot(contains('Color.alphaBlend('))); - expect(projection, isNot(contains('_colorFromHex(item.colorHex)'))); + expect(projection, contains('_colorFromHex(item.colorHex)')); + expect(projection, contains('deterministicSourceColor(item.sourceId')); expect(projection, isNot(contains('0xff4d7fa8'))); expect(projection, isNot(contains('0xff8db3d9'))); expect(projection, isNot(contains('0xff326b88'))); diff --git a/test/features/schedule/presentation/schedule_workspace_states_test.dart b/test/features/schedule/presentation/schedule_workspace_states_test.dart index c6c2109..ac5dbc6 100644 --- a/test/features/schedule/presentation/schedule_workspace_states_test.dart +++ b/test/features/schedule/presentation/schedule_workspace_states_test.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'package:busymax/src/app/app_bootstrap.dart'; +import 'package:busymax/src/app/busymax_design.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'; @@ -10,6 +11,7 @@ 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 '../../../test_localized_app.dart'; @@ -72,25 +74,130 @@ void main() { await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft); await tester.sendKeyEvent(LogicalKeyboardKey.keyF); await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft); + await tester.pumpAndSettle(); + + expect(find.byType(BusyMaxSearchField), findsOneWidget); + expect(find.byType(YaruSearchField), findsOneWidget); + expect(_searchFieldHasPrimaryFocus(tester), isTrue); + + FocusManager.instance.primaryFocus?.unfocus(); + await tester.pump(); + await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft); + await tester.sendKeyEvent(LogicalKeyboardKey.keyF); + await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft); + await tester.pumpAndSettle(); + expect(_searchFieldHasPrimaryFocus(tester), isTrue); + + await tester.enterText(find.byType(TextField), 'planning'); + await tester.pump(); + await tester.tap(find.byIcon(YaruIcons.edit_clear)); await tester.pump(); - expect(find.byType(TextField), findsOneWidget); + expect(find.byType(BusyMaxSearchField), findsOneWidget); + expect( + tester.widget(find.byType(TextField)).controller!.text, + '', + ); await tester.sendKeyEvent(LogicalKeyboardKey.escape); await tester.pump(); - expect(find.byType(TextField), findsNothing); + expect(find.byType(BusyMaxSearchField), findsNothing); }); + + testWidgets( + 'native search owns Linux entry state without a Flutter duplicate', + (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), + ); + + await headerBarService.handleNativeMethodCall( + const MethodCall('searchQueryChanged', 'planning'), + ); + await tester.pumpAndSettle(); + expect( + calls.where((call) => call.method == 'setState').last.arguments, + containsPair('searchQuery', 'planning'), + ); + + 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.pumpAndSettle(); + expect( + calls.where((call) => call.method == 'setState').last.arguments, + containsPair('searchActive', false), + ); + }, + ); +} + +bool _searchFieldHasPrimaryFocus(WidgetTester tester) { + final searchElement = tester.element(find.byType(BusyMaxSearchField)); + final focusContext = FocusManager.instance.primaryFocus?.context; + if (identical(focusContext, searchElement)) { + return true; + } + var found = false; + if (focusContext is Element) { + focusContext.visitAncestorElements((ancestor) { + found = identical(ancestor, searchElement); + return !found; + }); + } + return found; } Future _pumpWorkspace( WidgetTester tester, { required Stream> Function() accountsFactory, + LinuxHeaderBarService? headerBarService, }) async { final database = AppDatabase.memoryForTests(); addTearDown(database.close); - final headerBarService = LinuxHeaderBarService(isLinux: false); - addTearDown(headerBarService.dispose); + final resolvedHeaderBarService = + headerBarService ?? LinuxHeaderBarService(isLinux: false); + addTearDown(resolvedHeaderBarService.dispose); await tester.pumpWidget( ProviderScope( @@ -99,7 +206,9 @@ Future _pumpWorkspace( accountsStreamProvider.overrideWith((ref) => accountsFactory()), localTimeZoneProvider.overrideWithValue('UTC'), localSettingsStoreProvider.overrideWithValue(_MemorySettingsStore()), - linuxHeaderBarServiceProvider.overrideWithValue(headerBarService), + 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 8ab8c49..8c63db3 100644 --- a/test/features/schedule/presentation/schedule_workspace_task_mutations_test.dart +++ b/test/features/schedule/presentation/schedule_workspace_task_mutations_test.dart @@ -1,4 +1,5 @@ import 'package:busymax/src/app/app_bootstrap.dart'; +import 'package:busymax/src/app/busymax_design.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'; @@ -10,6 +11,7 @@ import 'package:busymax/src/task_providers/task_provider.dart'; import 'package:drift/drift.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:yaru/yaru.dart'; @@ -46,6 +48,45 @@ void main() { expect(find.text('Created from Schedule'), findsOneWidget); }); + testWidgets('task-list route defaults new tasks to that account and list', ( + tester, + ) async { + final harness = await _pumpScheduleWorkspace( + tester, + initialTaskAccountId: _accountId, + initialTaskListId: _projectListId, + ); + + await tester.tap(find.byTooltip('Create')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Task')); + await tester.pumpAndSettle(); + final titleField = find.byWidgetPredicate( + (widget) => + widget is TextField && widget.decoration?.labelText == 'Title', + ); + await tester.enterText(titleField, 'Created in Projects'); + await tester.pump(); + expect( + tester + .widget(find.byType(BusyMaxEditorHeader)) + .onSave == + null, + isFalse, + ); + await tester.tap(find.text('Create')); + await tester.pumpAndSettle(); + + final allTasks = await harness.database + .select(harness.database.tasks) + .get(); + expect(allTasks, isNotEmpty); + final createdTask = allTasks.single; + expect(createdTask.title, 'Created in Projects'); + expect(createdTask.accountId, _accountId); + expect(createdTask.taskListId, _projectListId); + }); + testWidgets('completing a task from Schedule refreshes its checkbox', ( tester, ) async { @@ -75,11 +116,48 @@ void main() { expect(task.status, 'completed'); expect(tester.widget(checkbox).value, isTrue); }); + + testWidgets('dirty deep-linked task confirms before Escape closes it', ( + tester, + ) async { + await _pumpScheduleWorkspace( + tester, + taskTitle: 'Opened from route', + initialTaskAccountId: _accountId, + initialTaskListId: _taskListId, + initialTaskId: 'task-1', + ); + + expect(find.text('Edit Task'), findsOneWidget); + expect(find.text('Opened from route'), findsWidgets); + + await tester.enterText(find.byType(TextField).first, 'Unsaved route edit'); + await tester.pump(); + + await tester.sendKeyEvent(LogicalKeyboardKey.escape); + await tester.pumpAndSettle(); + + expect(find.text('Discard changes?'), findsOneWidget); + await tester.tap(find.text('Cancel').last); + await tester.pumpAndSettle(); + expect(find.text('Edit Task'), findsOneWidget); + + await tester.sendKeyEvent(LogicalKeyboardKey.escape); + await tester.pumpAndSettle(); + await tester.tap(find.text('Discard')); + await tester.pumpAndSettle(); + + expect(find.text('Edit Task'), findsNothing); + expect(find.text('Opened from route'), findsOneWidget); + }); } Future<_ScheduleHarness> _pumpScheduleWorkspace( WidgetTester tester, { String? taskTitle, + String? initialTaskAccountId, + String? initialTaskListId, + String? initialTaskId, }) async { final database = AppDatabase.memoryForTests(); addTearDown(database.close); @@ -105,6 +183,16 @@ Future<_ScheduleHarness> _pumpScheduleWorkspace( updatedLocalAtUtc: _nowUtc, ), ); + await database.taskListsDao.upsertTaskList( + TaskListsCompanion.insert( + accountId: _accountId, + id: _projectListId, + title: 'Projects', + rawJson: '{}', + createdLocalAtUtc: _nowUtc, + updatedLocalAtUtc: _nowUtc, + ), + ); if (taskTitle != null) { await database.tasksDao.upsertTask( TasksCompanion.insert( @@ -147,7 +235,12 @@ Future<_ScheduleHarness> _pumpScheduleWorkspace( }), ], child: localizedTestApp( - child: const ScheduleWorkspace(initialScope: ScheduleScope.tasks), + child: ScheduleWorkspace( + initialScope: ScheduleScope.tasks, + initialTaskAccountId: initialTaskAccountId, + initialTaskListId: initialTaskListId, + initialTaskId: initialTaskId, + ), ), ), ); @@ -176,4 +269,5 @@ class _MemorySettingsStore implements LocalSettingsStore { const _accountId = 'google:schedule-test'; const _taskListId = 'inbox'; +const _projectListId = 'projects'; const _nowUtc = '2026-07-19T00:00:00.000Z'; diff --git a/test/features/schedule/schedule_search_test.dart b/test/features/schedule/schedule_search_test.dart index 8dfca0c..5b64ead 100644 --- a/test/features/schedule/schedule_search_test.dart +++ b/test/features/schedule/schedule_search_test.dart @@ -563,18 +563,22 @@ void main() { final repository = ScheduleRepository(database); final firstPage = await repository.listNoDateTasks( limit: 8, - filters: const ScheduleFilters( + filters: ScheduleFilters( accountIds: {'account'}, taskListFilterActive: true, - taskListIds: {'inbox'}, + taskListKeys: { + ScheduleTaskListKey(accountId: 'account', taskListId: 'inbox'), + }, ), ); final expandedPage = await repository.listNoDateTasks( limit: 12, - filters: const ScheduleFilters( + filters: ScheduleFilters( accountIds: {'account'}, taskListFilterActive: true, - taskListIds: {'inbox'}, + taskListKeys: { + ScheduleTaskListKey(accountId: 'account', taskListId: 'inbox'), + }, ), ); @@ -611,19 +615,23 @@ void main() { final firstPage = await repository.listOverdueTasks( before: DateTime(2026, 6, 10), limit: 8, - filters: const ScheduleFilters( + filters: ScheduleFilters( accountIds: {'account'}, taskListFilterActive: true, - taskListIds: {'inbox'}, + taskListKeys: { + ScheduleTaskListKey(accountId: 'account', taskListId: 'inbox'), + }, ), ); final expandedPage = await repository.listOverdueTasks( before: DateTime(2026, 6, 10), limit: 12, - filters: const ScheduleFilters( + filters: ScheduleFilters( accountIds: {'account'}, taskListFilterActive: true, - taskListIds: {'inbox'}, + taskListKeys: { + ScheduleTaskListKey(accountId: 'account', taskListId: 'inbox'), + }, ), ); @@ -644,6 +652,138 @@ void main() { expect(expandedPage.hasMore, isFalse); }); + test( + 'task-list filters keep equal provider list IDs account-qualified', + () async { + final database = AppDatabase(NativeDatabase.memory()); + addTearDown(database.close); + for (final accountId in ['account-a', 'account-b']) { + await database + .into(database.accounts) + .insert( + AccountsCompanion.insert( + id: accountId, + provider: const Value('google'), + authState: const Value('signed_in'), + createdAtUtc: _now, + updatedAtUtc: _now, + ), + ); + await database.taskListsDao.upsertTaskList( + TaskListsCompanion.insert( + accountId: accountId, + id: 'inbox', + title: 'Inbox', + rawJson: '{}', + createdLocalAtUtc: _now, + updatedLocalAtUtc: _now, + ), + ); + await database.tasksDao.upsertTask( + TasksCompanion.insert( + accountId: accountId, + taskListId: 'inbox', + id: 'shared-id', + title: 'Task from $accountId', + status: const Value('needsAction'), + dueUtc: const Value('2026-06-12'), + rawJson: '{}', + createdLocalAtUtc: _now, + updatedLocalAtUtc: _now, + ), + ); + } + + final items = await ScheduleRepository(database).listItems( + range: ScheduleRange.day(DateTime(2026, 6, 12)), + filters: ScheduleFilters( + accountIds: const {'account-a', 'account-b'}, + taskListFilterActive: true, + taskListKeys: { + ScheduleTaskListKey(accountId: 'account-a', taskListId: 'inbox'), + }, + includeCalendarEvents: false, + showNoDateTasks: false, + ), + ); + + expect(items.map((item) => item.title), ['Task from account-a']); + }, + ); + + test('deep-link target waits for a live account-qualified task', () async { + final database = AppDatabase(NativeDatabase.memory()); + addTearDown(database.close); + await database + .into(database.accounts) + .insert( + AccountsCompanion.insert( + id: 'account-a', + provider: const Value('google'), + authState: const Value('signed_in'), + createdAtUtc: _now, + updatedAtUtc: _now, + ), + ); + await database.taskListsDao.upsertTaskList( + TaskListsCompanion.insert( + accountId: 'account-a', + id: 'inbox', + title: 'Inbox', + rawJson: '{}', + createdLocalAtUtc: _now, + updatedLocalAtUtc: _now, + ), + ); + final repository = ScheduleRepository(database); + final targetFuture = repository + .watchTaskTarget( + accountId: 'account-a', + taskListId: 'inbox', + taskId: 'late-task', + ) + .where((target) => target != null) + .cast() + .first; + + await database.tasksDao.upsertTask( + TasksCompanion.insert( + accountId: 'account-a', + taskListId: 'inbox', + id: 'late-task', + title: 'Synced later', + status: const Value('needsAction'), + rawJson: '{}', + createdLocalAtUtc: _now, + updatedLocalAtUtc: _now, + ), + ); + + expect( + await targetFuture, + const ScheduleTaskTarget( + accountId: 'account-a', + taskListId: 'inbox', + taskId: 'late-task', + ), + ); + await (database.update(database.tasks)..where( + (row) => + row.accountId.equals('account-a') & + row.taskListId.equals('inbox') & + row.id.equals('late-task'), + )) + .write(const TasksCompanion(hidden: Value(true))); + expect( + await repository.findTaskTarget( + accountId: 'account-a', + taskListId: 'inbox', + taskId: 'late-task', + ), + null, + ); + }); + test('repository hides unavailable tasks', () async { final database = AppDatabase(NativeDatabase.memory()); addTearDown(database.close); diff --git a/test/features/schedule/schedule_source_visibility_test.dart b/test/features/schedule/schedule_source_visibility_test.dart new file mode 100644 index 0000000..d61bdf8 --- /dev/null +++ b/test/features/schedule/schedule_source_visibility_test.dart @@ -0,0 +1,45 @@ +import 'package:busymax/src/app/app_settings.dart'; +import 'package:busymax/src/features/task_lists/data/task_lists_repository.dart'; +import 'package:busymax/src/schedule/schedule_filters.dart'; +import 'package:busymax/src/schedule/schedule_source_visibility.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test('equal task-list IDs retain per-account schedule visibility', () { + const accountAInbox = ScheduleTaskListKey( + accountId: 'account-a', + taskListId: 'inbox', + ); + const accountBInbox = ScheduleTaskListKey( + accountId: 'account-b', + taskListId: 'inbox', + ); + final visibility = ScheduleSourceVisibility.fromSources( + calendarSources: const [], + taskLists: const [ + TaskListEntity( + accountId: 'account-a', + id: 'inbox', + title: 'Inbox A', + localDirty: false, + pendingDelete: false, + rawJson: '{}', + ), + TaskListEntity( + accountId: 'account-b', + id: 'inbox', + title: 'Inbox B', + localDirty: false, + pendingDelete: false, + rawJson: '{}', + ), + ], + settings: AppSettings.defaults().copyWith( + taskListScheduleVisibility: const {'account-a::inbox': false}, + ), + ); + + expect(visibility.visibleTaskListKeys, isNot(contains(accountAInbox))); + expect(visibility.visibleTaskListKeys, contains(accountBInbox)); + }); +} diff --git a/test/features/settings/presentation/settings_screen_test.dart b/test/features/settings/presentation/settings_screen_test.dart index f78baf8..bd7f428 100644 --- a/test/features/settings/presentation/settings_screen_test.dart +++ b/test/features/settings/presentation/settings_screen_test.dart @@ -1,11 +1,13 @@ -import 'dart:io'; +import 'dart:async'; 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:go_router/go_router.dart'; +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/config/build_config.dart'; import 'package:busymax/src/features/accounts/data/accounts_repository.dart'; @@ -14,6 +16,7 @@ import 'package:busymax/src/features/settings/presentation/settings_screen.dart' import 'package:busymax/src/features/sync/sync_auth_error.dart'; import 'package:busymax/src/platform/gtk_font_service.dart'; import 'package:busymax/src/features/task_lists/data/task_lists_repository.dart'; +import 'package:busymax/src/features/tasks/presentation/desktop_date_time_fields.dart'; import 'package:busymax/src/features/tasks/presentation/tasks_selection_state.dart'; import 'package:busymax/src/task_providers/task_provider.dart'; import 'package:busymax/l10n/generated/app_localizations.dart'; @@ -203,19 +206,40 @@ void main() { expect(scaffold.backgroundColor, isNot(gtkColors.window)); }); - test('Settings sidebar items have native-feeling side padding', () { - final source = File( - 'lib/src/features/settings/presentation/settings_screen.dart', - ).readAsStringSync(); + testWidgets('Settings uses Yaru navigation with selected semantics', ( + tester, + ) async { + final container = _container( + selectedAccountId: 'google:g', + authRepository: _FakeAuthRepository(), + accounts: const [_googleAccount], + ); + addTearDown(container.dispose); + + await _pumpSettings(tester, container, logicalSize: const Size(1000, 700)); + + expect(find.byType(YaruNavigationRail), findsOneWidget); + expect(find.byType(BusyMaxSidebarSurface), findsOneWidget); + final accountsSemantics = tester.widget( + find.byKey(const ValueKey('settings-navigation-accounts')), + ); + final scheduleSemantics = tester.widget( + find.byKey(const ValueKey('settings-navigation-schedule')), + ); + expect(accountsSemantics.properties.selected, isTrue); + expect(scheduleSemantics.properties.selected, isFalse); + + await tester.tap(find.text('Schedule')); + await tester.pumpAndSettle(); - expect(source, contains('const SizedBox(width: BusyMaxSpacing.md)')); expect( - source, - isNot( - contains( - 'const SizedBox(width: BusyMaxSpacing.xs),\n Icon(', - ), - ), + tester + .widget( + find.byKey(const ValueKey('settings-navigation-schedule')), + ) + .properties + .selected, + isTrue, ); }); @@ -269,6 +293,59 @@ void main() { expect(find.text('Day ends at'), findsOneWidget); }); + testWidgets('Settings narrow layout supports large text', (tester) async { + final container = _container( + selectedAccountId: 'google:g', + authRepository: _FakeAuthRepository(), + accounts: const [_googleAccount], + ); + addTearDown(container.dispose); + + await _pumpSettings( + tester, + container, + logicalSize: const Size(640, 700), + textScaler: const TextScaler.linear(2), + ); + + expect( + find.byKey(const ValueKey('settings-page-selector')), + findsOneWidget, + ); + expect(tester.takeException(), isNull); + }); + + testWidgets('Quiet-hour times are exposed and follow the master switch', ( + tester, + ) async { + final container = _container( + selectedAccountId: 'google:g', + authRepository: _FakeAuthRepository(), + accounts: const [_googleAccount], + ); + addTearDown(container.dispose); + + await _pumpSettings(tester, container, logicalSize: const Size(1000, 800)); + await tester.tap(find.text('Notifications')); + await tester.pumpAndSettle(); + + expect(find.text('Quiet hours start'), findsOneWidget); + expect(find.text('Quiet hours end'), findsOneWidget); + var timeRows = tester.widgetList( + find.byType(DesktopTimeValueRow), + ); + expect(timeRows.every((row) => !row.enabled), isTrue); + + await tester.ensureVisible(find.text('Quiet hours')); + await tester.tap(find.text('Quiet hours')); + await tester.pumpAndSettle(); + + timeRows = tester.widgetList( + find.byType(DesktopTimeValueRow), + ); + expect(timeRows.every((row) => row.enabled), isTrue); + }); + test('Schedule display hours persist and keep a valid range', () async { final store = _MemorySettingsStore(); final first = AppSettingsController(store); @@ -364,6 +441,38 @@ void main() { expect(find.text('schedule route'), findsOneWidget); }); + testWidgets('Settings back returns 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(); + expect(find.byType(SettingsScreen), findsOneWidget); + await tester.tap(find.byKey(const ValueKey('settings-page-selector'))); + await tester.pumpAndSettle(); + await tester.tap(find.text('Notifications')); + await tester.pumpAndSettle(); + expect(router.state.uri.queryParameters['page'], 'notifications'); + + await container + .read(linuxHeaderBarServiceProvider) + .handleNativeMethodCall(const MethodCall('back')); + await tester.pumpAndSettle(); + + expect(find.text('tasks route'), findsOneWidget); + }); + testWidgets('Removing last account routes to sign in cleanly', ( tester, ) async { @@ -423,6 +532,7 @@ Future _pumpSettings( WidgetTester tester, ProviderContainer container, { Size? logicalSize, + TextScaler? textScaler, }) async { if (logicalSize != null) { tester.view.devicePixelRatio = 1; @@ -430,21 +540,30 @@ Future _pumpSettings( addTearDown(tester.view.resetDevicePixelRatio); addTearDown(tester.view.resetPhysicalSize); } + final settings = textScaler == null + ? const SettingsScreen() + : Builder( + builder: (context) => MediaQuery( + data: MediaQuery.of(context).copyWith(textScaler: textScaler), + child: const SettingsScreen(), + ), + ); await tester.pumpWidget( UncontrolledProviderScope( container: container, - child: localizedTestApp(child: const SettingsScreen()), + child: localizedTestApp(child: settings), ), ); await tester.pumpAndSettle(); } -Future _pumpRoutedSettings( +Future _pumpRoutedSettings( WidgetTester tester, - ProviderContainer container, -) async { + ProviderContainer container, { + String initialLocation = '/settings', +}) async { final router = GoRouter( - initialLocation: '/settings', + initialLocation: initialLocation, routes: [ GoRoute(path: '/settings', builder: (_, _) => const SettingsScreen()), GoRoute( @@ -472,6 +591,7 @@ Future _pumpRoutedSettings( ), ); await tester.pumpAndSettle(); + return router; } class _FakeAuthRepository implements AuthRepository { diff --git a/test/features/tasks/presentation/desktop_date_time_fields_test.dart b/test/features/tasks/presentation/desktop_date_time_fields_test.dart new file mode 100644 index 0000000..7e312bd --- /dev/null +++ b/test/features/tasks/presentation/desktop_date_time_fields_test.dart @@ -0,0 +1,108 @@ +import 'package:busymax/src/app/busymax_design.dart'; +import 'package:busymax/src/features/tasks/presentation/desktop_date_time_fields.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../../../test_localized_app.dart'; + +void main() { + testWidgets('disabled date and time entries cannot receive focus', ( + tester, + ) async { + await tester.pumpWidget( + localizedTestApp( + child: const Scaffold( + body: Column( + children: [ + DesktopDateField( + label: 'Due date', + date: '2026-07-22', + enabled: false, + onChanged: _ignoreString, + ), + DesktopTimeField( + label: 'Due time', + time: '09:30', + enabled: false, + onChanged: _ignoreNullableString, + ), + ], + ), + ), + ), + ); + + final dateEntries = tester.widgetList( + find.descendant( + of: find.byType(DesktopDateField), + matching: find.byType(EditableText), + ), + ); + final timeEntries = tester.widgetList( + find.descendant( + of: find.byType(DesktopTimeField), + matching: find.byType(EditableText), + ), + ); + + expect(dateEntries, isNotEmpty); + expect(timeEntries, isNotEmpty); + expect( + [ + ...dateEntries, + ...timeEntries, + ].every((entry) => !entry.focusNode.canRequestFocus), + isTrue, + ); + + await tester.tap(find.byType(EditableText).first, warnIfMissed: false); + await tester.pump(); + expect( + [ + ...dateEntries, + ...timeEntries, + ].every((entry) => !entry.focusNode.hasFocus), + isTrue, + ); + }); + + testWidgets('fallback date picker follows the shared modal policy', ( + tester, + ) async { + late BuildContext hostContext; + await tester.pumpWidget( + localizedTestApp( + child: Builder( + builder: (context) { + hostContext = context; + return const Scaffold(body: SizedBox()); + }, + ), + ), + ); + + final result = showBusyMaxDateValueDialog( + hostContext, + label: 'Due date', + initialDate: '2026-07-22', + ); + await tester.pumpAndSettle(); + + expect(find.byType(BusyMaxDialogShell), findsOneWidget); + final barriers = tester.widgetList(find.byType(ModalBarrier)); + expect( + barriers.any( + (barrier) => barrier.color == busyMaxModalBarrierColor(hostContext), + ), + isTrue, + ); + + await tester.tap(find.text('Cancel')); + await tester.pumpAndSettle(); + expect(await result, isNull); + }); +} + +void _ignoreString(String value) {} + +void _ignoreNullableString(String? value) {} diff --git a/test/features/tasks/presentation/task_details_pane_test.dart b/test/features/tasks/presentation/task_details_pane_test.dart index 123fbe1..4ab9238 100644 --- a/test/features/tasks/presentation/task_details_pane_test.dart +++ b/test/features/tasks/presentation/task_details_pane_test.dart @@ -309,7 +309,6 @@ void main() { expect(find.text('Open'), findsNothing); expect(find.text('Done'), findsNothing); - expect(find.byType(SegmentedButton), findsNothing); }); testWidgets('no visible timezone helper text or UTC appears', (tester) async { @@ -511,8 +510,8 @@ void main() { expect(find.text('Due'), findsOneWidget); expect(find.text('Due date'), findsOneWidget); - expect(find.text('All Day'), findsNothing); - expect(find.text('Time Slot'), findsNothing); + expect(find.text('All day'), findsNothing); + expect(find.text('Time slot'), findsNothing); expect(find.text('Due time'), findsNothing); expect(find.text('Start'), findsNothing); expect(find.text('Start date'), findsNothing); @@ -896,12 +895,12 @@ void main() { ); expect(find.byType(BusyMaxTimeModeRow), findsOneWidget); - expect(find.text('All Day'), findsOneWidget); - expect(find.text('Time Slot'), findsOneWidget); + expect(find.text('All day'), findsOneWidget); + expect(find.text('Time slot'), findsOneWidget); expect(find.text('Due time'), findsOneWidget); expect(find.text('Start time'), findsOneWidget); - await tester.tap(find.text('All Day')); + await tester.tap(find.text('All day')); await tester.pumpAndSettle(); expect(find.text('Due time'), findsNothing); @@ -940,7 +939,7 @@ void main() { expect(find.text('Due time'), findsNothing); expect(find.text('Start time'), findsNothing); - await tester.tap(find.text('Time Slot')); + await tester.tap(find.text('Time slot')); await tester.pumpAndSettle(); expect(find.text('Due time'), findsWidgets); @@ -977,8 +976,8 @@ void main() { expect(find.byType(BusyMaxTimeModeRow), findsOneWidget); expect(find.text('Due time'), findsOneWidget); expect(find.text('Start time'), findsOneWidget); - expect(find.text('All Day'), findsOneWidget); - expect(find.text('Time Slot'), findsOneWidget); + expect(find.text('All day'), findsOneWidget); + expect(find.text('Time slot'), findsOneWidget); await tester.tap(find.text('Save')); await tester.pumpAndSettle(); diff --git a/test/platform/gtk_font_service_test.dart b/test/platform/gtk_font_service_test.dart index 41a7e76..0f8879c 100644 --- a/test/platform/gtk_font_service_test.dart +++ b/test/platform/gtk_font_service_test.dart @@ -1,10 +1,29 @@ import 'package:busymax/src/platform/gtk_font_service.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; void main() { TestWidgetsFlutterBinding.ensureInitialized(); + test('providers publish preloaded GTK settings on the first frame', () async { + const font = GtkFontSettings(family: 'Ubuntu Sans', size: 11); + const colors = GtkThemeColors( + brightness: Brightness.light, + window: Color(0xFFFFFFFF), + ); + final container = ProviderContainer( + overrides: [ + initialGtkFontSettingsProvider.overrideWithValue(font), + initialGtkThemeColorsProvider.overrideWithValue(colors), + ], + ); + addTearDown(container.dispose); + + expect(await container.read(gtkFontSettingsProvider.future), font); + expect(await container.read(gtkThemeColorsProvider.future), colors); + }); + test('reads native Ubuntu Sans 11 font settings', () async { const channel = MethodChannel('busymax_test/gtk_font_ubuntu'); TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger @@ -63,6 +82,25 @@ void main() { expect(settings, isNull); }); + test('native GTK settings errors fall back to null during preload', () async { + const channel = MethodChannel('busymax_test/gtk_settings_error'); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler( + channel, + (_) => throw PlatformException(code: 'unavailable'), + ); + addTearDown(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, null); + }); + + expect(await const GtkFontService(channel: channel).getGtkFont(), isNull); + expect( + await const GtkThemeService(channel: channel).getGtkThemeColors(), + isNull, + ); + }); + test('invalid native GTK font size does not crash', () async { const channel = MethodChannel('busymax_test/gtk_font_invalid_size'); TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger @@ -79,6 +117,43 @@ void main() { expect(settings, const GtkFontSettings(family: 'GTK Test Sans', size: 0)); }); + test('malformed native GTK payload types are ignored safely', () async { + const fontChannel = MethodChannel('busymax_test/gtk_font_malformed'); + const themeChannel = MethodChannel('busymax_test/gtk_theme_malformed'); + final messenger = + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger; + messenger.setMockMethodCallHandler( + fontChannel, + (_) async => {'family': 42, 'size': 11}, + ); + messenger.setMockMethodCallHandler( + themeChannel, + (_) async => { + 'brightness': 'light', + 'window': 42, + 'view': true, + 'foreground': ['#000000'], + }, + ); + addTearDown(() { + messenger + ..setMockMethodCallHandler(fontChannel, null) + ..setMockMethodCallHandler(themeChannel, null); + }); + + expect( + await const GtkFontService(channel: fontChannel).getGtkFont(), + isNull, + ); + final colors = await const GtkThemeService( + channel: themeChannel, + ).getGtkThemeColors(); + expect(colors?.brightness, Brightness.light); + expect(colors?.window, isNull); + expect(colors?.view, isNull); + expect(colors?.foreground, isNull); + }); + test('missing native GTK font size does not crash', () async { const channel = MethodChannel('busymax_test/gtk_font_missing_size'); TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger @@ -240,6 +315,34 @@ void main() { expect(settings, isNull); }); + test( + 'explicit theme preference is forwarded before palette lookup', + () async { + const channel = MethodChannel('busymax_test/gtk_theme_preference'); + final calls = []; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + calls.add(call); + return null; + }); + addTearDown(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, null); + }); + const service = GtkThemeService(channel: channel); + + await service.setPreferDark(true); + await service.setPreferDark(false); + await service.setPreferDark(null); + + expect(calls.map((call) => call.method), [ + 'setGtkThemePreference', + 'setGtkThemePreference', + ]); + expect(calls.map((call) => call.arguments), [true, false]); + }, + ); + test('theme color stream emits initial and updated values', () async { const events = EventChannel('busymax_test/gtk_theme_events_update'); MockStreamHandlerEventSink? sink; diff --git a/test/platform/linux_header_bar_service_test.dart b/test/platform/linux_header_bar_service_test.dart index 5e15df7..d9ff67d 100644 --- a/test/platform/linux_header_bar_service_test.dart +++ b/test/platform/linux_header_bar_service_test.dart @@ -10,8 +10,10 @@ const _scheduleHeaderState = BusyMaxHeaderBarState( title: 'July 2026', viewMode: ScheduleViewMode.month, canRefresh: true, - canCreate: true, + canCreateEvent: true, + canCreateTask: true, searchActive: false, + searchQuery: '', canShowSidebar: true, sidebarVisible: true, navigationVisible: true, @@ -23,8 +25,10 @@ const _settingsHeaderState = BusyMaxHeaderBarState( title: 'Settings', viewMode: ScheduleViewMode.month, canRefresh: false, - canCreate: false, + canCreateEvent: false, + canCreateTask: false, searchActive: false, + searchQuery: '', canShowSidebar: true, sidebarVisible: true, navigationVisible: false, @@ -67,6 +71,8 @@ void main() { agenda: 'Agenda', search: 'Search', create: 'Create', + createEvent: 'Event', + createTask: 'Task', refresh: 'Refresh', menu: 'Menu', previous: 'Previous', @@ -103,6 +109,7 @@ void main() { accentForegroundColor: Color(0xFFFFFFFF), popoverBackgroundColor: Color(0xFF36363A), borderColor: Color.fromRGBO(0, 0, 6, 0.75), + sidebarBorderColor: Color.fromRGBO(0, 0, 6, 0.75), shadeColor: Color.fromRGBO(0, 0, 6, 0.25), modalBarrierColor: Color.fromRGBO(0, 0, 0, 0.32), ), @@ -123,6 +130,8 @@ void main() { 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('sidebar', 'Toggle Sidebar')); expect(calls[1].arguments, containsPair('back', 'Back')); @@ -159,6 +168,10 @@ void main() { calls.last.arguments, containsPair('accentForegroundColor', '#FFFFFF'), ); + expect( + calls.last.arguments, + containsPair('sidebarBorderColor', 'rgba(0,0,6,0.75)'), + ); }); test('serializes CSS colors for native headerbar', () { @@ -173,6 +186,29 @@ void main() { ); }); + 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( 'sends complete header state atomically and diffs equal state', () async { @@ -196,8 +232,10 @@ void main() { title: 'July 2026', viewMode: ScheduleViewMode.month, canRefresh: true, - canCreate: false, + canCreateEvent: false, + canCreateTask: true, searchActive: true, + searchQuery: 'planning', canShowSidebar: false, sidebarVisible: false, navigationVisible: true, @@ -220,8 +258,10 @@ void main() { 'title': 'July 2026', 'viewMode': 'month', 'canRefresh': true, - 'canCreate': false, + 'canCreateEvent': false, + 'canCreateTask': true, 'searchActive': true, + 'searchQuery': 'planning', 'canShowSidebar': false, 'sidebarVisible': false, 'navigationVisible': true, @@ -229,6 +269,43 @@ void main() { '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), + ); }, ); @@ -337,7 +414,11 @@ void main() { addTearDown(settingsSession.dispose); await settingsSession.updateState(_settingsHeaderState); await scheduleSession.updateState( - _scheduleHeaderState.copyWith(title: 'Updated schedule'), + _scheduleHeaderState.copyWith( + title: 'Updated schedule', + searchActive: true, + searchQuery: 'meeting', + ), ); expect(scheduleSession.isAvailable, isTrue); @@ -353,6 +434,8 @@ void main() { 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), @@ -395,12 +478,108 @@ void main() { 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') { + 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)); + }); + test('native header controls keep visible keyboard focus indicators', () { final source = File('linux/runner/my_application.cc').readAsStringSync(); expect(source, contains('button.busymax-header-view-mode-button:focus {"')); expect(source, contains('button.busymax-header-popover-row:focus {"')); + expect( + source, + contains( + 'button.busymax-header-popover-row.busymax-keyboard-focus:focus {"', + ), + ); expect(source, contains('"box-shadow: inset 0 0 0 2px %s;"')); + expect(source, contains('gtk_window_get_focus_visible')); + expect(source, contains('configure_header_popover_row(self, item)')); + expect(source, contains('header_popover_row_key_press_cb')); + expect(source, contains('header_popover_row_button_press_cb')); + expect(source, isNot(contains('gtk_widget_set_can_focus(row, FALSE)'))); }); test('native sidebar availability is separate from expanded state', () { @@ -427,15 +606,17 @@ void main() { final session = service.claimSession(); addTearDown(session.dispose); - final nextAction = session.actions.take(5).toList(); - await service.handleNativeMethodCall(const MethodCall('create')); + final nextAction = session.actions.take(6).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('aboutBusyMax')); expect(await nextAction, [ - BusyMaxHeaderBarAction.create, + BusyMaxHeaderBarAction.createEvent, + BusyMaxHeaderBarAction.createTask, BusyMaxHeaderBarAction.continueSetup, BusyMaxHeaderBarAction.settings, BusyMaxHeaderBarAction.keyboardShortcuts, From 6e1584bbb3f2ceca4fbcfdfb3ec2060993565458 Mon Sep 17 00:00:00 2001 From: albert Date: Wed, 22 Jul 2026 22:46:46 -0700 Subject: [PATCH 05/73] Refactor header bar and theme colors --- lib/src/app/busymax_app.dart | 2 +- lib/src/app/busymax_design.dart | 60 +- lib/src/app/busymax_surface_colors.dart | 20 +- lib/src/app/busymax_yaru_theme.dart | 186 ++-- .../presentation/settings_screen.dart | 2 +- linux/runner/my_application.cc | 820 +++++++----------- test/app/busymax_menu_button_test.dart | 71 +- test/app/busymax_search_field_test.dart | 18 +- test/app/native_ui_audit_test.dart | 146 +++- test/app/theme_localization_test.dart | 212 ++++- .../presentation/schedule_toolbar_test.dart | 10 +- .../presentation/schedule_views_test.dart | 20 +- .../presentation/settings_screen_test.dart | 8 +- .../linux_header_bar_service_test.dart | 49 +- 14 files changed, 914 insertions(+), 710 deletions(-) diff --git a/lib/src/app/busymax_app.dart b/lib/src/app/busymax_app.dart index e35eff2..308756d 100644 --- a/lib/src/app/busymax_app.dart +++ b/lib/src/app/busymax_app.dart @@ -227,7 +227,7 @@ class _BusyMaxAppState extends ConsumerState { BusyMaxHeaderBarTheme( preferDark: preferDark, windowBackgroundColor: colors.window, - backgroundColor: colors.view, + backgroundColor: colors.headerbar, sidebarBackgroundColor: colors.sidebar, foregroundColor: colors.foreground, mutedForegroundColor: colors.mutedForeground, diff --git a/lib/src/app/busymax_design.dart b/lib/src/app/busymax_design.dart index 47e1d76..b82e034 100644 --- a/lib/src/app/busymax_design.dart +++ b/lib/src/app/busymax_design.dart @@ -483,64 +483,16 @@ InputDecoration busyMaxDropdownDecoration() { } MenuStyle busyMaxDropdownMenuStyle(BuildContext context, {double? minWidth}) { - final popupTheme = Theme.of(context).popupMenuTheme; - final colorScheme = Theme.of(context).colorScheme; - return MenuStyle( - backgroundColor: WidgetStatePropertyAll( - popupTheme.color ?? colorScheme.surfaceContainerHigh, - ), - surfaceTintColor: const WidgetStatePropertyAll(Colors.transparent), - shadowColor: WidgetStatePropertyAll(BusyMaxShadow.physicalColor(context)), - elevation: const WidgetStatePropertyAll(BusyMaxElevation.popover), - padding: const WidgetStatePropertyAll(EdgeInsets.symmetric(vertical: 4)), - shape: WidgetStatePropertyAll( - RoundedRectangleBorder( - borderRadius: BorderRadius.circular(BusyMaxRadius.headerButton), - ), - ), - side: const WidgetStatePropertyAll(BorderSide.none), - visualDensity: VisualDensity.standard, - minimumSize: WidgetStatePropertyAll(Size(minWidth ?? 0, 0)), + final base = Theme.of(context).menuTheme.style ?? const MenuStyle(); + return base.copyWith( + minimumSize: minWidth == null + ? null + : WidgetStatePropertyAll(Size(minWidth, 0)), ); } ButtonStyle busyMaxDropdownMenuItemStyle(BuildContext context) { - final colorScheme = Theme.of(context).colorScheme; - return ButtonStyle( - minimumSize: const WidgetStatePropertyAll(Size(0, 36)), - maximumSize: const WidgetStatePropertyAll(Size(double.infinity, 36)), - padding: const WidgetStatePropertyAll( - EdgeInsets.symmetric(horizontal: BusyMaxSpacing.md), - ), - tapTargetSize: MaterialTapTargetSize.shrinkWrap, - textStyle: WidgetStatePropertyAll(Theme.of(context).textTheme.labelLarge), - foregroundColor: WidgetStateProperty.resolveWith((states) { - if (states.contains(WidgetState.disabled)) { - return colorScheme.onSurfaceVariant.withValues(alpha: 0.55); - } - return colorScheme.onSurface; - }), - backgroundColor: WidgetStateProperty.resolveWith((states) { - if (states.contains(WidgetState.pressed)) { - return colorScheme.onSurfaceVariant.withValues(alpha: 0.12); - } - if (states.contains(WidgetState.hovered) || - states.contains(WidgetState.focused)) { - return colorScheme.onSurfaceVariant.withValues(alpha: 0.08); - } - return Colors.transparent; - }), - overlayColor: const WidgetStatePropertyAll(Colors.transparent), - side: const WidgetStatePropertyAll(BorderSide.none), - surfaceTintColor: const WidgetStatePropertyAll(Colors.transparent), - shadowColor: const WidgetStatePropertyAll(Colors.transparent), - shape: WidgetStatePropertyAll( - RoundedRectangleBorder( - borderRadius: BorderRadius.circular(BusyMaxRadius.headerButton), - ), - ), - animationDuration: Duration.zero, - ); + return Theme.of(context).menuButtonTheme.style ?? const ButtonStyle(); } ButtonStyle busyMaxPushButtonStyle(ButtonStyle? style) { diff --git a/lib/src/app/busymax_surface_colors.dart b/lib/src/app/busymax_surface_colors.dart index 92332aa..ffd79d9 100644 --- a/lib/src/app/busymax_surface_colors.dart +++ b/lib/src/app/busymax_surface_colors.dart @@ -174,16 +174,20 @@ BusyMaxSurfaceColors busyMaxFallbackSurfaceColors(Brightness brightness) { shade: Color.fromRGBO(0, 0, 6, 0.07), ), Brightness.dark => const BusyMaxSurfaceColors( - window: Color(0xFF1D1D20), + // Current Yaru/libadwaita semantic surface ladder. These values are the + // fallback when GTK 3 cannot expose a compatible role; flat or recessed + // legacy `.sidebar` and `popover.background` samples must not replace + // these raised roles. + window: Color(0xFF2C2C2C), view: Color(0xFF1D1D20), - sidebar: Color(0xFF2E2E32), - secondarySidebar: Color(0xFF2E2E32), - headerbar: Color(0xFF2E2E32), + sidebar: Color(0xFF393939), + secondarySidebar: Color(0xFF323232), + headerbar: Color(0xFF393939), headerbarFlat: Color(0xFF1D1D20), - card: Color(0xFF222226), - groupedSurface: Color(0xFF383838), - dialog: Color(0xFF222226), - popover: Color(0xFF383838), + card: Color(0xFF3D3D3D), + groupedSurface: Color(0xFF3D3D3D), + dialog: Color(0xFF3E3E3E), + popover: Color(0xFF3E3E3E), control: Color.fromRGBO(255, 255, 255, 0.10), controlHover: Color.fromRGBO(255, 255, 255, 0.14), controlActive: Color.fromRGBO(255, 255, 255, 0.18), diff --git a/lib/src/app/busymax_yaru_theme.dart b/lib/src/app/busymax_yaru_theme.dart index d7a8d7f..03a22ec 100644 --- a/lib/src/app/busymax_yaru_theme.dart +++ b/lib/src/app/busymax_yaru_theme.dart @@ -207,6 +207,16 @@ class BusyMaxYaruTheme { splashColor: colors.controlHover, focusColor: colors.controlActive, ); + final menuStyle = _semanticMenuSurfaceStyle( + base.menuTheme.style, + color: colors.popover, + shadowColor: colorScheme.shadow, + ); + final dropdownMenuStyle = _semanticMenuSurfaceStyle( + base.dropdownMenuTheme.menuStyle, + color: colors.popover, + shadowColor: colorScheme.shadow, + ); return base.copyWith( brightness: brightness, @@ -363,6 +373,10 @@ class BusyMaxYaruTheme { : BorderSide.none, ), ), + menuTheme: MenuThemeData( + style: menuStyle, + submenuIcon: base.menuTheme.submenuIcon, + ), chipTheme: base.chipTheme.copyWith( labelStyle: normalizer.apply( base.chipTheme.labelStyle, @@ -417,6 +431,7 @@ class BusyMaxYaruTheme { fallback: textTheme.bodyMedium, ), inputDecorationTheme: inputDecorationTheme, + menuStyle: dropdownMenuStyle, ), tabBarTheme: base.tabBarTheme.copyWith( labelStyle: normalizer.apply( @@ -621,28 +636,37 @@ class _BusyMaxResolvedSurfaceColors { final sampledView = _runtimeSurfaceColor(runtime.view, over: sampledWindow) ?? fallback.view; - final sampledSidebar = - _runtimeSurfaceColor(runtime.sidebar, over: sampledWindow) ?? - _runtimeSurfaceColor(runtime.secondarySidebar, over: sampledWindow) ?? - _runtimeSurfaceColor(runtime.headerbar, over: sampledWindow) ?? - fallback.sidebar; + final runtimeSidebar = _runtimeSurfaceColor( + runtime.sidebar, + over: sampledWindow, + ); + final sampledSidebar = runtimeSidebar ?? fallback.sidebar; + final runtimeSecondarySidebar = _runtimeSurfaceColor( + runtime.secondarySidebar, + over: sampledWindow, + ); final sampledSecondarySidebar = - _runtimeSurfaceColor(runtime.secondarySidebar, over: sampledWindow) ?? - sampledSidebar; - final sampledHeaderbar = - _runtimeSurfaceColor(runtime.headerbar, over: sampledWindow) ?? - sampledWindow; + runtimeSecondarySidebar ?? fallback.secondarySidebar; + final runtimeHeaderbar = _runtimeSurfaceColor( + runtime.headerbar, + over: sampledWindow, + ); + final sampledHeaderbar = runtimeHeaderbar ?? fallback.headerbar; final sampledHeaderbarFlat = _runtimeSurfaceColor(runtime.headerbarFlat, over: sampledView) ?? sampledView; - final runtimeCard = _runtimeSurfaceColor(runtime.card, over: sampledView); + final runtimeCard = _runtimeSurfaceColor(runtime.card, over: sampledWindow); final sampledCard = runtimeCard ?? fallback.card; - final sampledDialog = - _runtimeSurfaceColor(runtime.dialog, over: sampledWindow) ?? - sampledCard; - final sampledPopover = - _runtimeSurfaceColor(runtime.popover, over: sampledWindow) ?? - sampledCard; + final runtimeDialog = _runtimeSurfaceColor( + runtime.dialog, + over: sampledWindow, + ); + final sampledDialog = runtimeDialog ?? fallback.dialog; + final runtimePopover = _runtimeSurfaceColor( + runtime.popover, + over: sampledWindow, + ); + final sampledPopover = runtimePopover ?? fallback.popover; final sampledBackgrounds = [ sampledWindow, sampledView, @@ -667,31 +691,60 @@ class _BusyMaxResolvedSurfaceColors { : fallbackSurface; } - // BusyMax currently has one generic foreground role. Preserve each GTK - // surface independently when that role remains readable, and fall back - // only the conflicting role for mixed-luminance themes. + // BusyMax currently has one generic foreground role. Preserve compatible + // GTK roles when that foreground remains readable; raised roles receive + // the additional hierarchy validation below. final window = readableSurface(sampledWindow, fallback.window); final view = readableSurface(sampledView, fallback.view); - final sidebar = readableSurface(sampledSidebar, fallback.sidebar); - final secondarySidebar = readableSurface( - sampledSecondarySidebar, - fallback.secondarySidebar, + final sidebar = _resolvedRaisedSurface( + runtimeSidebar, + brightness: brightness, + parent: window, + foreground: foreground, + fallback: fallback.sidebar, + ); + final secondarySidebar = _resolvedRaisedSurface( + runtimeSecondarySidebar, + brightness: brightness, + parent: window, + foreground: foreground, + fallback: fallback.secondarySidebar, + ); + final headerbar = _resolvedRaisedSurface( + runtimeHeaderbar, + brightness: brightness, + parent: window, + foreground: foreground, + fallback: fallback.headerbar, ); - final headerbar = readableSurface(sampledHeaderbar, fallback.headerbar); final headerbarFlat = readableSurface( sampledHeaderbarFlat, fallback.headerbarFlat, ); - final card = readableSurface(sampledCard, fallback.card); - final dialog = readableSurface(sampledDialog, fallback.dialog); - final popover = readableSurface(sampledPopover, fallback.popover); - final groupedSurface = _resolvedGroupedSurface( + final card = _resolvedRaisedSurface( runtimeCard, brightness: brightness, - view: view, + parent: window, foreground: foreground, - fallback: fallback.groupedSurface, + fallback: fallback.card, ); + final dialog = _resolvedRaisedSurface( + runtimeDialog, + brightness: brightness, + parent: window, + foreground: foreground, + fallback: fallback.dialog, + ); + final popover = _resolvedRaisedSurface( + runtimePopover, + brightness: brightness, + parent: window, + foreground: foreground, + fallback: fallback.popover, + ); + // Boxed/grouped content is one semantic card role. Keeping a single + // resolved token prevents Settings, Agenda, and Year view from drifting. + final groupedSurface = card; final sidebarBorder = _resolvedSidebarBorder( runtime.sidebarBorder, brightness: brightness, @@ -735,14 +788,14 @@ class _BusyMaxResolvedSurfaceColors { groupedSurface: groupedSurface, dialog: dialog, popover: popover, - control: _runtimeColor(runtime.control), - controlHover: _runtimeColor(runtime.controlHover), - controlActive: _runtimeColor(runtime.controlActive), - activeToggle: _runtimeColor(runtime.activeToggle), + control: _runtimeOverlayColor(runtime.control), + controlHover: _runtimeOverlayColor(runtime.controlHover), + controlActive: _runtimeOverlayColor(runtime.controlActive), + activeToggle: _runtimeOverlayColor(runtime.activeToggle), foreground: foreground, mutedForeground: mutedForeground, disabledForeground: disabledForeground, - disabledControl: _runtimeColor(runtime.disabledControl), + disabledControl: _runtimeOverlayColor(runtime.disabledControl), border: _runtimeColor(runtime.border), subtleBorder: _runtimeColor(runtime.subtleBorder), sidebarBorder: sidebarBorder, @@ -751,28 +804,35 @@ class _BusyMaxResolvedSurfaceColors { } } -Color _resolvedGroupedSurface( - Color? runtimeCard, { +Color _resolvedRaisedSurface( + Color? runtimeSurface, { required Brightness brightness, - required Color view, + required Color parent, required Color foreground, required Color fallback, }) { - if (runtimeCard == null || _contrastRatio(foreground, runtimeCard) < 4.5) { - return fallback; - } - if (brightness == Brightness.dark) { - // GTK 3 themes without a card role can return the underlying view color - // for an arbitrary `.card` sample. Dark grouped content needs a genuinely - // raised surface; otherwise its border and shadow disappear into the view. - final isRaised = - runtimeCard.computeLuminance() > view.computeLuminance() && - _contrastRatio(runtimeCard, view) >= _minimumRaisedSurfaceContrast; - if (!isRaised) { - return fallback; + bool isReadable(Color color) => _contrastRatio(foreground, color) >= 4.5; + + bool hasExpectedHierarchy(Color color) { + if (brightness != Brightness.dark) { + return true; } + return color.computeLuminance() > parent.computeLuminance() && + _contrastRatio(color, parent) >= _minimumRaisedSurfaceContrast; } - return runtimeCard; + + if (runtimeSurface != null && + isReadable(runtimeSurface) && + hasExpectedHierarchy(runtimeSurface)) { + return runtimeSurface; + } + if (isReadable(fallback) && hasExpectedHierarchy(fallback)) { + return fallback; + } + + // A fixed fallback may itself be recessed against a brighter custom theme. + // Flat is safer than inverting the intended raised hierarchy. + return parent; } Color _resolvedSidebarBorder( @@ -803,6 +863,14 @@ Color? _runtimeColor(Color? color) { return color; } +Color? _runtimeOverlayColor(Color? color) { + final candidate = _runtimeColor(color); + if (candidate == null || candidate.a >= 1) { + return null; + } + return candidate; +} + Color? _runtimeShadeColor(Color? color, {required Color over}) { final runtime = _runtimeColor(color); if (runtime == null) { @@ -894,6 +962,20 @@ WidgetStateProperty _normalizeTextStyleProperty( }); } +/// Applies only the semantic floating-surface roles and retains Yaru's menu +/// geometry, item states, padding, and motion. +MenuStyle _semanticMenuSurfaceStyle( + MenuStyle? base, { + required Color color, + required Color shadowColor, +}) { + return (base ?? const MenuStyle()).copyWith( + backgroundColor: WidgetStatePropertyAll(color), + surfaceTintColor: WidgetStatePropertyAll(color), + shadowColor: WidgetStatePropertyAll(shadowColor), + ); +} + /// Applies runtime semantic colors and typography without replacing Yaru's /// geometry, focus treatment, hover/press overlays, or motion defaults. ButtonStyle _semanticButtonStyle( diff --git a/lib/src/features/settings/presentation/settings_screen.dart b/lib/src/features/settings/presentation/settings_screen.dart index e23b55a..97f6f5d 100644 --- a/lib/src/features/settings/presentation/settings_screen.dart +++ b/lib/src/features/settings/presentation/settings_screen.dart @@ -256,7 +256,7 @@ class _SettingsScreenState extends ConsumerState { }; return Scaffold( - backgroundColor: BusyMaxSurfaceColors.of(context).view, + backgroundColor: BusyMaxSurfaceColors.of(context).window, body: LayoutBuilder( builder: (context, constraints) { final showSidebar = BusyMaxLayoutRules.showSettingsSidebar( diff --git a/linux/runner/my_application.cc b/linux/runner/my_application.cc index fb6e365..e2bd1dc 100644 --- a/linux/runner/my_application.cc +++ b/linux/runner/my_application.cc @@ -33,10 +33,9 @@ constexpr gint kHeaderButtonHorizontalPadding = 8; constexpr gint kHeaderButtonSpacing = 6; constexpr gint kHeaderWindowControlsBalanceWidth = kHeaderButtonHeight * 3 + kHeaderButtonSpacing * 2; +constexpr gint kHeaderCenterMaximumWidthChars = 48; constexpr gint kHeaderOnboardingContentWidth = 480; constexpr gint kHeaderOnboardingSideWidth = 120; -constexpr gint kHeaderMenuPadding = kHeaderButtonSpacing; -constexpr gint kHeaderPopoverRowSpacing = 4; constexpr gint kHeaderSidebarContentInset = kHeaderButtonSpacing; constexpr gint kHeaderMainContentStartInset = kHeaderSidebarContentInset; constexpr gint kHeaderTooltipVerticalPadding = 5; @@ -59,8 +58,9 @@ constexpr gint kCompactAgendaWindowMaxWidth = 480 + kCompactAgendaWindowShadowMargin * 2; constexpr gint kCompactAgendaWindowMaxHeight = 840 + kCompactAgendaWindowShadowMargin * 2; -constexpr char kDefaultHeaderBarBackgroundColor[] = "#1D1D20"; -constexpr char kDefaultHeaderBarSidebarBackgroundColor[] = "#2E2E32"; +constexpr char kDefaultWindowBackgroundColor[] = "#2C2C2C"; +constexpr char kDefaultHeaderBarBackgroundColor[] = "#393939"; +constexpr char kDefaultHeaderBarSidebarBackgroundColor[] = "#393939"; struct _MyApplication { GtkApplication parent_instance; @@ -113,9 +113,6 @@ struct _MyApplication { GtkWidget* header_brand_label; GtkWidget* settings_menu_button; GtkWidget* settings_menu; - GtkWidget* settings_item; - GtkWidget* keyboard_shortcuts_item; - GtkWidget* about_item; GtkWidget* header_view_box; GtkWidget* header_title_label; GtkWidget* search_entry; @@ -127,18 +124,25 @@ struct _MyApplication { GtkWidget* view_mode_button; GtkWidget* view_mode_label; GtkWidget* view_mode_menu; - GtkWidget* view_mode_day_item; - GtkWidget* view_mode_week_item; - GtkWidget* view_mode_month_item; - GtkWidget* view_mode_year_item; - GtkWidget* view_mode_agenda_item; GtkWidget* search_button; GtkWidget* create_button; GtkWidget* create_menu; - GtkWidget* create_event_item; - GtkWidget* create_task_item; 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_day_label; + gchar* header_week_label; + gchar* header_month_label; + gchar* header_year_label; + gchar* header_agenda_label; + gchar* header_create_event_label; + gchar* header_create_task_label; + gchar* header_settings_label; + gchar* header_keyboard_shortcuts_label; + gchar* header_about_label; gchar* header_search_query; gboolean hide_on_close; gboolean suppress_header_bar_actions; @@ -508,8 +512,6 @@ static void refresh_header_bar_css(MyApplication* self) { const gchar* foreground_disabled_color = css_color_or(self->header_bar_disabled_foreground_color, "rgba(255,255,255,0.38)"); - const gchar* muted_foreground_color = css_color_or( - self->header_bar_muted_foreground_color, "rgba(255,255,255,0.70)"); const gchar* control_color = css_color_or(self->header_bar_control_color, "rgba(255,255,255,0.10)"); const gchar* control_hover_color = css_color_or( @@ -706,63 +708,6 @@ static void refresh_header_bar_css(MyApplication* self) { "border: 1px solid %s;" "box-shadow: 0 6px 18px %s;" "}" - "popover.busymax-header-popover button.busymax-header-popover-row {" - "color: %s;" - "background-color: transparent;" - "background-image: none;" - "border: none;" - "border-width: 0;" - "border-color: transparent;" - "border-image: none;" - "outline-color: transparent;" - "outline-style: none;" - "outline-width: 0;" - "outline-offset: 0;" - "box-shadow: none;" - "text-shadow: none;" - "-gtk-icon-shadow: none;" - "transition: none;" - "min-height: %dpx;" - "padding: 0 %dpx;" - "border-radius: %dpx;" - "}" - "popover.busymax-header-popover " - "button.busymax-header-popover-row:hover {" - "background-color: %s;" - "}" - "popover.busymax-header-popover " - "button.busymax-header-popover-row:focus {" - "background-color: transparent;" - "box-shadow: none;" - "}" - "popover.busymax-header-popover " - "button.busymax-header-popover-row.busymax-keyboard-focus:focus {" - "background-color: %s;" - "box-shadow: inset 0 0 0 2px %s;" - "}" - "popover.busymax-header-popover " - "button.busymax-header-popover-row:active," - "popover.busymax-header-popover " - "button.busymax-header-popover-row:checked {" - "background-color: transparent;" - "}" - "popover.busymax-header-popover " - "button.busymax-header-popover-row label {" - "color: %s;" - "}" - "popover.busymax-header-popover " - "button.busymax-header-popover-row image {" - "color: %s;" - "}" - "popover.busymax-header-popover " - "button.busymax-header-popover-row:disabled," - "popover.busymax-header-popover " - "button.busymax-header-popover-row:disabled label," - "popover.busymax-header-popover " - "button.busymax-header-popover-row:disabled image {" - "color: %s;" - "background-color: transparent;" - "}" "tooltip," "tooltip.background {" "margin: 0;" @@ -799,11 +744,7 @@ static void refresh_header_bar_css(MyApplication* self) { accent_color, accent_foreground_color, foreground_disabled_color, control_hover_color, control_hover_color, foreground_disabled_color, kHeaderButtonHeight, - popover_background_color, foreground_color, foreground_color, - border_color, shade_color, kHeaderButtonHeight, - kHeaderButtonHorizontalPadding, kHeaderButtonRadius, - control_hover_color, control_hover_color, accent_color, foreground_color, - muted_foreground_color, foreground_disabled_color, + popover_background_color, foreground_color, border_color, shade_color, kHeaderButtonRadius, shade_color, kHeaderTooltipVerticalPadding, kHeaderTooltipHorizontalPadding, kHeaderButtonRadius); @@ -1076,30 +1017,194 @@ static void close_header_menu_button(GtkWidget* menu_button) { } } -static GtkWidget* create_header_popup_window(MyApplication* self) { - GtkWidget* popover = gtk_popover_new(nullptr); - gtk_popover_set_position(GTK_POPOVER(popover), GTK_POS_BOTTOM); - gtk_popover_set_modal(GTK_POPOVER(popover), TRUE); - gtk_style_context_add_class(gtk_widget_get_style_context(popover), +static const gchar* header_view_mode_action(const gchar* mode); +static void set_header_view_mode(MyApplication* self, const gchar* mode); + +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 void update_header_view_mode_label(MyApplication* self) { + if (self->view_mode_label == nullptr || + !GTK_IS_LABEL(self->view_mode_label)) { + return; + } + const gchar* mode = + self->header_view_mode != nullptr ? self->header_view_mode : "week"; + gtk_label_set_text(GTK_LABEL(self->view_mode_label), + header_view_mode_label(self, mode)); +} + +static void set_header_menu_button_model(GtkWidget* button, + GMenuModel* model, + GtkWidget** tracked_popover) { + if (button == nullptr || !GTK_IS_MENU_BUTTON(button) || model == nullptr) { + return; + } + close_header_menu_button(button); + if (*tracked_popover != nullptr) { + clear_widget_pointer(tracked_popover); + } + gtk_menu_button_set_use_popover(GTK_MENU_BUTTON(button), TRUE); + gtk_menu_button_set_menu_model(GTK_MENU_BUTTON(button), model); + GtkPopover* popover = gtk_menu_button_get_popover(GTK_MENU_BUTTON(button)); + if (popover == nullptr || !GTK_IS_POPOVER(popover)) { + return; + } + track_widget_pointer(tracked_popover, GTK_WIDGET(popover)); + gtk_popover_set_position(popover, GTK_POS_BOTTOM); + gtk_style_context_add_class(gtk_widget_get_style_context(GTK_WIDGET(popover)), "busymax-header-popover"); - g_object_set_data(G_OBJECT(popover), "busymax-application", self); - return popover; } -static GtkWidget* create_header_popover_box(GtkWidget* popover) { - GtkWidget* box = - gtk_box_new(GTK_ORIENTATION_VERTICAL, kHeaderPopoverRowSpacing); - gtk_widget_set_margin_top(box, kHeaderMenuPadding); - gtk_widget_set_margin_bottom(box, kHeaderMenuPadding); - gtk_widget_set_margin_start(box, kHeaderMenuPadding); - gtk_widget_set_margin_end(box, kHeaderMenuPadding); - gtk_container_add(GTK_CONTAINER(popover), box); - return box; +static void append_header_view_mode_item(GMenu* menu, + const gchar* label, + const gchar* mode) { + g_autoptr(GMenuItem) item = g_menu_item_new(label, nullptr); + g_menu_item_set_action_and_target(item, "header.view-mode", "s", mode); + 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(); + g_menu_append(menu, self->header_settings_label, "header.settings"); + g_menu_append(menu, self->header_keyboard_shortcuts_label, + "header.keyboard-shortcuts"); + g_menu_append(menu, self->header_about_label, "header.about"); + 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"); + append_header_view_mode_item(menu, self->header_week_label, "week"); + append_header_view_mode_item(menu, self->header_month_label, "month"); + append_header_view_mode_item(menu, self->header_year_label, "year"); + append_header_view_mode_item(menu, self->header_agenda_label, "agenda"); + set_header_menu_button_model(self->view_mode_button, G_MENU_MODEL(menu), + &self->view_mode_menu); + update_header_view_mode_label(self); +} + +static void rebuild_header_create_menu_model(MyApplication* self) { + if (self->create_button == nullptr) { + return; + } + g_autoptr(GMenu) menu = g_menu_new(); + g_menu_append(menu, self->header_create_event_label, + "header.create-event"); + g_menu_append(menu, self->header_create_task_label, "header.create-task"); + 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) { + 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 || 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 void show_header_popover_content(GtkWidget* box) { - if (box != nullptr && GTK_IS_WIDGET(box)) { - gtk_widget_show_all(box); +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) 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)); } } @@ -1195,8 +1300,14 @@ static void set_widget_sensitive(GtkWidget* widget, gboolean sensitive) { static void set_header_create_capabilities(MyApplication* self, gboolean can_create_event, gboolean can_create_task) { - set_widget_sensitive(self->create_event_item, can_create_event); - set_widget_sensitive(self->create_task_item, 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) { @@ -1239,272 +1350,17 @@ static const gchar* header_view_mode_action(const gchar* mode) { return nullptr; } -static void set_header_view_mode(MyApplication* self, const gchar* mode); - -static GtkWidget* header_view_mode_item(MyApplication* self, - const gchar* mode) { - if (g_strcmp0(mode, "day") == 0) { - return self->view_mode_day_item; - } - if (g_strcmp0(mode, "week") == 0) { - return self->view_mode_week_item; - } - if (g_strcmp0(mode, "month") == 0) { - return self->view_mode_month_item; - } - if (g_strcmp0(mode, "year") == 0) { - return self->view_mode_year_item; - } - if (g_strcmp0(mode, "agenda") == 0) { - return self->view_mode_agenda_item; - } - return nullptr; -} - -static void set_header_view_mode_item_label(GtkWidget* item, - const gchar* label) { - if (item == nullptr || label == nullptr) { - return; - } - g_object_set_data_full(G_OBJECT(item), "busymax-header-label", - g_strdup(label), g_free); - GtkWidget* label_widget = static_cast( - g_object_get_data(G_OBJECT(item), "busymax-header-label-widget")); - if (label_widget != nullptr && GTK_IS_LABEL(label_widget)) { - gtk_label_set_text(GTK_LABEL(label_widget), label); - return; - } - if (GTK_IS_BUTTON(item)) { - gtk_button_set_label(GTK_BUTTON(item), label); - } -} - -static void set_header_view_mode_item_active(MyApplication* self, - GtkWidget* item, - gboolean active) { - if (item == nullptr || !GTK_IS_WIDGET(item)) { - return; - } - GtkWidget* check_widget = static_cast( - g_object_get_data(G_OBJECT(item), "busymax-header-check-widget")); - if (check_widget != nullptr && GTK_IS_WIDGET(check_widget)) { - gtk_widget_set_opacity(check_widget, active ? 1.0 : 0.0); - } -} - -static const gchar* header_view_mode_item_label(GtkWidget* item) { - if (item == nullptr) { - return ""; - } - const gchar* stored_label = static_cast( - g_object_get_data(G_OBJECT(item), "busymax-header-label")); - if (stored_label != nullptr) { - return stored_label; - } - const gchar* label = nullptr; - if (GTK_IS_BUTTON(item)) { - label = gtk_button_get_label(GTK_BUTTON(item)); - } - return label != nullptr ? label : ""; -} - -static void update_header_view_mode_label(MyApplication* self) { - if (self->view_mode_label == nullptr || - !GTK_IS_LABEL(self->view_mode_label)) { - return; - } - const gchar* mode = - self->header_view_mode != nullptr ? self->header_view_mode : "week"; - const gchar* label = header_view_mode_item_label( - header_view_mode_item(self, mode)); - gtk_label_set_text(GTK_LABEL(self->view_mode_label), label); -} - -static void update_header_view_mode_items(MyApplication* self) { - const gchar* mode = - self->header_view_mode != nullptr ? self->header_view_mode : "week"; - set_header_view_mode_item_active(self, self->view_mode_day_item, - g_strcmp0(mode, "day") == 0); - set_header_view_mode_item_active(self, self->view_mode_week_item, - g_strcmp0(mode, "week") == 0); - set_header_view_mode_item_active(self, self->view_mode_month_item, - g_strcmp0(mode, "month") == 0); - set_header_view_mode_item_active(self, self->view_mode_year_item, - g_strcmp0(mode, "year") == 0); - set_header_view_mode_item_active(self, self->view_mode_agenda_item, - g_strcmp0(mode, "agenda") == 0); -} - -static void header_view_mode_item_clicked_cb(GtkWidget* widget, - gpointer user_data) { - MyApplication* self = MY_APPLICATION(user_data); - if (self->suppress_header_bar_actions) { - return; - } - const gchar* mode = static_cast( - g_object_get_data(G_OBJECT(widget), "busymax-view-mode")); - if (header_view_mode_action(mode) == nullptr) { - return; - } - set_header_view_mode(self, mode); - close_header_menu_button(self->view_mode_button); - focus_flutter_view(self); - invoke_header_bar_action(self, header_view_mode_action(mode)); -} - -static void set_header_popover_row_keyboard_focus(GtkWidget* row, - gboolean visible) { - if (row == nullptr || !GTK_IS_WIDGET(row)) { - return; - } - GtkStyleContext* context = gtk_widget_get_style_context(row); - if (visible) { - gtk_style_context_add_class(context, "busymax-keyboard-focus"); - } else { - gtk_style_context_remove_class(context, "busymax-keyboard-focus"); - } -} - -static gboolean header_popover_row_focus_in_cb(GtkWidget* row, - GdkEventFocus* event, - gpointer user_data) { - MyApplication* self = MY_APPLICATION(user_data); - const gboolean focus_visible = - self->main_window != nullptr && GTK_IS_WINDOW(self->main_window) && - gtk_window_get_focus_visible(self->main_window); - set_header_popover_row_keyboard_focus(row, focus_visible); - return FALSE; -} - -static gboolean header_popover_row_focus_out_cb(GtkWidget* row, - GdkEventFocus* event, - gpointer user_data) { - set_header_popover_row_keyboard_focus(row, FALSE); - return FALSE; -} - -static gboolean header_popover_row_key_press_cb(GtkWidget* row, - GdkEventKey* event, - gpointer user_data) { - set_header_popover_row_keyboard_focus(row, TRUE); - return FALSE; -} - -static gboolean header_popover_row_button_press_cb(GtkWidget* row, - GdkEventButton* event, - gpointer user_data) { - set_header_popover_row_keyboard_focus(row, FALSE); - return FALSE; -} - -static void configure_header_popover_row(MyApplication* self, - GtkWidget* row) { - gtk_button_set_relief(GTK_BUTTON(row), GTK_RELIEF_NONE); - gtk_widget_set_halign(row, GTK_ALIGN_FILL); - gtk_widget_set_hexpand(row, TRUE); - gtk_style_context_add_class(gtk_widget_get_style_context(row), - GTK_STYLE_CLASS_FLAT); - gtk_style_context_add_class(gtk_widget_get_style_context(row), - "busymax-header-popover-row"); - g_signal_connect(row, "focus-in-event", - G_CALLBACK(header_popover_row_focus_in_cb), self); - g_signal_connect(row, "focus-out-event", - G_CALLBACK(header_popover_row_focus_out_cb), self); - g_signal_connect(row, "key-press-event", - G_CALLBACK(header_popover_row_key_press_cb), self); - g_signal_connect(row, "button-press-event", - G_CALLBACK(header_popover_row_button_press_cb), self); -} - -static GtkWidget* create_header_view_mode_item(MyApplication* self, - const gchar* mode, - const gchar* fallback_label) { - GtkWidget* item = gtk_button_new(); - GtkWidget* box = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, kHeaderButtonSpacing); - GtkWidget* check = - gtk_image_new_from_icon_name("object-select-symbolic", GTK_ICON_SIZE_MENU); - GtkWidget* label = gtk_label_new(fallback_label); - gtk_label_set_xalign(GTK_LABEL(label), 0.0); - gtk_widget_set_hexpand(label, TRUE); - gtk_box_pack_start(GTK_BOX(box), check, FALSE, FALSE, 0); - gtk_box_pack_start(GTK_BOX(box), label, TRUE, TRUE, 0); - gtk_container_add(GTK_CONTAINER(item), box); - gtk_widget_set_opacity(check, 0.0); - configure_header_popover_row(self, item); - g_object_set_data(G_OBJECT(item), "busymax-header-label-widget", label); - g_object_set_data(G_OBJECT(item), "busymax-header-check-widget", check); - g_object_set_data_full(G_OBJECT(item), "busymax-header-label", - g_strdup(fallback_label), g_free); - g_object_set_data_full(G_OBJECT(item), "busymax-view-mode", - g_strdup(mode), g_free); - g_signal_connect(item, "clicked", - G_CALLBACK(header_view_mode_item_clicked_cb), self); - return item; -} - -static void header_popover_action_item_clicked_cb(GtkWidget* widget, - gpointer user_data) { - MyApplication* self = MY_APPLICATION(user_data); - if (self->suppress_header_bar_actions) { - return; - } - const gchar* action = static_cast( - g_object_get_data(G_OBJECT(widget), "busymax-header-action")); - GtkWidget* popover = gtk_widget_get_ancestor(widget, GTK_TYPE_POPOVER); - if (popover != nullptr && GTK_IS_POPOVER(popover)) { - gtk_popover_popdown(GTK_POPOVER(popover)); - } - focus_flutter_view(self); - invoke_header_bar_action(self, action); -} - -static GtkWidget* create_header_popover_action_item( - MyApplication* self, - const gchar* action, - const gchar* fallback_label) { - GtkWidget* item = gtk_button_new(); - GtkWidget* label = gtk_label_new(fallback_label); - gtk_label_set_xalign(GTK_LABEL(label), 0.0); - gtk_widget_set_hexpand(label, TRUE); - gtk_container_add(GTK_CONTAINER(item), label); - configure_header_popover_row(self, item); - g_object_set_data(G_OBJECT(item), "busymax-header-label-widget", label); - g_object_set_data_full(G_OBJECT(item), "busymax-header-label", - g_strdup(fallback_label), g_free); - g_object_set_data_full(G_OBJECT(item), "busymax-header-action", - g_strdup(action), g_free); - g_signal_connect(item, "clicked", - G_CALLBACK(header_popover_action_item_clicked_cb), self); - return item; -} - -static void set_header_popover_action_item_label(GtkWidget* item, - const gchar* label) { - if (item == nullptr || label == nullptr) { - return; - } - GtkWidget* label_widget = static_cast( - g_object_get_data(G_OBJECT(item), "busymax-header-label-widget")); - if (label_widget != nullptr && GTK_IS_LABEL(label_widget)) { - gtk_label_set_text(GTK_LABEL(label_widget), label); - return; - } - if (GTK_IS_BUTTON(item)) { - gtk_button_set_label(GTK_BUTTON(item), label); - } -} - static void set_header_view_mode_labels(MyApplication* self, const gchar* day, const gchar* week, const gchar* month, const gchar* year, const gchar* agenda) { - set_header_view_mode_item_label(self->view_mode_day_item, day); - set_header_view_mode_item_label(self->view_mode_week_item, week); - set_header_view_mode_item_label(self->view_mode_month_item, month); - set_header_view_mode_item_label(self->view_mode_year_item, year); - set_header_view_mode_item_label(self->view_mode_agenda_item, 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_label(self); } @@ -1571,8 +1427,20 @@ static void set_header_view_mode(MyApplication* self, const gchar* mode) { 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_label(self); - update_header_view_mode_items(self); } static void update_header_title_balance_spacer(MyApplication* self) { @@ -1592,9 +1460,10 @@ static void update_header_title_box_geometry(MyApplication* self) { !GTK_IS_WIDGET(self->header_title_box)) { return; } - const gint width = self->header_onboarding_controls_visible - ? kHeaderOnboardingContentWidth - : -1; + 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 ? kHeaderOnboardingContentWidth : -1; gtk_widget_set_size_request(self->header_title_box, width, -1); } @@ -1784,17 +1653,18 @@ static void set_header_localized_labels(MyApplication* self, FlValue* args) { gtk_entry_set_placeholder_text(GTK_ENTRY(self->search_entry), search); } set_widget_tooltip(self->create_button, create); - set_header_popover_action_item_label(self->create_event_item, create_event); - set_header_popover_action_item_label(self->create_task_item, create_task); + 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(self->previous_button, previous); set_widget_tooltip(self->next_button, next); set_widget_tooltip(self->sidebar_collapsed_toggle_button, sidebar); - set_header_popover_action_item_label(self->settings_item, settings); - set_header_popover_action_item_label(self->keyboard_shortcuts_item, - keyboard_shortcuts); - set_header_popover_action_item_label(self->about_item, about_busymax); + replace_header_label(&self->header_settings_label, settings); + replace_header_label(&self->header_keyboard_shortcuts_label, + keyboard_shortcuts); + replace_header_label(&self->header_about_label, about_busymax); + rebuild_header_menu_models(self); } static GtkWidget* create_busymax_header_bar(MyApplication* self) { @@ -1804,6 +1674,7 @@ static GtkWidget* create_busymax_header_bar(MyApplication* self) { gtk_widget_set_hexpand(self->titlebar_box, TRUE); gtk_style_context_add_class(gtk_widget_get_style_context(self->titlebar_box), "busymax-titlebar"); + initialize_header_menu_actions(self); GtkHeaderBar* header_bar = GTK_HEADER_BAR(gtk_header_bar_new()); track_header_bar_pointer(self, header_bar); @@ -1843,36 +1714,12 @@ static GtkWidget* create_busymax_header_bar(MyApplication* self) { gtk_box_pack_start(GTK_BOX(brand_center_box), self->header_brand_label, FALSE, FALSE, 0); - track_widget_pointer(&self->settings_menu, create_header_popup_window(self)); - GtkWidget* settings_menu_box = create_header_popover_box(self->settings_menu); - - track_widget_pointer(&self->settings_item, - create_header_popover_action_item(self, "settings", - "Settings")); - track_widget_pointer(&self->keyboard_shortcuts_item, - create_header_popover_action_item( - self, "keyboardShortcuts", "Keyboard Shortcuts")); - track_widget_pointer(&self->about_item, - create_header_popover_action_item( - self, "aboutBusyMax", "About BusyMax")); - gtk_box_pack_start(GTK_BOX(settings_menu_box), self->settings_item, FALSE, - FALSE, 0); - gtk_box_pack_start(GTK_BOX(settings_menu_box), - self->keyboard_shortcuts_item, FALSE, FALSE, 0); - gtk_box_pack_start(GTK_BOX(settings_menu_box), self->about_item, FALSE, - FALSE, 0); - show_header_popover_content(settings_menu_box); - track_widget_pointer(&self->settings_menu_button, gtk_menu_button_new()); gtk_button_set_relief(GTK_BUTTON(self->settings_menu_button), GTK_RELIEF_NONE); gtk_button_set_image(GTK_BUTTON(self->settings_menu_button), gtk_image_new_from_icon_name("open-menu-symbolic", GTK_ICON_SIZE_MENU)); - gtk_menu_button_set_use_popover(GTK_MENU_BUTTON(self->settings_menu_button), - TRUE); - gtk_menu_button_set_popover(GTK_MENU_BUTTON(self->settings_menu_button), - self->settings_menu); gtk_style_context_add_class( gtk_widget_get_style_context(self->settings_menu_button), GTK_STYLE_CLASS_FLAT); @@ -1885,6 +1732,7 @@ static GtkWidget* create_busymax_header_bar(MyApplication* self) { 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), self->search_button, FALSE, FALSE, 0); @@ -1943,11 +1791,12 @@ static GtkWidget* create_busymax_header_bar(MyApplication* self) { track_widget_pointer(&self->header_title_box, gtk_box_new(GTK_ORIENTATION_HORIZONTAL, kHeaderButtonSpacing)); - gtk_widget_set_halign(self->header_title_box, GTK_ALIGN_CENTER); + 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); @@ -1971,13 +1820,16 @@ static GtkWidget* create_busymax_header_bar(MyApplication* self) { "busymax-header-title"); gtk_label_set_ellipsize(GTK_LABEL(self->header_title_label), PANGO_ELLIPSIZE_END); - gtk_label_set_max_width_chars(GTK_LABEL(self->header_title_label), 48); + gtk_label_set_max_width_chars(GTK_LABEL(self->header_title_label), + kHeaderCenterMaximumWidthChars); 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_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); @@ -2029,31 +1881,6 @@ static GtkWidget* create_busymax_header_bar(MyApplication* self) { track_widget_pointer(&self->header_view_box, gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 0)); - track_widget_pointer(&self->view_mode_menu, create_header_popup_window(self)); - GtkWidget* view_mode_menu_box = - create_header_popover_box(self->view_mode_menu); - - track_widget_pointer(&self->view_mode_day_item, - create_header_view_mode_item(self, "day", "Day")); - track_widget_pointer(&self->view_mode_week_item, - create_header_view_mode_item(self, "week", "Week")); - track_widget_pointer(&self->view_mode_month_item, - create_header_view_mode_item(self, "month", "Month")); - track_widget_pointer(&self->view_mode_year_item, - create_header_view_mode_item(self, "year", "Year")); - track_widget_pointer(&self->view_mode_agenda_item, - create_header_view_mode_item(self, "agenda", "Agenda")); - gtk_box_pack_start(GTK_BOX(view_mode_menu_box), self->view_mode_day_item, - FALSE, FALSE, 0); - gtk_box_pack_start(GTK_BOX(view_mode_menu_box), self->view_mode_week_item, - FALSE, FALSE, 0); - gtk_box_pack_start(GTK_BOX(view_mode_menu_box), self->view_mode_month_item, - FALSE, FALSE, 0); - gtk_box_pack_start(GTK_BOX(view_mode_menu_box), self->view_mode_year_item, - FALSE, FALSE, 0); - gtk_box_pack_start(GTK_BOX(view_mode_menu_box), self->view_mode_agenda_item, - FALSE, FALSE, 0); - show_header_popover_content(view_mode_menu_box); track_widget_pointer(&self->view_mode_button, gtk_menu_button_new()); gtk_button_set_relief(GTK_BUTTON(self->view_mode_button), GTK_RELIEF_NONE); @@ -2061,10 +1888,6 @@ static GtkWidget* create_busymax_header_bar(MyApplication* self) { GTK_STYLE_CLASS_FLAT); gtk_style_context_add_class(gtk_widget_get_style_context(self->view_mode_button), "busymax-header-view-mode-button"); - gtk_menu_button_set_use_popover(GTK_MENU_BUTTON(self->view_mode_button), - TRUE); - gtk_menu_button_set_popover(GTK_MENU_BUTTON(self->view_mode_button), - self->view_mode_menu); GtkWidget* view_mode_button_box = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, kHeaderButtonSpacing); @@ -2079,38 +1902,23 @@ static GtkWidget* create_busymax_header_bar(MyApplication* self) { 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_menu, create_header_popup_window(self)); - GtkWidget* create_menu_box = create_header_popover_box(self->create_menu); - track_widget_pointer( - &self->create_event_item, - create_header_popover_action_item(self, "createEvent", "Event")); - track_widget_pointer( - &self->create_task_item, - create_header_popover_action_item(self, "createTask", "Task")); - gtk_box_pack_start(GTK_BOX(create_menu_box), self->create_event_item, FALSE, - FALSE, 0); - gtk_box_pack_start(GTK_BOX(create_menu_box), self->create_task_item, FALSE, - FALSE, 0); - show_header_popover_content(create_menu_box); - track_widget_pointer(&self->create_button, gtk_menu_button_new()); gtk_button_set_relief(GTK_BUTTON(self->create_button), GTK_RELIEF_NONE); gtk_button_set_image( GTK_BUTTON(self->create_button), gtk_image_new_from_icon_name("list-add-symbolic", GTK_ICON_SIZE_MENU)); - gtk_menu_button_set_use_popover(GTK_MENU_BUTTON(self->create_button), TRUE); - gtk_menu_button_set_popover(GTK_MENU_BUTTON(self->create_button), - self->create_menu); gtk_style_context_add_class(gtk_widget_get_style_context(self->create_button), GTK_STYLE_CLASS_FLAT); gtk_style_context_add_class(gtk_widget_get_style_context(self->create_button), "busymax-header-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, @@ -2139,16 +1947,12 @@ static gboolean show_header_create_menu(MyApplication* self) { return FALSE; } + // A pointer-triggered Flutter command should behave like clicking the native + // menu button: retain focus on the trigger and let GTK move into the model + // only when the user starts keyboard navigation. + gtk_widget_grab_focus(self->create_button); gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(self->create_button), TRUE); - GtkWidget* first_item = - self->create_event_item != nullptr && - gtk_widget_get_sensitive(self->create_event_item) - ? self->create_event_item - : self->create_task_item; - if (first_item != nullptr && GTK_IS_WIDGET(first_item)) { - gtk_widget_grab_focus(first_item); - } - return TRUE; + return gtk_toggle_button_get_active(GTK_TOGGLE_BUTTON(self->create_button)); } static void header_bar_method_call_cb(FlMethodChannel* channel, @@ -2386,6 +2190,7 @@ static FlValue* get_gtk_theme_colors() { GdkRGBA window_color = {0, 0, 0, 0}; GdkRGBA view_color = {0, 0, 0, 0}; GdkRGBA sidebar_color = {0, 0, 0, 0}; + GdkRGBA secondary_sidebar_color = {0, 0, 0, 0}; GdkRGBA header_color = {0, 0, 0, 0}; GdkRGBA card_color = {0, 0, 0, 0}; GdkRGBA dialog_color = {0, 0, 0, 0}; @@ -2403,31 +2208,55 @@ static FlValue* get_gtk_theme_colors() { GtkStyleContext* window_context = gtk_widget_get_style_context(window); gtk_style_context_add_class(window_context, GTK_STYLE_CLASS_BACKGROUND); - lookup_context_color(window_context, "theme_bg_color", &window_color) || + lookup_context_color(window_context, "window_bg_color", &window_color) || + lookup_context_color(window_context, "theme_bg_color", &window_color) || sample_widget_background(window, GTK_STYLE_CLASS_BACKGROUND, GTK_STATE_FLAG_NORMAL, &window_color); - lookup_context_color(window_context, "theme_base_color", &view_color) || + lookup_context_color(window_context, "view_bg_color", &view_color) || + lookup_context_color(window_context, "theme_base_color", &view_color) || sample_widget_background(view, GTK_STYLE_CLASS_VIEW, GTK_STATE_FLAG_NORMAL, &view_color); - lookup_context_color(window_context, "theme_fg_color", &foreground_color) || + lookup_context_color(window_context, "window_fg_color", &foreground_color) || + lookup_context_color(window_context, "theme_fg_color", + &foreground_color) || sample_widget_color(window, GTK_STYLE_CLASS_BACKGROUND, GTK_STATE_FLAG_NORMAL, &foreground_color); lookup_context_color(window_context, "theme_unfocused_fg_color", &muted_foreground_color); lookup_context_color(window_context, "borders", &border_color); - lookup_context_color(window_context, "wm_shadow", &shade_color); - lookup_context_color(window_context, "theme_selected_bg_color", - &accent_color); - - sample_widget_background(sidebar, GTK_STYLE_CLASS_SIDEBAR, - GTK_STATE_FLAG_NORMAL, &sidebar_color); - sample_widget_background(header, GTK_STYLE_CLASS_TITLEBAR, - GTK_STATE_FLAG_NORMAL, &header_color); - sample_widget_background(card, "card", GTK_STATE_FLAG_NORMAL, &card_color); - sample_widget_background(dialog, GTK_STYLE_CLASS_BACKGROUND, - GTK_STATE_FLAG_NORMAL, &dialog_color); - sample_widget_background(popover, GTK_STYLE_CLASS_BACKGROUND, - GTK_STATE_FLAG_NORMAL, &popover_color); + lookup_context_color(window_context, "sidebar_border_color", + &sidebar_border_color); + lookup_context_color(window_context, "shade_color", &shade_color) || + lookup_context_color(window_context, "wm_shadow", &shade_color); + lookup_context_color(window_context, "accent_bg_color", &accent_color) || + lookup_context_color(window_context, "theme_selected_bg_color", + &accent_color); + + // Prefer public semantic roles. Classic GTK 3 themes often expose only + // widget-class styling, so retain those samples as compatibility input; + // Dart validates their hierarchy before accepting them as raised surfaces. + lookup_context_color(window_context, "sidebar_bg_color", &sidebar_color) || + sample_widget_background(sidebar, GTK_STYLE_CLASS_SIDEBAR, + GTK_STATE_FLAG_NORMAL, &sidebar_color); + lookup_context_color(window_context, "secondary_sidebar_bg_color", + &secondary_sidebar_color); + if (!color_is_visible(&secondary_sidebar_color) && + color_is_visible(&sidebar_color)) { + secondary_sidebar_color = sidebar_color; + } + lookup_context_color(window_context, "headerbar_bg_color", &header_color) || + sample_widget_background(header, GTK_STYLE_CLASS_TITLEBAR, + GTK_STATE_FLAG_NORMAL, &header_color); + lookup_context_color(window_context, "card_bg_color", &card_color) || + sample_widget_background(card, "card", GTK_STATE_FLAG_NORMAL, + &card_color); + lookup_context_color(window_context, "dialog_bg_color", &dialog_color) || + sample_widget_background(dialog, GTK_STYLE_CLASS_BACKGROUND, + GTK_STATE_FLAG_NORMAL, &dialog_color); + lookup_context_color(window_context, "popover_bg_color", &popover_color) || + sample_widget_background(popover, GTK_STYLE_CLASS_BACKGROUND, + GTK_STATE_FLAG_NORMAL, &popover_color); + sample_widget_background(control, nullptr, GTK_STATE_FLAG_NORMAL, &control_color); sample_widget_background(control, nullptr, GTK_STATE_FLAG_PRELIGHT, @@ -2438,8 +2267,6 @@ static FlValue* get_gtk_theme_colors() { if (color_is_visible(&border_color)) { subtle_border_color = border_color; subtle_border_color.alpha *= 0.56; - sidebar_border_color = border_color; - sidebar_border_color.alpha *= 0.72; } FlValue* result = fl_value_new_map(); @@ -2449,9 +2276,8 @@ static FlValue* get_gtk_theme_colors() { set_theme_color(result, "window", &window_color); set_theme_color(result, "view", &view_color); set_theme_color(result, "sidebar", &sidebar_color); - set_theme_color(result, "secondarySidebar", &sidebar_color); + set_theme_color(result, "secondarySidebar", &secondary_sidebar_color); set_theme_color(result, "headerbar", &header_color); - set_theme_color(result, "headerbarFlat", &view_color); set_theme_color(result, "card", &card_color); set_theme_color(result, "dialog", &dialog_color); set_theme_color(result, "popover", &popover_color); @@ -3481,6 +3307,14 @@ static void my_application_dispose(GObject* object) { g_clear_object(&self->gtk_font_settings_event_channel); disconnect_gtk_theme_colors_signals(self); 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); self->main_window = nullptr; clear_widget_pointer(&self->flutter_view); clear_widget_pointer(&self->titlebar_box); @@ -3497,9 +3331,6 @@ static void my_application_dispose(GObject* object) { clear_widget_pointer(&self->header_brand_label); clear_widget_pointer(&self->settings_menu_button); clear_widget_pointer(&self->settings_menu); - clear_widget_pointer(&self->settings_item); - clear_widget_pointer(&self->keyboard_shortcuts_item); - clear_widget_pointer(&self->about_item); clear_widget_pointer(&self->header_view_box); clear_widget_pointer(&self->header_title_label); clear_widget_pointer(&self->search_entry); @@ -3511,16 +3342,9 @@ static void my_application_dispose(GObject* object) { clear_widget_pointer(&self->view_mode_button); clear_widget_pointer(&self->view_mode_label); clear_widget_pointer(&self->view_mode_menu); - clear_widget_pointer(&self->view_mode_day_item); - clear_widget_pointer(&self->view_mode_week_item); - clear_widget_pointer(&self->view_mode_month_item); - clear_widget_pointer(&self->view_mode_year_item); - clear_widget_pointer(&self->view_mode_agenda_item); clear_widget_pointer(&self->search_button); clear_widget_pointer(&self->create_button); clear_widget_pointer(&self->create_menu); - clear_widget_pointer(&self->create_event_item); - clear_widget_pointer(&self->create_task_item); 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); @@ -3539,6 +3363,16 @@ static void my_application_dispose(GObject* object) { g_clear_pointer(&self->header_bar_shade_color, g_free); g_clear_pointer(&self->header_bar_modal_barrier_color, g_free); g_clear_pointer(&self->header_view_mode, 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_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_about_label, g_free); g_clear_pointer(&self->header_search_query, g_free); g_clear_pointer(&self->dart_entrypoint_arguments, g_strfreev); G_OBJECT_CLASS(my_application_parent_class)->dispose(object); @@ -3573,7 +3407,7 @@ static void my_application_init(MyApplication* self) { self->main_window_transparent_backing = FALSE; self->header_bar_css_provider = nullptr; self->header_bar_window_background_color = - g_strdup(kDefaultHeaderBarBackgroundColor); + g_strdup(kDefaultWindowBackgroundColor); self->header_bar_background_color = g_strdup(kDefaultHeaderBarBackgroundColor); self->header_bar_sidebar_background_color = @@ -3611,9 +3445,6 @@ static void my_application_init(MyApplication* self) { self->header_brand_label = nullptr; self->settings_menu_button = nullptr; self->settings_menu = nullptr; - self->settings_item = nullptr; - self->keyboard_shortcuts_item = nullptr; - self->about_item = nullptr; self->header_view_box = nullptr; self->header_title_label = nullptr; self->search_entry = nullptr; @@ -3625,18 +3456,25 @@ static void my_application_init(MyApplication* self) { self->view_mode_button = nullptr; self->view_mode_label = nullptr; self->view_mode_menu = nullptr; - self->view_mode_day_item = nullptr; - self->view_mode_week_item = nullptr; - self->view_mode_month_item = nullptr; - self->view_mode_year_item = nullptr; - self->view_mode_agenda_item = nullptr; self->search_button = nullptr; self->create_button = nullptr; self->create_menu = nullptr; - self->create_event_item = nullptr; - self->create_task_item = 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_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_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_about_label = g_strdup("About BusyMax"); self->header_search_query = g_strdup(""); self->header_search_active = FALSE; self->header_navigation_visible = TRUE; diff --git a/test/app/busymax_menu_button_test.dart b/test/app/busymax_menu_button_test.dart index 978b0b3..6f778e2 100644 --- a/test/app/busymax_menu_button_test.dart +++ b/test/app/busymax_menu_button_test.dart @@ -1,4 +1,5 @@ import 'package:busymax/src/app/busymax_design.dart'; +import 'package:busymax/src/app/busymax_yaru_theme.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:yaru/yaru.dart'; @@ -10,26 +11,33 @@ void main() { tester, ) async { String? selected; + final theme = BusyMaxYaruTheme.build( + brightness: Brightness.dark, + accentColor: BusyMaxLinuxPalette.ubuntuOrangeAccent, + ); await tester.pumpWidget( localizedTestApp( - child: Scaffold( - body: Center( - child: BusyMaxMenuButton( - tooltip: 'Options', - onSelected: (value) => selected = value, - entries: const [ - BusyMaxMenuEntry( - value: 'refresh', - label: 'Refresh calendar', - icon: YaruIcons.refresh, - ), - BusyMaxMenuEntry( - value: 'open', - label: 'Open in provider', - icon: Icons.open_in_browser_outlined, - ), - ], + child: Theme( + data: theme, + child: Scaffold( + body: Center( + child: BusyMaxMenuButton( + tooltip: 'Options', + onSelected: (value) => selected = value, + entries: const [ + BusyMaxMenuEntry( + value: 'refresh', + label: 'Refresh calendar', + icon: YaruIcons.refresh, + ), + BusyMaxMenuEntry( + value: 'open', + label: 'Open in provider', + icon: Icons.open_in_browser_outlined, + ), + ], + ), ), ), ), @@ -41,6 +49,35 @@ void main() { expect(find.text('Refresh calendar'), findsOneWidget); expect(find.text('Open in provider'), findsOneWidget); + final colors = theme.extension()!; + final anchor = tester.widget(find.byType(MenuAnchor)); + expect(anchor.style?.backgroundColor?.resolve(const {}), colors.popover); + expect( + anchor.style?.elevation?.resolve(const {}), + theme.menuTheme.style?.elevation?.resolve(const {}), + ); + expect( + anchor.style?.shape?.resolve(const {}), + theme.menuTheme.style?.shape?.resolve(const {}), + ); + expect( + tester + .widgetList(find.byType(Material)) + .where((material) => material.color == colors.popover), + isNotEmpty, + ); + for (final item in tester.widgetList( + find.byType(MenuItemButton), + )) { + expect( + item.style?.minimumSize?.resolve(const {}), + theme.menuButtonTheme.style?.minimumSize?.resolve(const {}), + ); + expect( + item.style?.maximumSize?.resolve(const {}), + theme.menuButtonTheme.style?.maximumSize?.resolve(const {}), + ); + } await tester.tap(find.byTooltip('Options')); await tester.pumpAndSettle(); diff --git a/test/app/busymax_search_field_test.dart b/test/app/busymax_search_field_test.dart index 46e5d2c..b1333a7 100644 --- a/test/app/busymax_search_field_test.dart +++ b/test/app/busymax_search_field_test.dart @@ -17,11 +17,17 @@ void main() { await tester.pumpWidget( MaterialApp( home: Scaffold( - body: BusyMaxSearchField( - controller: controller, - hintText: 'Search', - onChanged: changes.add, - onClear: () => clearCount += 1, + body: Align( + alignment: Alignment.topLeft, + child: SizedBox( + width: 420, + child: BusyMaxSearchField( + controller: controller, + hintText: 'Search', + onChanged: changes.add, + onClear: () => clearCount += 1, + ), + ), ), ), ), @@ -32,6 +38,8 @@ void main() { final field = tester.widget(find.byType(YaruSearchField)); expect(field.style, YaruSearchFieldStyle.filled); expect(field.height, kYaruTitleBarItemHeight); + expect(field.radius, const Radius.circular(kYaruTitleBarItemHeight)); + expect(tester.getSize(find.byType(BusyMaxSearchField)).width, 420); expect( field.clearIconSemanticLabel, MaterialLocalizations.of(context).clearButtonTooltip, diff --git a/test/app/native_ui_audit_test.dart b/test/app/native_ui_audit_test.dart index 9dca2bc..8e70bcc 100644 --- a/test/app/native_ui_audit_test.dart +++ b/test/app/native_ui_audit_test.dart @@ -413,9 +413,14 @@ void main() { expect(source, contains('settings_menu')); expect( source, - contains('create_header_popover_action_item(self, "settings"'), + contains( + 'g_menu_append(menu, self->header_settings_label, "header.settings")', + ), + ); + expect( + source, + contains('create_header_bridge_action(self, "about", "aboutBusyMax")'), ); - expect(source, contains('self, "aboutBusyMax", "About BusyMax"')); expect(source, isNot(contains('settingsAccounts'))); expect(source, isNot(contains('settingsDiagnostics'))); expect(source, contains('gtk_label_new(kApplicationDisplayName)')); @@ -591,16 +596,23 @@ void main() { ), ); expect(source, contains('transition: none;')); - expect(source, contains('gtk_popover_new(nullptr)')); expect(source, contains('gtk_popover_set_position')); expect(source, contains('GTK_POS_BOTTOM')); - expect(source, contains('create_header_popover_box')); expect(source, contains('gtk_popover_popdown')); expect(source, contains('gtk_menu_button_new()')); expect(source, contains('gtk_menu_button_set_use_popover')); - expect(source, contains('gtk_menu_button_set_popover')); + expect(source, contains('gtk_menu_button_set_menu_model')); 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('gtk_popover_popup'))); @@ -615,21 +627,14 @@ void main() { expect(source, contains('"busymax-header-popover"')); expect(source, contains('header_bar_popover_background_color')); expect(source, contains('"popoverBackgroundColor"')); - expect(source, contains('"busymax-header-popover-row"')); - expect(source, contains('kHeaderPopoverRowSpacing')); - expect( - source, - contains( - 'gtk_box_new(GTK_ORIENTATION_VERTICAL, kHeaderPopoverRowSpacing)', - ), - ); - expect(source, contains('button.busymax-header-popover-row:focus')); - expect(source, contains('busymax-keyboard-focus:focus')); - expect(source, contains('gtk_window_get_focus_visible')); - expect(source, contains('configure_header_popover_row(self, item)')); - expect(source, isNot(contains('gtk_widget_set_can_focus(item, FALSE)'))); - expect(source, contains('"object-select-symbolic"')); - expect(source, contains('gtk_widget_set_opacity(check_widget')); + 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(source, 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'))); @@ -638,9 +643,12 @@ void main() { expect(source, isNot(contains('g_object_set(item, "active"'))); expect( source, - contains('gtk_box_pack_start(GTK_BOX(view_mode_menu_box)'), + 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, 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'))); @@ -663,7 +671,7 @@ void main() { expect(source, contains('padding-left: 0;')); expect(source, contains('kHeaderButtonHeight')); expect(source, contains('kHeaderButtonSpacing')); - expect(source, contains('kHeaderMenuPadding = kHeaderButtonSpacing')); + expect(source, isNot(contains('kHeaderMenuPadding'))); expect(source, isNot(contains('padding: 4px;'))); expect(source, contains('setLocalizedLabels')); expect(source, contains('setSidebarWidth')); @@ -723,7 +731,8 @@ void main() { isNot(contains('menuitem.busymax-header-view-mode-item:checked')), ); expect(source, isNot(contains('busymax-header-view-mode-item-active'))); - expect(source, contains('create_header_popup_window')); + expect(source, isNot(contains('create_header_popup_window'))); + expect(source, contains('gtk_menu_button_set_menu_model')); expect(source, contains('busymax-header-primary-button')); expect(source, contains('fl_lookup_string_arg(args, "accentColor")')); expect( @@ -751,7 +760,8 @@ void main() { 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, contains('busymax-application')); + 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, @@ -781,8 +791,27 @@ void main() { contains('lookup_context_color(window_context, "wm_shadow"'), ); expect(source, isNot(contains('shade_color = border_color'))); - expect(source, contains('GTK_STYLE_CLASS_SIDEBAR')); expect(source, contains('GTK_STYLE_CLASS_VIEW')); + expect(source, contains('"window_bg_color"')); + expect(source, contains('"view_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, contains('GtkWidget* sidebar =')); + expect(source, contains('GtkWidget* popover =')); + expect(source, contains('sample_widget_background(sidebar')); + expect(source, contains('sample_widget_background(popover')); + expect( + source.indexOf('"sidebar_bg_color"'), + lessThan(source.indexOf('sample_widget_background(sidebar')), + ); + expect( + source.indexOf('"popover_bg_color"'), + lessThan(source.indexOf('sample_widget_background(popover')), + ); expect(source, contains('gtk_style_context_get_property')); expect(source, contains('"background-color"')); expect(source, isNot(contains('gtk_style_context_get_background_color'))); @@ -815,19 +844,34 @@ void main() { source, isNot(contains('create_header_toggle_text_button("Agenda"')), ); - expect(source, contains('create_header_view_mode_item(self, "year"')); + expect( + source, + contains( + 'append_header_view_mode_item(menu, self->header_year_label, "year")', + ), + ); expect(source, contains('return "viewModeYear"')); expect(source, contains('list-add-symbolic')); expect( source, - contains('create_header_popover_action_item(self, "createEvent"'), + contains( + 'g_menu_append(menu, self->header_create_event_label,\n' + ' "header.create-event")', + ), + ); + expect( + source, + contains( + 'g_menu_append(menu, self->header_create_task_label, ' + '"header.create-task")', + ), ); - expect(source, contains('self, "createTask", "Task"')); - expect(source, contains('gtk_menu_button_set_popover')); - expect(source, contains('button.busymax-header-popover-row:disabled')); + 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, contains('create_header_popover_action_item')); + expect(source, isNot(contains('create_header_popover_action_item'))); + expect(source, isNot(contains('button.busymax-header-popover-row'))); expect( source, contains('button.busymax-header-button.busymax-sidebar-toggle:checked'), @@ -858,13 +902,14 @@ void main() { expect(source, contains('"border: 1px solid %s;"')); expect(source, contains('"box-shadow: 0 6px 18px %s;"')); expect(source, contains('muted_foreground_color')); + expect(source, contains('kDefaultWindowBackgroundColor[] = "#2C2C2C"')); expect( source, - contains('kDefaultHeaderBarBackgroundColor[] = "#1D1D20"'), + contains('kDefaultHeaderBarBackgroundColor[] = "#393939"'), ); expect( source, - contains('kDefaultHeaderBarSidebarBackgroundColor[] = "#2E2E32"'), + contains('kDefaultHeaderBarSidebarBackgroundColor[] = "#393939"'), ); expect(source, contains('set_flutter_view_background_color')); expect(source, contains('header_bar_window_background_color')); @@ -1068,24 +1113,31 @@ void main() { expect(source, isNot(contains('return TextTheme('))); }); - test('shared menu button has no nested hover background', () { + test('shared menu button preserves Yaru menu geometry and states', () { final source = File('lib/src/app/busymax_design.dart').readAsStringSync(); + final menuStart = source.indexOf('MenuStyle busyMaxDropdownMenuStyle'); + final itemStart = source.indexOf( + 'ButtonStyle busyMaxDropdownMenuItemStyle', + ); + final itemEnd = source.indexOf( + 'ButtonStyle busyMaxPushButtonStyle', + itemStart, + ); + final menuBody = source.substring(menuStart, itemStart); + final itemBody = source.substring(itemStart, itemEnd); expect(source, contains('class BusyMaxMenuButton')); expect(source, contains('class BusyMaxMenuEntry')); - expect(source, contains('busyMaxDropdownMenuStyle')); - expect(source, contains('busyMaxDropdownMenuItemStyle')); expect(source, contains('builder: (context, controller, child)')); - expect( - source, - contains('side: const WidgetStatePropertyAll(BorderSide.none)'), - ); - expect( - source, - contains( - 'surfaceTintColor: const WidgetStatePropertyAll(Colors.transparent)', - ), - ); + expect(menuBody, contains('Theme.of(context).menuTheme.style')); + expect(menuBody, contains('base.copyWith(')); + expect(menuBody, contains('minimumSize:')); + expect(menuBody, isNot(contains('BusyMaxElevation'))); + expect(menuBody, isNot(contains('RoundedRectangleBorder'))); + expect(menuBody, isNot(contains('visualDensity:'))); + expect(itemBody, contains('Theme.of(context).menuButtonTheme.style')); + expect(itemBody, isNot(contains('WidgetStateProperty.resolveWith'))); + expect(itemBody, isNot(contains('backgroundColor:'))); expect(source, isNot(contains('_BusyMaxPopupMenuTrigger'))); expect(source, isNot(contains('MouseRegion('))); expect(source, isNot(contains('AnimatedContainer('))); diff --git a/test/app/theme_localization_test.dart b/test/app/theme_localization_test.dart index a4429b2..4c15550 100644 --- a/test/app/theme_localization_test.dart +++ b/test/app/theme_localization_test.dart @@ -186,13 +186,15 @@ void main() { expect(lightColors.groupedSurface, const Color(0xFFFFFFFF)); expect(lightColors.dialog, const Color(0xFFFAFAFB)); expect(lightColors.popover, const Color(0xFFFFFFFF)); - expect(darkColors.window, const Color(0xFF1D1D20)); + expect(darkColors.window, const Color(0xFF2C2C2C)); expect(darkColors.view, const Color(0xFF1D1D20)); - expect(darkColors.sidebar, const Color(0xFF2E2E32)); - expect(darkColors.card, const Color(0xFF222226)); - expect(darkColors.groupedSurface, const Color(0xFF383838)); - expect(darkColors.dialog, const Color(0xFF222226)); - expect(darkColors.popover, const Color(0xFF383838)); + expect(darkColors.sidebar, const Color(0xFF393939)); + expect(darkColors.secondarySidebar, const Color(0xFF323232)); + expect(darkColors.headerbar, const Color(0xFF393939)); + expect(darkColors.card, const Color(0xFF3D3D3D)); + expect(darkColors.groupedSurface, const Color(0xFF3D3D3D)); + expect(darkColors.dialog, const Color(0xFF3E3E3E)); + expect(darkColors.popover, const Color(0xFF3E3E3E)); expect(darkColors.sidebarBorder, const Color.fromRGBO(255, 255, 255, 0.10)); expect(darkColors.view, isNot(const Color(0xFF3E3E3E))); expect(light.scaffoldBackgroundColor, lightColors.window); @@ -203,6 +205,36 @@ void main() { expect(dark.dialogTheme.backgroundColor, darkColors.dialog); expect(light.popupMenuTheme.color, lightColors.popover); expect(dark.popupMenuTheme.color, darkColors.popover); + expect( + dark.menuTheme.style?.backgroundColor?.resolve(const {}), + darkColors.popover, + ); + expect( + dark.dropdownMenuTheme.menuStyle?.backgroundColor?.resolve(const {}), + darkColors.popover, + ); + final yaruDark = createYaruDarkTheme(primaryColor: _testAccentColor); + for (final pair in [ + (dark.menuTheme.style, yaruDark.menuTheme.style), + (dark.dropdownMenuTheme.menuStyle, yaruDark.dropdownMenuTheme.menuStyle), + ]) { + expect( + pair.$1?.elevation?.resolve(const {}), + pair.$2?.elevation?.resolve(const {}), + ); + expect( + pair.$1?.shape?.resolve(const {}), + pair.$2?.shape?.resolve(const {}), + ); + expect( + pair.$1?.side?.resolve(const {}), + pair.$2?.side?.resolve(const {}), + ); + expect( + pair.$1?.padding?.resolve(const {}), + pair.$2?.padding?.resolve(const {}), + ); + } expect(light.tooltipTheme.decoration, isA()); expect(dark.tooltipTheme.decoration, isA()); expect( @@ -608,6 +640,101 @@ void main() { ); }); + test('BusyMax rejects recessed legacy GTK3 sidebar and popover samples', () { + const gtkColors = GtkThemeColors( + brightness: Brightness.dark, + window: Color(0xFF2C2C2C), + view: Color(0xFF272727), + sidebar: Color(0xFF2A2A2A), + popover: Color(0xFF1D1D1D), + foreground: Color(0xFFF7F7F7), + ); + final theme = _buildBusyMaxTheme( + brightness: Brightness.dark, + gtkThemeColors: gtkColors, + ); + final colors = theme.extension()!; + final fallback = busyMaxFallbackSurfaceColors(Brightness.dark); + + expect(colors.sidebar, fallback.sidebar); + expect(colors.popover, fallback.popover); + expect(colors.sidebar, isNot(gtkColors.sidebar)); + expect(colors.popover, isNot(gtkColors.popover)); + expect( + colors.sidebar.computeLuminance(), + greaterThan(theme.colorScheme.surface.computeLuminance()), + ); + expect( + colors.popover.computeLuminance(), + greaterThan(theme.colorScheme.surface.computeLuminance()), + ); + expect(theme.popupMenuTheme.color, colors.popover); + expect( + theme.menuTheme.style?.backgroundColor?.resolve(const {}), + colors.popover, + ); + expect( + theme.dropdownMenuTheme.menuStyle?.backgroundColor?.resolve(const {}), + colors.popover, + ); + }); + + test('BusyMax composites translucent card roles over the window surface', () { + const gtkColors = GtkThemeColors( + brightness: Brightness.dark, + window: Color(0xFF2C2C2C), + view: Color(0xFF1D1D20), + card: Color.fromRGBO(255, 255, 255, 0.08), + ); + final colors = _buildBusyMaxTheme( + brightness: Brightness.dark, + gtkThemeColors: gtkColors, + ).extension()!; + final expected = Color.alphaBlend(gtkColors.card!, gtkColors.window!); + final wrongParent = Color.alphaBlend(gtkColors.card!, gtkColors.view!); + + expect(colors.card, expected); + expect(colors.groupedSurface, expected); + expect(colors.card, isNot(wrongParent)); + }); + + test( + 'BusyMax never makes raised roles recessed on a bright custom theme', + () { + const parent = Color(0xFF3E3E3E); + const gtkColors = GtkThemeColors( + brightness: Brightness.dark, + window: parent, + view: parent, + sidebar: Color(0xFF2A2A2A), + secondarySidebar: Color(0xFF303030), + headerbar: Color(0xFF303030), + card: Color(0xFF303030), + dialog: Color(0xFF303030), + popover: Color(0xFF303030), + ); + final colors = _buildBusyMaxTheme( + brightness: Brightness.dark, + gtkThemeColors: gtkColors, + ).extension()!; + + for (final raised in [ + colors.sidebar, + colors.secondarySidebar, + colors.headerbar, + colors.card, + colors.groupedSurface, + colors.dialog, + colors.popover, + ]) { + expect( + raised.computeLuminance(), + greaterThanOrEqualTo(parent.computeLuminance()), + ); + } + }, + ); + test('BusyMax grouped surfaces ignore unreadable GTK card samples', () { const gtkColors = GtkThemeColors( brightness: Brightness.light, @@ -698,7 +825,10 @@ void main() { theme.colorScheme.surfaceContainerHighest, const Color.fromRGBO(255, 255, 255, 0.14), ); - expect(theme.dialogTheme.backgroundColor, gtkColors.dialog); + expect( + theme.dialogTheme.backgroundColor, + busyMaxFallbackSurfaceColors(Brightness.dark).dialog, + ); expect(colors.sidebar, gtkColors.sidebar); expect(colors.control, const Color.fromRGBO(255, 255, 255, 0.10)); expect(colors.controlHover, const Color.fromRGBO(255, 255, 255, 0.14)); @@ -725,7 +855,29 @@ void main() { expect(theme.colorScheme.surfaceContainerHighest, gtkColors.controlHover); }); - test('BusyMax theme preserves a flat GTK surface hierarchy', () { + test('BusyMax theme rejects opaque GTK widget samples as overlay roles', () { + const gtkColors = GtkThemeColors( + brightness: Brightness.dark, + control: Color(0xFF2C2C2C), + controlHover: Color(0xFF343434), + controlActive: Color(0xFF131313), + activeToggle: Color(0xFF343434), + disabledControl: Color(0xFF202020), + ); + final colors = _buildBusyMaxTheme( + brightness: Brightness.dark, + gtkThemeColors: gtkColors, + ).extension()!; + final fallback = busyMaxFallbackSurfaceColors(Brightness.dark); + + expect(colors.control, fallback.control); + expect(colors.controlHover, fallback.controlHover); + expect(colors.controlActive, fallback.controlActive); + expect(colors.activeToggle, fallback.activeToggle); + expect(colors.disabledControl, fallback.disabledControl); + }); + + test('BusyMax theme avoids a recessed fallback for a flat custom role', () { const gtkColors = GtkThemeColors( brightness: Brightness.dark, window: Color(0xFF3E3E3E), @@ -739,7 +891,7 @@ void main() { expect(theme.scaffoldBackgroundColor, gtkColors.window); expect(theme.colorScheme.surface, gtkColors.view); - expect(theme.extension()?.sidebar, gtkColors.sidebar); + expect(theme.extension()?.sidebar, gtkColors.window); }); test('BusyMax theme keeps GTK semantic roles independent', () { @@ -757,11 +909,14 @@ void main() { expect(theme.colorScheme.surface, gtkColors.view); final colors = theme.extension()!; - expect(colors.sidebar, gtkColors.sidebar); + expect( + colors.sidebar, + busyMaxFallbackSurfaceColors(Brightness.dark).sidebar, + ); expect(colors.headerbar, gtkColors.headerbar); }); - test('BusyMax theme preserves black GTK surface samples', () { + test('BusyMax theme rejects a recessed black sidebar sample', () { const gtkColors = GtkThemeColors( brightness: Brightness.dark, window: Color(0xFF000000), @@ -777,11 +932,17 @@ void main() { expect(theme.scaffoldBackgroundColor, gtkColors.window); expect(theme.colorScheme.surface, gtkColors.view); - expect(colors.sidebar, gtkColors.sidebar); - expect(colors.headerbar, gtkColors.headerbar); + expect( + colors.sidebar, + busyMaxFallbackSurfaceColors(Brightness.dark).sidebar, + ); + expect( + colors.headerbar, + busyMaxFallbackSurfaceColors(Brightness.dark).headerbar, + ); }); - test('BusyMax theme preserves near-black GTK surface samples', () { + test('BusyMax theme rejects a flat near-black sidebar sample', () { const gtkColors = GtkThemeColors( brightness: Brightness.dark, window: Color(0xFF101010), @@ -797,8 +958,14 @@ void main() { expect(theme.scaffoldBackgroundColor, gtkColors.window); expect(theme.colorScheme.surface, gtkColors.view); - expect(colors.sidebar, gtkColors.sidebar); - expect(colors.headerbar, gtkColors.headerbar); + expect( + colors.sidebar, + busyMaxFallbackSurfaceColors(Brightness.dark).sidebar, + ); + expect( + colors.headerbar, + busyMaxFallbackSurfaceColors(Brightness.dark).headerbar, + ); }); test('BusyMax theme composites translucent GTK surface layers', () { @@ -822,8 +989,14 @@ void main() { expect(theme.scaffoldBackgroundColor, window); expect(theme.colorScheme.surface, view); - expect(colors.sidebar, Color.alphaBlend(gtkColors.sidebar!, window)); - expect(colors.headerbar, Color.alphaBlend(gtkColors.headerbar!, window)); + expect( + colors.sidebar, + busyMaxFallbackSurfaceColors(Brightness.dark).sidebar, + ); + expect( + colors.headerbar, + busyMaxFallbackSurfaceColors(Brightness.dark).headerbar, + ); }); test('BusyMax theme rejects unreadable GTK foreground samples', () { @@ -1002,7 +1175,8 @@ void main() { ); expect(source, contains('await service.setTheme(')); expect(source, contains('windowBackgroundColor: colors.window')); - expect(source, contains('backgroundColor: colors.view')); + expect(source, contains('backgroundColor: colors.headerbar')); + expect(source, isNot(contains('backgroundColor: colors.view'))); expect(source, contains('sidebarBackgroundColor: colors.sidebar')); expect(source, contains('controlHoverColor: colors.controlHover')); expect(source, contains('accentColor: colorScheme.primary')); diff --git a/test/features/schedule/presentation/schedule_toolbar_test.dart b/test/features/schedule/presentation/schedule_toolbar_test.dart index 882d2c6..df3b377 100644 --- a/test/features/schedule/presentation/schedule_toolbar_test.dart +++ b/test/features/schedule/presentation/schedule_toolbar_test.dart @@ -172,8 +172,14 @@ void main() { eventButton.style?.backgroundColor?.resolve({}), taskButton.style?.backgroundColor?.resolve({}), ); - expect(eventButton.style?.backgroundColor?.resolve({}), Colors.transparent); - expect(taskButton.style?.backgroundColor?.resolve({}), Colors.transparent); + final inheritedBackground = Theme.of( + tester.element(find.text('Task')), + ).menuButtonTheme.style?.backgroundColor?.resolve({}); + expect( + eventButton.style?.backgroundColor?.resolve({}), + inheritedBackground, + ); + expect(taskButton.style?.backgroundColor?.resolve({}), inheritedBackground); await tester.tap(find.text('Task')); await tester.pumpAndSettle(); diff --git a/test/features/schedule/presentation/schedule_views_test.dart b/test/features/schedule/presentation/schedule_views_test.dart index e0502c6..fb002a0 100644 --- a/test/features/schedule/presentation/schedule_views_test.dart +++ b/test/features/schedule/presentation/schedule_views_test.dart @@ -1,6 +1,7 @@ import 'dart:io'; import 'package:busymax/src/app/busymax_design.dart'; +import 'package:busymax/src/app/busymax_surface_colors.dart'; import 'package:busymax/src/features/schedule/presentation/schedule_agenda_view.dart'; import 'package:busymax/src/features/schedule/presentation/schedule_anchored_popover.dart'; import 'package:busymax/src/features/schedule/presentation/schedule_day_week_view.dart'; @@ -562,6 +563,10 @@ void main() { Theme.of(popoverContext).colorScheme.shadow, ); expect(popoverSurface.shadowColor.a, 1); + expect( + popoverSurface.color, + BusyMaxSurfaceColors.of(popoverContext).popover, + ); final editCenter = tester.getCenter(find.byIcon(Icons.edit_outlined)); final deleteCenter = tester.getCenter(find.byIcon(Icons.delete_outline)); @@ -2068,9 +2073,20 @@ void main() { ); expect( headerBar, - contains('create_header_popover_action_item(self, "createEvent"'), + contains( + 'g_menu_append(menu, self->header_create_event_label,\n' + ' "header.create-event")', + ), + ); + expect( + headerBar, + contains( + 'g_menu_append(menu, self->header_create_task_label, ' + '"header.create-task")', + ), ); - expect(headerBar, contains('self, "createTask", "Task"')); + 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( diff --git a/test/features/settings/presentation/settings_screen_test.dart b/test/features/settings/presentation/settings_screen_test.dart index bd7f428..24ba873 100644 --- a/test/features/settings/presentation/settings_screen_test.dart +++ b/test/features/settings/presentation/settings_screen_test.dart @@ -169,7 +169,9 @@ void main() { expect(find.text('Add Google account'), findsNothing); }); - testWidgets('Settings content uses the native view surface', (tester) async { + testWidgets('Settings content uses the native window surface', ( + tester, + ) async { final container = _container( selectedAccountId: 'google:g', authRepository: _FakeAuthRepository(), @@ -202,8 +204,8 @@ void main() { await tester.pumpAndSettle(); final scaffold = tester.widget(find.byType(Scaffold)); - expect(scaffold.backgroundColor, gtkColors.view); - expect(scaffold.backgroundColor, isNot(gtkColors.window)); + expect(scaffold.backgroundColor, gtkColors.window); + expect(scaffold.backgroundColor, isNot(gtkColors.view)); }); testWidgets('Settings uses Yaru navigation with selected semantics', ( diff --git a/test/platform/linux_header_bar_service_test.dart b/test/platform/linux_header_bar_service_test.dart index d9ff67d..4d503e0 100644 --- a/test/platform/linux_header_bar_service_test.dart +++ b/test/platform/linux_header_bar_service_test.dart @@ -563,22 +563,55 @@ void main() { expect(calls.where((call) => call.method == 'focusSearch'), hasLength(1)); }); - test('native header controls keep visible keyboard focus indicators', () { + test('native search uses a responsive theme-owned GTK entry', () { final source = File('linux/runner/my_application.cc').readAsStringSync(); - expect(source, contains('button.busymax-header-view-mode-button:focus {"')); - expect(source, contains('button.busymax-header-popover-row:focus {"')); + expect(source, contains('gtk_search_entry_new()')); + expect( + source, + contains( + 'gtk_widget_set_halign(self->header_title_box, GTK_ALIGN_FILL);', + ), + ); expect( source, contains( - 'button.busymax-header-popover-row.busymax-keyboard-focus:focus {"', + '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, isNot(contains('busymax-search-entry'))); + }); + + test('native header menus delegate row focus modality to GTK', () { + final source = File('linux/runner/my_application.cc').readAsStringSync(); + + expect(source, contains('button.busymax-header-view-mode-button:focus {"')); expect(source, contains('"box-shadow: inset 0 0 0 2px %s;"')); - expect(source, contains('gtk_window_get_focus_visible')); - expect(source, contains('configure_header_popover_row(self, item)')); - expect(source, contains('header_popover_row_key_press_cb')); - expect(source, contains('header_popover_row_button_press_cb')); + 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, 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)'))); }); From afed1142527fc42ff5af358fd18279cb92a2e650 Mon Sep 17 00:00:00 2001 From: albert Date: Wed, 22 Jul 2026 23:17:15 -0700 Subject: [PATCH 06/73] Update header bar and settings background colors --- lib/src/app/busymax_app.dart | 4 +++- .../features/settings/presentation/settings_screen.dart | 2 +- linux/runner/my_application.cc | 2 +- test/app/native_ui_audit_test.dart | 2 +- test/app/theme_localization_test.dart | 4 ++-- .../settings/presentation/settings_screen_test.dart | 8 +++----- 6 files changed, 11 insertions(+), 11 deletions(-) diff --git a/lib/src/app/busymax_app.dart b/lib/src/app/busymax_app.dart index 308756d..06e9360 100644 --- a/lib/src/app/busymax_app.dart +++ b/lib/src/app/busymax_app.dart @@ -227,7 +227,9 @@ class _BusyMaxAppState extends ConsumerState { BusyMaxHeaderBarTheme( preferDark: preferDark, windowBackgroundColor: colors.window, - backgroundColor: colors.headerbar, + // This header is deliberately borderless and visually continuous + // with the main pane, so it uses the flat header role. + backgroundColor: colors.headerbarFlat, sidebarBackgroundColor: colors.sidebar, foregroundColor: colors.foreground, mutedForegroundColor: colors.mutedForeground, diff --git a/lib/src/features/settings/presentation/settings_screen.dart b/lib/src/features/settings/presentation/settings_screen.dart index 97f6f5d..e23b55a 100644 --- a/lib/src/features/settings/presentation/settings_screen.dart +++ b/lib/src/features/settings/presentation/settings_screen.dart @@ -256,7 +256,7 @@ class _SettingsScreenState extends ConsumerState { }; return Scaffold( - backgroundColor: BusyMaxSurfaceColors.of(context).window, + backgroundColor: BusyMaxSurfaceColors.of(context).view, body: LayoutBuilder( builder: (context, constraints) { final showSidebar = BusyMaxLayoutRules.showSettingsSidebar( diff --git a/linux/runner/my_application.cc b/linux/runner/my_application.cc index e2bd1dc..6e1c481 100644 --- a/linux/runner/my_application.cc +++ b/linux/runner/my_application.cc @@ -59,7 +59,7 @@ constexpr gint kCompactAgendaWindowMaxWidth = constexpr gint kCompactAgendaWindowMaxHeight = 840 + kCompactAgendaWindowShadowMargin * 2; constexpr char kDefaultWindowBackgroundColor[] = "#2C2C2C"; -constexpr char kDefaultHeaderBarBackgroundColor[] = "#393939"; +constexpr char kDefaultHeaderBarBackgroundColor[] = "#1D1D20"; constexpr char kDefaultHeaderBarSidebarBackgroundColor[] = "#393939"; struct _MyApplication { diff --git a/test/app/native_ui_audit_test.dart b/test/app/native_ui_audit_test.dart index 8e70bcc..b4ad977 100644 --- a/test/app/native_ui_audit_test.dart +++ b/test/app/native_ui_audit_test.dart @@ -905,7 +905,7 @@ void main() { expect(source, contains('kDefaultWindowBackgroundColor[] = "#2C2C2C"')); expect( source, - contains('kDefaultHeaderBarBackgroundColor[] = "#393939"'), + contains('kDefaultHeaderBarBackgroundColor[] = "#1D1D20"'), ); expect( source, diff --git a/test/app/theme_localization_test.dart b/test/app/theme_localization_test.dart index 4c15550..32b05ca 100644 --- a/test/app/theme_localization_test.dart +++ b/test/app/theme_localization_test.dart @@ -1175,8 +1175,8 @@ void main() { ); expect(source, contains('await service.setTheme(')); expect(source, contains('windowBackgroundColor: colors.window')); - expect(source, contains('backgroundColor: colors.headerbar')); - expect(source, isNot(contains('backgroundColor: colors.view'))); + expect(source, contains('backgroundColor: colors.headerbarFlat')); + expect(source, isNot(contains('backgroundColor: colors.headerbar,'))); expect(source, contains('sidebarBackgroundColor: colors.sidebar')); expect(source, contains('controlHoverColor: colors.controlHover')); expect(source, contains('accentColor: colorScheme.primary')); diff --git a/test/features/settings/presentation/settings_screen_test.dart b/test/features/settings/presentation/settings_screen_test.dart index 24ba873..bd7f428 100644 --- a/test/features/settings/presentation/settings_screen_test.dart +++ b/test/features/settings/presentation/settings_screen_test.dart @@ -169,9 +169,7 @@ void main() { expect(find.text('Add Google account'), findsNothing); }); - testWidgets('Settings content uses the native window surface', ( - tester, - ) async { + testWidgets('Settings content uses the native view surface', (tester) async { final container = _container( selectedAccountId: 'google:g', authRepository: _FakeAuthRepository(), @@ -204,8 +202,8 @@ void main() { await tester.pumpAndSettle(); final scaffold = tester.widget(find.byType(Scaffold)); - expect(scaffold.backgroundColor, gtkColors.window); - expect(scaffold.backgroundColor, isNot(gtkColors.view)); + expect(scaffold.backgroundColor, gtkColors.view); + expect(scaffold.backgroundColor, isNot(gtkColors.window)); }); testWidgets('Settings uses Yaru navigation with selected semantics', ( From 838d7c5d12ca4f8cdaedd3fac2de3a049da37bc6 Mon Sep 17 00:00:00 2001 From: albert Date: Wed, 22 Jul 2026 23:42:09 -0700 Subject: [PATCH 07/73] Add GTK-style sidebar navigation and selectable tiles for settings. Update yaru dependency to version 10.2.0 --- lib/src/app/busymax_design.dart | 79 +++++++++++++++++++ .../presentation/settings_screen.dart | 39 +++------ pubspec.lock | 4 +- pubspec.yaml | 2 +- test/app/busymax_grouped_surface_test.dart | 65 +++++++++++++++ .../presentation/settings_screen_test.dart | 50 ++++++++++-- 6 files changed, 199 insertions(+), 40 deletions(-) diff --git a/lib/src/app/busymax_design.dart b/lib/src/app/busymax_design.dart index b82e034..d4fe0a0 100644 --- a/lib/src/app/busymax_design.dart +++ b/lib/src/app/busymax_design.dart @@ -951,6 +951,85 @@ class BusyMaxSidebarSurface extends StatelessWidget { } } +/// A GTK-style navigation list for a persistent desktop sidebar. +/// +/// The list delegates row interaction, focus handling, and selection geometry +/// to Yaru's master-detail controls while mapping their visual states to +/// BusyMax's semantic surface roles. +class BusyMaxSidebarNavigation extends StatelessWidget { + const BusyMaxSidebarNavigation({super.key, required this.children}); + + final List children; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colors = BusyMaxSurfaceColors.of(context); + final masterDetailTheme = YaruMasterDetailTheme.of(context); + + return Theme( + data: theme.copyWith( + listTileTheme: theme.listTileTheme.copyWith( + selectedColor: colors.foreground, + selectedTileColor: Color.alphaBlend(colors.control, colors.sidebar), + tileColor: Colors.transparent, + iconColor: colors.mutedForeground, + textColor: colors.foreground, + titleTextStyle: theme.textTheme.bodyMedium, + contentPadding: const EdgeInsets.symmetric( + horizontal: BusyMaxSpacing.sm, + ), + horizontalTitleGap: BusyMaxSpacing.sm, + minVerticalPadding: 0, + minLeadingWidth: BusyMaxSizes.iconSm, + minTileHeight: BusyMaxSizes.sidebarRowHeight, + visualDensity: VisualDensity.standard, + titleAlignment: ListTileTitleAlignment.center, + ), + ), + child: ListView.separated( + padding: + masterDetailTheme.listPadding ?? + const EdgeInsets.symmetric(vertical: BusyMaxSpacing.sm), + itemCount: children.length, + itemBuilder: (context, index) => children[index], + separatorBuilder: (context, index) => SizedBox( + height: masterDetailTheme.tileSpacing ?? BusyMaxSpacing.xxs, + ), + ), + ); + } +} + +/// A selectable row for [BusyMaxSidebarNavigation]. +class BusyMaxSidebarNavigationTile extends StatelessWidget { + const BusyMaxSidebarNavigationTile({ + super.key, + required this.selected, + required this.leading, + required this.title, + required this.onTap, + }); + + final bool selected; + final Widget leading; + final Widget title; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return YaruMasterTile( + selected: selected, + leading: IconTheme.merge( + data: const IconThemeData(size: BusyMaxSizes.iconSm), + child: leading, + ), + title: title, + onTap: onTap, + ); + } +} + class _BusyMaxGroupedListSurface extends StatelessWidget { const _BusyMaxGroupedListSurface({ required this.filled, diff --git a/lib/src/features/settings/presentation/settings_screen.dart b/lib/src/features/settings/presentation/settings_screen.dart index e23b55a..08c1c02 100644 --- a/lib/src/features/settings/presentation/settings_screen.dart +++ b/lib/src/features/settings/presentation/settings_screen.dart @@ -606,37 +606,18 @@ class _SettingsSidebar extends StatelessWidget { @override Widget build(BuildContext context) { - final sidebarColor = BusyMaxSurfaceColors.of(context).sidebar; return BusyMaxSidebarSurface( - child: YaruNavigationPageTheme( - data: YaruNavigationPageThemeData( - sideBarColor: sidebarColor, - railPadding: const EdgeInsets.symmetric( - horizontal: BusyMaxSpacing.xs, - vertical: BusyMaxSpacing.md, - ), - ), - child: YaruNavigationRail( - length: SettingsPage.values.length, - selectedIndex: SettingsPage.values.indexOf(selected), - onDestinationSelected: (index) => - onSelected(SettingsPage.values[index]), - itemBuilder: (context, index, isSelected) { - final page = SettingsPage.values[index]; - return Semantics( + child: BusyMaxSidebarNavigation( + children: [ + for (final page in SettingsPage.values) + BusyMaxSidebarNavigationTile( key: ValueKey('settings-navigation-${page.name}'), - container: true, - selected: isSelected, - child: YaruNavigationRailItem( - style: YaruNavigationRailStyle.labelledExtended, - width: BusyMaxSizes.sidebarWidth - 2 * BusyMaxSpacing.xs, - extendedSelectedIndicator: true, - icon: Icon(_settingsPageIcon(page)), - label: Text(_settingsPageLabel(context, page)), - ), - ); - }, - ), + selected: page == selected, + leading: Icon(_settingsPageIcon(page)), + title: Text(_settingsPageLabel(context, page)), + onTap: () => onSelected(page), + ), + ], ), ); } diff --git a/pubspec.lock b/pubspec.lock index ad7d353..2d67497 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1316,10 +1316,10 @@ packages: dependency: "direct main" description: name: yaru - sha256: "02475cdb668b96ea6bc11a483cafe3ea80c1aee5c3f2d856361c6ea1352e5849" + sha256: "95e801c52dfda458bcb772baee6b2575c711fd951616a24910d6d30e70f5fb0d" url: "https://pub.dev" source: hosted - version: "10.1.0" + version: "10.2.0" yaru_window: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index bd2d7f2..24725f4 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -40,7 +40,7 @@ dependencies: uuid: ^4.5.0 window_manager: ^0.5.1 xdg_status_notifier_item: ^0.0.1 - yaru: ^10.1.0 + yaru: ^10.2.0 dev_dependencies: flutter_test: diff --git a/test/app/busymax_grouped_surface_test.dart b/test/app/busymax_grouped_surface_test.dart index 3c4dfec..95272d3 100644 --- a/test/app/busymax_grouped_surface_test.dart +++ b/test/app/busymax_grouped_surface_test.dart @@ -173,6 +173,71 @@ void main() { expect(border.end.width, BusyMaxStroke.outline); }); + testWidgets( + 'sidebar navigation delegates native geometry and states to Yaru', + (tester) async { + var selectedSchedule = false; + await tester.pumpWidget( + _testApp( + SizedBox( + width: BusyMaxSizes.sidebarWidth, + height: 200, + child: BusyMaxSidebarSurface( + child: BusyMaxSidebarNavigation( + children: [ + BusyMaxSidebarNavigationTile( + selected: true, + leading: const Icon(YaruIcons.user), + title: const Text('Accounts'), + onTap: () {}, + ), + BusyMaxSidebarNavigationTile( + selected: false, + leading: const Icon(YaruIcons.calendar_day), + title: const Text('Schedule'), + onTap: () => selectedSchedule = true, + ), + ], + ), + ), + ), + ), + ); + + expect(find.byType(YaruMasterTile), findsNWidgets(2)); + expect(find.byType(YaruNavigationRailItem), findsNothing); + + final firstTile = find.byType(YaruMasterTile).first; + final localContext = tester.element(firstTile); + final localTheme = Theme.of(localContext); + final colors = BusyMaxSurfaceColors.of(localContext); + final listTileTheme = localTheme.listTileTheme; + final parentTheme = Theme.of( + tester.element(find.byType(BusyMaxSidebarNavigation)), + ); + + expect(tester.widget(firstTile).selected, isTrue); + expect(tester.getSize(firstTile).height, BusyMaxSizes.sidebarRowHeight); + expect( + listTileTheme.selectedTileColor, + Color.alphaBlend(colors.control, colors.sidebar), + ); + expect(listTileTheme.selectedColor, colors.foreground); + expect(listTileTheme.iconColor, colors.mutedForeground); + expect(listTileTheme.titleTextStyle, localTheme.textTheme.bodyMedium); + expect(listTileTheme.minTileHeight, BusyMaxSizes.sidebarRowHeight); + expect(listTileTheme.horizontalTitleGap, BusyMaxSpacing.sm); + expect(listTileTheme.minLeadingWidth, BusyMaxSizes.iconSm); + expect(localTheme.hoverColor, parentTheme.hoverColor); + expect(localTheme.focusColor, parentTheme.focusColor); + expect(localTheme.highlightColor, parentTheme.highlightColor); + + await tester.tap(find.text('Schedule')); + await tester.pump(); + expect(selectedSchedule, isTrue); + }, + ); + test('all primary sidebars reuse the shared boundary surface', () { for (final path in [ 'lib/src/features/schedule/presentation/schedule_sidebar.dart', diff --git a/test/features/settings/presentation/settings_screen_test.dart b/test/features/settings/presentation/settings_screen_test.dart index bd7f428..7b46b35 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:ui' as ui; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; @@ -206,9 +207,10 @@ void main() { expect(scaffold.backgroundColor, isNot(gtkColors.window)); }); - testWidgets('Settings uses Yaru navigation with selected semantics', ( + testWidgets('Settings uses Yaru master-detail rows with selected semantics', ( tester, ) async { + final semantics = tester.ensureSemantics(); final container = _container( selectedAccountId: 'google:g', authRepository: _FakeAuthRepository(), @@ -218,29 +220,61 @@ void main() { await _pumpSettings(tester, container, logicalSize: const Size(1000, 700)); - expect(find.byType(YaruNavigationRail), findsOneWidget); + expect(find.byType(BusyMaxSidebarNavigation), findsOneWidget); + expect( + find.byType(YaruMasterTile), + findsNWidgets(SettingsPage.values.length), + ); + expect(find.byType(YaruNavigationRail), findsNothing); expect(find.byType(BusyMaxSidebarSurface), findsOneWidget); - final accountsSemantics = tester.widget( + final accountsTile = tester.widget( find.byKey(const ValueKey('settings-navigation-accounts')), ); - final scheduleSemantics = tester.widget( + final scheduleTile = tester.widget( find.byKey(const ValueKey('settings-navigation-schedule')), ); - expect(accountsSemantics.properties.selected, isTrue); - expect(scheduleSemantics.properties.selected, isFalse); + expect(accountsTile.selected, isTrue); + expect(scheduleTile.selected, isFalse); + expect( + tester + .getSemantics( + find.byKey(const ValueKey('settings-navigation-accounts')), + ) + .flagsCollection + .isSelected, + ui.Tristate.isTrue, + ); + expect( + tester + .getSemantics( + find.byKey(const ValueKey('settings-navigation-schedule')), + ) + .flagsCollection + .isSelected, + ui.Tristate.isFalse, + ); await tester.tap(find.text('Schedule')); await tester.pumpAndSettle(); expect( tester - .widget( + .widget( find.byKey(const ValueKey('settings-navigation-schedule')), ) - .properties .selected, isTrue, ); + expect( + tester + .getSemantics( + find.byKey(const ValueKey('settings-navigation-schedule')), + ) + .flagsCollection + .isSelected, + ui.Tristate.isTrue, + ); + semantics.dispose(); }); testWidgets('Diagnostics stays inside Settings shell', (tester) async { From 625d7dcb43892f7acb3404d6dfff8821ccc787b3 Mon Sep 17 00:00:00 2001 From: albert Date: Thu, 23 Jul 2026 14:17:20 -0700 Subject: [PATCH 08/73] Refactor account synchronization and UI components for improved functionality and accessibility. Refactor header bar by removing unused color variables and simplifying background handling --- lib/l10n/app_de.arb | 24 +- lib/l10n/app_en.arb | 24 +- lib/l10n/app_es.arb | 24 +- lib/l10n/app_fr.arb | 24 +- lib/l10n/generated/app_localizations.dart | 114 +- lib/l10n/generated/app_localizations_de.dart | 63 +- lib/l10n/generated/app_localizations_en.dart | 63 +- lib/l10n/generated/app_localizations_es.dart | 63 +- lib/l10n/generated/app_localizations_fr.dart | 63 +- lib/main.dart | 51 +- lib/src/app/app_bootstrap.dart | 185 +- lib/src/app/busymax_about_dialog.dart | 3 +- lib/src/app/busymax_app.dart | 93 +- lib/src/app/busymax_design.dart | 934 ++++----- .../busymax_keyboard_shortcuts_dialog.dart | 4 +- lib/src/app/busymax_yaru_theme.dart | 175 +- lib/src/config/build_config.dart | 117 +- lib/src/core/logging/redacting_logger.dart | 3 + lib/src/demo/demo_profile.dart | 225 +++ lib/src/demo/demo_seed.dart | 395 ++++ .../accounts/data/accounts_repository.dart | 12 - .../features/auth/data/auth_repository.dart | 149 +- .../calendar/presentation/event_editor.dart | 11 +- .../presentation/calendar_day_semantics.dart | 34 + .../schedule/presentation/mini_calendar.dart | 57 +- .../presentation/schedule_create_menu.dart | 138 +- .../schedule_item_details_popover.dart | 21 +- .../presentation/schedule_month_view.dart | 261 +-- .../presentation/schedule_sidebar.dart | 8 +- .../presentation/schedule_toolbar.dart | 2 +- .../schedule_view_controller.dart | 26 - .../presentation/schedule_workspace.dart | 252 ++- .../presentation/schedule_year_view.dart | 22 +- .../schedule/presentation/source_picker.dart | 68 - .../presentation/account_removal_dialog.dart | 90 + .../presentation/settings_screen.dart | 127 +- .../sync/account_sync_operations.dart | 50 + .../presentation/task_lists_sidebar.dart | 682 ------- .../desktop_date_time_fields.dart | 241 +-- .../presentation/task_details_editor.dart | 10 +- .../tasks/presentation/task_details_pane.dart | 54 +- .../tasks/presentation/task_filters.dart | 179 -- .../features/tasks/presentation/task_row.dart | 77 - .../tasks/presentation/task_tree_view.dart | 463 ----- .../presentation/tasks_selection_state.dart | 5 - .../tasks/presentation/tasks_workspace.dart | 506 ----- lib/src/google_tasks/oauth/oauth_service.dart | 81 +- ...header_bar_configuration_synchronizer.dart | 154 ++ .../platform/linux_header_bar_service.dart | 50 - .../platform/main_window_command_bridge.dart | 4 +- linux/runner/my_application.cc | 473 +---- test/app/about_dialog_test.dart | 39 +- test/app/app_bootstrap_provider_test.dart | 16 +- test/app/busymax_grouped_surface_test.dart | 115 +- test/app/busymax_search_field_test.dart | 5 - test/app/high_contrast_theme_test.dart | 4 +- test/app/native_ui_audit_test.dart | 299 +-- test/app/theme_localization_test.dart | 107 +- test/config/build_config_test.dart | 36 + test/core/logging/redacting_logger_test.dart | 27 +- test/demo/demo_profile_test.dart | 158 ++ test/demo/demo_seed_test.dart | 53 + .../auth/data/auth_repository_test.dart | 332 ++-- .../auth/presentation/auth_routing_test.dart | 19 +- .../presentation/event_editor_test.dart | 167 +- .../schedule_create_menu_test.dart | 224 ++- .../presentation/schedule_toolbar_test.dart | 144 +- .../presentation/schedule_views_test.dart | 151 +- .../schedule_workspace_states_test.dart | 6 + .../presentation/settings_screen_test.dart | 211 +- .../desktop_date_time_fields_test.dart | 53 + .../presentation/task_details_pane_test.dart | 188 +- .../tasks_selection_state_test.dart | 595 ------ .../presentation/tasks_workspace_test.dart | 1731 ----------------- .../oauth/token_exchange_test.dart | 35 +- ...r_bar_configuration_synchronizer_test.dart | 135 ++ .../linux_header_bar_service_test.dart | 57 +- 77 files changed, 4646 insertions(+), 7215 deletions(-) create mode 100644 lib/src/demo/demo_profile.dart create mode 100644 lib/src/demo/demo_seed.dart create mode 100644 lib/src/features/schedule/presentation/calendar_day_semantics.dart delete mode 100644 lib/src/features/schedule/presentation/schedule_view_controller.dart delete mode 100644 lib/src/features/schedule/presentation/source_picker.dart create mode 100644 lib/src/features/settings/presentation/account_removal_dialog.dart create mode 100644 lib/src/features/sync/account_sync_operations.dart delete mode 100644 lib/src/features/task_lists/presentation/task_lists_sidebar.dart delete mode 100644 lib/src/features/tasks/presentation/task_filters.dart delete mode 100644 lib/src/features/tasks/presentation/task_row.dart delete mode 100644 lib/src/features/tasks/presentation/task_tree_view.dart delete mode 100644 lib/src/features/tasks/presentation/tasks_selection_state.dart delete mode 100644 lib/src/features/tasks/presentation/tasks_workspace.dart create mode 100644 lib/src/platform/linux_header_bar_configuration_synchronizer.dart create mode 100644 test/demo/demo_profile_test.dart create mode 100644 test/demo/demo_seed_test.dart delete mode 100644 test/features/tasks/presentation/tasks_selection_state_test.dart delete mode 100644 test/features/tasks/presentation/tasks_workspace_test.dart create mode 100644 test/platform/linux_header_bar_configuration_synchronizer_test.dart diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index 4a34978..ad78a50 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -197,10 +197,17 @@ "googleProvider": "Google", "microsoftProvider": "Microsoft", "signedInAccount": "Angemeldet", - "signOutThisAccount": "Dieses Konto abmelden", - "revokeThisAccount": "Dieses Konto widerrufen", - "disconnectThisAccount": "Dieses Konto trennen", - "deleteLocalDataForThisAccount": "Lokale Daten für dieses Konto löschen", + "removeAccount": "Konto entfernen…", + "removingAccount": "Konto wird entfernt…", + "removeAccountDescription": "Synchronisierung beenden und die Daten dieses Kontos von diesem Gerät entfernen.", + "removeAccountTitle": "{account} aus BusyMax entfernen?", + "@removeAccountTitle": {"placeholders": {"account": {"type": "String"}}}, + "removeAccountConfirmation": "Dadurch werden zwischengespeicherte Aufgaben, Kalender, Termine, Erinnerungen und ausstehende Offline-Änderungen von diesem Gerät gelöscht. Nicht synchronisierte Änderungen gehen verloren. Bei Google oder Microsoft wird nichts gelöscht.", + "revokeGoogleAccess": "BusyMax-Zugriff auf dieses Google-Konto ebenfalls widerrufen", + "revokeGoogleAccessDescription": "Vor einer erneuten Verbindung müssen Sie den Zugriff wieder gewähren.", + "removeAccountAction": "Konto entfernen", + "removeAccountFailed": "Das Konto konnte nicht vollständig entfernt werden. Versuchen Sie es erneut.", + "accountRemovedGoogleRevokeFailed": "Das Konto wurde von diesem Gerät entfernt, aber BusyMax konnte den Google-Zugriff nicht widerrufen. Sie können ihn in Ihrem Google-Konto widerrufen.", "newList": "Neue Liste", "signInToViewTaskLists": "Melden Sie sich an, um Aufgabenlisten zu sehen.", "noTaskListsSynced": "Noch keine Aufgabenlisten synchronisiert.", @@ -240,11 +247,6 @@ "completed": "Erledigt", "duePrefix": "Fällig {date}", "dateTimeDisplay": "{date}, {time}", - "searchTasks": "Aufgaben suchen", - "advancedFilters": "Erweiterte Filter", - "showCompleted": "Erledigte anzeigen", - "showHidden": "Ausgeblendete anzeigen", - "showAssigned": "Zugewiesene anzeigen", "taskDetails": "Aufgabendetails", "editTask": "Aufgabe bearbeiten", "noTaskSelected": "Keine Aufgabe ausgewählt.", @@ -311,10 +313,6 @@ "pendingSync": "Synchronisierung ausstehend", "synced": "Synchronisiert", "account": "Konto", - "signOut": "Abmelden", - "revokeGoogleAuthorization": "Google-Autorisierung widerrufen", - "deleteLocalData": "Lokale Daten löschen", - "deleteLocalDataConfirmation": "Dies entfernt das lokale Konto, synchronisierte Aufgaben und ausstehende Offline-Änderungen von diesem Gerät.", "sync": "Synchronisierung", "manualFullSync": "Manuelle vollständige Synchronisierung", "runInBackgroundWhenClosed": "Nach dem Schließen des Fensters weiter ausführen", diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 97112ff..2b73f86 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -201,10 +201,17 @@ "googleProvider": "Google", "microsoftProvider": "Microsoft", "signedInAccount": "Signed in", - "signOutThisAccount": "Sign out this account", - "revokeThisAccount": "Revoke this account", - "disconnectThisAccount": "Disconnect this account", - "deleteLocalDataForThisAccount": "Delete local data for this account", + "removeAccount": "Remove account…", + "removingAccount": "Removing account…", + "removeAccountDescription": "Stop syncing and remove this account’s data from this device.", + "removeAccountTitle": "Remove {account} from BusyMax?", + "@removeAccountTitle": {"placeholders": {"account": {"type": "String"}}}, + "removeAccountConfirmation": "This deletes cached tasks, calendars, events, reminders, and pending offline changes from this device. Unsynced changes will be lost. Nothing will be deleted from Google or Microsoft.", + "revokeGoogleAccess": "Also revoke BusyMax’s access to this Google Account", + "revokeGoogleAccessDescription": "You will need to grant access again before reconnecting.", + "removeAccountAction": "Remove account", + "removeAccountFailed": "Could not finish removing the account. Try again.", + "accountRemovedGoogleRevokeFailed": "The account was removed from this device, but BusyMax could not revoke Google access. You can revoke it from your Google Account.", "newList": "New list", "signInToViewTaskLists": "Sign in to view task lists.", "noTaskListsSynced": "No task lists synced yet.", @@ -253,11 +260,6 @@ "time": {"type": "String"} } }, - "searchTasks": "Search tasks", - "advancedFilters": "Advanced filters", - "showCompleted": "Show completed", - "showHidden": "Show hidden", - "showAssigned": "Show assigned", "taskDetails": "Task details", "editTask": "Edit Task", "noTaskSelected": "No task selected.", @@ -325,10 +327,6 @@ "pendingSync": "Pending sync", "synced": "Synced", "account": "Account", - "signOut": "Sign out", - "revokeGoogleAuthorization": "Revoke Google authorization", - "deleteLocalData": "Delete local data", - "deleteLocalDataConfirmation": "This removes the local account, synced tasks, and pending offline changes from this device.", "sync": "Sync", "manualFullSync": "Manual full sync", "runInBackgroundWhenClosed": "Continue running when the window is closed", diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index 849dbbc..7dcf019 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -197,10 +197,17 @@ "googleProvider": "Google", "microsoftProvider": "Microsoft", "signedInAccount": "Sesión iniciada", - "signOutThisAccount": "Cerrar sesión de esta cuenta", - "revokeThisAccount": "Revocar esta cuenta", - "disconnectThisAccount": "Desconectar esta cuenta", - "deleteLocalDataForThisAccount": "Eliminar datos locales de esta cuenta", + "removeAccount": "Eliminar cuenta…", + "removingAccount": "Eliminando cuenta…", + "removeAccountDescription": "Detener la sincronización y eliminar de este dispositivo los datos de esta cuenta.", + "removeAccountTitle": "¿Eliminar {account} de BusyMax?", + "@removeAccountTitle": {"placeholders": {"account": {"type": "String"}}}, + "removeAccountConfirmation": "Esto elimina de este dispositivo las tareas, los calendarios, los eventos, los recordatorios y los cambios sin conexión pendientes almacenados en caché. Los cambios no sincronizados se perderán. No se eliminará nada de Google ni Microsoft.", + "revokeGoogleAccess": "Revocar también el acceso de BusyMax a esta cuenta de Google", + "revokeGoogleAccessDescription": "Tendrás que volver a conceder acceso antes de reconectar la cuenta.", + "removeAccountAction": "Eliminar cuenta", + "removeAccountFailed": "No se pudo terminar de eliminar la cuenta. Inténtalo de nuevo.", + "accountRemovedGoogleRevokeFailed": "La cuenta se eliminó de este dispositivo, pero BusyMax no pudo revocar el acceso de Google. Puedes revocarlo desde tu cuenta de Google.", "newList": "Nueva lista", "signInToViewTaskLists": "Inicia sesión para ver las listas de tareas.", "noTaskListsSynced": "Aún no hay listas de tareas sincronizadas.", @@ -240,11 +247,6 @@ "completed": "Completadas", "duePrefix": "Vence {date}", "dateTimeDisplay": "{date}, {time}", - "searchTasks": "Buscar tareas", - "advancedFilters": "Filtros avanzados", - "showCompleted": "Mostrar completadas", - "showHidden": "Mostrar ocultas", - "showAssigned": "Mostrar asignadas", "taskDetails": "Detalles de la tarea", "editTask": "Editar tarea", "noTaskSelected": "No hay tarea seleccionada.", @@ -311,10 +313,6 @@ "pendingSync": "Sincronización pendiente", "synced": "Sincronizada", "account": "Cuenta", - "signOut": "Cerrar sesión", - "revokeGoogleAuthorization": "Revocar autorización de Google", - "deleteLocalData": "Eliminar datos locales", - "deleteLocalDataConfirmation": "Esto elimina de este dispositivo la cuenta local, las tareas sincronizadas y los cambios sin conexión pendientes.", "sync": "Sincronización", "manualFullSync": "Sincronización completa manual", "runInBackgroundWhenClosed": "Seguir ejecutándose al cerrar la ventana", diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index 0d44534..4eaddeb 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -197,10 +197,17 @@ "googleProvider": "Google", "microsoftProvider": "Microsoft", "signedInAccount": "Connecté", - "signOutThisAccount": "Déconnecter ce compte", - "revokeThisAccount": "Révoquer ce compte", - "disconnectThisAccount": "Dissocier ce compte", - "deleteLocalDataForThisAccount": "Supprimer les données locales de ce compte", + "removeAccount": "Supprimer le compte…", + "removingAccount": "Suppression du compte…", + "removeAccountDescription": "Arrêter la synchronisation et supprimer les données de ce compte de cet appareil.", + "removeAccountTitle": "Supprimer {account} de BusyMax ?", + "@removeAccountTitle": {"placeholders": {"account": {"type": "String"}}}, + "removeAccountConfirmation": "Cette action supprime de cet appareil les tâches, calendriers, événements, rappels et modifications hors ligne en attente mis en cache. Les modifications non synchronisées seront perdues. Aucune donnée ne sera supprimée de Google ou Microsoft.", + "revokeGoogleAccess": "Révoquer également l’accès de BusyMax à ce compte Google", + "revokeGoogleAccessDescription": "Vous devrez accorder à nouveau l’accès avant de reconnecter le compte.", + "removeAccountAction": "Supprimer le compte", + "removeAccountFailed": "Impossible de terminer la suppression du compte. Réessayez.", + "accountRemovedGoogleRevokeFailed": "Le compte a été supprimé de cet appareil, mais BusyMax n’a pas pu révoquer l’accès Google. Vous pouvez le révoquer dans votre compte Google.", "newList": "Nouvelle liste", "signInToViewTaskLists": "Connectez-vous pour voir les listes de tâches.", "noTaskListsSynced": "Aucune liste de tâches synchronisée.", @@ -240,11 +247,6 @@ "completed": "Terminées", "duePrefix": "Échéance {date}", "dateTimeDisplay": "{date} à {time}", - "searchTasks": "Rechercher des tâches", - "advancedFilters": "Filtres avancés", - "showCompleted": "Afficher les terminées", - "showHidden": "Afficher les masquées", - "showAssigned": "Afficher les assignées", "taskDetails": "Détails de la tâche", "editTask": "Modifier la tâche", "noTaskSelected": "Aucune tâche sélectionnée.", @@ -311,10 +313,6 @@ "pendingSync": "Synchronisation en attente", "synced": "Synchronisé", "account": "Compte", - "signOut": "Se déconnecter", - "revokeGoogleAuthorization": "Révoquer l’autorisation Google", - "deleteLocalData": "Supprimer les données locales", - "deleteLocalDataConfirmation": "Cela supprime de cet appareil le compte local, les tâches synchronisées et les changements hors ligne en attente.", "sync": "Synchronisation", "manualFullSync": "Synchronisation complète manuelle", "runInBackgroundWhenClosed": "Continuer à s’exécuter après la fermeture de la fenêtre", diff --git a/lib/l10n/generated/app_localizations.dart b/lib/l10n/generated/app_localizations.dart index 565d797..bfc693b 100644 --- a/lib/l10n/generated/app_localizations.dart +++ b/lib/l10n/generated/app_localizations.dart @@ -1266,29 +1266,65 @@ abstract class AppLocalizations { /// **'Signed in'** String get signedInAccount; - /// No description provided for @signOutThisAccount. + /// No description provided for @removeAccount. /// /// In en, this message translates to: - /// **'Sign out this account'** - String get signOutThisAccount; + /// **'Remove account…'** + String get removeAccount; - /// No description provided for @revokeThisAccount. + /// No description provided for @removingAccount. /// /// In en, this message translates to: - /// **'Revoke this account'** - String get revokeThisAccount; + /// **'Removing account…'** + String get removingAccount; - /// No description provided for @disconnectThisAccount. + /// No description provided for @removeAccountDescription. /// /// In en, this message translates to: - /// **'Disconnect this account'** - String get disconnectThisAccount; + /// **'Stop syncing and remove this account’s data from this device.'** + String get removeAccountDescription; - /// No description provided for @deleteLocalDataForThisAccount. + /// No description provided for @removeAccountTitle. /// /// In en, this message translates to: - /// **'Delete local data for this account'** - String get deleteLocalDataForThisAccount; + /// **'Remove {account} from BusyMax?'** + String removeAccountTitle(String account); + + /// No description provided for @removeAccountConfirmation. + /// + /// In en, this message translates to: + /// **'This deletes cached tasks, calendars, events, reminders, and pending offline changes from this device. Unsynced changes will be lost. Nothing will be deleted from Google or Microsoft.'** + String get removeAccountConfirmation; + + /// No description provided for @revokeGoogleAccess. + /// + /// In en, this message translates to: + /// **'Also revoke BusyMax’s access to this Google Account'** + String get revokeGoogleAccess; + + /// No description provided for @revokeGoogleAccessDescription. + /// + /// In en, this message translates to: + /// **'You will need to grant access again before reconnecting.'** + String get revokeGoogleAccessDescription; + + /// No description provided for @removeAccountAction. + /// + /// In en, this message translates to: + /// **'Remove account'** + String get removeAccountAction; + + /// No description provided for @removeAccountFailed. + /// + /// In en, this message translates to: + /// **'Could not finish removing the account. Try again.'** + String get removeAccountFailed; + + /// No description provided for @accountRemovedGoogleRevokeFailed. + /// + /// In en, this message translates to: + /// **'The account was removed from this device, but BusyMax could not revoke Google access. You can revoke it from your Google Account.'** + String get accountRemovedGoogleRevokeFailed; /// No description provided for @newList. /// @@ -1512,36 +1548,6 @@ abstract class AppLocalizations { /// **'{date} · {time}'** String dateTimeDisplay(String date, String time); - /// No description provided for @searchTasks. - /// - /// In en, this message translates to: - /// **'Search tasks'** - String get searchTasks; - - /// No description provided for @advancedFilters. - /// - /// In en, this message translates to: - /// **'Advanced filters'** - String get advancedFilters; - - /// No description provided for @showCompleted. - /// - /// In en, this message translates to: - /// **'Show completed'** - String get showCompleted; - - /// No description provided for @showHidden. - /// - /// In en, this message translates to: - /// **'Show hidden'** - String get showHidden; - - /// No description provided for @showAssigned. - /// - /// In en, this message translates to: - /// **'Show assigned'** - String get showAssigned; - /// No description provided for @taskDetails. /// /// In en, this message translates to: @@ -1938,30 +1944,6 @@ abstract class AppLocalizations { /// **'Account'** String get account; - /// No description provided for @signOut. - /// - /// In en, this message translates to: - /// **'Sign out'** - String get signOut; - - /// No description provided for @revokeGoogleAuthorization. - /// - /// In en, this message translates to: - /// **'Revoke Google authorization'** - String get revokeGoogleAuthorization; - - /// No description provided for @deleteLocalData. - /// - /// In en, this message translates to: - /// **'Delete local data'** - String get deleteLocalData; - - /// No description provided for @deleteLocalDataConfirmation. - /// - /// In en, this message translates to: - /// **'This removes the local account, synced tasks, and pending offline changes from this device.'** - String get deleteLocalDataConfirmation; - /// No description provided for @sync. /// /// In en, this message translates to: diff --git a/lib/l10n/generated/app_localizations_de.dart b/lib/l10n/generated/app_localizations_de.dart index 62c2367..50cd8af 100644 --- a/lib/l10n/generated/app_localizations_de.dart +++ b/lib/l10n/generated/app_localizations_de.dart @@ -654,17 +654,42 @@ class AppLocalizationsDe extends AppLocalizations { String get signedInAccount => 'Angemeldet'; @override - String get signOutThisAccount => 'Dieses Konto abmelden'; + String get removeAccount => 'Konto entfernen…'; @override - String get revokeThisAccount => 'Dieses Konto widerrufen'; + String get removingAccount => 'Konto wird entfernt…'; @override - String get disconnectThisAccount => 'Dieses Konto trennen'; + String get removeAccountDescription => + 'Synchronisierung beenden und die Daten dieses Kontos von diesem Gerät entfernen.'; @override - String get deleteLocalDataForThisAccount => - 'Lokale Daten für dieses Konto löschen'; + String removeAccountTitle(String account) { + return '$account aus BusyMax entfernen?'; + } + + @override + String get removeAccountConfirmation => + 'Dadurch werden zwischengespeicherte Aufgaben, Kalender, Termine, Erinnerungen und ausstehende Offline-Änderungen von diesem Gerät gelöscht. Nicht synchronisierte Änderungen gehen verloren. Bei Google oder Microsoft wird nichts gelöscht.'; + + @override + String get revokeGoogleAccess => + 'BusyMax-Zugriff auf dieses Google-Konto ebenfalls widerrufen'; + + @override + String get revokeGoogleAccessDescription => + 'Vor einer erneuten Verbindung müssen Sie den Zugriff wieder gewähren.'; + + @override + String get removeAccountAction => 'Konto entfernen'; + + @override + String get removeAccountFailed => + 'Das Konto konnte nicht vollständig entfernt werden. Versuchen Sie es erneut.'; + + @override + String get accountRemovedGoogleRevokeFailed => + 'Das Konto wurde von diesem Gerät entfernt, aber BusyMax konnte den Google-Zugriff nicht widerrufen. Sie können ihn in Ihrem Google-Konto widerrufen.'; @override String get newList => 'Neue Liste'; @@ -793,21 +818,6 @@ class AppLocalizationsDe extends AppLocalizations { return '$date, $time'; } - @override - String get searchTasks => 'Aufgaben suchen'; - - @override - String get advancedFilters => 'Erweiterte Filter'; - - @override - String get showCompleted => 'Erledigte anzeigen'; - - @override - String get showHidden => 'Ausgeblendete anzeigen'; - - @override - String get showAssigned => 'Zugewiesene anzeigen'; - @override String get taskDetails => 'Aufgabendetails'; @@ -1011,19 +1021,6 @@ class AppLocalizationsDe extends AppLocalizations { @override String get account => 'Konto'; - @override - String get signOut => 'Abmelden'; - - @override - String get revokeGoogleAuthorization => 'Google-Autorisierung widerrufen'; - - @override - String get deleteLocalData => 'Lokale Daten löschen'; - - @override - String get deleteLocalDataConfirmation => - 'Dies entfernt das lokale Konto, synchronisierte Aufgaben und ausstehende Offline-Änderungen von diesem Gerät.'; - @override String get sync => 'Synchronisierung'; diff --git a/lib/l10n/generated/app_localizations_en.dart b/lib/l10n/generated/app_localizations_en.dart index 19e570f..5dd1f1e 100644 --- a/lib/l10n/generated/app_localizations_en.dart +++ b/lib/l10n/generated/app_localizations_en.dart @@ -647,17 +647,42 @@ class AppLocalizationsEn extends AppLocalizations { String get signedInAccount => 'Signed in'; @override - String get signOutThisAccount => 'Sign out this account'; + String get removeAccount => 'Remove account…'; @override - String get revokeThisAccount => 'Revoke this account'; + String get removingAccount => 'Removing account…'; @override - String get disconnectThisAccount => 'Disconnect this account'; + String get removeAccountDescription => + 'Stop syncing and remove this account’s data from this device.'; @override - String get deleteLocalDataForThisAccount => - 'Delete local data for this account'; + String removeAccountTitle(String account) { + return 'Remove $account from BusyMax?'; + } + + @override + String get removeAccountConfirmation => + 'This deletes cached tasks, calendars, events, reminders, and pending offline changes from this device. Unsynced changes will be lost. Nothing will be deleted from Google or Microsoft.'; + + @override + String get revokeGoogleAccess => + 'Also revoke BusyMax’s access to this Google Account'; + + @override + String get revokeGoogleAccessDescription => + 'You will need to grant access again before reconnecting.'; + + @override + String get removeAccountAction => 'Remove account'; + + @override + String get removeAccountFailed => + 'Could not finish removing the account. Try again.'; + + @override + String get accountRemovedGoogleRevokeFailed => + 'The account was removed from this device, but BusyMax could not revoke Google access. You can revoke it from your Google Account.'; @override String get newList => 'New list'; @@ -784,21 +809,6 @@ class AppLocalizationsEn extends AppLocalizations { return '$date · $time'; } - @override - String get searchTasks => 'Search tasks'; - - @override - String get advancedFilters => 'Advanced filters'; - - @override - String get showCompleted => 'Show completed'; - - @override - String get showHidden => 'Show hidden'; - - @override - String get showAssigned => 'Show assigned'; - @override String get taskDetails => 'Task details'; @@ -1000,19 +1010,6 @@ class AppLocalizationsEn extends AppLocalizations { @override String get account => 'Account'; - @override - String get signOut => 'Sign out'; - - @override - String get revokeGoogleAuthorization => 'Revoke Google authorization'; - - @override - String get deleteLocalData => 'Delete local data'; - - @override - String get deleteLocalDataConfirmation => - 'This removes the local account, synced tasks, and pending offline changes from this device.'; - @override String get sync => 'Sync'; diff --git a/lib/l10n/generated/app_localizations_es.dart b/lib/l10n/generated/app_localizations_es.dart index 7dfa09d..c689210 100644 --- a/lib/l10n/generated/app_localizations_es.dart +++ b/lib/l10n/generated/app_localizations_es.dart @@ -654,17 +654,42 @@ class AppLocalizationsEs extends AppLocalizations { String get signedInAccount => 'Sesión iniciada'; @override - String get signOutThisAccount => 'Cerrar sesión de esta cuenta'; + String get removeAccount => 'Eliminar cuenta…'; @override - String get revokeThisAccount => 'Revocar esta cuenta'; + String get removingAccount => 'Eliminando cuenta…'; @override - String get disconnectThisAccount => 'Desconectar esta cuenta'; + String get removeAccountDescription => + 'Detener la sincronización y eliminar de este dispositivo los datos de esta cuenta.'; @override - String get deleteLocalDataForThisAccount => - 'Eliminar datos locales de esta cuenta'; + String removeAccountTitle(String account) { + return '¿Eliminar $account de BusyMax?'; + } + + @override + String get removeAccountConfirmation => + 'Esto elimina de este dispositivo las tareas, los calendarios, los eventos, los recordatorios y los cambios sin conexión pendientes almacenados en caché. Los cambios no sincronizados se perderán. No se eliminará nada de Google ni Microsoft.'; + + @override + String get revokeGoogleAccess => + 'Revocar también el acceso de BusyMax a esta cuenta de Google'; + + @override + String get revokeGoogleAccessDescription => + 'Tendrás que volver a conceder acceso antes de reconectar la cuenta.'; + + @override + String get removeAccountAction => 'Eliminar cuenta'; + + @override + String get removeAccountFailed => + 'No se pudo terminar de eliminar la cuenta. Inténtalo de nuevo.'; + + @override + String get accountRemovedGoogleRevokeFailed => + 'La cuenta se eliminó de este dispositivo, pero BusyMax no pudo revocar el acceso de Google. Puedes revocarlo desde tu cuenta de Google.'; @override String get newList => 'Nueva lista'; @@ -792,21 +817,6 @@ class AppLocalizationsEs extends AppLocalizations { return '$date, $time'; } - @override - String get searchTasks => 'Buscar tareas'; - - @override - String get advancedFilters => 'Filtros avanzados'; - - @override - String get showCompleted => 'Mostrar completadas'; - - @override - String get showHidden => 'Mostrar ocultas'; - - @override - String get showAssigned => 'Mostrar asignadas'; - @override String get taskDetails => 'Detalles de la tarea'; @@ -1009,19 +1019,6 @@ class AppLocalizationsEs extends AppLocalizations { @override String get account => 'Cuenta'; - @override - String get signOut => 'Cerrar sesión'; - - @override - String get revokeGoogleAuthorization => 'Revocar autorización de Google'; - - @override - String get deleteLocalData => 'Eliminar datos locales'; - - @override - String get deleteLocalDataConfirmation => - 'Esto elimina de este dispositivo la cuenta local, las tareas sincronizadas y los cambios sin conexión pendientes.'; - @override String get sync => 'Sincronización'; diff --git a/lib/l10n/generated/app_localizations_fr.dart b/lib/l10n/generated/app_localizations_fr.dart index ce4b495..9d6f09f 100644 --- a/lib/l10n/generated/app_localizations_fr.dart +++ b/lib/l10n/generated/app_localizations_fr.dart @@ -654,17 +654,42 @@ class AppLocalizationsFr extends AppLocalizations { String get signedInAccount => 'Connecté'; @override - String get signOutThisAccount => 'Déconnecter ce compte'; + String get removeAccount => 'Supprimer le compte…'; @override - String get revokeThisAccount => 'Révoquer ce compte'; + String get removingAccount => 'Suppression du compte…'; @override - String get disconnectThisAccount => 'Dissocier ce compte'; + String get removeAccountDescription => + 'Arrêter la synchronisation et supprimer les données de ce compte de cet appareil.'; @override - String get deleteLocalDataForThisAccount => - 'Supprimer les données locales de ce compte'; + String removeAccountTitle(String account) { + return 'Supprimer $account de BusyMax ?'; + } + + @override + String get removeAccountConfirmation => + 'Cette action supprime de cet appareil les tâches, calendriers, événements, rappels et modifications hors ligne en attente mis en cache. Les modifications non synchronisées seront perdues. Aucune donnée ne sera supprimée de Google ou Microsoft.'; + + @override + String get revokeGoogleAccess => + 'Révoquer également l’accès de BusyMax à ce compte Google'; + + @override + String get revokeGoogleAccessDescription => + 'Vous devrez accorder à nouveau l’accès avant de reconnecter le compte.'; + + @override + String get removeAccountAction => 'Supprimer le compte'; + + @override + String get removeAccountFailed => + 'Impossible de terminer la suppression du compte. Réessayez.'; + + @override + String get accountRemovedGoogleRevokeFailed => + 'Le compte a été supprimé de cet appareil, mais BusyMax n’a pas pu révoquer l’accès Google. Vous pouvez le révoquer dans votre compte Google.'; @override String get newList => 'Nouvelle liste'; @@ -793,21 +818,6 @@ class AppLocalizationsFr extends AppLocalizations { return '$date à $time'; } - @override - String get searchTasks => 'Rechercher des tâches'; - - @override - String get advancedFilters => 'Filtres avancés'; - - @override - String get showCompleted => 'Afficher les terminées'; - - @override - String get showHidden => 'Afficher les masquées'; - - @override - String get showAssigned => 'Afficher les assignées'; - @override String get taskDetails => 'Détails de la tâche'; @@ -1010,19 +1020,6 @@ class AppLocalizationsFr extends AppLocalizations { @override String get account => 'Compte'; - @override - String get signOut => 'Se déconnecter'; - - @override - String get revokeGoogleAuthorization => 'Révoquer l’autorisation Google'; - - @override - String get deleteLocalData => 'Supprimer les données locales'; - - @override - String get deleteLocalDataConfirmation => - 'Cela supprime de cet appareil le compte local, les tâches synchronisées et les changements hors ligne en attente.'; - @override String get sync => 'Synchronisation'; diff --git a/lib/main.dart b/lib/main.dart index 48db015..0175546 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -8,19 +8,29 @@ import 'src/app/app_bootstrap.dart'; import 'src/app/busymax_app.dart'; import 'src/config/build_config.dart'; import 'src/core/logging/redacting_logger.dart'; +import 'src/demo/demo_profile.dart'; import 'src/features/schedule/application/compact_agenda_data.dart'; import 'src/features/schedule/presentation/compact_agenda_app.dart'; +import 'src/platform/busymax_window_args.dart'; import 'src/platform/gtk_font_service.dart'; import 'src/platform/main_window_command_client.dart'; -import 'src/platform/busymax_window_args.dart'; Future main(List args) async { WidgetsFlutterBinding.ensureInitialized(); + final windowController = await WindowController.fromCurrentEngine(); + final windowArgs = BusyMaxWindowArgs.parse(windowController.arguments); + final buildConfig = BuildConfig.fromEnvironment(); + final LocalSettingsStore settingsStore; + if (buildConfig.useFakeProviderData) { + final settings = busyMaxDemoSettings(buildConfig.demoTheme); + settingsStore = InMemoryLocalSettingsStore(settings.toJson()); + } else { + settingsStore = const JsonFileLocalSettingsStore(); + } + final systemAccentFuture = SystemTheme.accentColor.load(); final initialGtkFontFuture = const GtkFontService().getGtkFont(); - final initialAppSettingsFuture = loadInitialAppSettings( - const JsonFileLocalSettingsStore(), - ); + final initialAppSettingsFuture = loadInitialAppSettings(settingsStore); final initialAppSettings = await initialAppSettingsFuture; final gtkThemeService = const GtkThemeService(); await gtkThemeService.setPreferDark( @@ -40,29 +50,40 @@ Future main(List args) async { final initialGtkFont = desktopSettings[1] as GtkFontSettings?; final initialGtkThemeColors = desktopSettings[2] as GtkThemeColors?; - final windowController = await WindowController.fromCurrentEngine(); - final windowArgs = BusyMaxWindowArgs.parse(windowController.arguments); final overrides = [ - buildConfigProvider.overrideWithValue(BuildConfig.fromEnvironment()), + buildConfigProvider.overrideWithValue(buildConfig), + localSettingsStoreProvider.overrideWithValue(settingsStore), initialAppSettingsProvider.overrideWithValue(initialAppSettings), initialGtkFontSettingsProvider.overrideWithValue(initialGtkFont), initialGtkThemeColorsProvider.overrideWithValue(initialGtkThemeColors), ]; + final demoProfile = buildConfig.useFakeProviderData + ? await BusyMaxDemoProfile.create() + : null; + final applicationOverrides = [...overrides, ...?demoProfile?.overrides]; switch (windowArgs.kind) { case BusyMaxWindowKind.main: - runApp(ProviderScope(overrides: overrides, child: const BusyMaxApp())); + runApp( + ProviderScope( + overrides: applicationOverrides, + child: const BusyMaxApp(), + ), + ); case BusyMaxWindowKind.compactAgenda: await configureCompactAgendaNativeWindow(); + final compactOverrides = [...applicationOverrides]; + if (demoProfile == null) { + compactOverrides.add( + compactAgendaDataLoaderProvider.overrideWithValue( + (ref, query) => + const MainWindowCommandClient().compactAgendaSnapshot(query), + ), + ); + } runApp( ProviderScope( - overrides: [ - ...overrides, - compactAgendaDataLoaderProvider.overrideWithValue( - (ref, query) => - const MainWindowCommandClient().compactAgendaSnapshot(query), - ), - ], + overrides: compactOverrides, child: BusyMaxCompactAgendaApp( windowController: windowController, windowArgs: windowArgs, diff --git a/lib/src/app/app_bootstrap.dart b/lib/src/app/app_bootstrap.dart index fac8081..1241259 100644 --- a/lib/src/app/app_bootstrap.dart +++ b/lib/src/app/app_bootstrap.dart @@ -15,6 +15,7 @@ import '../features/auth/data/auth_repository.dart'; import '../features/feedback/data/feedback_api_client.dart'; import '../features/notifications/desktop_notification_service.dart'; import '../features/notifications/notification_scheduler.dart'; +import '../features/sync/account_sync_operations.dart'; import '../features/sync/all_accounts_sync_scheduler.dart'; import '../features/sync/calendar_sync_engine.dart'; import '../features/sync/pending_mutation_sync_requester.dart'; @@ -100,6 +101,10 @@ final applicationOAuthServiceProvider = Provider((ref) { final oAuthServiceProvider = applicationOAuthServiceProvider; +final applicationOAuthGatewayProvider = Provider( + (ref) => ref.watch(applicationOAuthServiceProvider), +); + final microsoftOAuthServiceProvider = Provider((ref) { return MicrosoftOAuthService( config: ref.watch(buildConfigProvider), @@ -109,6 +114,11 @@ final microsoftOAuthServiceProvider = Provider((ref) { ); }); +final applicationMicrosoftOAuthServiceProvider = + Provider( + (ref) => ref.watch(microsoftOAuthServiceProvider), + ); + final authenticatedHttpClientProvider = Provider((ref) { return AuthenticatedHttpClient( inner: ref.watch(retryingHttpClientProvider), @@ -147,10 +157,10 @@ final compactAgendaWindowServiceProvider = Provider( final authRepositoryProvider = Provider((ref) { return AuthRepository( - oAuth: ref.watch(applicationOAuthServiceProvider), + oAuth: ref.watch(applicationOAuthGatewayProvider), database: ref.watch(databaseProvider), accountsRepository: ref.watch(accountsRepositoryProvider), - microsoftOAuth: ref.watch(microsoftOAuthServiceProvider), + microsoftOAuth: ref.watch(applicationMicrosoftOAuthServiceProvider), ); }); @@ -264,22 +274,49 @@ final microsoftAsGoogleTasksApiClientForAccountProvider = ); }); -typedef SyncEngineForAccountFactory = SyncEngine Function(String accountId); +final taskRemoteApiClientForAccountProvider = + Provider.family((ref, accountId) { + final accounts = ref.watch(accountsStreamProvider).valueOrNull; + AccountEntity? account; + for (final candidate in accounts ?? const []) { + if (candidate.id == accountId) { + account = candidate; + break; + } + } + if (account == null) { + return null; + } + return switch (account.provider) { + TaskProvider.microsoft => ref.watch( + microsoftAsGoogleTasksApiClientForAccountProvider(accountId), + ), + TaskProvider.google => ref.watch( + googleTasksApiClientForAccountProvider(accountId), + ), + }; + }); + +typedef SyncEngineForAccountFactory = + SyncEngine Function(String accountId, TaskProvider provider); final syncEngineForAccountFactoryProvider = Provider((ref) { - return (accountId) { - final apiClient = accountId.startsWith('microsoft:') - ? ref.read( - microsoftAsGoogleTasksApiClientForAccountProvider(accountId), - ) - : ref.read(googleTasksApiClientForAccountProvider(accountId)); + return (accountId, provider) { + final apiClient = switch (provider) { + TaskProvider.microsoft => ref.read( + microsoftAsGoogleTasksApiClientForAccountProvider(accountId), + ), + TaskProvider.google => ref.read( + googleTasksApiClientForAccountProvider(accountId), + ), + }; return SyncEngine( database: ref.read(databaseProvider), apiClient: apiClient, accountId: accountId, - fullRefreshOnly: accountId.startsWith('microsoft:'), + fullRefreshOnly: provider == TaskProvider.microsoft, onConflictBlocked: ref .read(desktopNotificationServiceProvider) .notifyConflict, @@ -288,14 +325,19 @@ final syncEngineForAccountFactoryProvider = }); typedef CalendarSyncEngineForAccountFactory = - CalendarSyncEngine Function(String accountId); + CalendarSyncEngine Function(String accountId, TaskProvider provider); final calendarSyncEngineForAccountFactoryProvider = Provider((ref) { - return (accountId) { - final client = accountId.startsWith('microsoft:') - ? ref.read(microsoftCalendarApiClientForAccountProvider(accountId)) - : ref.read(googleCalendarApiClientForAccountProvider(accountId)); + return (accountId, provider) { + final client = switch (provider) { + TaskProvider.microsoft => ref.read( + microsoftCalendarApiClientForAccountProvider(accountId), + ), + TaskProvider.google => ref.read( + googleCalendarApiClientForAccountProvider(accountId), + ), + }; return CalendarSyncEngine( database: ref.read(databaseProvider), client: client, @@ -309,23 +351,54 @@ final calendarSyncEngineForAccountFactoryProvider = }; }); +final accountSyncOperationsProvider = Provider((ref) { + final accountsRepository = ref.watch(accountsRepositoryProvider); + + Future providerForAccount(String accountId) async { + final account = await accountsRepository.accountById(accountId); + if (account == null) { + throw StateError('Account $accountId is unavailable.'); + } + return account.provider; + } + + return DelegatingAccountSyncOperations( + syncTasks: (accountId, {required full}) async { + final provider = await providerForAccount(accountId); + final engine = ref.read(syncEngineForAccountFactoryProvider)( + accountId, + provider, + ); + if (full) { + await engine.fullSync(); + } else { + await engine.incrementalSync(); + } + }, + syncCalendar: (accountId, {required full}) async { + final provider = await providerForAccount(accountId); + final engine = ref.read(calendarSyncEngineForAccountFactoryProvider)( + accountId, + provider, + ); + if (full) { + await engine.fullSync(); + } else { + await engine.incrementalSync(); + } + }, + ); +}); + typedef SignedInSyncRunner = Future Function(String accountId, bool initial); final signedInSyncRunnerProvider = Provider((ref) { return (accountId, initial) async { - final syncEngine = ref.read(syncEngineForAccountFactoryProvider)(accountId); - final calendarSyncEngine = ref.read( - calendarSyncEngineForAccountFactoryProvider, - )(accountId); try { - if (initial) { - await syncEngine.fullSync(); - await calendarSyncEngine.fullSync(); - } else { - await syncEngine.incrementalSync(); - await calendarSyncEngine.incrementalSync(); - } + await ref + .read(accountSyncOperationsProvider) + .syncAccount(accountId, full: initial); } on Object catch (error) { await _markAccountReconnectRequiredIfMissingSyncToken( ref, @@ -342,11 +415,8 @@ typedef AllAccountsSyncRunner = Future Function(); final allAccountsSyncRunnerProvider = Provider((ref) { Future syncAccount(String accountId) async { await ref - .read(syncEngineForAccountFactoryProvider)(accountId) - .incrementalSync(); - await ref - .read(calendarSyncEngineForAccountFactoryProvider)(accountId) - .incrementalSync(); + .read(accountSyncOperationsProvider) + .syncAccount(accountId, full: false); } return () { @@ -406,20 +476,7 @@ final googleTasksApiClientProvider = Provider((ref) { if (accountId == null) { return null; } - final selectedAccount = ref.watch(selectedAccountProvider); - final provider = - selectedAccount?.provider ?? - (accountId.startsWith('microsoft:') - ? TaskProvider.microsoft - : TaskProvider.google); - return switch (provider) { - TaskProvider.microsoft => ref.watch( - microsoftAsGoogleTasksApiClientForAccountProvider(accountId), - ), - TaskProvider.google => ref.watch( - googleTasksApiClientForAccountProvider(accountId), - ), - }; + return ref.watch(taskRemoteApiClientForAccountProvider(accountId)); }); final taskListsRepositoryProvider = Provider((ref) { @@ -460,15 +517,10 @@ final tasksRepositoryProvider = Provider((ref) { final tasksRepositoryForAccountProvider = Provider.family((ref, accountId) { - final apiClient = accountId.startsWith('microsoft:') - ? ref.watch( - microsoftAsGoogleTasksApiClientForAccountProvider(accountId), - ) - : ref.watch(googleTasksApiClientForAccountProvider(accountId)); return TasksRepository( database: ref.watch(databaseProvider), accountId: accountId, - apiClient: apiClient, + apiClient: ref.watch(taskRemoteApiClientForAccountProvider(accountId)), onMutationQueued: ref .watch(pendingMutationSyncRequesterForAccountProvider(accountId)) .request, @@ -480,19 +532,15 @@ final tasksRepositoryForAccountProvider = final Provider syncEngineProvider = Provider((ref) { final accountId = ref.watch(activeAccountProvider); final apiClient = ref.watch(googleTasksApiClientProvider); - if (accountId == null || apiClient == null) { + final account = ref.watch(selectedAccountProvider); + if (accountId == null || apiClient == null || account?.id != accountId) { return null; } - final provider = - ref.watch(selectedAccountProvider)?.provider ?? - (accountId.startsWith('microsoft:') - ? TaskProvider.microsoft - : TaskProvider.google); return SyncEngine( database: ref.watch(databaseProvider), apiClient: apiClient, accountId: accountId, - fullRefreshOnly: provider == TaskProvider.microsoft, + fullRefreshOnly: account!.provider == TaskProvider.microsoft, onConflictBlocked: ref .watch(desktopNotificationServiceProvider) .notifyConflict, @@ -502,13 +550,14 @@ final Provider syncEngineProvider = Provider((ref) { final pendingMutationSyncRequesterProvider = Provider((ref) { final accountId = ref.watch(activeAccountProvider); - final syncEngine = ref.watch(syncEngineProvider); - if (accountId == null || syncEngine == null) { + if (accountId == null) { return null; } final requester = PendingMutationSyncRequester( - sync: syncEngine.incrementalSync, + sync: () => ref + .read(accountSyncOperationsProvider) + .syncTasks(accountId, full: false), onSyncFailure: ref .watch(desktopNotificationServiceProvider) .notifySyncFailure, @@ -524,11 +573,10 @@ final pendingMutationSyncRequesterProvider = final pendingMutationSyncRequesterForAccountProvider = Provider.family((ref, accountId) { - final syncEngine = ref.watch(syncEngineForAccountFactoryProvider)( - accountId, - ); final requester = PendingMutationSyncRequester( - sync: syncEngine.incrementalSync, + sync: () => ref + .read(accountSyncOperationsProvider) + .syncTasks(accountId, full: false), onSyncFailure: ref .watch(desktopNotificationServiceProvider) .notifySyncFailure, @@ -561,11 +609,8 @@ final pendingOpResolutionServiceProvider = final syncSchedulerProvider = Provider((ref) { Future syncAccount(String accountId) async { await ref - .read(syncEngineForAccountFactoryProvider)(accountId) - .incrementalSync(); - await ref - .read(calendarSyncEngineForAccountFactoryProvider)(accountId) - .incrementalSync(); + .read(accountSyncOperationsProvider) + .syncAccount(accountId, full: false); } final scheduler = AllAccountsSyncScheduler( diff --git a/lib/src/app/busymax_about_dialog.dart b/lib/src/app/busymax_about_dialog.dart index 373a7aa..6ee2c49 100644 --- a/lib/src/app/busymax_about_dialog.dart +++ b/lib/src/app/busymax_about_dialog.dart @@ -132,7 +132,8 @@ class BusyMaxAboutDialog extends StatelessWidget { PositionedDirectional( top: BusyMaxSpacing.sm, end: BusyMaxSpacing.sm, - child: BusyMaxDialogCloseButton( + child: YaruIconButton( + icon: const Icon(Icons.close, size: BusyMaxSizes.iconSm), tooltip: l10n.close, onPressed: () => Navigator.of(context).pop(), ), diff --git a/lib/src/app/busymax_app.dart b/lib/src/app/busymax_app.dart index 06e9360..22fa507 100644 --- a/lib/src/app/busymax_app.dart +++ b/lib/src/app/busymax_app.dart @@ -7,6 +7,7 @@ import 'package:ubuntu_localizations/ubuntu_localizations.dart'; import '../platform/busymax_tray_service.dart'; import '../platform/gtk_font_service.dart'; +import '../platform/linux_header_bar_configuration_synchronizer.dart'; import '../platform/linux_header_bar_service.dart'; import '../platform/linux_window_service.dart'; import '../platform/main_window_command_bridge.dart'; @@ -44,15 +45,22 @@ class _BusyMaxAppState extends ConsumerState { bool? _lastTrayEnabled; bool _startMinimizedHandled = false; bool _settingsReady = false; + late final BusyMaxHeaderBarConfigurationSynchronizer + _headerBarConfigurationSynchronizer; @override void initState() { super.initState(); + _headerBarConfigurationSynchronizer = + BusyMaxHeaderBarConfigurationSynchronizer( + ref.read(linuxHeaderBarServiceProvider), + ); unawaited(_waitForSettings()); } @override void dispose() { + _headerBarConfigurationSynchronizer.dispose(); final tray = _trayService; if (tray != null) { unawaited(tray.stop()); @@ -130,7 +138,7 @@ class _BusyMaxAppState extends ConsumerState { supportedLocales: AppLocalizations.supportedLocales, builder: (context, child) { final l10n = AppLocalizations.of(context); - _configureNativeHeaderBarTheme(context, ref); + _configureNativeHeaderBarTheme(context); _configureBackgroundServices( ref, settings, @@ -176,7 +184,8 @@ class _BusyMaxAppState extends ConsumerState { ), }, child: MainWindowCommandBridge( - child: _BusyMaxWindowCornerClip( + child: ColoredBox( + color: BusyMaxSurfaceColors.of(context).window, child: child ?? const SizedBox.shrink(), ), ), @@ -189,9 +198,8 @@ class _BusyMaxAppState extends ConsumerState { ); } - void _configureNativeHeaderBarTheme(BuildContext context, WidgetRef ref) { + void _configureNativeHeaderBarTheme(BuildContext context) { final colors = BusyMaxSurfaceColors.of(context); - final colorScheme = Theme.of(context).colorScheme; final l10n = AppLocalizations.of(context); final materialL10n = MaterialLocalizations.of(context); final modalBarrierColor = busyMaxModalBarrierColor(context); @@ -217,37 +225,23 @@ class _BusyMaxAppState extends ConsumerState { keyboardShortcuts: l10n.keyboardShortcuts, aboutBusyMax: l10n.aboutBusyMax, ); - WidgetsBinding.instance.addPostFrameCallback((_) { - final service = ref.read(linuxHeaderBarServiceProvider); - unawaited(() async { - await service.initialize(); - await service.setLocalizedLabels(labels); - await service.setSidebarWidth(BusyMaxSizes.sidebarWidth); - await service.setTheme( - BusyMaxHeaderBarTheme( - preferDark: preferDark, - windowBackgroundColor: colors.window, - // This header is deliberately borderless and visually continuous - // with the main pane, so it uses the flat header role. - backgroundColor: colors.headerbarFlat, - sidebarBackgroundColor: colors.sidebar, - foregroundColor: colors.foreground, - mutedForegroundColor: colors.mutedForeground, - disabledForegroundColor: colors.disabledForeground, - controlColor: colors.control, - controlHoverColor: colors.controlHover, - controlActiveColor: colors.controlActive, - accentColor: colorScheme.primary, - accentForegroundColor: colorScheme.onPrimary, - popoverBackgroundColor: colors.popover, - borderColor: colors.border, - sidebarBorderColor: colors.sidebarBorder, - shadeColor: colors.shade, - modalBarrierColor: modalBarrierColor, - ), - ); - }()); - }); + _headerBarConfigurationSynchronizer.schedule( + BusyMaxHeaderBarConfiguration( + labels: labels, + sidebarWidth: BusyMaxSizes.sidebarWidth, + theme: BusyMaxHeaderBarTheme( + preferDark: preferDark, + windowBackgroundColor: colors.window, + // This header is deliberately borderless and visually continuous + // with the main pane, so it uses the flat header role. + backgroundColor: colors.headerbarFlat, + sidebarBackgroundColor: colors.sidebar, + foregroundColor: colors.foreground, + sidebarBorderColor: colors.sidebarBorder, + modalBarrierColor: modalBarrierColor, + ), + ), + ); } void _configureBackgroundServices( @@ -260,6 +254,15 @@ class _BusyMaxAppState extends ConsumerState { } final windowService = ref.read(linuxWindowServiceProvider); + if (ref.read(buildConfigProvider).useFakeProviderData) { + _lastTrayEnabled = false; + _setHideOnClose(windowService, false); + final tray = _trayService; + if (tray != null) { + unawaited(tray.stop()); + } + return; + } final trayEnabled = settings.showTrayIcon || @@ -370,23 +373,3 @@ class _KeyboardShortcutsIntent extends Intent { class _OpenSettingsIntent extends Intent { const _OpenSettingsIntent(); } - -class _BusyMaxWindowCornerClip extends StatelessWidget { - const _BusyMaxWindowCornerClip({required this.child}); - - final Widget child; - - @override - Widget build(BuildContext context) { - return ClipRRect( - borderRadius: const BorderRadius.vertical( - bottom: Radius.circular(BusyMaxRadius.window), - ), - clipBehavior: Clip.antiAlias, - child: ColoredBox( - color: BusyMaxSurfaceColors.of(context).window, - child: child, - ), - ); - } -} diff --git a/lib/src/app/busymax_design.dart b/lib/src/app/busymax_design.dart index d4fe0a0..7efe166 100644 --- a/lib/src/app/busymax_design.dart +++ b/lib/src/app/busymax_design.dart @@ -3,6 +3,7 @@ import 'dart:ui' as ui; import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:ubuntu_widgets/ubuntu_widgets.dart'; import 'package:yaru/yaru.dart'; @@ -27,7 +28,7 @@ abstract final class BusyMaxRadius { static const double md = kYaruContainerRadius; static const double lg = kYaruContainerRadius; static const double headerButton = kYaruButtonRadius; - static const double window = kYaruContainerRadius; + static const double window = kYaruWindowRadius; } abstract final class BusyMaxSizes { @@ -50,9 +51,6 @@ abstract final class BusyMaxSizes { static const double sidebarActionButton = headerIconButton; static const double sidebarActionIcon = headerIcon; static const double miniCalendarWeekButton = headerIconButton; - static const double aboutCloseButton = - headerIconButton - BusyMaxSpacing.sm - BusyMaxSpacing.xxs; - static const double popoverActionButton = headerIconButton; static const double popoverArrowWidth = 18; static const double popoverArrowHeight = 10; } @@ -303,53 +301,6 @@ class _BusyMaxPopoverOutlinePainter extends CustomPainter { } } -class BusyMaxCircularAction extends StatelessWidget { - const BusyMaxCircularAction({ - super.key, - required this.icon, - required this.tooltip, - required this.onPressed, - this.destructive = false, - }); - - final IconData icon; - final String tooltip; - final VoidCallback onPressed; - final bool destructive; - - @override - Widget build(BuildContext context) { - final surfaceColors = BusyMaxSurfaceColors.of(context); - final foregroundColor = destructive - ? Theme.of(context).colorScheme.error - : surfaceColors.mutedForeground; - return Tooltip( - message: tooltip, - child: Material( - color: surfaceColors.control, - shape: const CircleBorder(), - clipBehavior: Clip.antiAlias, - child: InkWell( - customBorder: const CircleBorder(), - hoverColor: surfaceColors.controlHover, - focusColor: surfaceColors.controlHover, - highlightColor: surfaceColors.controlHover, - splashColor: Colors.transparent, - onTap: onPressed, - child: SizedBox.square( - dimension: BusyMaxSizes.popoverActionButton, - child: Icon( - icon, - size: BusyMaxSizes.iconSm, - color: foregroundColor, - ), - ), - ), - ), - ); - } -} - RoundedRectangleBorder busyMaxHeaderButtonShape() { return RoundedRectangleBorder( borderRadius: BorderRadius.circular(BusyMaxRadius.headerButton), @@ -443,45 +394,6 @@ WidgetStateProperty busyMaxHeaderButtonBackground( }); } -ButtonStyle busyMaxDropdownButtonStyle(BuildContext context) { - return ButtonStyle( - minimumSize: const WidgetStatePropertyAll( - Size(BusyMaxSizes.headerIconButton, BusyMaxSizes.headerIconButton), - ), - padding: const WidgetStatePropertyAll( - EdgeInsets.symmetric(horizontal: BusyMaxSpacing.lg), - ), - tapTargetSize: MaterialTapTargetSize.shrinkWrap, - foregroundColor: WidgetStatePropertyAll( - Theme.of(context).colorScheme.onSurfaceVariant, - ), - backgroundColor: WidgetStateProperty.resolveWith((states) { - return Colors.transparent; - }), - overlayColor: const WidgetStatePropertyAll(Colors.transparent), - side: const WidgetStatePropertyAll(BorderSide.none), - shape: WidgetStatePropertyAll(busyMaxHeaderButtonShape()), - elevation: const WidgetStatePropertyAll(0), - shadowColor: const WidgetStatePropertyAll(Colors.transparent), - surfaceTintColor: const WidgetStatePropertyAll(Colors.transparent), - animationDuration: Duration.zero, - ); -} - -InputDecoration busyMaxDropdownDecoration() { - return const InputDecoration( - filled: false, - isCollapsed: true, - border: InputBorder.none, - enabledBorder: InputBorder.none, - focusedBorder: InputBorder.none, - disabledBorder: InputBorder.none, - errorBorder: InputBorder.none, - focusedErrorBorder: InputBorder.none, - contentPadding: EdgeInsets.zero, - ); -} - MenuStyle busyMaxDropdownMenuStyle(BuildContext context, {double? minWidth}) { final base = Theme.of(context).menuTheme.style ?? const MenuStyle(); return base.copyWith( @@ -680,6 +592,46 @@ abstract final class BusyMaxPushButton { child: child, ); } + + /// A destructive desktop action. + /// + /// Keep destructive emphasis on the final action in a confirmation dialog; + /// ordinary destructive rows should continue to use semantic error + /// foregrounds without becoming accent-filled buttons. + static PushButton destructive({ + required BuildContext context, + required Widget child, + required VoidCallback? onPressed, + VoidCallback? onLongPress, + ValueChanged? onHover, + ValueChanged? onFocusChange, + ButtonStyle? style, + FocusNode? focusNode, + bool autofocus = false, + Clip clipBehavior = Clip.none, + WidgetStatesController? statesController, + Key? key, + }) { + final colorScheme = Theme.of(context).colorScheme; + return PushButton.elevated( + key: key, + onPressed: onPressed, + onLongPress: onLongPress, + onHover: onHover, + onFocusChange: onFocusChange, + style: busyMaxPushButtonStyle( + ElevatedButton.styleFrom( + backgroundColor: colorScheme.error, + foregroundColor: colorScheme.onError, + ).merge(style), + ), + focusNode: focusNode, + autofocus: autofocus, + clipBehavior: clipBehavior, + statesController: statesController, + child: child, + ); + } } abstract final class BusyMaxHeaderPushButton { @@ -1214,7 +1166,6 @@ class BusyMaxCategoryEditorRow extends StatelessWidget { required this.categories, required this.suggestions, required this.adding, - required this.controller, required this.onAddPressed, required this.onSubmitted, required this.onCancelAdding, @@ -1227,7 +1178,6 @@ class BusyMaxCategoryEditorRow extends StatelessWidget { final List categories; final List suggestions; final bool adding; - final TextEditingController controller; final VoidCallback onAddPressed; final ValueChanged onSubmitted; final VoidCallback onCancelAdding; @@ -1236,11 +1186,10 @@ class BusyMaxCategoryEditorRow extends StatelessWidget { @override Widget build(BuildContext context) { - final visibleSuggestions = [ - for (final suggestion in suggestions) - if (suggestion.trim().isNotEmpty && !categories.contains(suggestion)) - suggestion, - ]; + final visibleSuggestions = _visibleCategorySuggestions( + suggestions: suggestions, + selectedCategories: categories, + ); return BusyMaxActionRow( title: title, leading: const Icon(Icons.sell_outlined), @@ -1257,8 +1206,7 @@ class BusyMaxCategoryEditorRow extends StatelessWidget { onDeleted: () => onDeleted(category), ), if (adding) ...[ - _BusyMaxCategoryInputChip( - controller: controller, + _BusyMaxCategoryInput( hintText: addLabel, suggestions: visibleSuggestions, inputKey: inputKey, @@ -1282,43 +1230,15 @@ class _BusyMaxCategoryChip extends StatelessWidget { @override Widget build(BuildContext context) { - final colorScheme = Theme.of(context).colorScheme; - final surfaceColors = BusyMaxSurfaceColors.of(context); - return DecoratedBox( - decoration: BoxDecoration( - color: surfaceColors.control, - borderRadius: BorderRadius.circular(BusyMaxRadius.headerButton), - ), - child: Padding( - padding: const EdgeInsetsDirectional.only( - start: BusyMaxSpacing.md, - end: BusyMaxSpacing.xs, - top: BusyMaxSpacing.xs, - bottom: BusyMaxSpacing.xs, - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 160), - child: Text( - label, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.labelLarge, - ), - ), - const SizedBox(width: BusyMaxSpacing.xs), - _BusyMaxCategoryIconAction( - icon: YaruIcons.window_close, - tooltip: - '${MaterialLocalizations.of(context).deleteButtonTooltip} $label', - color: colorScheme.onSurfaceVariant, - onPressed: onDeleted, - ), - ], - ), + return InputChip( + label: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 160), + child: Text(label, maxLines: 1, overflow: TextOverflow.ellipsis), ), + deleteIcon: const Icon(YaruIcons.window_close), + deleteButtonTooltipMessage: + '${MaterialLocalizations.of(context).deleteButtonTooltip} $label', + onDeleted: onDeleted, ); } } @@ -1331,48 +1251,16 @@ class _BusyMaxAddCategoryChip extends StatelessWidget { @override Widget build(BuildContext context) { - final colorScheme = Theme.of(context).colorScheme; - return Material( - color: Colors.transparent, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(BusyMaxRadius.headerButton), - ), - child: InkWell( - borderRadius: BorderRadius.circular(BusyMaxRadius.headerButton), - hoverColor: busyMaxEditorRowHoverColor(context), - onTap: onPressed, - child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: BusyMaxSpacing.md, - vertical: BusyMaxSpacing.xs, - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - YaruIcons.plus, - size: BusyMaxSizes.iconSm, - color: colorScheme.onSurfaceVariant, - ), - const SizedBox(width: BusyMaxSpacing.xs), - Text( - label, - style: Theme.of(context).textTheme.labelLarge?.copyWith( - color: colorScheme.onSurfaceVariant, - fontWeight: FontWeight.w600, - ), - ), - ], - ), - ), - ), + return ActionChip( + avatar: const Icon(YaruIcons.plus, size: BusyMaxSizes.iconSm), + label: Text(label), + onPressed: onPressed, ); } } -class _BusyMaxCategoryInputChip extends StatefulWidget { - const _BusyMaxCategoryInputChip({ - required this.controller, +class _BusyMaxCategoryInput extends StatefulWidget { + const _BusyMaxCategoryInput({ required this.hintText, required this.suggestions, this.inputKey, @@ -1380,7 +1268,6 @@ class _BusyMaxCategoryInputChip extends StatefulWidget { required this.onCancel, }); - final TextEditingController controller; final String hintText; final List suggestions; final Key? inputKey; @@ -1388,289 +1275,165 @@ class _BusyMaxCategoryInputChip extends StatefulWidget { final VoidCallback onCancel; @override - State<_BusyMaxCategoryInputChip> createState() => - _BusyMaxCategoryInputChipState(); + State<_BusyMaxCategoryInput> createState() => _BusyMaxCategoryInputState(); } -class _BusyMaxCategoryInputChipState extends State<_BusyMaxCategoryInputChip> { - late final FocusNode _focusNode; - - @override - void initState() { - super.initState(); - _focusNode = FocusNode(); - } - - @override - void dispose() { - _focusNode.dispose(); - super.dispose(); - } +class _BusyMaxCategoryInputState extends State<_BusyMaxCategoryInput> { + FocusNode? _requestedFocusNode; @override Widget build(BuildContext context) { - final colorScheme = Theme.of(context).colorScheme; - final surfaceColors = BusyMaxSurfaceColors.of(context); - return DecoratedBox( - decoration: BoxDecoration( - color: surfaceColors.control, - borderRadius: BorderRadius.circular(BusyMaxRadius.headerButton), - ), - child: Padding( - padding: const EdgeInsetsDirectional.only( - start: BusyMaxSpacing.md, - end: BusyMaxSpacing.xs, - ), - child: SizedBox( - width: 180, - height: 30, - child: Row( - children: [ - Expanded( - child: RawAutocomplete( - textEditingController: widget.controller, - focusNode: _focusNode, - displayStringForOption: (option) => option, - optionsViewOpenDirection: OptionsViewOpenDirection.down, - optionsBuilder: _categoryOptionsFor, - onSelected: widget.onSubmitted, - fieldViewBuilder: - (context, controller, focusNode, onFieldSubmitted) { - return TextField( - key: widget.inputKey, - controller: controller, - focusNode: focusNode, - autofocus: true, - decoration: busyMaxDropdownDecoration().copyWith( - hintText: widget.hintText, - ), - textInputAction: TextInputAction.done, - onSubmitted: _submitTypedCategory, - ); - }, - optionsViewBuilder: (context, onSelected, options) { - return _BusyMaxCategoryAutocompleteOptions( - options: options.toList(growable: false), - onSelected: onSelected, - ); - }, + final materialL10n = MaterialLocalizations.of(context); + return SizedBox( + width: 260, + child: Focus( + onKeyEvent: (_, event) { + if (event is KeyDownEvent && + event.logicalKey == LogicalKeyboardKey.escape) { + widget.onCancel(); + return KeyEventResult.handled; + } + return KeyEventResult.ignored; + }, + child: YaruAutocomplete( + displayStringForOption: (option) => option, + optionsMaxHeight: 240, + optionsBuilder: (value) => + _matchingCategorySuggestions(widget.suggestions, value), + onSelected: widget.onSubmitted, + fieldViewBuilder: (context, controller, focusNode, onFieldSubmitted) { + _requestInitialFocus(focusNode); + + void submitTypedCategory() { + final category = _canonicalCategory( + controller.text, + widget.suggestions, + ); + if (category != null) { + widget.onSubmitted(category); + } + } + + return TextField( + key: widget.inputKey, + controller: controller, + focusNode: focusNode, + autofocus: true, + decoration: InputDecoration( + hintText: widget.hintText, + suffixIcon: Row( + mainAxisSize: MainAxisSize.min, + children: [ + YaruIconButton( + tooltip: materialL10n.okButtonLabel, + icon: const Icon(YaruIcons.checkmark), + onPressed: submitTypedCategory, + ), + YaruIconButton( + tooltip: materialL10n.cancelButtonLabel, + icon: const Icon(YaruIcons.window_close), + onPressed: widget.onCancel, + ), + ], ), ), - _BusyMaxCategoryIconAction( - icon: YaruIcons.checkmark, - tooltip: MaterialLocalizations.of(context).okButtonLabel, - color: colorScheme.onSurfaceVariant, - onPressed: () => _submitTypedCategory(widget.controller.text), - ), - const SizedBox(width: BusyMaxSpacing.xs), - _BusyMaxCategoryIconAction( - icon: YaruIcons.window_close, - tooltip: MaterialLocalizations.of(context).cancelButtonLabel, - color: colorScheme.onSurfaceVariant, - onPressed: widget.onCancel, - ), - ], - ), + textInputAction: TextInputAction.done, + onSubmitted: (value) { + final options = _matchingCategorySuggestions( + widget.suggestions, + TextEditingValue(text: value), + ); + if (options.isEmpty) { + submitTypedCategory(); + } else { + onFieldSubmitted(); + } + }, + ); + }, ), ), ); } - Iterable _categoryOptionsFor(TextEditingValue value) { - final query = value.text.trim().toLowerCase(); - if (query.isEmpty) { - return const []; + void _requestInitialFocus(FocusNode focusNode) { + if (identical(_requestedFocusNode, focusNode)) { + return; } - final matching = [ - for (final suggestion in widget.suggestions) - if (suggestion.toLowerCase().contains(query)) suggestion, - ]; - matching.sort((left, right) { - final leftLower = left.toLowerCase(); - final rightLower = right.toLowerCase(); - final leftStarts = leftLower.startsWith(query); - final rightStarts = rightLower.startsWith(query); - if (leftStarts != rightStarts) { - return leftStarts ? -1 : 1; + _requestedFocusNode = focusNode; + // This field is inserted into an already-focused editor, so TextField's + // autofocus alone does not reliably move focus from the Add chip. + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted && identical(_requestedFocusNode, focusNode)) { + focusNode.requestFocus(); } - return leftLower.compareTo(rightLower); }); - return matching.take(8); } +} - void _submitTypedCategory(String value) { - final trimmed = value.trim(); - if (trimmed.isEmpty) { - return; +List _visibleCategorySuggestions({ + required List suggestions, + required List selectedCategories, +}) { + final selected = { + for (final category in selectedCategories) _normalizedCategory(category), + }; + final seen = {}; + return [ + for (final suggestion in suggestions) + if (suggestion.trim() case final trimmed + when trimmed.isNotEmpty && + !selected.contains(_normalizedCategory(trimmed)) && + seen.add(_normalizedCategory(trimmed))) + trimmed, + ]; +} + +Iterable _matchingCategorySuggestions( + List suggestions, + TextEditingValue value, +) { + final query = _normalizedCategory(value.text); + if (query.isEmpty) { + return const []; + } + final matching = [ + for (final suggestion in suggestions) + if (_normalizedCategory(suggestion).contains(query)) suggestion, + ]; + matching.sort((left, right) { + final leftNormalized = _normalizedCategory(left); + final rightNormalized = _normalizedCategory(right); + final leftExact = leftNormalized == query; + final rightExact = rightNormalized == query; + if (leftExact != rightExact) { + return leftExact ? -1 : 1; } - String? existing; - for (final suggestion in widget.suggestions) { - if (suggestion.toLowerCase() == trimmed.toLowerCase()) { - existing = suggestion; - break; - } + final leftStarts = leftNormalized.startsWith(query); + final rightStarts = rightNormalized.startsWith(query); + if (leftStarts != rightStarts) { + return leftStarts ? -1 : 1; } - widget.onSubmitted(existing ?? trimmed); - } -} - -class _BusyMaxCategoryIconAction extends StatelessWidget { - const _BusyMaxCategoryIconAction({ - required this.icon, - required this.tooltip, - required this.color, - required this.onPressed, + return leftNormalized.compareTo(rightNormalized); }); - - static const _size = 22.0; - - final IconData icon; - final String tooltip; - final Color color; - final VoidCallback onPressed; - - @override - Widget build(BuildContext context) { - final surfaceColors = BusyMaxSurfaceColors.of(context); - return Tooltip( - message: tooltip, - child: Material( - color: Colors.transparent, - borderRadius: BorderRadius.circular(BusyMaxRadius.headerButton), - clipBehavior: Clip.antiAlias, - child: InkWell( - borderRadius: BorderRadius.circular(BusyMaxRadius.headerButton), - hoverColor: surfaceColors.controlHover, - focusColor: surfaceColors.controlHover, - highlightColor: surfaceColors.controlActive, - splashColor: Colors.transparent, - onTap: onPressed, - child: SizedBox.square( - dimension: _size, - child: Icon(icon, size: BusyMaxSizes.iconSm, color: color), - ), - ), - ), - ); - } + return matching.take(8); } -class _BusyMaxCategoryAutocompleteOptions extends StatelessWidget { - const _BusyMaxCategoryAutocompleteOptions({ - required this.options, - required this.onSelected, - }); - - final List options; - final ValueChanged onSelected; - - @override - Widget build(BuildContext context) { - if (options.isEmpty) { - return const SizedBox.shrink(); +String? _canonicalCategory(String value, List suggestions) { + final trimmed = value.trim(); + if (trimmed.isEmpty) { + return null; + } + final normalized = _normalizedCategory(trimmed); + for (final suggestion in suggestions) { + if (_normalizedCategory(suggestion) == normalized) { + return suggestion; } - final popupTheme = Theme.of(context).popupMenuTheme; - final colorScheme = Theme.of(context).colorScheme; - return LayoutBuilder( - builder: (context, constraints) { - final width = constraints.maxWidth.isFinite - ? constraints.maxWidth - : 180.0; - final labelWidth = (width - BusyMaxSpacing.md * 2).clamp(0.0, width); - return Align( - alignment: Alignment.topLeft, - child: Material( - color: popupTheme.color ?? colorScheme.surfaceContainerHigh, - elevation: BusyMaxElevation.popover, - shadowColor: BusyMaxShadow.physicalColor(context), - surfaceTintColor: Colors.transparent, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(BusyMaxRadius.headerButton), - ), - child: ConstrainedBox( - constraints: BoxConstraints( - minWidth: width, - maxWidth: width, - maxHeight: 240, - ), - child: Padding( - padding: const EdgeInsets.symmetric(vertical: 4), - child: ListView.builder( - shrinkWrap: true, - padding: EdgeInsets.zero, - itemCount: options.length, - itemBuilder: (context, index) { - final option = options[index]; - return _BusyMaxCategoryAutocompleteOption( - width: width, - labelWidth: labelWidth, - option: option, - onSelected: onSelected, - ); - }, - ), - ), - ), - ), - ); - }, - ); } + return trimmed; } -class _BusyMaxCategoryAutocompleteOption extends StatelessWidget { - const _BusyMaxCategoryAutocompleteOption({ - required this.width, - required this.labelWidth, - required this.option, - required this.onSelected, - }); - - final double width; - final double labelWidth; - final String option; - final ValueChanged onSelected; - - @override - Widget build(BuildContext context) { - final colorScheme = Theme.of(context).colorScheme; - return SizedBox( - width: width, - height: 36, - child: Material( - color: Colors.transparent, - borderRadius: BorderRadius.circular(BusyMaxRadius.headerButton), - clipBehavior: Clip.antiAlias, - child: InkWell( - borderRadius: BorderRadius.circular(BusyMaxRadius.headerButton), - hoverColor: colorScheme.onSurfaceVariant.withValues(alpha: 0.08), - focusColor: colorScheme.onSurfaceVariant.withValues(alpha: 0.08), - highlightColor: colorScheme.onSurfaceVariant.withValues(alpha: 0.12), - splashColor: Colors.transparent, - onTap: () => onSelected(option), - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: BusyMaxSpacing.md), - child: Align( - alignment: AlignmentDirectional.centerStart, - child: SizedBox( - width: labelWidth, - child: Text( - option, - maxLines: 1, - overflow: TextOverflow.ellipsis, - softWrap: false, - style: Theme.of(context).textTheme.labelLarge?.copyWith( - color: colorScheme.onSurface, - ), - ), - ), - ), - ), - ), - ), - ); - } -} +String _normalizedCategory(String value) => value.trim().toLowerCase(); class BusyMaxCalendarValueRow extends StatelessWidget { const BusyMaxCalendarValueRow({ @@ -1843,23 +1606,40 @@ class BusyMaxComboRow extends StatelessWidget { : width.clamp(120.0, double.infinity).toDouble(); final selector = SizedBox( width: selectorWidth, - child: MenuButtonBuilder( - selected: selected, - values: values, - menuPosition: PopupMenuPosition.under, - decoration: busyMaxDropdownDecoration(), - style: busyMaxDropdownButtonStyle(context), - menuStyle: busyMaxDropdownMenuStyle( - context, - minWidth: selectorWidth, - ), - itemStyle: busyMaxDropdownMenuItemStyle(context), - itemBuilder: (context, value, _) => - menuItemBuilder?.call(context, value) ?? Text(labelFor(value)), - onSelected: enabled ? onSelected : null, - child: - selectedBuilder?.call(context, selected) ?? - Text(labelFor(selected), overflow: TextOverflow.ellipsis), + child: BusyMaxMenuButton( + tooltip: tooltip ?? title, + entries: [ + for (final value in values) + BusyMaxMenuEntry( + value: value, + label: labelFor(value), + child: menuItemBuilder?.call(context, value), + ), + ], + onSelected: onSelected, + minMenuWidth: selectorWidth, + menuPosition: null, + enabled: enabled, + triggerBuilder: (context, onPressed, focusNode) { + return OutlinedButton( + focusNode: focusNode, + onPressed: onPressed, + child: Row( + children: [ + Expanded( + child: + selectedBuilder?.call(context, selected) ?? + Text( + labelFor(selected), + overflow: TextOverflow.ellipsis, + ), + ), + const SizedBox(width: BusyMaxSpacing.sm), + const Icon(YaruIcons.pan_down), + ], + ), + ); + }, ), ); final trailing = Row( @@ -1983,6 +1763,7 @@ class BusyMaxMenuEntry { required this.value, required this.label, this.icon, + this.child, this.enabled = true, this.checked = false, this.tooltip, @@ -1992,6 +1773,7 @@ class BusyMaxMenuEntry { final T value; final String label; final IconData? icon; + final Widget? child; final bool enabled; final bool checked; final String? tooltip; @@ -2005,6 +1787,61 @@ typedef BusyMaxMenuTriggerBuilder = FocusNode focusNode, ); +/// Controls keyboard-driven opening of a [BusyMaxMenuButton]. +/// +/// Pointer activation remains owned by the button so a mouse click does not +/// paint a keyboard focus ring. Commands and shortcuts use +/// [openForKeyboard], which transfers focus to the first enabled menu item. +class BusyMaxMenuController { + Object? _owner; + bool Function()? _openForKeyboard; + VoidCallback? _close; + bool Function()? _isOpen; + + bool get isAttached => _owner != null; + + bool get isOpen => _isOpen?.call() ?? false; + + /// Opens the attached menu in keyboard modality. + /// + /// Returns false when the menu is not attached, is disabled, or is already + /// open. + bool openForKeyboard() { + final open = _openForKeyboard; + if (open == null) { + return false; + } + return open(); + } + + void close() => _close?.call(); + + void _attach({ + required Object owner, + required bool Function() openForKeyboard, + required VoidCallback close, + required bool Function() isOpen, + }) { + // Flutter can mount a replacement responsive subtree before disposing its + // predecessor. Point commands at the newest attachment; the owner check + // in [_detach] prevents the retiring state from detaching its successor. + _owner = owner; + _openForKeyboard = openForKeyboard; + _close = close; + _isOpen = isOpen; + } + + void _detach(Object owner) { + if (!identical(_owner, owner)) { + return; + } + _owner = null; + _openForKeyboard = null; + _close = null; + _isOpen = null; + } +} + class BusyMaxMenuButton extends StatefulWidget { const BusyMaxMenuButton({ super.key, @@ -2026,7 +1863,7 @@ class BusyMaxMenuButton extends StatefulWidget { final double minMenuWidth; final Offset? menuPosition; final BusyMaxMenuTriggerBuilder? triggerBuilder; - final MenuController? controller; + final BusyMaxMenuController? controller; final bool enabled; @override @@ -2034,30 +1871,52 @@ class BusyMaxMenuButton extends StatefulWidget { } class _BusyMaxMenuButtonState extends State> { - final _internalController = MenuController(); - final _triggerFocusNode = FocusNode(debugLabel: 'BusyMax menu trigger'); + final _menuController = MenuController(); + late final FocusNode _triggerFocusNode; + final List _entryFocusNodes = []; - MenuController get _controller => widget.controller ?? _internalController; + @override + void initState() { + super.initState(); + _triggerFocusNode = FocusNode( + debugLabel: 'BusyMax menu trigger', + onKeyEvent: _handleTriggerKeyEvent, + ); + _synchronizeEntryFocusNodes(); + _attachExternalController(); + } @override void didUpdateWidget(covariant BusyMaxMenuButton oldWidget) { super.didUpdateWidget(oldWidget); - if (oldWidget.enabled && !widget.enabled && _controller.isOpen) { - _controller.close(); + if (!identical(oldWidget.controller, widget.controller)) { + oldWidget.controller?._detach(this); + _attachExternalController(); + } + _synchronizeEntryFocusNodes(); + if (oldWidget.enabled && !widget.enabled && _menuController.isOpen) { + _menuController.close(); } } @override void dispose() { + widget.controller?._detach(this); _triggerFocusNode.dispose(); + for (final focusNode in _entryFocusNodes) { + focusNode.dispose(); + } super.dispose(); } @override Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; + final reservesLeadingSpace = widget.entries.any( + (entry) => entry.checked || entry.icon != null, + ); return MenuAnchor( - controller: _controller, + controller: _menuController, childFocusNode: _triggerFocusNode, crossAxisUnconstrained: false, style: busyMaxDropdownMenuStyle(context, minWidth: widget.minMenuWidth), @@ -2090,23 +1949,55 @@ class _BusyMaxMenuButtonState extends State> { ); }, menuChildren: [ - for (final entry in widget.entries) + for (var index = 0; index < widget.entries.length; index += 1) _BusyMaxMenuEntryButton( - entry: entry, + entry: widget.entries[index], + focusNode: _entryFocusNodes[index], + reserveLeadingSpace: reservesLeadingSpace, onSelected: (value) { widget.onSelected(value); - _controller.close(); + _menuController.close(); }, ), ], ); } + KeyEventResult _handleTriggerKeyEvent(FocusNode node, KeyEvent event) { + if (!widget.enabled || event is! KeyDownEvent) { + return KeyEventResult.ignored; + } + final key = event.logicalKey; + if (key == LogicalKeyboardKey.enter || + key == LogicalKeyboardKey.space || + key == LogicalKeyboardKey.arrowDown) { + if (_menuController.isOpen) { + if (key == LogicalKeyboardKey.arrowDown) { + _focusFirstEnabledEntry(); + } else { + _menuController.close(); + } + } else { + _openForKeyboard(); + } + return KeyEventResult.handled; + } + if (key == LogicalKeyboardKey.escape && _menuController.isOpen) { + _menuController.close(); + return KeyEventResult.handled; + } + return KeyEventResult.ignored; + } + void _toggleMenu(MenuController controller) { if (controller.isOpen) { controller.close(); return; } + _openMenu(controller); + } + + void _openMenu(MenuController controller) { final position = widget.menuPosition; if (position == null) { controller.open(); @@ -2114,15 +2005,61 @@ class _BusyMaxMenuButtonState extends State> { controller.open(position: position); } } + + bool _openForKeyboard() { + if (!widget.enabled || _menuController.isOpen) { + return false; + } + _triggerFocusNode.requestFocus(); + _openMenu(_menuController); + _focusFirstEnabledEntry(); + return true; + } + + void _focusFirstEnabledEntry() { + final index = widget.entries.indexWhere((entry) => entry.enabled); + if (index < 0) { + return; + } + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted && _menuController.isOpen) { + _entryFocusNodes[index].requestFocus(); + } + }); + } + + void _attachExternalController() { + widget.controller?._attach( + owner: this, + openForKeyboard: _openForKeyboard, + close: _menuController.close, + isOpen: () => _menuController.isOpen, + ); + } + + void _synchronizeEntryFocusNodes() { + while (_entryFocusNodes.length < widget.entries.length) { + _entryFocusNodes.add( + FocusNode(debugLabel: 'BusyMax menu entry ${_entryFocusNodes.length}'), + ); + } + while (_entryFocusNodes.length > widget.entries.length) { + _entryFocusNodes.removeLast().dispose(); + } + } } class _BusyMaxMenuEntryButton extends StatelessWidget { const _BusyMaxMenuEntryButton({ required this.entry, + required this.focusNode, + required this.reserveLeadingSpace, required this.onSelected, }); final BusyMaxMenuEntry entry; + final FocusNode focusNode; + final bool reserveLeadingSpace; final ValueChanged onSelected; @override @@ -2131,9 +2068,12 @@ class _BusyMaxMenuEntryButton extends StatelessWidget { final foreground = entry.destructive ? colorScheme.error : null; final iconData = entry.checked ? YaruIcons.checkmark : entry.icon; final icon = iconData == null - ? const SizedBox.square(dimension: BusyMaxSizes.iconSm) + ? reserveLeadingSpace + ? const SizedBox.square(dimension: BusyMaxSizes.iconSm) + : null : Icon(iconData, size: BusyMaxSizes.iconSm, color: foreground); final row = MenuItemButton( + focusNode: focusNode, leadingIcon: icon, onPressed: entry.enabled ? () => onSelected(entry.value) : null, style: busyMaxDropdownMenuItemStyle(context).copyWith( @@ -2141,12 +2081,14 @@ class _BusyMaxMenuEntryButton extends StatelessWidget { ? null : WidgetStatePropertyAll(foreground), ), - child: Text( - entry.label, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: foreground == null ? null : TextStyle(color: foreground), - ), + child: + entry.child ?? + Text( + entry.label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: foreground == null ? null : TextStyle(color: foreground), + ), ); if (entry.enabled || entry.tooltip == null) { @@ -2512,46 +2454,6 @@ class BusyMaxModalEditorScaffold extends StatelessWidget { } } -class BusyMaxDialogCloseButton extends StatelessWidget { - const BusyMaxDialogCloseButton({ - super.key, - required this.tooltip, - required this.onPressed, - }); - - final String tooltip; - final VoidCallback onPressed; - - @override - Widget build(BuildContext context) { - final surfaceColors = BusyMaxSurfaceColors.of(context); - return Tooltip( - message: tooltip, - child: Material( - color: surfaceColors.control, - shape: const CircleBorder(), - clipBehavior: Clip.antiAlias, - child: InkWell( - customBorder: const CircleBorder(), - hoverColor: surfaceColors.controlHover, - focusColor: surfaceColors.controlHover, - highlightColor: surfaceColors.controlActive, - splashColor: Colors.transparent, - onTap: onPressed, - child: SizedBox.square( - dimension: BusyMaxSizes.aboutCloseButton, - child: Icon( - Icons.close, - size: BusyMaxSizes.iconSm, - color: surfaceColors.mutedForeground, - ), - ), - ), - ), - ); - } -} - class BusyMaxModalEditorSurface extends StatelessWidget { const BusyMaxModalEditorSurface({ super.key, @@ -2773,7 +2675,6 @@ class BusyMaxConfirmDialog extends StatelessWidget { @override Widget build(BuildContext context) { - final colorScheme = Theme.of(context).colorScheme; return BusyMaxDialogShell( title: title, maxWidth: 460, @@ -2782,16 +2683,17 @@ class BusyMaxConfirmDialog extends StatelessWidget { onPressed: () => Navigator.of(context).pop(false), child: Text(context.l10n.cancel), ), - BusyMaxPushButton.suggested( - style: destructive - ? ElevatedButton.styleFrom( - backgroundColor: colorScheme.error, - foregroundColor: colorScheme.onError, - ) - : null, - onPressed: () => Navigator.of(context).pop(true), - child: Text(confirmLabel), - ), + if (destructive) + BusyMaxPushButton.destructive( + context: context, + onPressed: () => Navigator.of(context).pop(true), + child: Text(confirmLabel), + ) + else + BusyMaxPushButton.suggested( + onPressed: () => Navigator.of(context).pop(true), + child: Text(confirmLabel), + ), ], children: [Text(message)], ); diff --git a/lib/src/app/busymax_keyboard_shortcuts_dialog.dart b/lib/src/app/busymax_keyboard_shortcuts_dialog.dart index 72ad604..8ef50d0 100644 --- a/lib/src/app/busymax_keyboard_shortcuts_dialog.dart +++ b/lib/src/app/busymax_keyboard_shortcuts_dialog.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:yaru/yaru.dart'; import '../l10n/l10n.dart'; import '../platform/linux_header_bar_service.dart'; @@ -206,7 +207,8 @@ class BusyMaxKeyboardShortcutsDialog extends StatelessWidget { PositionedDirectional( top: BusyMaxSpacing.sm, end: BusyMaxSpacing.sm, - child: BusyMaxDialogCloseButton( + child: YaruIconButton( + icon: const Icon(Icons.close, size: BusyMaxSizes.iconSm), tooltip: l10n.close, onPressed: () => Navigator.of(context).pop(), ), diff --git a/lib/src/app/busymax_yaru_theme.dart b/lib/src/app/busymax_yaru_theme.dart index 03a22ec..b8cdd62 100644 --- a/lib/src/app/busymax_yaru_theme.dart +++ b/lib/src/app/busymax_yaru_theme.dart @@ -90,61 +90,30 @@ class BusyMaxYaruTheme { outlineVariant: colors.subtleBorder, scrim: BusyMaxLinuxPalette.dark5, ); - final inputBorder = OutlineInputBorder( - borderSide: BorderSide(color: colors.border), - borderRadius: BorderRadius.circular(6), - ); - final focusedInputBorder = OutlineInputBorder( - borderSide: BorderSide(color: accentColor, width: 2), - borderRadius: BorderRadius.circular(6), - ); final normalizer = _TextStyleNormalizer( gtkFontFamily: gtkFontFamily, gtkFontSize: gtkFontSize, ); final textTheme = _busyMaxTextTheme( base.textTheme, - brightness: brightness, colors: colors, normalizer: normalizer, ); - final inputDecorationTheme = base.inputDecorationTheme.copyWith( - filled: true, - fillColor: colors.control, - border: inputBorder, - enabledBorder: inputBorder, - focusedBorder: focusedInputBorder, - focusedErrorBorder: inputBorder.copyWith( - borderSide: BorderSide(color: colorScheme.error, width: 2), - ), - contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), - labelStyle: normalizer.apply( - base.inputDecorationTheme.labelStyle, - fallback: textTheme.bodyMedium, - ), - floatingLabelStyle: normalizer.apply( - base.inputDecorationTheme.floatingLabelStyle, - fallback: textTheme.bodyMedium, - color: accentColor, - ), - hintStyle: normalizer.apply( - base.inputDecorationTheme.hintStyle, - fallback: textTheme.bodyMedium, - color: colors.mutedForeground, - ), - helperStyle: normalizer.apply( - base.inputDecorationTheme.helperStyle, - fallback: textTheme.bodySmall, - ), - errorStyle: normalizer.apply( - base.inputDecorationTheme.errorStyle, - fallback: textTheme.bodySmall, - color: colorScheme.error, - ), - counterStyle: normalizer.apply( - base.inputDecorationTheme.counterStyle, - fallback: textTheme.bodySmall, - ), + final inputDecorationTheme = _semanticInputDecorationTheme( + base.inputDecorationTheme, + colors: colors, + accentColor: accentColor, + errorColor: colorScheme.error, + normalizer: normalizer, + textTheme: textTheme, + ); + final dropdownInputDecorationTheme = _semanticInputDecorationTheme( + base.dropdownMenuTheme.inputDecorationTheme ?? base.inputDecorationTheme, + colors: colors, + accentColor: accentColor, + errorColor: colorScheme.error, + normalizer: normalizer, + textTheme: textTheme, ); final outlinedButtonStyle = _semanticButtonStyle( base.outlinedButtonTheme.style, @@ -202,10 +171,6 @@ class BusyMaxYaruTheme { borderColor: colors.border, selectedBorderColor: colors.border, disabledBorderColor: colors.disabledForeground, - hoverColor: colors.controlHover, - highlightColor: colors.controlActive, - splashColor: colors.controlHover, - focusColor: colors.controlActive, ); final menuStyle = _semanticMenuSurfaceStyle( base.menuTheme.style, @@ -250,10 +215,12 @@ class BusyMaxYaruTheme { dialogTheme: base.dialogTheme.copyWith( backgroundColor: colors.dialog, surfaceTintColor: colors.dialog, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), - side: BorderSide(color: colors.border), - ), + shape: highContrast + ? _withOutlineSide( + base.dialogTheme.shape, + BorderSide(color: colors.border), + ) + : base.dialogTheme.shape, titleTextStyle: normalizer.apply( base.dialogTheme.titleTextStyle, fallback: textTheme.titleLarge, @@ -327,8 +294,7 @@ class BusyMaxYaruTheme { return colors.border; }), ), - checkboxTheme: CheckboxThemeData( - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(3)), + checkboxTheme: base.checkboxTheme.copyWith( fillColor: WidgetStateProperty.resolveWith((states) { if (states.contains(WidgetState.disabled)) { return colors.disabledControl; @@ -366,12 +332,12 @@ class BusyMaxYaruTheme { fallback: textTheme.bodyMedium, color: colors.foreground, ), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(8), - side: highContrast - ? BorderSide(color: colors.border) - : BorderSide.none, - ), + shape: highContrast + ? _withOutlineSide( + base.popupMenuTheme.shape, + BorderSide(color: colors.border), + ) + : base.popupMenuTheme.shape, ), menuTheme: MenuThemeData( style: menuStyle, @@ -430,7 +396,7 @@ class BusyMaxYaruTheme { base.dropdownMenuTheme.textStyle, fallback: textTheme.bodyMedium, ), - inputDecorationTheme: inputDecorationTheme, + inputDecorationTheme: dropdownInputDecorationTheme, menuStyle: dropdownMenuStyle, ), tabBarTheme: base.tabBarTheme.copyWith( @@ -483,12 +449,9 @@ class BusyMaxYaruTheme { static TextTheme _busyMaxTextTheme( TextTheme base, { - required Brightness brightness, required BusyMaxSurfaceColors colors, required _TextStyleNormalizer normalizer, }) { - assert(brightness == Brightness.light || brightness == Brightness.dark); - TextStyle? apply(TextStyle? style, {Color? color}) => normalizer.apply(style, color: color); @@ -501,13 +464,13 @@ class BusyMaxYaruTheme { headlineSmall: apply(base.headlineSmall, color: colors.foreground), titleLarge: apply(base.titleLarge, color: colors.foreground), titleMedium: apply(base.titleMedium, color: colors.foreground), - titleSmall: apply(base.titleSmall, color: colors.mutedForeground), + titleSmall: apply(base.titleSmall, color: colors.foreground), bodyLarge: apply(base.bodyLarge, color: colors.foreground), bodyMedium: apply(base.bodyMedium, color: colors.foreground), - bodySmall: apply(base.bodySmall, color: colors.mutedForeground), - labelLarge: apply(base.labelLarge, color: colors.mutedForeground), - labelMedium: apply(base.labelMedium, color: colors.mutedForeground), - labelSmall: apply(base.labelSmall, color: colors.mutedForeground), + bodySmall: apply(base.bodySmall, color: colors.foreground), + labelLarge: apply(base.labelLarge, color: colors.foreground), + labelMedium: apply(base.labelMedium, color: colors.foreground), + labelSmall: apply(base.labelSmall, color: colors.foreground), ); } } @@ -962,6 +925,76 @@ WidgetStateProperty _normalizeTextStyleProperty( }); } +/// Applies semantic colors and GTK typography while retaining the input +/// geometry supplied by Yaru, including component-specific constraints. +InputDecorationThemeData _semanticInputDecorationTheme( + InputDecorationThemeData base, { + required BusyMaxSurfaceColors colors, + required Color accentColor, + required Color errorColor, + required _TextStyleNormalizer normalizer, + required TextTheme textTheme, +}) { + final disabledBorderColor = colors.border.withValues( + alpha: colors.border.a * 0.6, + ); + + InputBorder? borderWithColor(InputBorder? border, Color color) { + return border?.copyWith( + borderSide: border.borderSide.copyWith(color: color), + ); + } + + return base.copyWith( + border: borderWithColor(base.border, colors.border), + enabledBorder: borderWithColor(base.enabledBorder, colors.border), + focusedBorder: borderWithColor(base.focusedBorder, accentColor), + errorBorder: borderWithColor(base.errorBorder, errorColor), + focusedErrorBorder: borderWithColor(base.focusedErrorBorder, errorColor), + disabledBorder: borderWithColor(base.disabledBorder, disabledBorderColor), + activeIndicatorBorder: base.activeIndicatorBorder?.copyWith( + color: accentColor, + ), + outlineBorder: base.outlineBorder?.copyWith(color: colors.border), + iconColor: colors.foreground, + labelStyle: normalizer.apply( + base.labelStyle, + fallback: textTheme.bodyMedium, + ), + floatingLabelStyle: normalizer.apply( + base.floatingLabelStyle, + fallback: textTheme.bodyMedium, + color: accentColor, + ), + hintStyle: normalizer.apply( + base.hintStyle, + fallback: textTheme.bodyMedium, + color: colors.mutedForeground, + ), + helperStyle: normalizer.apply( + base.helperStyle, + fallback: textTheme.bodySmall, + ), + errorStyle: normalizer.apply( + base.errorStyle, + fallback: textTheme.bodySmall, + color: errorColor, + ), + counterStyle: normalizer.apply( + base.counterStyle, + fallback: textTheme.bodySmall, + ), + ); +} + +ShapeBorder? _withOutlineSide(ShapeBorder? shape, BorderSide side) { + return switch (shape) { + final InputBorder input => input.copyWith(borderSide: side), + final OutlinedBorder outlined => outlined.copyWith(side: side), + _ => shape, + }; +} + /// Applies only the semantic floating-surface roles and retains Yaru's menu /// geometry, item states, padding, and motion. MenuStyle _semanticMenuSurfaceStyle( diff --git a/lib/src/config/build_config.dart b/lib/src/config/build_config.dart index f0dae00..10227b0 100644 --- a/lib/src/config/build_config.dart +++ b/lib/src/config/build_config.dart @@ -1,5 +1,7 @@ import 'package:flutter/foundation.dart'; +enum BusyMaxDemoTheme { system, light, dark } + class BuildConfig { const BuildConfig({ required this.googleOAuthClientId, @@ -13,43 +15,65 @@ class BuildConfig { required this.oauthAuthorizationEndpoint, required this.oauthTokenEndpoint, required this.oauthRevocationEndpoint, + this.useFakeProviderData = false, + this.demoTheme = BusyMaxDemoTheme.system, }) : apiBaseUrl = apiBaseUrl ?? googleApiBaseUrl; - factory BuildConfig.fromEnvironment() => const BuildConfig( - googleOAuthClientId: String.fromEnvironment('GOOGLE_OAUTH_CLIENT_ID'), - googleOAuthClientSecret: String.fromEnvironment( - 'GOOGLE_OAUTH_CLIENT_SECRET', - ), - microsoftOAuthClientId: String.fromEnvironment('MICROSOFT_OAUTH_CLIENT_ID'), - microsoftOAuthAuthorityTenant: String.fromEnvironment( - 'MICROSOFT_OAUTH_AUTHORITY_TENANT', - defaultValue: 'common', - ), - microsoftGraphBaseUrl: String.fromEnvironment( - 'MICROSOFT_GRAPH_BASE_URL', - defaultValue: 'https://graph.microsoft.com/v1.0', - ), - googleApiBaseUrl: String.fromEnvironment( - 'GOOGLE_API_BASE_URL', - defaultValue: 'https://www.googleapis.com', - ), - feedbackEndpoint: String.fromEnvironment( - 'BUSYSTACK_FEEDBACK_ENDPOINT', - defaultValue: 'https://busystack.org/api/feedback', - ), - oauthAuthorizationEndpoint: String.fromEnvironment( - 'GOOGLE_OAUTH_AUTHORIZATION_ENDPOINT', - defaultValue: 'https://accounts.google.com/o/oauth2/v2/auth', - ), - oauthTokenEndpoint: String.fromEnvironment( - 'GOOGLE_OAUTH_TOKEN_ENDPOINT', - defaultValue: 'https://oauth2.googleapis.com/token', - ), - oauthRevocationEndpoint: String.fromEnvironment( - 'GOOGLE_OAUTH_REVOCATION_ENDPOINT', - defaultValue: 'https://oauth2.googleapis.com/revoke', - ), - ); + factory BuildConfig.fromEnvironment() { + const fakeProviderDataRequested = bool.fromEnvironment('BUSYMAX_FAKE_DATA'); + final useFakeProviderData = busyMaxDemoModeEnabled( + requested: fakeProviderDataRequested, + releaseMode: kReleaseMode, + ); + return BuildConfig( + googleOAuthClientId: const String.fromEnvironment( + 'GOOGLE_OAUTH_CLIENT_ID', + ), + googleOAuthClientSecret: const String.fromEnvironment( + 'GOOGLE_OAUTH_CLIENT_SECRET', + ), + microsoftOAuthClientId: const String.fromEnvironment( + 'MICROSOFT_OAUTH_CLIENT_ID', + ), + microsoftOAuthAuthorityTenant: const String.fromEnvironment( + 'MICROSOFT_OAUTH_AUTHORITY_TENANT', + defaultValue: 'common', + ), + microsoftGraphBaseUrl: const String.fromEnvironment( + 'MICROSOFT_GRAPH_BASE_URL', + defaultValue: 'https://graph.microsoft.com/v1.0', + ), + googleApiBaseUrl: const String.fromEnvironment( + 'GOOGLE_API_BASE_URL', + defaultValue: 'https://www.googleapis.com', + ), + feedbackEndpoint: const String.fromEnvironment( + 'BUSYSTACK_FEEDBACK_ENDPOINT', + defaultValue: 'https://busystack.org/api/feedback', + ), + oauthAuthorizationEndpoint: const String.fromEnvironment( + 'GOOGLE_OAUTH_AUTHORIZATION_ENDPOINT', + defaultValue: 'https://accounts.google.com/o/oauth2/v2/auth', + ), + oauthTokenEndpoint: const String.fromEnvironment( + 'GOOGLE_OAUTH_TOKEN_ENDPOINT', + defaultValue: 'https://oauth2.googleapis.com/token', + ), + oauthRevocationEndpoint: const String.fromEnvironment( + 'GOOGLE_OAUTH_REVOCATION_ENDPOINT', + defaultValue: 'https://oauth2.googleapis.com/revoke', + ), + useFakeProviderData: useFakeProviderData, + demoTheme: useFakeProviderData + ? parseBusyMaxDemoTheme( + const String.fromEnvironment( + 'BUSYMAX_FAKE_THEME', + defaultValue: 'system', + ), + ) + : BusyMaxDemoTheme.system, + ); + } final String googleOAuthClientId; final String googleOAuthClientSecret; @@ -62,10 +86,13 @@ class BuildConfig { final String oauthAuthorizationEndpoint; final String oauthTokenEndpoint; final String oauthRevocationEndpoint; + final bool useFakeProviderData; + final BusyMaxDemoTheme demoTheme; - bool get hasGoogleOAuthClientId => googleOAuthClientId.trim().isNotEmpty; + bool get hasGoogleOAuthClientId => + useFakeProviderData || googleOAuthClientId.trim().isNotEmpty; bool get hasMicrosoftOAuthClientId => - microsoftOAuthClientId.trim().isNotEmpty; + !useFakeProviderData && microsoftOAuthClientId.trim().isNotEmpty; bool get hasAnyProviderConfigured => hasGoogleOAuthClientId || hasMicrosoftOAuthClientId; @@ -87,3 +114,19 @@ class BuildConfig { 'Set MICROSOFT_OAUTH_CLIENT_ID.'; } } + +@visibleForTesting +bool busyMaxDemoModeEnabled({ + required bool requested, + required bool releaseMode, +}) { + return requested && !releaseMode; +} + +BusyMaxDemoTheme parseBusyMaxDemoTheme(String value) { + return switch (value.trim().toLowerCase()) { + 'light' => BusyMaxDemoTheme.light, + 'dark' => BusyMaxDemoTheme.dark, + _ => BusyMaxDemoTheme.system, + }; +} diff --git a/lib/src/core/logging/redacting_logger.dart b/lib/src/core/logging/redacting_logger.dart index 880b074..8261685 100644 --- a/lib/src/core/logging/redacting_logger.dart +++ b/lib/src/core/logging/redacting_logger.dart @@ -7,6 +7,9 @@ final _sensitivePatterns = [ RegExp(r'Bearer\s+[A-Za-z0-9._~+/=-]+', caseSensitive: false), RegExp(r'(^|[?&\s])access_token=[^&\s]+', caseSensitive: false), RegExp(r'(^|[?&\s])refresh_token=[^&\s]+', caseSensitive: false), + // RFC 7009 uses the generic `token` field. Restrict this pattern to URL + // query syntax so ordinary prose such as "token=value" remains readable. + RegExp(r'([?&])token=[^&\s]+', caseSensitive: false), RegExp(r'(^|[?&\s])client_secret=[^&\s]+', caseSensitive: false), RegExp(r'"client_secret"\s*:\s*"[^"]*"', caseSensitive: false), RegExp(r'client_secret\s*:\s*[^,\n\s]+', caseSensitive: false), diff --git a/lib/src/demo/demo_profile.dart b/lib/src/demo/demo_profile.dart new file mode 100644 index 0000000..a92bc3e --- /dev/null +++ b/lib/src/demo/demo_profile.dart @@ -0,0 +1,225 @@ +import 'dart:async'; + +import 'package:desktop_notifications/desktop_notifications.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:http/http.dart' as http; + +import '../app/app_bootstrap.dart'; +import '../config/build_config.dart'; +import '../db/app_database.dart'; +import '../features/feedback/data/feedback_api_client.dart'; +import '../features/feedback/data/feedback_submission.dart'; +import '../features/notifications/desktop_notification_service.dart'; +import '../features/notifications/notification_scheduler.dart'; +import '../features/sync/account_sync_operations.dart'; +import '../features/sync/all_accounts_sync_scheduler.dart'; +import '../google_tasks/api/google_tasks_api_surface.dart'; +import '../google_tasks/oauth/oauth_models.dart'; +import '../google_tasks/oauth/oauth_service.dart'; +import '../google_tasks/oauth/oauth_token_store.dart'; +import 'demo_seed.dart'; + +AppSettings busyMaxDemoSettings(BusyMaxDemoTheme theme) { + final themeModePreference = switch (theme) { + BusyMaxDemoTheme.system => BusyMaxThemeModePreference.system, + BusyMaxDemoTheme.light => BusyMaxThemeModePreference.light, + BusyMaxDemoTheme.dark => BusyMaxThemeModePreference.dark, + }; + return AppSettings.defaults().copyWith( + themeModePreference: themeModePreference, + notifySyncFailures: false, + notifyConflicts: false, + notifyDueToday: false, + notifyEventReminders: false, + notifyTaskReminders: false, + runInBackgroundWhenClosed: false, + showTrayIcon: false, + startMinimizedToTray: false, + quitExitsCompletely: true, + ); +} + +class InMemoryLocalSettingsStore implements LocalSettingsStore { + InMemoryLocalSettingsStore([Map initial = const {}]) + : _value = Map.from(initial); + + Map _value; + + Map get snapshot => Map.unmodifiable(_value); + + @override + Future> load() async { + return Map.from(_value); + } + + @override + Future save(Map json) async { + _value = Map.from(json); + } +} + +class BusyMaxDemoProfile { + BusyMaxDemoProfile._(this.database); + + static Future create({DateTime? now}) async { + final database = AppDatabase.memoryForTests(); + try { + await seedBusyMaxDemoData(database, now: now); + return BusyMaxDemoProfile._(database); + } on Object { + await database.close(); + rethrow; + } + } + + final AppDatabase database; + + List get overrides { + return [ + databaseProvider.overrideWith((ref) { + ref.onDispose(database.close); + return database; + }), + baseHttpClientProvider.overrideWith((ref) { + final client = _BlockedDemoHttpClient(); + ref.onDispose(client.close); + return client; + }), + oAuthTokenStoreProvider.overrideWithValue(InMemoryOAuthTokenStore()), + applicationOAuthGatewayProvider.overrideWithValue( + DemoOAuthGateway(activeAccountId: busyMaxDemoAccountId), + ), + applicationMicrosoftOAuthServiceProvider.overrideWithValue(null), + accountSyncOperationsProvider.overrideWithValue( + const DisabledAccountSyncOperations(), + ), + taskRemoteApiClientForAccountProvider.overrideWith( + (ref, accountId) => null, + ), + pendingOpResolutionServiceProvider.overrideWithValue(null), + feedbackSubmissionServiceProvider.overrideWithValue( + const DemoFeedbackSubmissionService(), + ), + desktopNotificationBackendProvider.overrideWithValue( + const DemoDesktopNotificationBackend(), + ), + syncSchedulerProvider.overrideWith((ref) { + final scheduler = AllAccountsSyncScheduler( + listSignedInAccounts: () async => const [], + syncAccount: (_) async {}, + onSyncFailure: (_) async {}, + interval: Duration.zero, + ); + ref.onDispose(scheduler.stop); + return scheduler; + }), + notificationSchedulerProvider.overrideWith((ref) { + final scheduler = NotificationScheduler( + database: ref.watch(databaseProvider), + notifications: ref.watch(desktopNotificationServiceProvider), + ); + ref.onDispose(scheduler.stop); + return scheduler; + }), + ]; + } +} + +class DemoOAuthGateway implements OAuthGateway { + DemoOAuthGateway({String? activeAccountId}) + : _activeAccountId = activeAccountId; + + String? _activeAccountId; + + OAuthTokenSet get _tokenSet => OAuthTokenSet( + accessToken: 'busymax-demo-access-token', + refreshToken: 'busymax-demo-refresh-token', + expiresAtUtc: DateTime.utc(2100), + tokenType: 'Bearer', + scopes: Set.of(googleBusyMaxOAuthScopes), + ); + + @override + Future get activeAccountId async => _activeAccountId; + + @override + Future cancelSignIn() async {} + + @override + Future clearLocalSession({String? accountId}) async { + if (accountId == null || accountId == _activeAccountId) { + _activeAccountId = null; + } + } + + @override + Future fetchUserInfo(OAuthTokenSet tokenSet) async { + return const GoogleUserInfo( + subject: 'demo-user', + name: 'Alex Morgan', + email: 'alex@example.com', + rawJson: { + 'sub': 'demo-user', + 'name': 'Alex Morgan', + 'email': 'alex@example.com', + }, + ); + } + + @override + Future readActiveTokenSet() async { + return _activeAccountId == null ? null : _tokenSet; + } + + @override + Future refreshActiveToken() async => _tokenSet; + + @override + Future revokeAndSignOutAccount(String accountId) { + return clearLocalSession(accountId: accountId); + } + + @override + Future revokeAuthorization(String accountId) async {} + + @override + Future signIn({String? loginHint}) async { + _activeAccountId = busyMaxDemoAccountId; + return OAuthSignInResult( + accountId: busyMaxDemoAccountId, + tokenSet: _tokenSet, + ); + } +} + +class DemoFeedbackSubmissionService implements FeedbackSubmissionService { + const DemoFeedbackSubmissionService(); + + @override + Future submit(FeedbackSubmission submission) async { + return FeedbackReceipt(id: 'demo-${submission.submissionId}'); + } +} + +class DemoDesktopNotificationBackend implements DesktopNotificationBackend { + const DemoDesktopNotificationBackend(); + + @override + Future close() async {} + + @override + Future notify( + String summary, { + String body = '', + List hints = const [], + List actions = const [], + DesktopNotificationActionHandler? onAction, + }) async {} +} + +class _BlockedDemoHttpClient extends http.BaseClient { + @override + Future send(http.BaseRequest request) { + throw StateError('Network access is disabled in BusyMax demo mode.'); + } +} diff --git a/lib/src/demo/demo_seed.dart b/lib/src/demo/demo_seed.dart new file mode 100644 index 0000000..23c60ac --- /dev/null +++ b/lib/src/demo/demo_seed.dart @@ -0,0 +1,395 @@ +import 'dart:convert'; + +import 'package:drift/drift.dart'; + +import '../db/app_database.dart'; +import '../features/accounts/data/accounts_repository.dart'; +import '../google_tasks/api/google_tasks_api_surface.dart'; + +const busyMaxDemoAccountId = 'demo-google-account'; +const busyMaxDemoWorkCalendarId = 'demo-calendar-work'; +const busyMaxDemoPersonalCalendarId = 'demo-calendar-personal'; +const busyMaxDemoInboxId = 'demo-list-inbox'; +const busyMaxDemoPersonalTasksId = 'demo-list-personal'; + +Future seedBusyMaxDemoData(AppDatabase database, {DateTime? now}) async { + final current = now ?? DateTime.now(); + final today = DateTime(current.year, current.month, current.day); + final timestamp = current.toUtc().toIso8601String(); + final localTimestamp = current.millisecondsSinceEpoch; + + await database.transaction(() async { + await database + .into(database.accounts) + .insert( + AccountsCompanion.insert( + id: busyMaxDemoAccountId, + provider: const Value('google'), + providerAccountId: const Value('demo-user'), + displayName: const Value('Alex Morgan'), + email: const Value('alex@example.com'), + authState: const Value(accountAuthStateSignedIn), + grantedScopes: Value(googleBusyMaxOAuthScopes.join(' ')), + createdAtUtc: timestamp, + updatedAtUtc: timestamp, + lastSuccessfulSyncAtUtc: Value(timestamp), + lastFullSyncAtUtc: Value(timestamp), + ), + ); + + for (final source in [ + ( + id: busyMaxDemoWorkCalendarId, + providerId: 'work@example.com', + summary: 'Work', + primary: true, + color: '#3584E4', + ), + ( + id: busyMaxDemoPersonalCalendarId, + providerId: 'personal@example.com', + summary: 'Personal', + primary: false, + color: '#9141AC', + ), + ]) { + await database + .into(database.calendarSources) + .insert( + CalendarSourcesCompanion.insert( + id: source.id, + accountId: busyMaxDemoAccountId, + provider: 'google', + providerCalendarId: source.providerId, + summary: source.summary, + primaryCalendar: Value(source.primary), + backgroundColor: Value(source.color), + foregroundColor: const Value('#FFFFFF'), + accessRole: const Value('owner'), + rawJson: Value( + jsonEncode({ + 'id': source.providerId, + 'summary': source.summary, + 'backgroundColor': source.color, + 'foregroundColor': '#FFFFFF', + 'accessRole': 'owner', + }), + ), + createdAtLocal: localTimestamp, + updatedAtLocal: localTimestamp, + ), + ); + } + + final events = <_DemoEvent>[ + _DemoEvent.timed( + id: 'demo-event-planning', + sourceId: busyMaxDemoWorkCalendarId, + providerCalendarId: 'work@example.com', + title: 'Product planning', + day: today, + startHour: 9, + duration: const Duration(hours: 1), + description: 'Review the roadmap and agree on this week’s priorities.', + location: 'Meeting room Cedar', + ), + _DemoEvent.timed( + id: 'demo-event-design-review', + sourceId: busyMaxDemoWorkCalendarId, + providerCalendarId: 'work@example.com', + title: 'Design review', + day: today, + startHour: 11, + startMinute: 30, + duration: const Duration(minutes: 45), + description: 'Final accessibility and interaction review.', + ), + _DemoEvent.allDay( + id: 'demo-event-focus', + sourceId: busyMaxDemoPersonalCalendarId, + providerCalendarId: 'personal@example.com', + title: 'Focus day', + day: today, + ), + _DemoEvent.timed( + id: 'demo-event-customer', + sourceId: busyMaxDemoWorkCalendarId, + providerCalendarId: 'work@example.com', + title: 'Customer check-in', + day: _calendarDay(today, 1), + startHour: 14, + duration: const Duration(minutes: 30), + description: 'Walk through the new scheduling workflow.', + ), + _DemoEvent.timed( + id: 'demo-event-gym', + sourceId: busyMaxDemoPersonalCalendarId, + providerCalendarId: 'personal@example.com', + title: 'Gym', + day: _calendarDay(today, 2), + startHour: 18, + duration: const Duration(hours: 1), + ), + _DemoEvent.allDay( + id: 'demo-event-release', + sourceId: busyMaxDemoWorkCalendarId, + providerCalendarId: 'work@example.com', + title: 'Release milestone', + day: _calendarDay(today, 6), + ), + ]; + for (final event in events) { + await database + .into(database.calendarEvents) + .insert(event.toCompanion(localTimestamp)); + } + + for (final list in [ + (id: busyMaxDemoInboxId, title: 'Inbox', kind: 'tasks#taskList'), + ( + id: busyMaxDemoPersonalTasksId, + title: 'Personal', + kind: 'tasks#taskList', + ), + ]) { + await database + .into(database.taskLists) + .insert( + TaskListsCompanion.insert( + accountId: busyMaxDemoAccountId, + id: list.id, + kind: Value(list.kind), + title: list.title, + rawJson: jsonEncode({ + 'id': list.id, + 'kind': list.kind, + 'title': list.title, + }), + lastSyncedAtUtc: Value(timestamp), + createdLocalAtUtc: timestamp, + updatedLocalAtUtc: timestamp, + ), + ); + } + + final tasks = <_DemoTask>[ + _DemoTask( + id: 'demo-task-prototype', + listId: busyMaxDemoInboxId, + title: 'Polish calendar prototype', + notes: 'Check keyboard navigation and both color schemes.', + due: today, + position: '0001', + ), + _DemoTask( + id: 'demo-task-notes', + listId: busyMaxDemoInboxId, + title: 'Send meeting notes', + due: today, + position: '0002', + ), + _DemoTask( + id: 'demo-task-release', + listId: busyMaxDemoInboxId, + title: 'Prepare release checklist', + due: _calendarDay(today, 2), + position: '0003', + ), + _DemoTask( + id: 'demo-task-expenses', + listId: busyMaxDemoPersonalTasksId, + title: 'Submit expenses', + due: _calendarDay(today, -1), + position: '0001', + ), + const _DemoTask( + id: 'demo-task-reading', + listId: busyMaxDemoPersonalTasksId, + title: 'Choose next book', + position: '0002', + ), + _DemoTask( + id: 'demo-task-groceries', + listId: busyMaxDemoPersonalTasksId, + title: 'Pick up groceries', + due: _calendarDay(today, 1), + position: '0003', + completed: true, + ), + ]; + for (final task in tasks) { + await database.into(database.tasks).insert(task.toCompanion(timestamp)); + } + }); +} + +class _DemoEvent { + const _DemoEvent._({ + required this.id, + required this.sourceId, + required this.providerCalendarId, + required this.title, + required this.allDay, + this.start, + this.end, + this.startDate, + this.endDate, + this.description, + this.location, + }); + + factory _DemoEvent.timed({ + required String id, + required String sourceId, + required String providerCalendarId, + required String title, + required DateTime day, + required int startHour, + required Duration duration, + int startMinute = 0, + String? description, + String? location, + }) { + final start = DateTime( + day.year, + day.month, + day.day, + startHour, + startMinute, + ); + return _DemoEvent._( + id: id, + sourceId: sourceId, + providerCalendarId: providerCalendarId, + title: title, + allDay: false, + start: start, + end: start.add(duration), + description: description, + location: location, + ); + } + + factory _DemoEvent.allDay({ + required String id, + required String sourceId, + required String providerCalendarId, + required String title, + required DateTime day, + }) { + return _DemoEvent._( + id: id, + sourceId: sourceId, + providerCalendarId: providerCalendarId, + title: title, + allDay: true, + startDate: _date(day), + endDate: _date(_calendarDay(day, 1)), + ); + } + + final String id; + final String sourceId; + final String providerCalendarId; + final String title; + final bool allDay; + final DateTime? start; + final DateTime? end; + final String? startDate; + final String? endDate; + final String? description; + final String? location; + + CalendarEventsCompanion toCompanion(int timestamp) { + return CalendarEventsCompanion.insert( + id: id, + accountId: busyMaxDemoAccountId, + calendarSourceId: sourceId, + provider: 'google', + providerCalendarId: providerCalendarId, + providerEventId: id, + title: title, + status: const Value('confirmed'), + description: Value(description), + location: Value(location), + allDay: Value(allDay), + startDate: Value(startDate), + startDateTime: Value(start?.toIso8601String()), + endDate: Value(endDate), + endDateTime: Value(end?.toIso8601String()), + remindersJson: const Value('{"useDefault":false,"overrides":[]}'), + rawJson: Value( + jsonEncode({ + 'id': id, + 'summary': title, + 'status': 'confirmed', + 'start': allDay + ? {'date': startDate} + : {'dateTime': start?.toIso8601String()}, + 'end': allDay + ? {'date': endDate} + : {'dateTime': end?.toIso8601String()}, + }), + ), + createdAtLocal: timestamp, + updatedAtLocal: timestamp, + ); + } +} + +class _DemoTask { + const _DemoTask({ + required this.id, + required this.listId, + required this.title, + required this.position, + this.notes, + this.due, + this.completed = false, + }); + + final String id; + final String listId; + final String title; + final String position; + final String? notes; + final DateTime? due; + final bool completed; + + TasksCompanion toCompanion(String timestamp) { + final dueDate = due == null ? null : _date(due!); + return TasksCompanion.insert( + accountId: busyMaxDemoAccountId, + taskListId: listId, + id: id, + kind: const Value('tasks#task'), + title: title, + position: Value(position), + notes: Value(notes), + status: Value(completed ? 'completed' : 'needsAction'), + dueUtc: Value(dueDate), + completedUtc: Value(completed ? timestamp : null), + rawJson: jsonEncode({ + 'id': id, + 'kind': 'tasks#task', + 'title': title, + if (notes != null) 'notes': notes, + if (dueDate != null) 'due': dueDate, + 'status': completed ? 'completed' : 'needsAction', + }), + lastSyncedAtUtc: Value(timestamp), + createdLocalAtUtc: timestamp, + updatedLocalAtUtc: timestamp, + ); + } +} + +DateTime _calendarDay(DateTime day, int offset) { + return DateTime(day.year, day.month, day.day + offset); +} + +String _date(DateTime value) { + return '${value.year.toString().padLeft(4, '0')}-' + '${value.month.toString().padLeft(2, '0')}-' + '${value.day.toString().padLeft(2, '0')}'; +} diff --git a/lib/src/features/accounts/data/accounts_repository.dart b/lib/src/features/accounts/data/accounts_repository.dart index 20b2ec2..eadfa6b 100644 --- a/lib/src/features/accounts/data/accounts_repository.dart +++ b/lib/src/features/accounts/data/accounts_repository.dart @@ -6,7 +6,6 @@ import '../../../db/app_database.dart'; import '../../../task_providers/task_provider.dart'; const accountAuthStateSignedIn = 'signed_in'; -const accountAuthStateSignedOut = 'signed_out'; const accountAuthStateReauthRequired = 'reauth_required'; class AccountEntity { @@ -195,17 +194,6 @@ class AccountsRepository { )..where((account) => account.id.equals(id))).write(companion); } - Future markSignedOut(String accountId) { - return (_database.update( - _database.accounts, - )..where((account) => account.id.equals(accountId))).write( - AccountsCompanion( - authState: const Value(accountAuthStateSignedOut), - updatedAtUtc: Value(_now()), - ), - ); - } - Future markReconnectRequired(String accountId) { return (_database.update( _database.accounts, diff --git a/lib/src/features/auth/data/auth_repository.dart b/lib/src/features/auth/data/auth_repository.dart index dbf8cff..9f63a29 100644 --- a/lib/src/features/auth/data/auth_repository.dart +++ b/lib/src/features/auth/data/auth_repository.dart @@ -62,6 +62,28 @@ class AuthSessionState { bool get isSignedIn => status == AuthSessionStatus.signedIn; } +enum AccountAuthorizationRevocationStatus { notRequested, succeeded, failed } + +@immutable +class AccountRemovalResult { + const AccountRemovalResult({ + required this.authorizationRevocationStatus, + this.alreadyRemoved = false, + }); + + const AccountRemovalResult.alreadyRemoved() + : authorizationRevocationStatus = + AccountAuthorizationRevocationStatus.notRequested, + alreadyRemoved = true; + + final AccountAuthorizationRevocationStatus authorizationRevocationStatus; + final bool alreadyRemoved; + + bool get authorizationRevocationFailed => + authorizationRevocationStatus == + AccountAuthorizationRevocationStatus.failed; +} + class AuthRepository { AuthRepository({ required OAuthGateway oAuth, @@ -80,6 +102,7 @@ class AuthRepository { final AppDatabase _database; final AccountsRepository _accountsRepository; final MicrosoftOAuthService? _microsoftOAuth; + final RedactingLogger _logger = RedactingLogger(Logger('AuthRepository')); Future loadSession() async { final accounts = await _accountsRepository.listSignedInAccounts(); @@ -94,7 +117,10 @@ class AuthRepository { final result = await _oAuth.signIn(); final missingScopes = _missingRequiredGoogleApiScopes(result.tokenSet); if (missingScopes.isNotEmpty) { - await _oAuth.revokeAndSignOutAccount(result.accountId); + await _bestEffortInsufficientScopeCleanup( + 'Google', + () => _oAuth.revokeAndSignOutAccount(result.accountId), + ); throw OAuthException( 'OAuthMissingRequiredScope', _googleMissingScopesMessage(missingScopes), @@ -115,7 +141,10 @@ class AuthRepository { } final result = await microsoftOAuth.signIn(); if (!_hasRequiredMicrosoftScopes(result.tokenSet)) { - await microsoftOAuth.signOutAccount(result.accountId); + await _bestEffortInsufficientScopeCleanup( + 'Microsoft', + () => microsoftOAuth.signOutAccount(result.accountId), + ); throw const OAuthException( 'MicrosoftOAuthMissingRequiredScope', 'Required Microsoft To Do permission was not granted.', @@ -134,73 +163,70 @@ class AuthRepository { return AuthSessionState.signedIn(result.accountId); } - Future signOut({String? accountId}) async { - final targetAccountId = accountId ?? await _oAuth.activeAccountId; - if (targetAccountId == null) { - return; - } - await _deactivateAccount(targetAccountId, reconnectRequired: false); - if (targetAccountId.startsWith('microsoft:')) { - await _microsoftOAuth?.signOutAccount(targetAccountId); - } else { - await _oAuth.signOutAccount(targetAccountId); + Future _bestEffortInsufficientScopeCleanup( + String provider, + Future Function() cleanup, + ) async { + try { + await cleanup(); + } on Object catch (error) { + _logger.warning( + '$provider authorization cleanup failed after insufficient ' + 'permissions: $error', + ); } } Future markReconnectRequired(String accountId) async { - await _deactivateAccount(accountId, reconnectRequired: true); - if (accountId.startsWith('microsoft:')) { - await _microsoftOAuth?.signOutAccount(accountId); - } else { - await _oAuth.signOutAccount(accountId); - } - } - - Future revokeAndSignOut({String? accountId}) async { - final targetAccountId = accountId ?? await _oAuth.activeAccountId; - if (targetAccountId == null) { + final account = await _accountsRepository.accountById(accountId); + if (account == null) { return; } - await _deactivateAccount(targetAccountId, reconnectRequired: false); - if (targetAccountId.startsWith('microsoft:')) { - await _microsoftOAuth?.signOutAccount(targetAccountId); - } else { - await _oAuth.revokeAndSignOutAccount(targetAccountId); + await _database.transaction(() async { + await _accountsRepository.markReconnectRequired(accountId); + await _deleteScheduledNotifications(accountId); + }); + switch (account.provider) { + case TaskProvider.microsoft: + await _microsoftOAuth?.signOutAccount(accountId); + case TaskProvider.google: + await _oAuth.clearLocalSession(accountId: accountId); } } - Future deleteLocalAccountData({String? accountId}) async { - final targetAccountId = accountId ?? await _oAuth.activeAccountId; - if (targetAccountId == null) { - return; + Future removeAccount({ + required String accountId, + bool revokeAuthorization = false, + }) async { + final account = await _accountsRepository.accountById(accountId); + if (account == null) { + return const AccountRemovalResult.alreadyRemoved(); } - await _database.transaction(() async { - await _deleteScheduledNotifications(targetAccountId); - await (_database.delete( - _database.accounts, - )..where((row) => row.id.equals(targetAccountId))).go(); - }); + var revocationStatus = AccountAuthorizationRevocationStatus.notRequested; + if (revokeAuthorization && account.provider == TaskProvider.google) { + try { + await _oAuth.revokeAuthorization(accountId); + revocationStatus = AccountAuthorizationRevocationStatus.succeeded; + } on Object catch (error) { + _logger.warning( + 'Google authorization revocation failed during account removal: ' + '$error', + ); + revocationStatus = AccountAuthorizationRevocationStatus.failed; + } + } - if (targetAccountId.startsWith('microsoft:')) { - await _microsoftOAuth?.signOutAccount(targetAccountId); + if (account.provider == TaskProvider.microsoft) { + await _microsoftOAuth?.signOutAccount(accountId); } else { - await _oAuth.signOutAccount(targetAccountId); + await _oAuth.clearLocalSession(accountId: accountId); } - } + await _accountsRepository.deleteAccount(accountId); - Future _deactivateAccount( - String accountId, { - required bool reconnectRequired, - }) { - return _database.transaction(() async { - if (reconnectRequired) { - await _accountsRepository.markReconnectRequired(accountId); - } else { - await _accountsRepository.markSignedOut(accountId); - } - await _deleteScheduledNotifications(accountId); - }); + return AccountRemovalResult( + authorizationRevocationStatus: revocationStatus, + ); } Future _deleteScheduledNotifications(String accountId) { @@ -410,21 +436,6 @@ class AuthSessionController extends StateNotifier { } } - Future revokeAndSignOut() async { - await _repository.revokeAndSignOut(accountId: state.accountId); - state = const AuthSessionState.signedOut(); - } - - Future signOut() async { - await _repository.signOut(accountId: state.accountId); - state = const AuthSessionState.signedOut(); - } - - Future deleteLocalAccountData() async { - await _repository.deleteLocalAccountData(accountId: state.accountId); - state = const AuthSessionState.signedOut(); - } - Future cancelSignIn() async { _signInGeneration += 1; await _repository.cancelSignIn(); diff --git a/lib/src/features/calendar/presentation/event_editor.dart b/lib/src/features/calendar/presentation/event_editor.dart index f9b46e4..ca1809b 100644 --- a/lib/src/features/calendar/presentation/event_editor.dart +++ b/lib/src/features/calendar/presentation/event_editor.dart @@ -91,7 +91,6 @@ class _EventEditorState extends State { late EventEditorDraft _draft; final _shortcutFocusNode = FocusNode(debugLabel: 'Event editor shortcuts'); final _guestController = TextEditingController(); - final _categoryController = TextEditingController(); String? _guestError; var _addingGuest = false; var _addingCategory = false; @@ -107,7 +106,6 @@ class _EventEditorState extends State { void dispose() { _shortcutFocusNode.dispose(); _guestController.dispose(); - _categoryController.dispose(); super.dispose(); } @@ -467,7 +465,6 @@ class _EventEditorState extends State { setState(() { if (source.provider != TaskProvider.microsoft) { _addingCategory = false; - _categoryController.clear(); } _draft = _draft.copyWith( accountId: source.accountId, @@ -634,7 +631,6 @@ class _EventEditorState extends State { widget.categorySuggestionsByAccount[_draft.accountId] ?? const [], adding: _addingCategory, - controller: _categoryController, inputKey: const Key('event-category-input'), onAddPressed: () { setState(() { @@ -643,7 +639,6 @@ class _EventEditorState extends State { }, onSubmitted: _addCategory, onCancelAdding: () { - _categoryController.clear(); setState(() { _addingCategory = false; }); @@ -731,10 +726,12 @@ class _EventEditorState extends State { void _addCategory(String value) { final category = value.trim(); - if (category.isEmpty || _draft.categories.contains(category)) { + if (category.isEmpty || + _draft.categories.any( + (existing) => existing.toLowerCase() == category.toLowerCase(), + )) { return; } - _categoryController.clear(); setState(() { _addingCategory = false; _draft = _draft.copyWith(categories: [..._draft.categories, category]); diff --git a/lib/src/features/schedule/presentation/calendar_day_semantics.dart b/lib/src/features/schedule/presentation/calendar_day_semantics.dart new file mode 100644 index 0000000..003d27c --- /dev/null +++ b/lib/src/features/schedule/presentation/calendar_day_semantics.dart @@ -0,0 +1,34 @@ +import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; + +/// Gives every interactive calendar day one consistent desktop accessibility +/// contract, regardless of the visual calendar that renders it. +class BusyMaxCalendarDaySemantics extends StatelessWidget { + const BusyMaxCalendarDaySemantics({ + super.key, + required this.day, + required this.selected, + required this.onTap, + required this.child, + }); + + final DateTime day; + final bool selected; + final VoidCallback onTap; + final Widget child; + + @override + Widget build(BuildContext context) { + final locale = Localizations.localeOf(context).toLanguageTag(); + final label = DateFormat.yMMMMEEEEd(locale).format(day); + + return Semantics( + container: true, + button: true, + selected: selected, + label: label, + onTap: onTap, + child: Tooltip(message: label, excludeFromSemantics: true, child: child), + ); + } +} diff --git a/lib/src/features/schedule/presentation/mini_calendar.dart b/lib/src/features/schedule/presentation/mini_calendar.dart index 7233eed..5e1ed11 100644 --- a/lib/src/features/schedule/presentation/mini_calendar.dart +++ b/lib/src/features/schedule/presentation/mini_calendar.dart @@ -9,6 +9,7 @@ import '../../../app/busymax_surface_colors.dart'; import '../../../l10n/l10n.dart'; import '../../../schedule/schedule_item.dart'; import '../../../schedule/schedule_projection.dart'; +import 'calendar_day_semantics.dart'; class MiniCalendar extends StatelessWidget { const MiniCalendar({ @@ -135,10 +136,8 @@ class MiniCalendar extends StatelessWidget { row * DateTime.daysPerWeek, ), weekNumberExtent: weekNumberExtent, - selectedMonth: selectedDate.month, - selectedYear: selectedDate.year, + selectedDate: selectedDate, groupedItems: groupedItems, - locale: locale, onDaySelected: onSelected, onWeekSelected: onWeekSelected, ), @@ -158,20 +157,16 @@ class _MiniCalendarWeekRow extends StatelessWidget { const _MiniCalendarWeekRow({ required this.weekStart, required this.weekNumberExtent, - required this.selectedMonth, - required this.selectedYear, + required this.selectedDate, required this.groupedItems, - required this.locale, required this.onDaySelected, required this.onWeekSelected, }); final DateTime weekStart; final double weekNumberExtent; - final int selectedMonth; - final int selectedYear; + final DateTime selectedDate; final Map> groupedItems; - final String locale; final ValueChanged onDaySelected; final ValueChanged onWeekSelected; @@ -191,10 +186,8 @@ class _MiniCalendarWeekRow extends StatelessWidget { Expanded( child: _MiniCalendarDayButton( day: _addCalendarDays(weekStart, column), - selectedMonth: selectedMonth, - selectedYear: selectedYear, + selectedDate: selectedDate, groupedItems: groupedItems, - locale: locale, onSelected: onDaySelected, ), ), @@ -252,34 +245,35 @@ class _MiniCalendarWeekNumberButton extends StatelessWidget { class _MiniCalendarDayButton extends StatelessWidget { const _MiniCalendarDayButton({ required this.day, - required this.selectedMonth, - required this.selectedYear, + required this.selectedDate, required this.groupedItems, - required this.locale, required this.onSelected, }); final DateTime day; - final int selectedMonth; - final int selectedYear; + final DateTime selectedDate; final Map> groupedItems; - final String locale; final ValueChanged onSelected; @override Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; final surfaceColors = BusyMaxSurfaceColors.of(context); + final selected = _sameDay(day, selectedDate); final today = _sameDay(day, DateTime.now()); - final currentMonth = - selectedYear == DateTime.now().year && - selectedMonth == DateTime.now().month; - final highlightToday = today && currentMonth; + final inDisplayedMonth = + day.year == selectedDate.year && day.month == selectedDate.month; + final displayingCurrentMonth = + selectedDate.year == DateTime.now().year && + selectedDate.month == DateTime.now().month; + final highlightToday = today && displayingCurrentMonth; final items = groupedItems[ScheduleProjection.day(day)] ?? const []; - return Tooltip( - message: DateFormat.yMMMMEEEEd(locale).format(day), + return BusyMaxCalendarDaySemantics( + day: day, + selected: selected, + onTap: () => onSelected(day), child: LayoutBuilder( builder: (context, constraints) { final canShowIndicators = @@ -297,6 +291,7 @@ class _MiniCalendarDayButton extends StatelessWidget { ); return InkWell( onTap: () => onSelected(day), + excludeFromSemantics: true, customBorder: const CircleBorder(), child: Column( mainAxisAlignment: MainAxisAlignment.center, @@ -305,7 +300,9 @@ class _MiniCalendarDayButton extends StatelessWidget { dimension: markerSize, child: DecoratedBox( decoration: BoxDecoration( - color: highlightToday + color: selected + ? colorScheme.primary + : highlightToday ? surfaceColors.controlActive : null, shape: BoxShape.circle, @@ -316,12 +313,16 @@ class _MiniCalendarDayButton extends StatelessWidget { child: Text( '${day.day}', style: TextStyle( - color: highlightToday + color: selected + ? colorScheme.onPrimary + : highlightToday ? surfaceColors.foreground - : day.month == selectedMonth + : inDisplayedMonth ? null : colorScheme.onSurfaceVariant, - fontWeight: highlightToday ? FontWeight.w600 : null, + fontWeight: selected || highlightToday + ? FontWeight.w600 + : null, ), ), ), diff --git a/lib/src/features/schedule/presentation/schedule_create_menu.dart b/lib/src/features/schedule/presentation/schedule_create_menu.dart index 3f34897..9ffd801 100644 --- a/lib/src/features/schedule/presentation/schedule_create_menu.dart +++ b/lib/src/features/schedule/presentation/schedule_create_menu.dart @@ -1,58 +1,114 @@ import 'package:flutter/material.dart'; import '../../../app/busymax_design.dart'; -import '../../../app/busymax_dialogs.dart'; +import '../../../app/busymax_surface_colors.dart'; import '../../../l10n/l10n.dart'; -import '../../../platform/linux_header_bar_service.dart'; +import 'schedule_anchored_popover.dart'; enum ScheduleCreateChoice { event, task } +ScheduleCreateChoice? singleAvailableScheduleCreateChoice({ + required bool canCreateEvent, + required bool canCreateTask, +}) { + if (canCreateEvent == canCreateTask) { + return null; + } + return canCreateEvent + ? ScheduleCreateChoice.event + : ScheduleCreateChoice.task; +} + Future showScheduleCreateMenu({ required BuildContext context, + BuildContext? anchorContext, + Offset? anchorPoint, bool canCreateEvent = true, bool canCreateTask = true, - LinuxHeaderBarService? headerBarService, }) { - return showBusyMaxModalDialog( - context, - headerBarService: headerBarService, - builder: (dialogContext) { - return Dialog( - child: ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 320), - child: Padding( - padding: const EdgeInsets.all(BusyMaxSpacing.lg), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Text( - dialogContext.l10n.createChoiceTitle, - style: Theme.of(dialogContext).textTheme.titleMedium, - ), - const SizedBox(height: BusyMaxSpacing.md), - BusyMaxPushButton.standard( - onPressed: canCreateEvent - ? () => Navigator.of( - dialogContext, - ).pop(ScheduleCreateChoice.event) - : null, - child: Text(dialogContext.l10n.createEventAtTime), - ), - const SizedBox(height: BusyMaxSpacing.sm), - BusyMaxPushButton.standard( - onPressed: canCreateTask - ? () => Navigator.of( - dialogContext, - ).pop(ScheduleCreateChoice.task) - : null, - child: Text(dialogContext.l10n.createTaskAtDate), - ), - ], - ), - ), + if (!canCreateEvent && !canCreateTask) { + return Future.value(); + } + + return showScheduleAnchoredPopover( + context: context, + anchorContext: anchorContext ?? context, + anchorPoint: anchorPoint, + semanticLabel: context.l10n.createChoiceTitle, + preferredWidth: 220, + minimumWidth: 180, + preferredMinimumHeight: 120, + builder: (context, arrowSide, arrowAlignment) { + return BusyMaxPopoverSurface( + color: BusyMaxSurfaceColors.of(context).popover, + arrowSide: arrowSide, + arrowAlignment: arrowAlignment, + padding: const EdgeInsets.symmetric(vertical: BusyMaxSpacing.xs), + child: _ScheduleCreateMenuItems( + canCreateEvent: canCreateEvent, + canCreateTask: canCreateTask, + autofocusFirstItem: anchorPoint == null, ), ); }, ); } + +class _ScheduleCreateMenuItems extends StatelessWidget { + const _ScheduleCreateMenuItems({ + required this.canCreateEvent, + required this.canCreateTask, + required this.autofocusFirstItem, + }); + + final bool canCreateEvent; + final bool canCreateTask; + final bool autofocusFirstItem; + + @override + Widget build(BuildContext context) { + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _ScheduleCreateMenuItem( + choice: ScheduleCreateChoice.event, + label: context.l10n.createEventAtTime, + enabled: canCreateEvent, + autofocus: autofocusFirstItem && canCreateEvent, + ), + _ScheduleCreateMenuItem( + choice: ScheduleCreateChoice.task, + label: context.l10n.createTaskAtDate, + enabled: canCreateTask, + autofocus: autofocusFirstItem && !canCreateEvent && canCreateTask, + ), + ], + ); + } +} + +class _ScheduleCreateMenuItem extends StatelessWidget { + const _ScheduleCreateMenuItem({ + required this.choice, + required this.label, + required this.enabled, + required this.autofocus, + }); + + final ScheduleCreateChoice choice; + final String label; + final bool enabled; + final bool autofocus; + + @override + Widget build(BuildContext context) { + return MenuItemButton( + autofocus: autofocus, + leadingIcon: const SizedBox.square(dimension: BusyMaxSizes.iconSm), + onPressed: enabled ? () => Navigator.of(context).pop(choice) : null, + style: busyMaxDropdownMenuItemStyle(context), + child: Text(label, maxLines: 1, overflow: TextOverflow.ellipsis), + ); + } +} diff --git a/lib/src/features/schedule/presentation/schedule_item_details_popover.dart b/lib/src/features/schedule/presentation/schedule_item_details_popover.dart index 4f048a7..7d03017 100644 --- a/lib/src/features/schedule/presentation/schedule_item_details_popover.dart +++ b/lib/src/features/schedule/presentation/schedule_item_details_popover.dart @@ -120,29 +120,32 @@ class _PopoverActions extends StatelessWidget { @override Widget build(BuildContext context) { final actions = [ - BusyMaxCircularAction( - icon: Icons.download_outlined, + YaruIconButton( + icon: const Icon(Icons.download_outlined, size: BusyMaxSizes.iconSm), tooltip: context.l10n.export, onPressed: () => Navigator.of(context).pop(ScheduleItemDetailsAction.export), ), if (item.capabilities.canEdit) - BusyMaxCircularAction( - icon: Icons.edit_outlined, + YaruIconButton( + icon: const Icon(Icons.edit_outlined, size: BusyMaxSizes.iconSm), tooltip: _editLabel(context, item), onPressed: () => Navigator.of(context).pop(ScheduleItemDetailsAction.edit), ), if (item.capabilities.canDelete) - BusyMaxCircularAction( - icon: Icons.delete_outline, + YaruIconButton( + icon: Icon( + Icons.delete_outline, + size: BusyMaxSizes.iconSm, + color: Theme.of(context).colorScheme.error, + ), tooltip: context.l10n.delete, - destructive: true, onPressed: () => Navigator.of(context).pop(ScheduleItemDetailsAction.delete), ), - BusyMaxCircularAction( - icon: Icons.close, + YaruIconButton( + icon: const Icon(Icons.close, size: BusyMaxSizes.iconSm), tooltip: MaterialLocalizations.of(context).closeButtonTooltip, onPressed: () => Navigator.of(context).pop(), ), diff --git a/lib/src/features/schedule/presentation/schedule_month_view.dart b/lib/src/features/schedule/presentation/schedule_month_view.dart index 859f7f9..0900338 100644 --- a/lib/src/features/schedule/presentation/schedule_month_view.dart +++ b/lib/src/features/schedule/presentation/schedule_month_view.dart @@ -10,6 +10,7 @@ import '../../../l10n/l10n.dart'; import '../../../schedule/schedule_item.dart'; import '../../../schedule/schedule_projection.dart'; import '../../../schedule/schedule_range.dart'; +import 'calendar_day_semantics.dart'; import 'schedule_item_chip.dart'; import 'schedule_item_selection.dart'; import 'schedule_more_popover.dart'; @@ -153,139 +154,150 @@ class _MonthDayCell extends StatelessWidget { final surfaceColors = BusyMaxSurfaceColors.of(context); final today = DateUtils.isSameDay(day, DateTime.now()); - return Material( - color: selected - ? Color.alphaBlend(surfaceColors.control, colorScheme.surface) - : colorScheme.surface, - child: InkWell( - onTap: onSelect, - onDoubleTap: onCreate, - child: LayoutBuilder( - builder: (context, constraints) { - const headerHeight = 24.0; - const itemHeight = 22.0; - const moreHeight = 24.0; - const itemGap = BusyMaxSpacing.xs; - final contentHeight = math.max( - 0.0, - constraints.maxHeight - BusyMaxSpacing.xs * 2, - ); - final availableRowsHeight = math.max( - 0.0, - contentHeight - headerHeight - itemGap, - ); - final rowSlots = (availableRowsHeight / (itemHeight + itemGap)) - .floor(); - final needsOverflowRow = items.length > rowSlots; - final visibleCount = needsOverflowRow - ? math.max(0, rowSlots - 1) - : math.min(items.length, rowSlots); - final visible = items.take(visibleCount).toList(); - final overflow = items.length - visible.length; - final showOverflow = - overflow > 0 && availableRowsHeight >= moreHeight; + return BusyMaxCalendarDaySemantics( + day: day, + selected: selected, + onTap: onSelect, + child: Material( + color: selected + ? Color.alphaBlend(surfaceColors.control, colorScheme.surface) + : colorScheme.surface, + child: InkWell( + onTap: onSelect, + onDoubleTap: onCreate, + excludeFromSemantics: true, + child: LayoutBuilder( + builder: (context, constraints) { + const headerHeight = 24.0; + const itemHeight = 22.0; + const moreHeight = 24.0; + const itemGap = BusyMaxSpacing.xs; + final contentHeight = math.max( + 0.0, + constraints.maxHeight - BusyMaxSpacing.xs * 2, + ); + final availableRowsHeight = math.max( + 0.0, + contentHeight - headerHeight - itemGap, + ); + final rowSlots = (availableRowsHeight / (itemHeight + itemGap)) + .floor(); + final needsOverflowRow = items.length > rowSlots; + final visibleCount = needsOverflowRow + ? math.max(0, rowSlots - 1) + : math.min(items.length, rowSlots); + final visible = items.take(visibleCount).toList(); + final overflow = items.length - visible.length; + final showOverflow = + overflow > 0 && availableRowsHeight >= moreHeight; - if (contentHeight < headerHeight + itemGap) { - return Padding( - padding: const EdgeInsets.all(BusyMaxSpacing.xs), - child: Align( - alignment: AlignmentDirectional.topStart, - child: SizedBox( - width: 26, - height: contentHeight, - child: FittedBox( - fit: BoxFit.scaleDown, - alignment: AlignmentDirectional.topStart, - child: _MonthDayNumber( - day: day, - selected: selected, - today: today, - inCurrentMonth: inCurrentMonth, + if (contentHeight < headerHeight + itemGap) { + return Padding( + padding: const EdgeInsets.all(BusyMaxSpacing.xs), + child: Align( + alignment: AlignmentDirectional.topStart, + child: SizedBox( + width: 26, + height: contentHeight, + child: FittedBox( + fit: BoxFit.scaleDown, + alignment: AlignmentDirectional.topStart, + child: _MonthDayNumber( + day: day, + selected: selected, + today: today, + inCurrentMonth: inCurrentMonth, + ), ), ), ), - ), - ); - } + ); + } - return Padding( - padding: const EdgeInsets.all(BusyMaxSpacing.xs), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Row( - children: [ - _MonthDayNumber( - day: day, - selected: selected, - today: today, - inCurrentMonth: inCurrentMonth, - ), - const Spacer(), - if (selected) - SizedBox.square( - dimension: 24, - child: YaruIconButton( - tooltip: context.l10n.create, - icon: const Icon(YaruIcons.plus, size: 16), - onPressed: onCreate, + return Padding( + padding: const EdgeInsets.all(BusyMaxSpacing.xs), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + children: [ + _MonthDayNumber( + day: day, + selected: selected, + today: today, + inCurrentMonth: inCurrentMonth, + ), + const Spacer(), + if (selected) + SizedBox.square( + dimension: 24, + child: YaruIconButton( + tooltip: context.l10n.create, + icon: const Icon(YaruIcons.plus, size: 16), + onPressed: onCreate, + ), ), + ], + ), + const SizedBox(height: BusyMaxSpacing.xs), + for (final item in visible) + Padding( + padding: const EdgeInsets.only( + bottom: BusyMaxSpacing.xs, + ), + child: ScheduleItemChip( + item: item, + height: itemHeight, + compact: true, + onTap: (context, [globalPosition]) => + onItemSelected(context, item, globalPosition), + onTaskCompletionChanged: item is TaskScheduleItem + ? (completed) => + onTaskCompletionChanged(item, completed) + : null, ), - ], - ), - const SizedBox(height: BusyMaxSpacing.xs), - for (final item in visible) - Padding( - padding: const EdgeInsets.only(bottom: BusyMaxSpacing.xs), - child: ScheduleItemChip( - item: item, - height: itemHeight, - compact: true, - onTap: (context, [globalPosition]) => - onItemSelected(context, item, globalPosition), - onTaskCompletionChanged: item is TaskScheduleItem - ? (completed) => - onTaskCompletionChanged(item, completed) - : null, ), - ), - if (showOverflow) - Align( - alignment: AlignmentDirectional.centerStart, - child: Builder( - builder: (anchorContext) => TextButton( - style: TextButton.styleFrom( - padding: const EdgeInsets.symmetric(horizontal: 6), - minimumSize: const Size(0, moreHeight), - tapTargetSize: MaterialTapTargetSize.shrinkWrap, + if (showOverflow) + Align( + alignment: AlignmentDirectional.centerStart, + child: Builder( + builder: (anchorContext) => TextButton( + style: TextButton.styleFrom( + padding: const EdgeInsets.symmetric( + horizontal: 6, + ), + minimumSize: const Size(0, moreHeight), + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), + onPressed: () async { + final selection = await showScheduleMorePopover( + context: context, + anchorContext: anchorContext, + day: day, + items: items, + onTaskCompletionChanged: + onTaskCompletionChanged, + ); + if (selection == null || + !context.mounted || + !anchorContext.mounted) { + return; + } + onItemSelected( + anchorContext, + selection.item, + selection.anchorPoint, + ); + }, + child: Text(context.l10n.moreItems(overflow)), ), - onPressed: () async { - final selection = await showScheduleMorePopover( - context: context, - anchorContext: anchorContext, - day: day, - items: items, - onTaskCompletionChanged: onTaskCompletionChanged, - ); - if (selection == null || - !context.mounted || - !anchorContext.mounted) { - return; - } - onItemSelected( - anchorContext, - selection.item, - selection.anchorPoint, - ); - }, - child: Text(context.l10n.moreItems(overflow)), ), ), - ), - ], - ), - ); - }, + ], + ), + ); + }, + ), ), ), ); @@ -310,12 +322,13 @@ class _MonthDayNumber extends StatelessWidget { final colorScheme = Theme.of(context).colorScheme; final surfaceColors = BusyMaxSurfaceColors.of(context); return Container( + key: ValueKey('month-day-marker-${day.toIso8601String()}'), width: 26, height: 22, alignment: Alignment.center, decoration: BoxDecoration( color: selected - ? surfaceColors.controlActive + ? colorScheme.primary : today ? surfaceColors.controlActive : null, @@ -325,7 +338,7 @@ class _MonthDayNumber extends StatelessWidget { '${day.day}', style: Theme.of(context).textTheme.labelMedium?.copyWith( color: selected - ? surfaceColors.foreground + ? colorScheme.onPrimary : today ? surfaceColors.foreground : inCurrentMonth diff --git a/lib/src/features/schedule/presentation/schedule_sidebar.dart b/lib/src/features/schedule/presentation/schedule_sidebar.dart index 61a37d0..6fd5e26 100644 --- a/lib/src/features/schedule/presentation/schedule_sidebar.dart +++ b/lib/src/features/schedule/presentation/schedule_sidebar.dart @@ -579,8 +579,8 @@ Future _refreshCalendarSource( ) async { try { await ref - .read(calendarSyncEngineForAccountFactoryProvider)(source.accountId) - .incrementalSync(); + .read(accountSyncOperationsProvider) + .syncCalendar(source.accountId, full: false); } on Object catch (error) { if (!context.mounted) { return; @@ -596,8 +596,8 @@ Future _refreshTaskListAccount( ) async { try { await ref - .read(syncEngineForAccountFactoryProvider)(accountId) - .incrementalSync(); + .read(accountSyncOperationsProvider) + .syncTasks(accountId, full: false); } on Object catch (error) { if (!context.mounted) { return; diff --git a/lib/src/features/schedule/presentation/schedule_toolbar.dart b/lib/src/features/schedule/presentation/schedule_toolbar.dart index 225634d..9668a95 100644 --- a/lib/src/features/schedule/presentation/schedule_toolbar.dart +++ b/lib/src/features/schedule/presentation/schedule_toolbar.dart @@ -53,7 +53,7 @@ class ScheduleToolbar extends StatelessWidget { final VoidCallback? onToggleSidebar; final VoidCallback? onSearch; final ValueChanged? onMenuSelected; - final MenuController? createMenuController; + final BusyMaxMenuController? createMenuController; @override Widget build(BuildContext context) { diff --git a/lib/src/features/schedule/presentation/schedule_view_controller.dart b/lib/src/features/schedule/presentation/schedule_view_controller.dart deleted file mode 100644 index 238d417..0000000 --- a/lib/src/features/schedule/presentation/schedule_view_controller.dart +++ /dev/null @@ -1,26 +0,0 @@ -import '../../../schedule/schedule_scope.dart'; -import '../../../schedule/schedule_view_mode.dart'; - -class ScheduleViewState { - const ScheduleViewState({ - required this.selectedDate, - required this.mode, - required this.scope, - }); - - final DateTime selectedDate; - final ScheduleViewMode mode; - final ScheduleScope scope; - - ScheduleViewState copyWith({ - DateTime? selectedDate, - ScheduleViewMode? mode, - ScheduleScope? scope, - }) { - return ScheduleViewState( - selectedDate: selectedDate ?? this.selectedDate, - mode: mode ?? this.mode, - scope: scope ?? this.scope, - ); - } -} diff --git a/lib/src/features/schedule/presentation/schedule_workspace.dart b/lib/src/features/schedule/presentation/schedule_workspace.dart index 825f75f..4c63ed9 100644 --- a/lib/src/features/schedule/presentation/schedule_workspace.dart +++ b/lib/src/features/schedule/presentation/schedule_workspace.dart @@ -14,6 +14,7 @@ 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 '../../../core/logging/redacting_logger.dart'; import '../../../features/accounts/data/accounts_repository.dart'; import '../../../features/calendar/data/calendar_repository.dart'; @@ -163,6 +164,7 @@ class _ScheduleWorkspaceState extends ConsumerState { static const _agendaPageDays = 30; static const _agendaInitialTaskBucketLimit = 8; static const _agendaTaskBucketPageSize = 8; + static const _pointerAnchorLifetime = Duration(seconds: 1); var _selectedDate = DateTime.now(); var _mode = ScheduleViewMode.week; @@ -185,7 +187,10 @@ class _ScheduleWorkspaceState extends ConsumerState { var _latestCanCreateTask = false; var _latestItems = const []; final _itemAnchorContexts = {}; - final _createMenuController = MenuController(); + final _createMenuController = BusyMaxMenuController(); + final _pointerAnchorClock = Stopwatch()..start(); + Offset? _recentSchedulePointerPosition; + Duration? _recentSchedulePointerTime; ScheduleWorkspaceCommand? _pendingAnchoredCommand; List _pendingAnchoredSources = const []; @@ -447,95 +452,102 @@ class _ScheduleWorkspaceState extends ConsumerState { const Divider(height: 1), ], Expanded( - child: _ScheduleBody( - isLoading: scheduleLoading, - isUnavailable: scheduleUnavailable, - mode: displayMode, - range: displayRange, - selectedDate: searchHasQuery - ? 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, - onEmptySlot: (start) => unawaited( - _openCreateChoice( - accounts, - visibleSources, - start, - canCreateTask: canCreateTask, + child: Listener( + behavior: HitTestBehavior.translucent, + onPointerDown: _recordSchedulePointer, + child: _ScheduleBody( + isLoading: scheduleLoading, + isUnavailable: scheduleUnavailable, + mode: displayMode, + range: displayRange, + selectedDate: searchHasQuery + ? 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, + onEmptySlot: (start) => unawaited( + _openCreateChoice( + accounts, + visibleSources, + start, + canCreateTask: canCreateTask, + ), ), - ), - onCreateAtDay: (day) => unawaited( - _openCreateChoice( - accounts, - visibleSources, - DateTime(day.year, day.month, day.day, 9), - canCreateTask: canCreateTask, + onCreateAtDay: (day) => unawaited( + _openCreateChoice( + accounts, + visibleSources, + DateTime(day.year, day.month, day.day, 9), + canCreateTask: canCreateTask, + ), ), + onNewEvent: () => unawaited( + _openNewEvent(visibleSources, _selectedDate), + ), + onNewTask: () => + unawaited(_openNewTask(accounts)), + onPrevious: _previous, + onNext: _next, + onAgendaLoadMore: + !searchHasQuery && + _mode == ScheduleViewMode.agenda + ? _loadMoreAgendaDays + : null, + hasMoreAgendaOverdueTasks: + !searchHasQuery && + _mode == ScheduleViewMode.agenda && + (snapshot.data?.hasMoreOverdueTasks ?? false), + hasMoreAgendaNoDateTasks: + !searchHasQuery && + _mode == ScheduleViewMode.agenda && + (snapshot.data?.hasMoreNoDateTasks ?? false), + onAgendaLoadMoreOverdue: + !searchHasQuery && + _mode == ScheduleViewMode.agenda + ? _loadMoreAgendaOverdueTasks + : null, + onAgendaLoadMoreNoDate: + !searchHasQuery && + _mode == ScheduleViewMode.agenda + ? _loadMoreAgendaNoDateTasks + : null, + onItemSelected: + (context, item, [globalPosition]) => + unawaited( + _openItem( + context, + item, + visibleSources, + globalPosition: globalPosition, + ), + ), + onItemAnchorAvailable: _handleItemAnchorAvailable, + onTaskCompletionChanged: _setTaskCompleted, + canCreateEvent: writableSources.isNotEmpty, + canCreateTask: canCreateTask, + searchActive: searchHasQuery, ), - onNewEvent: () => unawaited( - _openNewEvent(visibleSources, _selectedDate), - ), - onNewTask: () => unawaited(_openNewTask(accounts)), - onPrevious: _previous, - onNext: _next, - onAgendaLoadMore: - !searchHasQuery && - _mode == ScheduleViewMode.agenda - ? _loadMoreAgendaDays - : null, - hasMoreAgendaOverdueTasks: - !searchHasQuery && - _mode == ScheduleViewMode.agenda && - (snapshot.data?.hasMoreOverdueTasks ?? false), - hasMoreAgendaNoDateTasks: - !searchHasQuery && - _mode == ScheduleViewMode.agenda && - (snapshot.data?.hasMoreNoDateTasks ?? false), - onAgendaLoadMoreOverdue: - !searchHasQuery && - _mode == ScheduleViewMode.agenda - ? _loadMoreAgendaOverdueTasks - : null, - onAgendaLoadMoreNoDate: - !searchHasQuery && - _mode == ScheduleViewMode.agenda - ? _loadMoreAgendaNoDateTasks - : null, - onItemSelected: (context, item, [globalPosition]) => - unawaited( - _openItem( - context, - item, - visibleSources, - globalPosition: globalPosition, - ), - ), - onItemAnchorAvailable: _handleItemAnchorAvailable, - onTaskCompletionChanged: _setTaskCompleted, - canCreateEvent: writableSources.isNotEmpty, - canCreateTask: canCreateTask, - searchActive: searchHasQuery, ), ), ], ); return Scaffold( + backgroundColor: BusyMaxSurfaceColors.of(context).view, body: LayoutBuilder( builder: (context, constraints) { final showSidebar = BusyMaxLayoutRules.showSidebar( @@ -1350,33 +1362,93 @@ class _ScheduleWorkspaceState extends ConsumerState { required bool canCreateTask, }) async { final writableSources = writableCalendarSources(sources); - if (writableSources.isEmpty && !canCreateTask) { + final anchorPoint = _takeRecentSchedulePointerPosition(); + final canCreateEvent = writableSources.isNotEmpty; + if (!canCreateEvent && !canCreateTask) { + return; + } + final directChoice = singleAvailableScheduleCreateChoice( + canCreateEvent: canCreateEvent, + canCreateTask: canCreateTask, + ); + if (directChoice != null) { + await _openScheduleCreateChoice( + directChoice, + accounts: accounts, + writableSources: writableSources, + start: start, + ); return; } final choice = await showScheduleCreateMenu( context: context, - canCreateEvent: writableSources.isNotEmpty, - canCreateTask: canCreateTask, - headerBarService: ref.read(linuxHeaderBarServiceProvider), + anchorContext: _createChoiceAnchorContext(), + anchorPoint: anchorPoint, ); if (!mounted || choice == null) { return; } + await _openScheduleCreateChoice( + choice, + accounts: accounts, + writableSources: writableSources, + start: start, + ); + } + + Future _openScheduleCreateChoice( + ScheduleCreateChoice choice, { + required List accounts, + required List writableSources, + required DateTime start, + }) async { switch (choice) { case ScheduleCreateChoice.event: - unawaited(_openNewEvent(writableSources, start)); + await _openNewEvent(writableSources, start); case ScheduleCreateChoice.task: await _openNewTask(accounts, due: _day(start)); } } + void _recordSchedulePointer(PointerDownEvent event) { + _recentSchedulePointerPosition = event.position; + _recentSchedulePointerTime = _pointerAnchorClock.elapsed; + } + + Offset? _takeRecentSchedulePointerPosition() { + final position = _recentSchedulePointerPosition; + final recordedAt = _recentSchedulePointerTime; + _recentSchedulePointerPosition = null; + _recentSchedulePointerTime = null; + if (HardwareKeyboard.instance.logicalKeysPressed.isNotEmpty || + position == null || + recordedAt == null) { + return null; + } + final age = _pointerAnchorClock.elapsed - recordedAt; + return age <= _pointerAnchorLifetime ? position : null; + } + + BuildContext _createChoiceAnchorContext() { + final focusedContext = FocusManager.instance.primaryFocus?.context; + if (focusedContext == null || + !focusedContext.mounted || + ModalRoute.of(focusedContext) != ModalRoute.of(context)) { + return context; + } + final renderObject = focusedContext.findRenderObject(); + return renderObject is RenderBox && renderObject.hasSize + ? focusedContext + : context; + } + void _openCreateAtSelectedDate() { if (_nativeHeaderBarAvailable && _headerBarSession.isCurrent) { unawaited(_headerBarSession.showCreateMenu()); return; } if (_showFlutterHeaderFallback) { - _createMenuController.open(); + _createMenuController.openForKeyboard(); } } @@ -1790,8 +1862,8 @@ class _ScheduleWorkspaceState extends ConsumerState { Future _syncCalendarMutation(String accountId) async { try { await ref - .read(calendarSyncEngineForAccountFactoryProvider)(accountId) - .incrementalSync(); + .read(accountSyncOperationsProvider) + .syncCalendar(accountId, full: false); } on Object catch (error) { if (!mounted) { return; diff --git a/lib/src/features/schedule/presentation/schedule_year_view.dart b/lib/src/features/schedule/presentation/schedule_year_view.dart index 8eb86c5..82c005a 100644 --- a/lib/src/features/schedule/presentation/schedule_year_view.dart +++ b/lib/src/features/schedule/presentation/schedule_year_view.dart @@ -8,6 +8,7 @@ import '../../../app/busymax_design.dart'; import '../../../app/busymax_surface_colors.dart'; import '../../../schedule/schedule_item.dart'; import '../../../schedule/schedule_projection.dart'; +import 'calendar_day_semantics.dart'; class ScheduleYearView extends StatelessWidget { const ScheduleYearView({ @@ -194,7 +195,6 @@ class _YearMonthGrid extends StatelessWidget { final key = ScheduleProjection.day(day); return _YearDayCell( day: day, - locale: locale, selected: DateUtils.isSameDay(day, selectedDate), today: DateUtils.isSameDay(day, DateTime.now()), items: groupedItems[key] ?? const [], @@ -214,7 +214,6 @@ class _YearMonthGrid extends StatelessWidget { class _YearDayCell extends StatelessWidget { const _YearDayCell({ required this.day, - required this.locale, required this.selected, required this.today, required this.items, @@ -223,7 +222,6 @@ class _YearDayCell extends StatelessWidget { }); final DateTime day; - final String locale; final bool selected; final bool today; final List items; @@ -234,8 +232,10 @@ class _YearDayCell extends StatelessWidget { Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; final surfaceColors = BusyMaxSurfaceColors.of(context); - return Tooltip( - message: DateFormat.yMMMMEEEEd(locale).format(day), + return BusyMaxCalendarDaySemantics( + day: day, + selected: selected, + onTap: onSelected, child: LayoutBuilder( builder: (context, constraints) { if (constraints.maxHeight <= 0) { @@ -253,12 +253,14 @@ class _YearDayCell extends StatelessWidget { (canShowIndicators ? BusyMaxSpacing.xxs : 0), ), ); - final textColor = (today || selected) + final textColor = selected + ? colorScheme.onPrimary + : today ? surfaceColors.foreground : colorScheme.onSurface; - final markerColor = today - ? surfaceColors.controlActive - : selected + final markerColor = selected + ? colorScheme.primary + : today ? surfaceColors.controlActive : Colors.transparent; @@ -266,10 +268,12 @@ class _YearDayCell extends StatelessWidget { borderRadius: BorderRadius.circular(BusyMaxRadius.headerButton), onTap: onSelected, onDoubleTap: onCreate, + excludeFromSemantics: true, child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ Container( + key: ValueKey('year-day-marker-${day.toIso8601String()}'), width: markerSize, height: markerSize, alignment: Alignment.center, diff --git a/lib/src/features/schedule/presentation/source_picker.dart b/lib/src/features/schedule/presentation/source_picker.dart deleted file mode 100644 index cf9ee9e..0000000 --- a/lib/src/features/schedule/presentation/source_picker.dart +++ /dev/null @@ -1,68 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:ubuntu_widgets/ubuntu_widgets.dart'; - -import '../../../app/busymax_design.dart'; -import '../../../features/calendar/data/calendar_repository.dart'; - -class SourcePicker extends StatelessWidget { - const SourcePicker({ - super.key, - required this.sources, - required this.selectedSourceId, - required this.onSelected, - this.labelText, - this.decoration, - }); - - final List sources; - final String? selectedSourceId; - final ValueChanged onSelected; - final String? labelText; - final InputDecoration? decoration; - - @override - Widget build(BuildContext context) { - final selected = sources.any((source) => source.id == selectedSourceId) - ? selectedSourceId - : sources.isEmpty - ? null - : sources.first.id; - if (selected == null) { - return const SizedBox.shrink(); - } - final label = labelText; - final inputDecoration = - decoration ?? - (label == null - ? busyMaxDropdownDecoration() - : busyMaxDropdownDecoration().copyWith( - labelText: label, - floatingLabelBehavior: FloatingLabelBehavior.auto, - )); - return MenuButtonBuilder( - selected: selected, - values: [for (final source in sources) source.id], - menuPosition: PopupMenuPosition.under, - decoration: inputDecoration, - style: busyMaxDropdownButtonStyle(context), - menuStyle: busyMaxDropdownMenuStyle(context), - itemStyle: busyMaxDropdownMenuItemStyle(context), - itemBuilder: (context, value, _) { - final source = sources.firstWhere((source) => source.id == value); - return Text(source.summary, overflow: TextOverflow.ellipsis); - }, - onSelected: (value) { - final match = sources.firstWhere((source) => source.id == value); - onSelected(match); - }, - child: Align( - alignment: AlignmentDirectional.centerEnd, - child: Text( - sources.firstWhere((source) => source.id == selected).summary, - textAlign: TextAlign.end, - overflow: TextOverflow.ellipsis, - ), - ), - ); - } -} diff --git a/lib/src/features/settings/presentation/account_removal_dialog.dart b/lib/src/features/settings/presentation/account_removal_dialog.dart new file mode 100644 index 0000000..b5fb221 --- /dev/null +++ b/lib/src/features/settings/presentation/account_removal_dialog.dart @@ -0,0 +1,90 @@ +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'; + +@immutable +class AccountRemovalOptions { + const AccountRemovalOptions({required this.revokeGoogleAuthorization}); + + final bool revokeGoogleAuthorization; +} + +Future showBusyMaxAccountRemovalDialog( + BuildContext context, { + required String accountLabel, + required bool canRevokeGoogleAuthorization, + LinuxHeaderBarService? headerBarService, +}) { + return showBusyMaxModalDialog( + context, + headerBarService: headerBarService, + barrierDismissible: false, + builder: (dialogContext) => _AccountRemovalDialog( + accountLabel: accountLabel, + canRevokeGoogleAuthorization: canRevokeGoogleAuthorization, + ), + ); +} + +class _AccountRemovalDialog extends StatefulWidget { + const _AccountRemovalDialog({ + required this.accountLabel, + required this.canRevokeGoogleAuthorization, + }); + + final String accountLabel; + final bool canRevokeGoogleAuthorization; + + @override + State<_AccountRemovalDialog> createState() => _AccountRemovalDialogState(); +} + +class _AccountRemovalDialogState extends State<_AccountRemovalDialog> { + var _revokeGoogleAuthorization = false; + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + return BusyMaxDialogShell( + title: l10n.removeAccountTitle(widget.accountLabel), + maxWidth: 500, + actions: [ + BusyMaxPushButton.standard( + autofocus: true, + onPressed: () => Navigator.of(context).pop(), + child: Text(l10n.cancel), + ), + BusyMaxPushButton.destructive( + key: const Key('confirm-account-removal'), + context: context, + onPressed: () => Navigator.of(context).pop( + AccountRemovalOptions( + revokeGoogleAuthorization: _revokeGoogleAuthorization, + ), + ), + child: Text(l10n.removeAccountAction), + ), + ], + children: [ + 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(), + ), + ], + ], + ); + } +} diff --git a/lib/src/features/settings/presentation/settings_screen.dart b/lib/src/features/settings/presentation/settings_screen.dart index 08c1c02..85ffe16 100644 --- a/lib/src/features/settings/presentation/settings_screen.dart +++ b/lib/src/features/settings/presentation/settings_screen.dart @@ -4,6 +4,7 @@ import 'dart:io'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; +import 'package:logging/logging.dart'; import 'package:yaru/yaru.dart'; import '../../../app/busymax_about_dialog.dart'; @@ -13,6 +14,7 @@ import '../../../app/busymax_design.dart'; import '../../../app/busymax_dialogs.dart'; import '../../../app/busymax_keyboard_shortcuts_dialog.dart'; import '../../../app/busymax_layout.dart'; +import '../../../core/logging/redacting_logger.dart'; import '../../../google_tasks/oauth/oauth_models.dart'; import '../../../l10n/l10n.dart'; import '../../../platform/linux_header_bar_service.dart'; @@ -22,7 +24,9 @@ import '../../auth/data/auth_repository.dart'; import '../../diagnostics/presentation/diagnostics_screen.dart'; import '../../sync/sync_auth_error.dart'; import '../../tasks/presentation/desktop_date_time_fields.dart'; -import '../../tasks/presentation/tasks_selection_state.dart'; +import 'account_removal_dialog.dart'; + +final _settingsLogger = RedactingLogger(Logger('SettingsScreen')); class SettingsScreen extends ConsumerStatefulWidget { const SettingsScreen({super.key, this.initialPage = SettingsPage.accounts}); @@ -40,6 +44,7 @@ class _SettingsScreenState extends ConsumerState { var _headerBarReady = false; var _nativeHeaderBarAvailable = false; TaskProvider? _connectingProvider; + final _removingAccountIds = {}; @override void initState() { @@ -90,10 +95,9 @@ class _SettingsScreenState extends ConsumerState { onReconnect: (account) => unawaited(_connectAccount(account.provider)), onCreateTaskList: (accountId) => _createTaskList(context, ref, accountId), - onSignOut: (accountId) => _signOut(context, ref, accountId), - onDisconnect: (accountId) => _disconnect(context, ref, accountId), - onDeleteLocalData: (accountId) => - _deleteLocalData(context, ref, accountId), + removingAccountIds: _removingAccountIds, + onRemoveAccount: (account) => + unawaited(_removeAccount(context, ref, account)), ), SettingsPage.schedule => BusyMaxGroupedList( title: l10n.scheduleDisplaySettings, @@ -449,17 +453,6 @@ class _SettingsScreenState extends ConsumerState { }); } - Future _signOut( - BuildContext context, - WidgetRef ref, - String accountId, - ) async { - await ref.read(authRepositoryProvider).signOut(accountId: accountId); - if (context.mounted) { - await _afterAccountRemoved(context, ref, accountId); - } - } - Future _connectAccount(TaskProvider provider) async { if (_connectingProvider != null) { return; @@ -506,42 +499,48 @@ class _SettingsScreenState extends ConsumerState { } } - Future _disconnect( + Future _removeAccount( BuildContext context, WidgetRef ref, - String accountId, + AccountEntity account, ) async { - await ref - .read(authRepositoryProvider) - .revokeAndSignOut(accountId: accountId); - if (context.mounted) { - await _afterAccountRemoved(context, ref, accountId); + if (_removingAccountIds.contains(account.id)) { + return; } - } - Future _deleteLocalData( - BuildContext context, - WidgetRef ref, - String accountId, - ) async { - final confirmed = await showBusyMaxConfirm( + final options = await showBusyMaxAccountRemovalDialog( context, - title: context.l10n.deleteLocalData, - message: context.l10n.deleteLocalDataConfirmation, - confirmLabel: context.l10n.delete, - destructive: true, + accountLabel: account.displayLabel, + canRevokeGoogleAuthorization: + account.provider == TaskProvider.google && account.isSignedIn, headerBarService: ref.read(linuxHeaderBarServiceProvider), ); - if (!context.mounted || !confirmed) { + if (!context.mounted || options == null) { return; } - if (context.mounted) { - await ref + setState(() => _removingAccountIds.add(account.id)); + try { + final result = await ref .read(authRepositoryProvider) - .deleteLocalAccountData(accountId: accountId); + .removeAccount( + accountId: account.id, + revokeAuthorization: options.revokeGoogleAuthorization, + ); if (context.mounted) { - await _afterAccountRemoved(context, ref, accountId); + if (result.authorizationRevocationFailed) { + _showMessage(context, context.l10n.accountRemovedGoogleRevokeFailed); + } + await _afterAccountRemoved(context, ref, account.id); + } + } on Object catch (error) { + _settingsLogger.warning('Account removal failed: $error'); + if (context.mounted) { + _showMessage(context, context.l10n.removeAccountFailed); + } + } finally { + if (mounted) { + setState(() => _removingAccountIds.remove(account.id)); } } } @@ -805,9 +804,8 @@ class _AccountManagementSection extends StatelessWidget { required this.onAddMicrosoft, required this.onReconnect, required this.onCreateTaskList, - required this.onSignOut, - required this.onDisconnect, - required this.onDeleteLocalData, + required this.removingAccountIds, + required this.onRemoveAccount, }); final List accounts; @@ -818,9 +816,8 @@ class _AccountManagementSection extends StatelessWidget { final VoidCallback onAddMicrosoft; final void Function(AccountEntity account) onReconnect; final void Function(String accountId) onCreateTaskList; - final void Function(String accountId) onSignOut; - final void Function(String accountId) onDisconnect; - final void Function(String accountId) onDeleteLocalData; + final Set removingAccountIds; + final void Function(AccountEntity account) onRemoveAccount; @override Widget build(BuildContext context) { @@ -860,11 +857,12 @@ class _AccountManagementSection extends StatelessWidget { for (final account in accounts) _AccountManagementCard( account: account, - onReconnect: connecting ? null : () => onReconnect(account), + removing: removingAccountIds.contains(account.id), + onReconnect: connecting || removingAccountIds.contains(account.id) + ? null + : () => onReconnect(account), onCreateTaskList: () => onCreateTaskList(account.id), - onSignOut: () => onSignOut(account.id), - onDisconnect: () => onDisconnect(account.id), - onDeleteLocalData: () => onDeleteLocalData(account.id), + onRemoveAccount: () => onRemoveAccount(account), ), ], ); @@ -874,19 +872,17 @@ class _AccountManagementSection extends StatelessWidget { class _AccountManagementCard extends StatelessWidget { const _AccountManagementCard({ required this.account, + required this.removing, required this.onReconnect, required this.onCreateTaskList, - required this.onSignOut, - required this.onDisconnect, - required this.onDeleteLocalData, + required this.onRemoveAccount, }); final AccountEntity account; + final bool removing; final VoidCallback? onReconnect; final VoidCallback onCreateTaskList; - final VoidCallback onSignOut; - final VoidCallback onDisconnect; - final VoidCallback onDeleteLocalData; + final VoidCallback onRemoveAccount; @override Widget build(BuildContext context) { @@ -910,27 +906,18 @@ class _AccountManagementCard extends StatelessWidget { BusyMaxActionRow( title: l10n.newList, leading: const Icon(YaruIcons.plus), - onTap: onCreateTaskList, - ), - BusyMaxActionRow( - title: l10n.signOutThisAccount, - leading: const Icon(YaruIcons.log_out), - onTap: onSignOut, + onTap: removing ? null : onCreateTaskList, ), ], BusyMaxActionRow( - title: l10n.disconnectThisAccount, - leading: const Icon(YaruIcons.insert_link), - onTap: onDisconnect, - ), - BusyMaxActionRow( - title: l10n.deleteLocalDataForThisAccount, + title: removing ? l10n.removingAccount : l10n.removeAccount, + subtitle: l10n.removeAccountDescription, leading: Icon( YaruIcons.trash, color: Theme.of(context).colorScheme.error, ), destructive: true, - onTap: onDeleteLocalData, + onTap: removing ? null : onRemoveAccount, ), ], ); @@ -942,10 +929,6 @@ Future _afterAccountRemoved( WidgetRef ref, String removedAccountId, ) async { - ref.read(selectedTaskListIdProvider.notifier).state = null; - ref.read(selectedTaskIdProvider.notifier).state = null; - ref.read(allTasksModeProvider.notifier).state = true; - final accounts = await ref .read(accountsRepositoryProvider) .listSignedInAccounts(); diff --git a/lib/src/features/sync/account_sync_operations.dart b/lib/src/features/sync/account_sync_operations.dart new file mode 100644 index 0000000..1b72a10 --- /dev/null +++ b/lib/src/features/sync/account_sync_operations.dart @@ -0,0 +1,50 @@ +typedef AccountSyncAction = + Future Function(String accountId, {required bool full}); + +abstract interface class AccountSyncOperations { + Future syncAccount(String accountId, {required bool full}); + + Future syncTasks(String accountId, {required bool full}); + + Future syncCalendar(String accountId, {required bool full}); +} + +final class DelegatingAccountSyncOperations implements AccountSyncOperations { + const DelegatingAccountSyncOperations({ + required AccountSyncAction syncTasks, + required AccountSyncAction syncCalendar, + }) : _syncTasks = syncTasks, + _syncCalendar = syncCalendar; + + final AccountSyncAction _syncTasks; + final AccountSyncAction _syncCalendar; + + @override + Future syncAccount(String accountId, {required bool full}) async { + await syncTasks(accountId, full: full); + await syncCalendar(accountId, full: full); + } + + @override + Future syncTasks(String accountId, {required bool full}) { + return _syncTasks(accountId, full: full); + } + + @override + Future syncCalendar(String accountId, {required bool full}) { + return _syncCalendar(accountId, full: full); + } +} + +final class DisabledAccountSyncOperations implements AccountSyncOperations { + const DisabledAccountSyncOperations(); + + @override + Future syncAccount(String accountId, {required bool full}) async {} + + @override + Future syncTasks(String accountId, {required bool full}) async {} + + @override + Future syncCalendar(String accountId, {required bool full}) async {} +} diff --git a/lib/src/features/task_lists/presentation/task_lists_sidebar.dart b/lib/src/features/task_lists/presentation/task_lists_sidebar.dart deleted file mode 100644 index b90325b..0000000 --- a/lib/src/features/task_lists/presentation/task_lists_sidebar.dart +++ /dev/null @@ -1,682 +0,0 @@ -import 'dart:async'; -import 'dart:convert'; - -import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:go_router/go_router.dart'; -import 'package:yaru/yaru.dart'; - -import '../../../app/app_bootstrap.dart'; -import '../../../app/busymax_design.dart'; -import '../../../app/busymax_dialogs.dart'; -import '../../../features/accounts/data/accounts_repository.dart'; -import '../../../l10n/l10n.dart'; -import '../../../task_providers/task_provider.dart'; -import '../../tasks/presentation/tasks_selection_state.dart'; -import '../../sync/sync_auth_error.dart'; -import '../data/task_lists_repository.dart'; - -class TaskListsSidebar extends ConsumerWidget { - const TaskListsSidebar({super.key}); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final selectedListId = ref.watch(selectedTaskListIdProvider); - final selectedAccount = ref.watch(selectedAccountProvider); - final allTasksMode = ref.watch(allTasksModeProvider); - final accounts = ref.watch(accountsStreamProvider).valueOrNull ?? const []; - final l10n = context.l10n; - - return SizedBox( - width: BusyMaxSizes.sidebarWidth, - child: BusyMaxSidebarSurface( - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - const _SidebarHeader(), - const Divider(height: 1), - Expanded( - child: accounts.isEmpty - ? Center(child: Text(l10n.signInToViewTaskLists)) - : ListView( - padding: const EdgeInsets.fromLTRB( - BusyMaxSpacing.sm, - BusyMaxSpacing.sm, - BusyMaxSpacing.sm, - BusyMaxSpacing.sm, - ), - children: [ - _AllTasksRow( - selected: allTasksMode, - onTap: () => _selectAllTasks(ref), - ), - Padding( - padding: const EdgeInsets.fromLTRB( - BusyMaxSpacing.sm, - BusyMaxSpacing.lg, - BusyMaxSpacing.sm, - BusyMaxSpacing.xs, - ), - child: Text( - l10n.accounts, - style: busyMaxSectionHeaderStyle(context), - ), - ), - for (final account in accounts) - _AccountTaskListsSection( - key: ValueKey(account.id), - account: account, - repository: ref.watch( - taskListsRepositoryForAccountProvider(account.id), - ), - selected: - !allTasksMode && - account.id == selectedAccount?.id, - selectedListId: - !allTasksMode && - account.id == selectedAccount?.id - ? selectedListId - : null, - onSelectList: (list) => - _selectTaskList(ref, account.id, list.id), - onCreateList: (repository) => - _createList(context, repository), - onRenameList: (repository, list) => - _renameList(context, repository, list), - onDeleteList: (repository, list) => - _deleteList(context, ref, repository, list), - ), - ], - ), - ), - const Divider(height: 1), - Padding( - padding: const EdgeInsets.fromLTRB( - BusyMaxSpacing.sm, - BusyMaxSpacing.sm, - BusyMaxSpacing.sm, - BusyMaxSpacing.sm, - ), - child: _SidebarFooterButton( - icon: YaruIcons.settings, - label: l10n.settings, - onTap: () => unawaited(context.push('/settings')), - ), - ), - ], - ), - ), - ); - } - - Future _renameList( - BuildContext context, - TaskListsRepository repository, - TaskListEntity list, - ) async { - final title = await showBusyMaxTextPrompt( - context, - title: context.l10n.renameList, - label: context.l10n.title, - actionLabel: context.l10n.rename, - initialValue: list.title, - ); - if (title == null || title.trim().isEmpty || title.trim() == list.title) { - return; - } - await repository.renameTaskList(list.id, title.trim()); - } - - Future _createList( - BuildContext context, - TaskListsRepository repository, - ) async { - final title = await showBusyMaxTextPrompt( - context, - title: context.l10n.newList, - label: context.l10n.title, - actionLabel: context.l10n.create, - ); - if (title == null || title.trim().isEmpty) { - return; - } - await repository.createTaskList(title.trim()); - } - - Future _deleteList( - BuildContext context, - WidgetRef ref, - TaskListsRepository repository, - TaskListEntity list, - ) async { - final confirmed = await showBusyMaxConfirm( - context, - title: context.l10n.deleteList, - message: context.l10n.deleteListConfirmation(list.title), - confirmLabel: context.l10n.delete, - destructive: true, - ); - if (!confirmed) { - return; - } - await repository.deleteTaskList(list.id); - final selectedListId = ref.read(selectedTaskListIdProvider); - if (selectedListId == list.id) { - ref.read(selectedTaskListIdProvider.notifier).state = null; - ref.read(selectedTaskIdProvider.notifier).state = null; - ref.read(allTasksModeProvider.notifier).state = true; - } - } - - void _selectAllTasks(WidgetRef ref) { - ref.read(selectedTaskListIdProvider.notifier).state = null; - ref.read(selectedTaskIdProvider.notifier).state = null; - ref.read(allTasksModeProvider.notifier).state = true; - } - - void _selectTaskList(WidgetRef ref, String accountId, String taskListId) { - final currentAccountId = ref.read(selectedAccountProvider)?.id; - if (currentAccountId != accountId) { - ref.read(selectedAccountIdProvider.notifier).state = accountId; - unawaited(_syncSelectedAccount(ref, accountId)); - } - - ref.read(selectedTaskListIdProvider.notifier).state = taskListId; - ref.read(selectedTaskIdProvider.notifier).state = null; - ref.read(allTasksModeProvider.notifier).state = false; - } -} - -Future _syncSelectedAccount(WidgetRef ref, String accountId) async { - try { - await ref.read(signedInSyncRunnerProvider)(accountId, false); - } on Object catch (error) { - if (!isMissingOAuthTokenError(error)) { - return; - } - try { - await ref.read(authRepositoryProvider).markReconnectRequired(accountId); - } on Object { - // The selected-account change should not surface an unhandled sync error. - } - } -} - -class _AccountTaskListsSection extends StatefulWidget { - const _AccountTaskListsSection({ - super.key, - required this.account, - required this.repository, - required this.selected, - required this.selectedListId, - required this.onSelectList, - required this.onCreateList, - required this.onRenameList, - required this.onDeleteList, - }); - - final AccountEntity account; - final TaskListsRepository repository; - final bool selected; - final String? selectedListId; - final void Function(TaskListEntity list) onSelectList; - final Future Function(TaskListsRepository repository) onCreateList; - final Future Function( - TaskListsRepository repository, - TaskListEntity list, - ) - onRenameList; - final Future Function( - TaskListsRepository repository, - TaskListEntity list, - ) - onDeleteList; - - @override - State<_AccountTaskListsSection> createState() => - _AccountTaskListsSectionState(); -} - -class _AccountTaskListsSectionState extends State<_AccountTaskListsSection> { - late Stream> _taskLists; - var _expanded = true; - - @override - void initState() { - super.initState(); - _taskLists = _watchTaskLists(); - } - - @override - void didUpdateWidget(covariant _AccountTaskListsSection oldWidget) { - super.didUpdateWidget(oldWidget); - if (oldWidget.repository != widget.repository || - oldWidget.account.id != widget.account.id) { - _taskLists = _watchTaskLists(); - _expanded = true; - } - } - - Stream> _watchTaskLists() { - return widget.repository.watchTaskLists(); - } - - @override - Widget build(BuildContext context) { - final l10n = context.l10n; - - return Padding( - padding: const EdgeInsets.only(bottom: 6), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - _AccountSectionHeader( - account: widget.account, - expanded: _expanded, - onCreateList: () => widget.onCreateList(widget.repository), - onTap: () { - setState(() { - _expanded = !_expanded; - }); - }, - ), - StreamBuilder>( - stream: _taskLists, - builder: (context, snapshot) { - if (!_expanded) { - return const SizedBox.shrink(); - } - - final lists = snapshot.data ?? const []; - if (lists.isEmpty) { - return Padding( - padding: const EdgeInsets.fromLTRB(48, 6, 10, 8), - child: Text( - l10n.noTaskListsSynced, - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - ), - ); - } - - return Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [for (final list in lists) _buildListRow(list)], - ); - }, - ), - ], - ), - ); - } - - Widget _buildListRow(TaskListEntity list) { - final isMicrosoft = widget.account.provider == TaskProvider.microsoft; - final canRenameDelete = !isMicrosoft || list.canRenameOrDeleteForMicrosoft; - return _TaskListRow( - list: list, - selected: widget.selected && list.id == widget.selectedListId, - isMicrosoft: isMicrosoft, - canRenameDelete: canRenameDelete, - onTap: () => widget.onSelectList(list), - onRename: canRenameDelete - ? () => widget.onRenameList(widget.repository, list) - : null, - onDelete: canRenameDelete - ? () => widget.onDeleteList(widget.repository, list) - : null, - ); - } -} - -class _AllTasksRow extends StatelessWidget { - const _AllTasksRow({required this.selected, required this.onTap}); - - final bool selected; - final VoidCallback onTap; - - @override - Widget build(BuildContext context) { - return _SidebarSelectableTile( - selected: selected, - padding: EdgeInsets.zero, - onTap: onTap, - leading: const Icon(Icons.all_inbox, size: 18), - title: Text(context.l10n.allTasks), - ); - } -} - -class _AccountSectionHeader extends StatelessWidget { - const _AccountSectionHeader({ - required this.account, - required this.expanded, - required this.onTap, - required this.onCreateList, - }); - - final AccountEntity account; - final bool expanded; - final VoidCallback onTap; - final VoidCallback onCreateList; - - @override - Widget build(BuildContext context) { - return Padding( - padding: EdgeInsets.zero, - child: YaruListTile( - onTap: onTap, - leading: Icon(_providerIcon(account.provider), size: 18), - title: Text( - _providerHeaderLabel(context, account.provider), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - subtitle: Text( - _accountIdentityLabel(context, account), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - trailing: Row( - mainAxisSize: MainAxisSize.min, - children: [ - YaruIconButton( - tooltip: context.l10n.newList, - iconSize: 28, - icon: const Icon(YaruIcons.plus), - onPressed: onCreateList, - ), - AnimatedRotation( - turns: expanded ? 0.25 : 0, - duration: const Duration(milliseconds: 160), - child: const Icon(YaruIcons.pan_end, size: 16), - ), - ], - ), - ), - ); - } -} - -class _SidebarSelectableTile extends StatelessWidget { - const _SidebarSelectableTile({ - required this.selected, - required this.title, - this.leading, - this.trailing, - this.onTap, - this.padding = const EdgeInsets.symmetric(horizontal: 8), - }); - - final bool selected; - final Widget title; - final Widget? leading; - final Widget? trailing; - final VoidCallback? onTap; - final EdgeInsetsGeometry padding; - - @override - Widget build(BuildContext context) { - return Padding( - padding: padding, - child: YaruSelectableContainer( - selected: selected, - selectionColor: Theme.of(context).listTileTheme.selectedTileColor, - padding: EdgeInsets.zero, - onTap: onTap, - child: YaruListTile(leading: leading, title: title, trailing: trailing), - ), - ); - } -} - -class _SidebarHeader extends StatelessWidget { - const _SidebarHeader(); - - @override - Widget build(BuildContext context) { - final l10n = context.l10n; - return Padding( - padding: const EdgeInsets.fromLTRB(16, 16, 12, 12), - child: LayoutBuilder( - builder: (context, constraints) { - final logo = Image.asset( - 'assets/branding/busymax-logo.png', - width: 26, - height: 26, - errorBuilder: (context, error, stackTrace) => - const Icon(YaruIcons.task_list_filled, size: 26), - ); - if (constraints.maxWidth < 74) { - return Align( - alignment: AlignmentDirectional.centerStart, - child: FittedBox(fit: BoxFit.scaleDown, child: logo), - ); - } - - return Row( - children: [ - logo, - const SizedBox(width: 10), - Expanded( - child: Text( - l10n.appTitle, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.titleMedium, - ), - ), - ], - ); - }, - ), - ); - } -} - -class _TaskListRow extends StatelessWidget { - const _TaskListRow({ - required this.list, - required this.selected, - required this.isMicrosoft, - required this.canRenameDelete, - required this.onTap, - required this.onRename, - required this.onDelete, - }); - - final TaskListEntity list; - final bool selected; - final bool isMicrosoft; - final bool canRenameDelete; - final VoidCallback onTap; - final Future Function()? onRename; - final Future Function()? onDelete; - - @override - Widget build(BuildContext context) { - final l10n = context.l10n; - return _SidebarSelectableTile( - selected: selected, - padding: const EdgeInsets.only( - left: 34, - top: BusyMaxSpacing.xxs, - bottom: BusyMaxSpacing.xxs, - ), - onTap: onTap, - leading: Icon( - list.localDirty ? YaruIcons.sync_error : YaruIcons.task_list, - size: 17, - ), - title: Text(list.title, maxLines: 1, overflow: TextOverflow.ellipsis), - trailing: Row( - mainAxisSize: MainAxisSize.min, - children: [ - if (isMicrosoft && list.isMicrosoftBuiltIn) - Tooltip( - message: l10n.builtInMicrosoftListCannotRenameDelete, - child: SizedBox( - width: 58, - child: BusyMaxInlineBadge( - label: l10n.builtInMicrosoftList, - tooltip: l10n.builtInMicrosoftListCannotRenameDelete, - ), - ), - ), - if (list.pendingDelete) - const Icon(YaruIcons.trash, size: 16) - else - Opacity( - opacity: 0.72, - child: BusyMaxMenuButton<_TaskListAction>( - tooltip: l10n.listActions, - onSelected: (action) { - switch (action) { - case _TaskListAction.rename: - if (canRenameDelete) { - onRename?.call(); - } - break; - case _TaskListAction.delete: - if (canRenameDelete) { - onDelete?.call(); - } - break; - } - }, - entries: [ - BusyMaxMenuEntry( - value: _TaskListAction.rename, - label: l10n.rename, - icon: Icons.edit_outlined, - enabled: canRenameDelete, - ), - BusyMaxMenuEntry( - value: _TaskListAction.delete, - label: l10n.delete, - icon: YaruIcons.trash, - enabled: canRenameDelete, - destructive: true, - ), - ], - ), - ), - ], - ), - ); - } -} - -enum _TaskListAction { rename, delete } - -class _SidebarFooterButton extends StatelessWidget { - const _SidebarFooterButton({ - required this.icon, - required this.label, - required this.onTap, - }); - - final IconData icon; - final String label; - final VoidCallback onTap; - - @override - Widget build(BuildContext context) { - return YaruListTile( - titleText: label, - leading: Icon(icon, size: 17), - onTap: onTap, - ); - } -} - -IconData _providerIcon(TaskProvider? provider) { - return switch (provider) { - TaskProvider.microsoft => Icons.account_circle_outlined, - TaskProvider.google => YaruIcons.globe, - null => YaruIcons.user, - }; -} - -String _providerHeaderLabel(BuildContext context, TaskProvider provider) { - final l10n = context.l10n; - return switch (provider) { - TaskProvider.google => l10n.googleTasksProvider, - TaskProvider.microsoft => l10n.microsoftTodoProvider, - }; -} - -String _accountIdentityLabel(BuildContext context, AccountEntity account) { - final metadata = _accountMetadata(account); - final name = _firstAccountIdentityValue([ - account.displayName, - metadata['displayName'], - metadata['name'], - ]); - final email = _firstAccountIdentityValue([ - account.email, - metadata['email'], - metadata['mail'], - metadata['userPrincipalName'], - ]); - final providerName = account.provider.displayName.trim(); - - if (name != null && - name.isNotEmpty && - email != null && - email.isNotEmpty && - name != email) { - return '$name · $email'; - } - - if (email != null && email.isNotEmpty) { - return email; - } - - final providerAccountId = account.providerAccountId?.trim(); - if (providerAccountId != null && - providerAccountId.isNotEmpty && - providerAccountId.contains('@')) { - return providerAccountId; - } - - if (name != null && name.isNotEmpty && name != providerName) { - return name; - } - - return context.l10n.signedInAccount; -} - -Map _accountMetadata(AccountEntity account) { - final jsonText = account.providerMetadataJson; - if (jsonText == null || jsonText.trim().isEmpty) { - return const {}; - } - - try { - final decoded = jsonDecode(jsonText); - if (decoded is! Map) { - return const {}; - } - return { - for (final entry in decoded.entries) - if (entry.key is String && - entry.value is String && - (entry.value as String).trim().isNotEmpty) - entry.key as String: (entry.value as String).trim(), - }; - } on FormatException { - return const {}; - } -} - -String? _firstAccountIdentityValue(Iterable values) { - for (final value in values) { - final trimmed = value?.trim(); - if (trimmed != null && trimmed.isNotEmpty) { - return trimmed; - } - } - return null; -} diff --git a/lib/src/features/tasks/presentation/desktop_date_time_fields.dart b/lib/src/features/tasks/presentation/desktop_date_time_fields.dart index 7c90875..b0bc85b 100644 --- a/lib/src/features/tasks/presentation/desktop_date_time_fields.dart +++ b/lib/src/features/tasks/presentation/desktop_date_time_fields.dart @@ -195,7 +195,7 @@ class _DesktopDateFieldState extends State { @override Widget build(BuildContext context) { - final dateEntry = _withoutInternalDateTimeEntryLabel( + final dateEntry = _withoutFloatingEntryLabel( context, YaruDateTimeEntry( controller: _controller, @@ -360,7 +360,7 @@ class _DesktopDateValueDialogState extends State<_DesktopDateValueDialog> { ), ], children: [ - _withoutInternalDateTimeEntryLabel( + _withoutFloatingEntryLabel( context, YaruDateTimeEntry( controller: _controller, @@ -482,46 +482,29 @@ class _DesktopTimeValueDialog extends StatefulWidget { } class _DesktopTimeValueDialogState extends State<_DesktopTimeValueDialog> { - late final TextEditingController _controller; - final _focusNode = FocusNode(); + late final YaruTimeEntryController _controller; + final _formKey = GlobalKey(); TimeOfDay? _selected; - var _invalid = false; @override void initState() { super.initState(); _selected = parseTimeOfDay(widget.time); - _controller = TextEditingController( - text: _selected == null ? '' : encodeTimeOfDay(_selected!), - ); - WidgetsBinding.instance.addPostFrameCallback((_) { - if (mounted) { - _focusNode.requestFocus(); - _controller.selection = TextSelection( - baseOffset: 0, - extentOffset: _controller.text.length, - ); - } - }); - } - - @override - void dispose() { - _controller.dispose(); - _focusNode.dispose(); - super.dispose(); + _controller = YaruTimeEntryController(timeOfDay: _selected); } @override Widget build(BuildContext context) { - final timeEntry = _BusyMaxTimeTextEntry( + final timeEntry = _BusyMaxTimeEntry( controller: _controller, - focusNode: _focusNode, label: widget.label, - errorText: _invalid - ? MaterialLocalizations.of(context).invalidTimeLabel - : null, - onChanged: _setSelectedTimeText, + acceptEmpty: widget.allowEmpty, + autofocus: true, + onChanged: (time) { + setState(() { + _selected = time; + }); + }, onSubmitted: (_) => _submit(), ); return BusyMaxDialogShell( @@ -533,27 +516,17 @@ class _DesktopTimeValueDialogState extends State<_DesktopTimeValueDialog> { child: Text(context.l10n.cancel), ), BusyMaxPushButton.suggested( - onPressed: !_invalid && (widget.allowEmpty || _selected != null) - ? _submit - : null, + onPressed: widget.allowEmpty || _selected != null ? _submit : null, child: Text(MaterialLocalizations.of(context).okButtonLabel), ), ], - children: [_withoutInternalDateTimeEntryLabel(context, timeEntry)], + children: [Form(key: _formKey, child: timeEntry)], ); } - void _setSelectedTimeText(String value) { - final trimmed = value.trim(); - final parsed = parseTimeInput(trimmed); - setState(() { - _selected = parsed; - _invalid = trimmed.isNotEmpty && parsed == null; - }); - } - void _submit() { - if (_invalid || (!widget.allowEmpty && _selected == null)) { + if (!(_formKey.currentState?.validate() ?? false) || + (!widget.allowEmpty && _selected == null)) { return; } widget.onChanged(_selected == null ? null : encodeTimeOfDay(_selected!)); @@ -562,15 +535,14 @@ class _DesktopTimeValueDialogState extends State<_DesktopTimeValueDialog> { } class _DesktopTimeFieldState extends State { - late final TextEditingController _controller; + late final YaruTimeEntryController _controller; var _syncingController = false; @override void initState() { super.initState(); - final time = parseTimeOfDay(widget.time); - _controller = TextEditingController( - text: time == null ? '' : encodeTimeOfDay(time), + _controller = YaruTimeEntryController( + timeOfDay: parseTimeOfDay(widget.time), ); } @@ -579,38 +551,26 @@ class _DesktopTimeFieldState extends State { super.didUpdateWidget(oldWidget); if (oldWidget.time != widget.time) { final nextTime = parseTimeOfDay(widget.time); - final nextText = nextTime == null ? '' : encodeTimeOfDay(nextTime); - if (_controller.text != nextText) { + if (_controller.timeOfDay != nextTime) { _syncingController = true; - _controller.text = nextText; + _controller.timeOfDay = nextTime; _syncingController = false; } } } - @override - void dispose() { - _controller.dispose(); - super.dispose(); - } - @override Widget build(BuildContext context) { - final timeEntry = _withoutInternalDateTimeEntryLabel( - context, - _BusyMaxTimeTextEntry( - controller: _controller, - label: widget.label, - onChanged: (time) { - if (_syncingController) { - return; - } - final parsed = parseTimeInput(time); - if (time.trim().isEmpty || parsed != null) { - widget.onChanged(parsed == null ? null : encodeTimeOfDay(parsed)); - } - }, - ), + final timeEntry = _BusyMaxTimeEntry( + controller: _controller, + label: widget.label, + acceptEmpty: true, + onChanged: (time) { + if (_syncingController) { + return; + } + widget.onChanged(time == null ? null : encodeTimeOfDay(time)); + }, ); return YaruListTile.square( leading: const Icon(Icons.schedule), @@ -629,100 +589,54 @@ class _DesktopTimeFieldState extends State { } } -class _BusyMaxTimeTextEntry extends StatelessWidget { - const _BusyMaxTimeTextEntry({ +class _BusyMaxTimeEntry extends StatelessWidget { + const _BusyMaxTimeEntry({ required this.controller, required this.label, + required this.acceptEmpty, required this.onChanged, - this.focusNode, - this.errorText, + this.autofocus = false, this.onSubmitted, }); - final TextEditingController controller; - final FocusNode? focusNode; + final YaruTimeEntryController controller; final String label; - final ValueChanged onChanged; - final ValueChanged? onSubmitted; - final String? errorText; + final bool acceptEmpty; + final bool autofocus; + final ValueChanged onChanged; + final ValueChanged? onSubmitted; @override Widget build(BuildContext context) { - return TextFormField( - controller: controller, - focusNode: focusNode, - keyboardType: TextInputType.datetime, - textInputAction: TextInputAction.done, - textAlign: TextAlign.center, - inputFormatters: [ - FilteringTextInputFormatter.allow(RegExp('[0-9:]')), - const _BusyMaxTimeInputFormatter(), - ], - decoration: InputDecoration( - hintText: '--:--', - labelText: label, - isDense: true, - errorText: errorText, + final localizations = MaterialLocalizations.of(context); + return _withoutFloatingEntryLabel( + context, + YaruTimeEntry( + controller: controller, + autofocus: autofocus, + force24HourFormat: MediaQuery.alwaysUse24HourFormatOf(context) + ? true + : null, + acceptEmpty: acceptEmpty, + clearIconSemanticLabel: label, + errorFormatText: localizations.invalidTimeLabel, + errorInvalidText: localizations.invalidTimeLabel, + onChanged: onChanged, + onFieldSubmitted: onSubmitted, ), - onChanged: onChanged, - onFieldSubmitted: onSubmitted, - ); - } -} - -class _BusyMaxTimeInputFormatter extends TextInputFormatter { - const _BusyMaxTimeInputFormatter(); - - @override - TextEditingValue formatEditUpdate( - TextEditingValue oldValue, - TextEditingValue newValue, - ) { - final text = _formatTimeEntryInput(newValue.text); - return TextEditingValue( - text: text, - selection: TextSelection.collapsed(offset: text.length), ); } } -String _formatTimeEntryInput(String value) { - if (value.contains(':')) { - return value.length <= 5 ? value : value.substring(0, 5); - } - - final digits = value.length <= 4 ? value : value.substring(0, 4); - if (digits.length <= 2) { - return digits; - } - - if (digits.length == 3) { - final twoDigitHour = int.tryParse(digits.substring(0, 2)); - if (twoDigitHour != null && twoDigitHour <= 23) { - return '${digits.substring(0, 2)}:${digits.substring(2)}'; - } - return '${digits.substring(0, 1)}:${digits.substring(1)}'; - } - - return '${digits.substring(0, 2)}:${digits.substring(2)}'; -} - -Widget _withoutInternalDateTimeEntryLabel(BuildContext context, Widget child) { +Widget _withoutFloatingEntryLabel(BuildContext context, Widget child) { // The date/time rows already provide the visible label through // YaruListTile.square, so field labels are hidden here to avoid duplicates. final theme = Theme.of(context); - const hiddenLabelStyle = TextStyle( - color: Colors.transparent, - fontSize: 0, - height: 0, - ); return Theme( data: theme.copyWith( inputDecorationTheme: theme.inputDecorationTheme.copyWith( floatingLabelBehavior: FloatingLabelBehavior.never, - labelStyle: hiddenLabelStyle, - floatingLabelStyle: hiddenLabelStyle, ), ), child: child, @@ -805,47 +719,6 @@ TimeOfDay? parseTimeOfDay(String? time) { return TimeOfDay(hour: hour, minute: minute); } -TimeOfDay? parseTimeInput(String? time) { - final trimmed = time?.trim(); - if (trimmed == null || trimmed.isEmpty) { - return null; - } - - if (trimmed.contains(':')) { - final parts = trimmed.split(':'); - if (parts.length != 2 || parts.first.isEmpty || parts.last.isEmpty) { - return null; - } - return _timeOfDayFromParts(parts.first, parts.last); - } - - if (trimmed.length <= 2) { - return _timeOfDayFromParts(trimmed, '00'); - } - if (trimmed.length == 3) { - return _timeOfDayFromParts(trimmed.substring(0, 1), trimmed.substring(1)); - } - if (trimmed.length == 4) { - return _timeOfDayFromParts(trimmed.substring(0, 2), trimmed.substring(2)); - } - - return null; -} - -TimeOfDay? _timeOfDayFromParts(String hourText, String minuteText) { - final hour = int.tryParse(hourText); - final minute = int.tryParse(minuteText); - if (hour == null || - minute == null || - hour < 0 || - hour > 23 || - minute < 0 || - minute > 59) { - return null; - } - return TimeOfDay(hour: hour, minute: minute); -} - String encodeDateOnly(DateTime date) { return '${date.year.toString().padLeft(4, '0')}-' '${date.month.toString().padLeft(2, '0')}-' diff --git a/lib/src/features/tasks/presentation/task_details_editor.dart b/lib/src/features/tasks/presentation/task_details_editor.dart index bd6ae37..7a201a1 100644 --- a/lib/src/features/tasks/presentation/task_details_editor.dart +++ b/lib/src/features/tasks/presentation/task_details_editor.dart @@ -97,7 +97,6 @@ class TaskDetailsEditor extends StatefulWidget { class _TaskDetailsEditorState extends State { final _titleController = TextEditingController(); final _notesController = TextEditingController(); - final _categoryController = TextEditingController(); final _shortcutFocusNode = FocusNode(debugLabel: 'Task editor shortcuts'); TaskDetailsDraft? _draft; @@ -142,7 +141,6 @@ class _TaskDetailsEditorState extends State { _shortcutFocusNode.dispose(); _titleController.dispose(); _notesController.dispose(); - _categoryController.dispose(); super.dispose(); } @@ -580,7 +578,6 @@ class _TaskDetailsEditorState extends State { categories: draft.categories, suggestions: widget.categorySuggestions, adding: _addingCategory, - controller: _categoryController, inputKey: const Key('task-category-input'), onAddPressed: () { setState(() { @@ -589,7 +586,6 @@ class _TaskDetailsEditorState extends State { }, onSubmitted: (value) => _addCategory(draft, value), onCancelAdding: () { - _categoryController.clear(); setState(() { _addingCategory = false; }); @@ -601,10 +597,12 @@ class _TaskDetailsEditorState extends State { void _addCategory(TaskDetailsDraft draft, String value) { final currentDraft = _draft ?? draft; final category = value.trim(); - if (category.isEmpty || currentDraft.categories.contains(category)) { + if (category.isEmpty || + currentDraft.categories.any( + (existing) => existing.toLowerCase() == category.toLowerCase(), + )) { return; } - _categoryController.clear(); setState(() { _addingCategory = false; }); diff --git a/lib/src/features/tasks/presentation/task_details_pane.dart b/lib/src/features/tasks/presentation/task_details_pane.dart index 51138c4..ccc7166 100644 --- a/lib/src/features/tasks/presentation/task_details_pane.dart +++ b/lib/src/features/tasks/presentation/task_details_pane.dart @@ -123,15 +123,33 @@ class _TaskDetailsPaneState extends ConsumerState { final repository = _tasksRepository(ref, _effectiveAccountId); final listsRepository = _taskListsRepository(ref, _effectiveAccountId); final localTimeZone = ref.watch(localTimeZoneProvider); - final accounts = ref.watch(accountsStreamProvider).valueOrNull ?? const []; - final account = _accountForId(accounts, _effectiveAccountId); - final provider = - account?.provider ?? _providerForAccountId(_effectiveAccountId); - final capabilities = capabilitiesForProvider(provider); + final accounts = ref.watch(accountsStreamProvider); + final storedAccount = _accountForId( + accounts.valueOrNull ?? const [], + _effectiveAccountId, + ); + final cachedAccount = _lastAccount?.id == _effectiveAccountId + ? _lastAccount + : null; + final account = + storedAccount ?? + ((accounts.isLoading || accounts.hasError || _editorDirty) + ? cachedAccount + : null); final taskStream = _watchTask(repository); final listsStream = _watchTaskLists(listsRepository); final categorySuggestionsStream = _watchCategorySuggestions(repository); + if (account == null) { + if (accounts.hasValue && !accounts.isLoading) { + WidgetsBinding.instance.addPostFrameCallback( + (_) => widget.onClose?.call(), + ); + } + return const SizedBox.shrink(); + } + final capabilities = capabilitiesForProvider(account.provider); + return StreamBuilder( stream: taskStream, builder: (context, taskSnapshot) { @@ -144,7 +162,7 @@ class _TaskDetailsPaneState extends ConsumerState { taskLists: _lastTaskLists, capabilities: _lastCapabilities ?? capabilities, localTimeZone: _lastLocalTimeZone ?? localTimeZone, - account: _lastAccount, + account: cachedAccount ?? account, categorySuggestions: _lastCategorySuggestions, ); } @@ -256,7 +274,7 @@ class _TaskDetailsPaneState extends ConsumerState { required List taskLists, required TaskProviderCapabilities capabilities, required String localTimeZone, - required AccountEntity? account, + required AccountEntity account, required List categorySuggestions, }) { return TaskDetailsEditor( @@ -265,11 +283,7 @@ class _TaskDetailsPaneState extends ConsumerState { capabilities: capabilities, localTimeZone: localTimeZone, categorySuggestions: categorySuggestions, - accountLabel: _accountEditorLabel( - context, - account, - account?.provider ?? _providerForAccountId(_effectiveAccountId), - ), + accountLabel: _accountEditorLabel(context, account), onRefresh: () { unawaited(_refreshTask(repository, task)); }, @@ -457,21 +471,7 @@ AccountEntity? _accountForId(List accounts, String accountId) { return null; } -TaskProvider _providerForAccountId(String accountId) { - return accountId.startsWith('microsoft:') - ? TaskProvider.microsoft - : TaskProvider.google; -} - -String _accountEditorLabel( - BuildContext context, - AccountEntity? account, - TaskProvider provider, -) { - if (account == null) { - return _providerEditorLabel(context, provider); - } - +String _accountEditorLabel(BuildContext context, AccountEntity account) { final metadata = _accountMetadata(account); final email = _firstDisplayIdentity([ account.email, diff --git a/lib/src/features/tasks/presentation/task_filters.dart b/lib/src/features/tasks/presentation/task_filters.dart deleted file mode 100644 index 6a766bc..0000000 --- a/lib/src/features/tasks/presentation/task_filters.dart +++ /dev/null @@ -1,179 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; - -import '../../../app/app_bootstrap.dart'; -import '../../../app/busymax_design.dart'; -import '../../../l10n/l10n.dart'; -import '../../../task_providers/task_provider.dart'; -import '../data/tasks_repository.dart'; -import 'tasks_selection_state.dart'; - -final taskViewFilterProvider = StateProvider( - (ref) => const TaskViewFilter(), -); - -final visibleTaskFilterCapabilitiesProvider = - Provider((ref) { - final allTasksMode = ref.watch(allTasksModeProvider); - if (!allTasksMode) { - return ref.watch(selectedAccountCapabilitiesProvider); - } - - final accounts = - ref.watch(accountsStreamProvider).valueOrNull ?? const []; - final providers = accounts.map((account) => account.provider).toSet(); - final capabilities = providers.map(capabilitiesForProvider).toList(); - - return TaskProviderCapabilities( - supportsDueDate: capabilities.any((item) => item.supportsDueDate), - supportsDueTime: capabilities.any((item) => item.supportsDueTime), - supportsStartDateTime: capabilities.any( - (item) => item.supportsStartDateTime, - ), - supportsReminderDateTime: capabilities.any( - (item) => item.supportsReminderDateTime, - ), - supportsRecurrence: capabilities.any((item) => item.supportsRecurrence), - supportsImportance: capabilities.any((item) => item.supportsImportance), - supportsCategories: capabilities.any((item) => item.supportsCategories), - supportsTaskHierarchy: capabilities.any( - (item) => item.supportsTaskHierarchy, - ), - supportsCrossListMove: capabilities.any( - (item) => item.supportsCrossListMove, - ), - supportsClearCompleted: capabilities.any( - (item) => item.supportsClearCompleted, - ), - supportsHiddenTasks: capabilities.any( - (item) => item.supportsHiddenTasks, - ), - supportsAssignedTasks: capabilities.any( - (item) => item.supportsAssignedTasks, - ), - supportsListRename: capabilities.any((item) => item.supportsListRename), - supportsListDelete: capabilities.any((item) => item.supportsListDelete), - ); - }); - -class TaskFiltersBar extends ConsumerStatefulWidget { - const TaskFiltersBar({super.key}); - - @override - ConsumerState createState() => _TaskFiltersBarState(); -} - -class _TaskFiltersBarState extends ConsumerState { - final _searchController = TextEditingController(); - - @override - void dispose() { - _searchController.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - final filter = ref.watch(taskViewFilterProvider); - final notifier = ref.read(taskViewFilterProvider.notifier); - final capabilities = ref.watch(visibleTaskFilterCapabilitiesProvider); - final l10n = context.l10n; - - if (_searchController.text != filter.searchQuery) { - _searchController.value = TextEditingValue( - text: filter.searchQuery, - selection: TextSelection.collapsed(offset: filter.searchQuery.length), - ); - } - - return Row( - children: [ - Expanded( - child: BusyMaxSearchField( - controller: _searchController, - autofocus: false, - hintText: l10n.searchTasks, - onClear: () { - notifier.state = filter.copyWith(searchQuery: ''); - }, - onChanged: (value) { - notifier.state = filter.copyWith(searchQuery: value); - }, - ), - ), - const SizedBox(width: BusyMaxSpacing.sm), - BusyMaxMenuButton<_FilterToggle>( - tooltip: l10n.advancedFilters, - onSelected: (toggle) { - switch (toggle) { - case _FilterToggle.completed: - notifier.state = filter.copyWith( - showCompleted: !filter.showCompleted, - ); - break; - case _FilterToggle.hidden: - notifier.state = filter.copyWith( - showHidden: !filter.showHidden, - ); - break; - case _FilterToggle.assigned: - notifier.state = filter.copyWith( - showAssigned: !filter.showAssigned, - ); - break; - } - }, - entries: [ - BusyMaxMenuEntry( - value: _FilterToggle.completed, - label: l10n.showCompleted, - checked: filter.showCompleted, - ), - BusyMaxMenuEntry( - value: _FilterToggle.hidden, - label: l10n.showHidden, - checked: filter.showHidden, - enabled: capabilities.supportsHiddenTasks, - ), - BusyMaxMenuEntry( - value: _FilterToggle.assigned, - label: l10n.showAssigned, - checked: filter.showAssigned, - enabled: capabilities.supportsAssignedTasks, - ), - ], - ), - ], - ); - } -} - -extension TaskViewFilterCopy on TaskViewFilter { - TaskViewFilter copyWith({ - bool? showCompleted, - bool? showDeleted, - bool? showHidden, - bool? showAssigned, - String? searchQuery, - DateTime? completedMin, - DateTime? completedMax, - DateTime? dueMin, - DateTime? dueMax, - DateTime? updatedMin, - }) { - return TaskViewFilter( - showCompleted: showCompleted ?? this.showCompleted, - showDeleted: showDeleted ?? this.showDeleted, - showHidden: showHidden ?? this.showHidden, - showAssigned: showAssigned ?? this.showAssigned, - searchQuery: searchQuery ?? this.searchQuery, - completedMin: completedMin ?? this.completedMin, - completedMax: completedMax ?? this.completedMax, - dueMin: dueMin ?? this.dueMin, - dueMax: dueMax ?? this.dueMax, - updatedMin: updatedMin ?? this.updatedMin, - ); - } -} - -enum _FilterToggle { completed, hidden, assigned } diff --git a/lib/src/features/tasks/presentation/task_row.dart b/lib/src/features/tasks/presentation/task_row.dart deleted file mode 100644 index 1ac69fb..0000000 --- a/lib/src/features/tasks/presentation/task_row.dart +++ /dev/null @@ -1,77 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:yaru/yaru.dart'; - -import '../../../app/busymax_design.dart'; - -class BusyMaxTaskRow extends StatelessWidget { - const BusyMaxTaskRow({ - super.key, - required this.title, - required this.completed, - required this.selected, - required this.checkbox, - this.metadata, - this.trailing, - this.depth = 0, - this.onTap, - }); - - final String title; - final bool completed; - final bool selected; - final Widget checkbox; - final String? metadata; - final Widget? trailing; - final int depth; - final VoidCallback? onTap; - - @override - Widget build(BuildContext context) { - final titleStyle = Theme.of(context).textTheme.bodyMedium?.copyWith( - decoration: completed ? TextDecoration.lineThrough : null, - ); - final metadataStyle = Theme.of(context).textTheme.bodySmall?.copyWith( - decoration: completed ? TextDecoration.lineThrough : null, - ); - - final content = YaruSelectableContainer( - selected: selected, - onTap: onTap, - padding: EdgeInsets.zero, - selectionColor: Theme.of(context).listTileTheme.selectedTileColor, - child: YaruListTile.square( - leading: Align(alignment: Alignment.topCenter, child: checkbox), - title: Text( - title, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: titleStyle, - ), - subtitle: metadata == null || metadata!.isEmpty - ? null - : Text( - metadata!, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: metadataStyle, - ), - trailing: trailing, - ), - ); - - return Padding( - padding: EdgeInsets.only( - left: BusyMaxSpacing.sm + depth * 22, - right: BusyMaxSpacing.sm, - top: BusyMaxSpacing.xxs, - bottom: BusyMaxSpacing.xxs, - ), - child: ConstrainedBox( - constraints: const BoxConstraints( - minHeight: BusyMaxSizes.taskRowMinHeight, - ), - child: content, - ), - ); - } -} diff --git a/lib/src/features/tasks/presentation/task_tree_view.dart b/lib/src/features/tasks/presentation/task_tree_view.dart deleted file mode 100644 index 1610575..0000000 --- a/lib/src/features/tasks/presentation/task_tree_view.dart +++ /dev/null @@ -1,463 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:yaru/yaru.dart'; - -import '../../../app/busymax_design.dart'; -import '../../../app/app_bootstrap.dart'; -import '../../../google_tasks/api/google_tasks_json.dart'; -import '../../../l10n/l10n.dart'; -import '../../../task_providers/task_provider.dart'; -import '../data/tasks_repository.dart'; -import 'desktop_date_time_fields.dart'; -import 'task_filters.dart'; -import 'task_row.dart'; -import 'tasks_selection_state.dart'; - -class TaskTreeView extends ConsumerWidget { - const TaskTreeView({ - super.key, - required this.taskListId, - required this.showAllTasks, - this.selectedTaskId, - this.onOpenTask, - this.onCreateTask, - this.onRefreshAll, - }); - - final String? taskListId; - final bool showAllTasks; - final String? selectedTaskId; - final void Function( - TaskEntity task, - String taskListId, - bool stayInAllTasksMode, - )? - onOpenTask; - final VoidCallback? onCreateTask; - final VoidCallback? onRefreshAll; - - @override - Widget build(BuildContext context, WidgetRef ref) { - final selectedTaskListId = taskListId; - final repository = ref.watch(tasksRepositoryProvider); - final filter = ref.watch(taskViewFilterProvider); - final l10n = context.l10n; - if (repository == null) { - return Center(child: Text(l10n.signInToViewTasks)); - } - - if (showAllTasks) { - final accounts = - ref.watch(accountsStreamProvider).valueOrNull ?? const []; - if (accounts.isEmpty) { - return BusyMaxEmptyState( - icon: YaruIcons.user, - title: l10n.signInToViewTasks, - ); - } - return StreamBuilder>( - stream: repository.watchAllTaskTreeGroups([ - for (final account in accounts) account.id, - ], filter), - builder: (context, snapshot) { - if (!snapshot.hasData) { - return const Center(child: CircularProgressIndicator()); - } - final groups = snapshot.data ?? const []; - if (groups.isEmpty) { - if (filter.searchQuery.trim().isNotEmpty) { - return _SearchEmptyState(label: l10n.noTasks); - } - return BusyMaxEmptyState( - icon: YaruIcons.task_list, - title: l10n.noTasksYet, - message: l10n.noTasksYetMessage, - actions: [ - if (onCreateTask != null) - BusyMaxToolbarButton( - icon: YaruIcons.plus, - label: l10n.newTask, - tooltip: l10n.newTask, - suggested: true, - onPressed: onCreateTask, - ), - if (onRefreshAll != null) - BusyMaxToolbarButton( - icon: YaruIcons.refresh, - label: l10n.refreshAll, - tooltip: l10n.refreshAll, - onPressed: onRefreshAll, - ), - ], - ); - } - final entries = _sortedAllTaskEntries(groups); - final sections = _bucketAllTaskEntries(context, entries); - return ListView( - padding: const EdgeInsets.symmetric(vertical: BusyMaxSpacing.sm), - children: [ - for (final section in sections) ...[ - _TaskSectionHeader(section.title), - for (final entry in section.entries) - _TaskNodeTile( - node: entry.node, - taskListId: entry.group.taskListId, - selectedTaskId: selectedTaskId, - capabilities: capabilitiesForProvider(entry.group.provider), - stayInAllTasksMode: true, - sourceLabel: entry.sourceLabel, - onOpenTask: onOpenTask, - ), - ], - ], - ); - }, - ); - } - - if (selectedTaskListId == null) { - return Center( - child: Padding( - padding: const EdgeInsets.all(24), - child: Text(l10n.selectOrCreateTaskList), - ), - ); - } - - return StreamBuilder>( - stream: repository.watchTaskTree(selectedTaskListId, filter), - builder: (context, snapshot) { - if (!snapshot.hasData) { - return const Center(child: CircularProgressIndicator()); - } - final nodes = snapshot.data ?? const []; - if (nodes.isEmpty) { - if (filter.searchQuery.trim().isNotEmpty) { - return _SearchEmptyState(label: l10n.noTasks); - } - return BusyMaxEmptyState( - icon: YaruIcons.task_list, - title: l10n.noTasksInList, - actions: [ - if (onCreateTask != null) - BusyMaxToolbarButton( - icon: YaruIcons.plus, - label: l10n.newTask, - tooltip: l10n.newTask, - suggested: true, - onPressed: onCreateTask, - ), - ], - ); - } - return ListView( - padding: const EdgeInsets.symmetric(vertical: BusyMaxSpacing.sm), - children: [ - for (final node in nodes) - _TaskNodeTile( - node: node, - taskListId: selectedTaskListId, - selectedTaskId: selectedTaskId, - capabilities: ref.watch(selectedAccountCapabilitiesProvider), - stayInAllTasksMode: false, - onOpenTask: onOpenTask, - ), - ], - ); - }, - ); - } -} - -class _SearchEmptyState extends StatelessWidget { - const _SearchEmptyState({required this.label}); - - final String label; - - @override - Widget build(BuildContext context) { - return Center( - child: Text( - label, - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - ), - ); - } -} - -class _AllTaskNodeEntry { - const _AllTaskNodeEntry({required this.group, required this.node}); - - final TaskTreeGroup group; - final TaskTreeNode node; - - String get sourceLabel => - '${group.provider.displayName} · ${group.accountLabel} · ${group.taskListTitle}'; -} - -class _AllTasksSection { - const _AllTasksSection({required this.title, required this.entries}); - - final String title; - final List<_AllTaskNodeEntry> entries; -} - -List<_AllTaskNodeEntry> _sortedAllTaskEntries(List groups) { - final entries = [ - for (final group in groups) - for (final node in group.nodes) - _AllTaskNodeEntry(group: group, node: node), - ]; - entries.sort(_compareAllTaskEntries); - return entries; -} - -int _compareAllTaskEntries(_AllTaskNodeEntry left, _AllTaskNodeEntry right) { - final dueComparison = _compareNullableDate( - _sortDate(left.node.task), - _sortDate(right.node.task), - ); - if (dueComparison != 0) { - return dueComparison; - } - - return _compareStrings( - [ - left.node.task.title, - left.group.provider.displayName, - left.group.accountLabel, - left.group.taskListTitle, - left.node.task.id, - ], - [ - right.node.task.title, - right.group.provider.displayName, - right.group.accountLabel, - right.group.taskListTitle, - right.node.task.id, - ], - ); -} - -List<_AllTasksSection> _bucketAllTaskEntries( - BuildContext context, - List<_AllTaskNodeEntry> entries, -) { - final buckets = <_TaskDueBucket, List<_AllTaskNodeEntry>>{ - for (final bucket in _TaskDueBucket.values) bucket: [], - }; - for (final entry in entries) { - buckets[_bucketFor(entry.node.task)]!.add(entry); - } - - final l10n = context.l10n; - final titles = { - _TaskDueBucket.overdue: l10n.overdue, - _TaskDueBucket.today: l10n.today, - _TaskDueBucket.tomorrow: l10n.tomorrow, - _TaskDueBucket.upcoming: l10n.upcoming, - _TaskDueBucket.noDate: l10n.noDate, - _TaskDueBucket.completed: l10n.completed, - }; - - return [ - for (final bucket in _TaskDueBucket.values) - if (buckets[bucket]!.isNotEmpty) - _AllTasksSection(title: titles[bucket]!, entries: buckets[bucket]!), - ]; -} - -enum _TaskDueBucket { overdue, today, tomorrow, upcoming, noDate, completed } - -_TaskDueBucket _bucketFor(TaskEntity task) { - if (task.status == 'completed') { - return _TaskDueBucket.completed; - } - final due = _sortDate(task); - if (due == null) { - return _TaskDueBucket.noDate; - } - final today = DateTime.now(); - final dueDate = DateTime(due.year, due.month, due.day); - final todayDate = DateTime(today.year, today.month, today.day); - if (dueDate.isBefore(todayDate)) { - return _TaskDueBucket.overdue; - } - if (isSameDate(dueDate, todayDate)) { - return _TaskDueBucket.today; - } - if (isSameDate(dueDate, todayDate.add(const Duration(days: 1)))) { - return _TaskDueBucket.tomorrow; - } - return _TaskDueBucket.upcoming; -} - -DateTime? _sortDate(TaskEntity task) { - final due = task.dueUtc; - if (due == null || due.isEmpty) { - return null; - } - return DateTime.tryParse(due); -} - -int _compareNullableDate(DateTime? left, DateTime? right) { - if (left == null && right == null) { - return 0; - } - if (left == null) { - return 1; - } - if (right == null) { - return -1; - } - return left.compareTo(right); -} - -int _compareStrings(List left, List right) { - for (var index = 0; index < left.length; index += 1) { - final comparison = left[index].toLowerCase().compareTo( - right[index].toLowerCase(), - ); - if (comparison != 0) { - return comparison; - } - } - return 0; -} - -class _TaskNodeTile extends ConsumerWidget { - const _TaskNodeTile({ - required this.node, - required this.taskListId, - required this.selectedTaskId, - required this.capabilities, - required this.stayInAllTasksMode, - this.onOpenTask, - this.sourceLabel, - this.depth = 0, - }); - - final TaskTreeNode node; - final String taskListId; - final String? selectedTaskId; - final TaskProviderCapabilities capabilities; - final bool stayInAllTasksMode; - final void Function( - TaskEntity task, - String taskListId, - bool stayInAllTasksMode, - )? - onOpenTask; - final String? sourceLabel; - final int depth; - - @override - Widget build(BuildContext context, WidgetRef ref) { - final task = node.task; - final completed = task.status == 'completed'; - final repository = ref.watch( - tasksRepositoryForAccountProvider(task.accountId), - ); - final localTimeZone = ref.watch(localTimeZoneProvider); - final l10n = context.l10n; - - final selected = task.id == selectedTaskId; - final dueDate = formatDesktopDate(context, task.dueUtc); - final source = sourceLabel; - - final metadata = [ - if (dueDate.isNotEmpty) dueDate, - if (source != null) source, - ].join(' · '); - - return Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - BusyMaxTaskRow( - key: ValueKey('task-row-${task.accountId}/$taskListId/${task.id}'), - selected: selected, - depth: depth, - completed: completed, - title: task.title, - metadata: metadata.isEmpty ? null : metadata, - onTap: () { - ref.read(selectedAccountIdProvider.notifier).state = task.accountId; - ref.read(selectedTaskListIdProvider.notifier).state = taskListId; - ref.read(selectedTaskIdProvider.notifier).state = task.id; - ref.read(allTasksModeProvider.notifier).state = stayInAllTasksMode; - onOpenTask?.call(task, taskListId, stayInAllTasksMode); - }, - checkbox: YaruCheckbox( - value: completed, - onChanged: (value) { - final status = value == true ? 'completed' : 'needsAction'; - final fields = { - 'status': status, - 'completed': value == true - ? DateTime.now().toUtc().toIso8601String() - : null, - }; - if (capabilities.supportsDueTime) { - final timeZone = - task.microsoftCompletedTimeZone ?? localTimeZone; - fields['microsoftCompletedTimeZone'] = timeZone; - fields['microsoftCompletedDateTime'] = value == true - ? _graphDateTimeNow(timeZone) - : null; - } - repository.patchTask(taskListId, task.id, TaskPatchInput(fields)); - }, - ), - trailing: task.localDirty || task.pendingMove - ? Tooltip( - message: l10n.pendingSync, - child: const Icon(Icons.sync_problem, size: 16), - ) - : null, - ), - for (final child in node.children) - _TaskNodeTile( - node: child, - taskListId: taskListId, - selectedTaskId: selectedTaskId, - capabilities: capabilities, - stayInAllTasksMode: stayInAllTasksMode, - onOpenTask: onOpenTask, - sourceLabel: sourceLabel, - depth: depth + 1, - ), - ], - ); - } -} - -class _TaskSectionHeader extends StatelessWidget { - const _TaskSectionHeader(this.title); - - final String title; - - @override - Widget build(BuildContext context) { - return Padding( - padding: const EdgeInsets.fromLTRB( - BusyMaxSpacing.lg, - BusyMaxSpacing.md, - BusyMaxSpacing.lg, - BusyMaxSpacing.xs, - ), - child: Text(title, style: busyMaxSectionHeaderStyle(context)), - ); - } -} - -Map _graphDateTimeNow(String timeZone) { - final now = DateTime.now(); - final date = encodeGoogleDateOnly(now); - final time = - '${now.hour.toString().padLeft(2, '0')}:' - '${now.minute.toString().padLeft(2, '0')}:' - '${now.second.toString().padLeft(2, '0')}'; - return {'dateTime': '${date}T$time', 'timeZone': timeZone}; -} diff --git a/lib/src/features/tasks/presentation/tasks_selection_state.dart b/lib/src/features/tasks/presentation/tasks_selection_state.dart deleted file mode 100644 index 5de37b2..0000000 --- a/lib/src/features/tasks/presentation/tasks_selection_state.dart +++ /dev/null @@ -1,5 +0,0 @@ -import 'package:flutter_riverpod/flutter_riverpod.dart'; - -final allTasksModeProvider = StateProvider((ref) => true); -final selectedTaskListIdProvider = StateProvider((ref) => null); -final selectedTaskIdProvider = StateProvider((ref) => null); diff --git a/lib/src/features/tasks/presentation/tasks_workspace.dart b/lib/src/features/tasks/presentation/tasks_workspace.dart deleted file mode 100644 index 334a776..0000000 --- a/lib/src/features/tasks/presentation/tasks_workspace.dart +++ /dev/null @@ -1,506 +0,0 @@ -import 'dart:async'; - -import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:flutter/services.dart'; -import 'package:yaru/yaru.dart'; - -import '../../../app/app_bootstrap.dart'; -import '../../../app/busymax_design.dart'; -import '../../../app/busymax_dialogs.dart'; -import '../../../app/busymax_layout.dart'; -import '../../../l10n/l10n.dart'; -import '../../../platform/linux_header_bar_service.dart'; -import '../../accounts/data/accounts_repository.dart'; -import '../../sync/sync_auth_error.dart'; -import '../../sync/sync_engine.dart'; -import '../../task_lists/data/task_lists_repository.dart'; -import '../../task_lists/presentation/task_lists_sidebar.dart'; -import '../data/tasks_repository.dart'; -import 'task_details_pane.dart'; -import 'task_filters.dart'; -import 'new_task_dialog.dart'; -import 'tasks_selection_state.dart'; -import 'task_tree_view.dart'; - -class TasksWorkspace extends ConsumerStatefulWidget { - const TasksWorkspace({super.key, this.selectedListId, this.selectedTaskId}); - - final String? selectedListId; - final String? selectedTaskId; - - @override - ConsumerState createState() => _TasksWorkspaceState(); -} - -class _TasksWorkspaceState extends ConsumerState { - _TaskDetailsTarget? _detailsTarget; - var _detailsDirty = false; - late final LinuxHeaderBarService _headerBarService; - - @override - void initState() { - super.initState(); - _headerBarService = ref.read(linuxHeaderBarServiceProvider); - _scheduleRouteSelectionSeed(); - } - - @override - void didUpdateWidget(covariant TasksWorkspace oldWidget) { - super.didUpdateWidget(oldWidget); - if (oldWidget.selectedListId != widget.selectedListId || - oldWidget.selectedTaskId != widget.selectedTaskId) { - _scheduleRouteSelectionSeed(); - } - } - - @override - void dispose() { - if (_detailsTarget != null) { - unawaited(releaseBusyMaxModalBarrier(_headerBarService)); - } - super.dispose(); - } - - @override - Widget build(BuildContext context) { - final allTasksMode = ref.watch(allTasksModeProvider); - final selectedListId = ref.watch(selectedTaskListIdProvider); - final selectedTaskId = ref.watch(selectedTaskIdProvider); - final showAllTasks = - widget.selectedListId == null && - (selectedListId == null || allTasksMode); - - return Scaffold( - body: LayoutBuilder( - builder: (context, constraints) { - final width = constraints.maxWidth; - final showSidebar = BusyMaxLayoutRules.showSidebar(width); - final taskContent = Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - _TasksToolbar( - selectedListId: selectedListId, - showAllTasks: showAllTasks, - ), - const Divider(height: 1), - const Padding( - padding: EdgeInsets.fromLTRB(12, 10, 12, 10), - child: TaskFiltersBar(), - ), - const Divider(height: 1), - Expanded( - child: TaskTreeView( - taskListId: selectedListId, - showAllTasks: showAllTasks, - selectedTaskId: selectedTaskId, - onOpenTask: _openTaskDetails, - onCreateTask: () => _createTaskFromWorkspace( - context, - ref, - selectedListId: selectedListId, - ), - onRefreshAll: showAllTasks - ? () => _refreshAllAccounts(context, ref) - : null, - ), - ), - ], - ); - final workspaceContent = showSidebar - ? YaruPanedView( - pane: const TaskListsSidebar(), - page: taskContent, - layoutDelegate: const YaruResizablePaneDelegate( - initialPaneSize: BusyMaxSizes.sidebarWidth, - minPaneSize: BusyMaxSizes.sidebarWidth, - minPageSize: BusyMaxLayoutRules.taskPageMinWidth, - paneSide: YaruPaneSide.start, - ), - ) - : taskContent; - final workspace = _TaskContentWithDetailsOverlay( - taskContent: workspaceContent, - target: _detailsTarget, - onClose: _closeTaskDetails, - onDirtyChanged: (dirty) { - _detailsDirty = dirty; - }, - onTaskSwitchCancelled: _restoreTaskSelection, - ); - return CallbackShortcuts( - bindings: { - const SingleActivator(LogicalKeyboardKey.escape): () { - unawaited(_requestCloseTaskDetails(context)); - }, - }, - child: Focus(autofocus: true, child: workspace), - ); - }, - ), - ); - } - - void _scheduleRouteSelectionSeed() { - final routeListId = widget.selectedListId; - final routeTaskId = widget.selectedTaskId; - if (routeListId == null && routeTaskId == null) { - return; - } - - WidgetsBinding.instance.addPostFrameCallback((_) { - if (!mounted) { - return; - } - ref.read(selectedTaskListIdProvider.notifier).state = routeListId; - ref.read(selectedTaskIdProvider.notifier).state = routeTaskId; - ref.read(allTasksModeProvider.notifier).state = routeListId == null; - }); - } - - void _restoreTaskSelection(TaskEntity task) { - ref.read(selectedAccountIdProvider.notifier).state = task.accountId; - ref.read(selectedTaskListIdProvider.notifier).state = task.taskListId; - ref.read(selectedTaskIdProvider.notifier).state = task.id; - setState(() { - _detailsTarget = _TaskDetailsTarget( - accountId: task.accountId, - taskListId: task.taskListId, - taskId: task.id, - ); - _detailsDirty = false; - }); - unawaited(acquireBusyMaxModalBarrier(_headerBarService)); - } - - void _openTaskDetails( - TaskEntity task, - String taskListId, - bool stayInAllTasksMode, - ) { - ref.read(selectedAccountIdProvider.notifier).state = task.accountId; - ref.read(selectedTaskListIdProvider.notifier).state = taskListId; - ref.read(selectedTaskIdProvider.notifier).state = task.id; - ref.read(allTasksModeProvider.notifier).state = stayInAllTasksMode; - setState(() { - _detailsTarget = _TaskDetailsTarget( - accountId: task.accountId, - taskListId: taskListId, - taskId: task.id, - ); - _detailsDirty = false; - }); - unawaited(acquireBusyMaxModalBarrier(_headerBarService)); - } - - void _closeTaskDetails() { - if (_detailsTarget == null) { - return; - } - setState(() { - _detailsTarget = null; - _detailsDirty = false; - }); - unawaited(releaseBusyMaxModalBarrier(_headerBarService)); - } - - Future _requestCloseTaskDetails(BuildContext context) async { - if (_detailsTarget == null) { - return; - } - if (_detailsDirty) { - final discard = await showBusyMaxConfirm( - context, - title: context.l10n.discardChanges, - message: context.l10n.discardChangesConfirmation, - confirmLabel: context.l10n.discard, - destructive: true, - headerBarService: _headerBarService, - ); - if (!discard || !mounted) { - return; - } - } - _closeTaskDetails(); - } -} - -class _TaskDetailsTarget { - const _TaskDetailsTarget({ - required this.accountId, - required this.taskListId, - required this.taskId, - }); - - final String accountId; - final String taskListId; - final String taskId; -} - -class _TaskContentWithDetailsOverlay extends StatelessWidget { - const _TaskContentWithDetailsOverlay({ - required this.taskContent, - required this.target, - required this.onClose, - required this.onDirtyChanged, - required this.onTaskSwitchCancelled, - }); - - final Widget taskContent; - final _TaskDetailsTarget? target; - final VoidCallback onClose; - final ValueChanged onDirtyChanged; - final ValueChanged onTaskSwitchCancelled; - - @override - Widget build(BuildContext context) { - final target = this.target; - if (target == null) { - return taskContent; - } - - return LayoutBuilder( - builder: (context, constraints) { - final maxWidth = (constraints.maxWidth - 24) - .clamp(0, BusyMaxSizes.compactDetailsWidth) - .toDouble(); - final minWidth = maxWidth < 560 ? maxWidth : 560.0; - return Stack( - children: [ - taskContent, - ModalBarrier( - color: busyMaxModalBarrierColor(context), - dismissible: false, - ), - Center( - child: BusyMaxModalEditorSurface( - minWidth: minWidth, - maxWidth: maxWidth, - maxHeight: constraints.maxHeight - 24, - child: TaskDetailsPane( - accountId: target.accountId, - taskListId: target.taskListId, - taskId: target.taskId, - onClose: onClose, - onDirtyChanged: onDirtyChanged, - onTaskSwitchCancelled: onTaskSwitchCancelled, - ), - ), - ), - ], - ); - }, - ); - } -} - -class _TasksToolbar extends ConsumerWidget { - const _TasksToolbar({ - required this.selectedListId, - required this.showAllTasks, - }); - - final String? selectedListId; - final bool showAllTasks; - - @override - Widget build(BuildContext context, WidgetRef ref) { - final tasksRepository = ref.watch(tasksRepositoryProvider); - final listsRepository = ref.watch(taskListsRepositoryProvider); - final syncEngine = ref.watch(syncEngineProvider); - final syncEngineForAccount = ref.watch(syncEngineForAccountFactoryProvider); - final accounts = ref.watch(accountsStreamProvider).valueOrNull ?? const []; - final capabilities = ref.watch(selectedAccountCapabilitiesProvider); - final hasList = selectedListId != null && !showAllTasks; - final hasAccounts = accounts.isNotEmpty; - final refreshLabel = showAllTasks - ? context.l10n.refreshAll - : context.l10n.refreshList; - final l10n = context.l10n; - - return LayoutBuilder( - builder: (context, constraints) { - final compactActions = constraints.maxWidth < 720; - return _ToolbarTitle( - selectedListId: selectedListId, - showAllTasks: showAllTasks, - repository: listsRepository, - actions: [ - BusyMaxToolbarButton( - compact: compactActions, - suggested: true, - tooltip: l10n.newTask, - label: l10n.newTask, - icon: YaruIcons.plus, - onPressed: showAllTasks - ? hasAccounts - ? () => _createTaskFromWorkspace( - context, - ref, - selectedListId: selectedListId, - ) - : null - : !hasList || tasksRepository == null - ? null - : () => _createTaskFromWorkspace( - context, - ref, - selectedListId: selectedListId, - ), - ), - if (!showAllTasks && capabilities.supportsClearCompleted) - BusyMaxToolbarButton( - compact: compactActions, - tooltip: l10n.clearCompleted, - label: l10n.clearCompleted, - icon: Icons.cleaning_services, - onPressed: !hasList || tasksRepository == null - ? null - : () => tasksRepository.clearCompleted(selectedListId!), - ), - BusyMaxToolbarButton( - compact: compactActions, - tooltip: refreshLabel, - label: refreshLabel, - icon: YaruIcons.refresh, - onPressed: showAllTasks - ? hasAccounts - ? () => _refreshAll( - context, - accounts, - syncEngineForAccount, - ) - : null - : !hasList || syncEngine == null - ? null - : () => _refreshList(context, syncEngine), - ), - ], - ); - }, - ); - } -} - -class _ToolbarTitle extends StatelessWidget { - const _ToolbarTitle({ - required this.selectedListId, - required this.showAllTasks, - required this.repository, - required this.actions, - }); - - final String? selectedListId; - final bool showAllTasks; - final TaskListsRepository? repository; - final List actions; - - @override - Widget build(BuildContext context) { - if (showAllTasks) { - return BusyMaxToolbar(title: context.l10n.allTasks, actions: actions); - } - - final listId = selectedListId; - final listsRepository = repository; - if (listId == null || listsRepository == null) { - return BusyMaxToolbar(title: context.l10n.tasks, actions: actions); - } - - return StreamBuilder( - stream: listsRepository.watchTaskList(listId), - builder: (context, snapshot) { - final title = snapshot.data?.title ?? listId; - return BusyMaxToolbar( - title: context.l10n.tasksInList(title), - actions: actions, - ); - }, - ); - } -} - -Future _createTaskFromWorkspace( - BuildContext context, - WidgetRef ref, { - required String? selectedListId, -}) async { - final accounts = ref.read(accountsStreamProvider).valueOrNull ?? const []; - if (accounts.isEmpty) { - return; - } - final draft = await showBusyMaxNewTaskDialog( - context, - ref: ref, - accounts: accounts, - initialAccountId: ref.read(selectedAccountProvider)?.id, - initialListId: selectedListId, - headerBarService: ref.read(linuxHeaderBarServiceProvider), - ); - if (draft == null) { - return; - } - await ref - .read(tasksRepositoryForAccountProvider(draft.accountId)) - .createTask(draft.taskListId, draft.input); -} - -Future _refreshList(BuildContext context, SyncEngine syncEngine) async { - try { - await syncEngine.incrementalSync(); - if (!context.mounted) { - return; - } - ScaffoldMessenger.of( - context, - ).showSnackBar(SnackBar(content: Text(context.l10n.listRefreshed))); - } on Object catch (error) { - if (!context.mounted) { - return; - } - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(context.l10n.refreshFailed(syncFailureMessage(error))), - ), - ); - } -} - -Future _refreshAllAccounts(BuildContext context, WidgetRef ref) async { - final accounts = ref.read(accountsStreamProvider).valueOrNull ?? const []; - if (accounts.isEmpty) { - return; - } - await _refreshAll( - context, - accounts, - ref.read(syncEngineForAccountFactoryProvider), - ); -} - -Future _refreshAll( - BuildContext context, - List accounts, - SyncEngineForAccountFactory syncEngineForAccount, -) async { - try { - for (final account in accounts) { - await syncEngineForAccount(account.id).incrementalSync(); - } - if (!context.mounted) { - return; - } - ScaffoldMessenger.of( - context, - ).showSnackBar(SnackBar(content: Text(context.l10n.allTasksRefreshed))); - } on Object catch (error) { - if (!context.mounted) { - return; - } - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(context.l10n.refreshFailed(syncFailureMessage(error))), - ), - ); - } -} diff --git a/lib/src/google_tasks/oauth/oauth_service.dart b/lib/src/google_tasks/oauth/oauth_service.dart index ea67bc4..b356707 100644 --- a/lib/src/google_tasks/oauth/oauth_service.dart +++ b/lib/src/google_tasks/oauth/oauth_service.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:convert'; import 'package:crypto/crypto.dart'; @@ -22,14 +23,10 @@ abstract interface class OAuthGateway { Future refreshActiveToken(); - Future signOut(); - - Future signOutAccount(String accountId); - - Future revokeAndSignOut(); - Future revokeAndSignOutAccount(String accountId); + Future revokeAuthorization(String accountId); + Future clearLocalSession({String? accountId}); Future cancelSignIn(); @@ -42,17 +39,20 @@ class OAuthService implements OAuthGateway { required OAuthTokenStore tokenStore, required OAuthLoopbackFlow loopbackFlow, DateTime Function()? nowUtc, + Duration authorizationRevocationTimeout = const Duration(seconds: 10), }) : _config = config, _httpClient = httpClient, _tokenStore = tokenStore, _loopbackFlow = loopbackFlow, - _nowUtc = nowUtc ?? (() => DateTime.now().toUtc()); + _nowUtc = nowUtc ?? (() => DateTime.now().toUtc()), + _authorizationRevocationTimeout = authorizationRevocationTimeout; final BuildConfig _config; final http.Client _httpClient; final OAuthTokenStore _tokenStore; final OAuthLoopbackFlow _loopbackFlow; final DateTime Function() _nowUtc; + final Duration _authorizationRevocationTimeout; final RedactingLogger _logger = RedactingLogger(Logger('OAuthService')); @override @@ -148,18 +148,6 @@ class OAuthService implements OAuthGateway { @override Future cancelSignIn() => _loopbackFlow.cancel(); - @override - Future signOut() => _tokenStore.clearActiveAccount(); - - @override - Future signOutAccount(String accountId) async { - final active = await _tokenStore.readActiveAccountId(); - await _tokenStore.clearTokenSet(accountId); - if (active == accountId) { - await _tokenStore.clearActiveAccount(); - } - } - Future exchangeAuthorizationCode({ required String code, required String codeVerifier, @@ -292,34 +280,47 @@ class OAuthService implements OAuthGateway { } @override - Future revokeAndSignOut() async { - final accountId = await _tokenStore.readActiveAccountId(); - if (accountId == null) { - return; + Future revokeAndSignOutAccount(String accountId) async { + try { + await revokeAuthorization(accountId); + } finally { + await clearLocalSession(accountId: accountId); } - - await revokeAndSignOutAccount(accountId); } @override - Future revokeAndSignOutAccount(String accountId) async { - final active = await _tokenStore.readActiveAccountId(); + Future revokeAuthorization(String accountId) async { final tokenSet = await _tokenStore.readTokenSet(accountId); final token = tokenSet?.refreshToken ?? tokenSet?.accessToken; + if (token == null || token.isEmpty) { + throw const OAuthException( + 'OAuthRevocationUnavailable', + 'No local Google authorization is available to revoke.', + ); + } + + late final http.Response response; try { - if (token != null && token.isNotEmpty) { - await _httpClient.post( - Uri.parse( - _config.oauthRevocationEndpoint, - ).replace(queryParameters: {'token': token}), - headers: {'Content-Type': 'application/x-www-form-urlencoded'}, - ); - } - } finally { - await _tokenStore.clearTokenSet(accountId); - if (active == accountId) { - await _tokenStore.clearActiveAccount(); - } + response = await _httpClient + .post( + Uri.parse(_config.oauthRevocationEndpoint), + headers: {'Content-Type': 'application/x-www-form-urlencoded'}, + body: {'token': token}, + ) + .timeout(_authorizationRevocationTimeout); + } on TimeoutException { + throw const OAuthException( + 'OAuthRevocationTimedOut', + 'Google authorization revocation timed out.', + ); + } + + if (response.statusCode < 200 || response.statusCode >= 300) { + throw OAuthException( + 'OAuthRevocationFailed', + 'Google authorization revocation failed ' + '(HTTP ${response.statusCode}).', + ); } } diff --git a/lib/src/platform/linux_header_bar_configuration_synchronizer.dart b/lib/src/platform/linux_header_bar_configuration_synchronizer.dart new file mode 100644 index 0000000..05accfd --- /dev/null +++ b/lib/src/platform/linux_header_bar_configuration_synchronizer.dart @@ -0,0 +1,154 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/scheduler.dart'; + +import 'linux_header_bar_service.dart'; + +@immutable +final class BusyMaxHeaderBarConfiguration { + const BusyMaxHeaderBarConfiguration({ + required this.labels, + required this.sidebarWidth, + required this.theme, + }); + + final BusyMaxHeaderBarLabels labels; + final double sidebarWidth; + final BusyMaxHeaderBarTheme theme; + + @override + bool operator ==(Object other) { + return identical(this, other) || + other is BusyMaxHeaderBarConfiguration && + labels == other.labels && + sidebarWidth == other.sidebarWidth && + theme == other.theme; + } + + @override + int get hashCode => Object.hash(labels, sidebarWidth, 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.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_service.dart b/lib/src/platform/linux_header_bar_service.dart index bb1c73e..08c63c6 100644 --- a/lib/src/platform/linux_header_bar_service.dart +++ b/lib/src/platform/linux_header_bar_service.dart @@ -183,17 +183,7 @@ class BusyMaxHeaderBarTheme { required this.backgroundColor, required this.sidebarBackgroundColor, required this.foregroundColor, - required this.mutedForegroundColor, - required this.disabledForegroundColor, - required this.controlColor, - required this.controlHoverColor, - required this.controlActiveColor, - required this.accentColor, - required this.accentForegroundColor, - required this.popoverBackgroundColor, - required this.borderColor, required this.sidebarBorderColor, - required this.shadeColor, required this.modalBarrierColor, }); @@ -202,17 +192,7 @@ class BusyMaxHeaderBarTheme { final Color backgroundColor; final Color sidebarBackgroundColor; final Color foregroundColor; - final Color mutedForegroundColor; - final Color disabledForegroundColor; - final Color controlColor; - final Color controlHoverColor; - final Color controlActiveColor; - final Color accentColor; - final Color accentForegroundColor; - final Color popoverBackgroundColor; - final Color borderColor; final Color sidebarBorderColor; - final Color shadeColor; final Color modalBarrierColor; Map toJson() { @@ -222,17 +202,7 @@ class BusyMaxHeaderBarTheme { 'backgroundColor': busyMaxCssColor(backgroundColor), 'sidebarBackgroundColor': busyMaxCssColor(sidebarBackgroundColor), 'foregroundColor': busyMaxCssColor(foregroundColor), - 'mutedForegroundColor': busyMaxCssColor(mutedForegroundColor), - 'disabledForegroundColor': busyMaxCssColor(disabledForegroundColor), - 'controlColor': busyMaxCssColor(controlColor), - 'controlHoverColor': busyMaxCssColor(controlHoverColor), - 'controlActiveColor': busyMaxCssColor(controlActiveColor), - 'accentColor': busyMaxCssColor(accentColor), - 'accentForegroundColor': busyMaxCssColor(accentForegroundColor), - 'popoverBackgroundColor': busyMaxCssColor(popoverBackgroundColor), - 'borderColor': busyMaxCssColor(borderColor), 'sidebarBorderColor': busyMaxCssColor(sidebarBorderColor), - 'shadeColor': busyMaxCssColor(shadeColor), 'modalBarrierColor': busyMaxCssColor(modalBarrierColor), }; } @@ -246,17 +216,7 @@ class BusyMaxHeaderBarTheme { other.backgroundColor == backgroundColor && other.sidebarBackgroundColor == sidebarBackgroundColor && other.foregroundColor == foregroundColor && - other.mutedForegroundColor == mutedForegroundColor && - other.disabledForegroundColor == disabledForegroundColor && - other.controlColor == controlColor && - other.controlHoverColor == controlHoverColor && - other.controlActiveColor == controlActiveColor && - other.accentColor == accentColor && - other.accentForegroundColor == accentForegroundColor && - other.popoverBackgroundColor == popoverBackgroundColor && - other.borderColor == borderColor && other.sidebarBorderColor == sidebarBorderColor && - other.shadeColor == shadeColor && other.modalBarrierColor == modalBarrierColor; } @@ -267,17 +227,7 @@ class BusyMaxHeaderBarTheme { backgroundColor, sidebarBackgroundColor, foregroundColor, - mutedForegroundColor, - disabledForegroundColor, - controlColor, - controlHoverColor, - controlActiveColor, - accentColor, - accentForegroundColor, - popoverBackgroundColor, - borderColor, sidebarBorderColor, - shadeColor, modalBarrierColor, ); } diff --git a/lib/src/platform/main_window_command_bridge.dart b/lib/src/platform/main_window_command_bridge.dart index 1182d6f..7108b84 100644 --- a/lib/src/platform/main_window_command_bridge.dart +++ b/lib/src/platform/main_window_command_bridge.dart @@ -162,8 +162,8 @@ class _MainWindowCommandBridgeState Future _syncCalendarAccount(String accountId) async { try { await ref - .read(calendarSyncEngineForAccountFactoryProvider)(accountId) - .incrementalSync(); + .read(accountSyncOperationsProvider) + .syncCalendar(accountId, full: false); } on Object catch (error) { if (isMissingOAuthTokenError(error)) { try { diff --git a/linux/runner/my_application.cc b/linux/runner/my_application.cc index 6e1c481..8dd7e9e 100644 --- a/linux/runner/my_application.cc +++ b/linux/runner/my_application.cc @@ -1,7 +1,6 @@ #include "my_application.h" #include -#include #include #include #include @@ -28,8 +27,6 @@ constexpr char kCompactAgendaWindowChannel[] = "io.busystack.busymax/compact_agenda_window"; constexpr gint64 kHeaderBarStateSchemaVersion = 3; constexpr gint kHeaderButtonHeight = 34; -constexpr gint kHeaderButtonRadius = 8; -constexpr gint kHeaderButtonHorizontalPadding = 8; constexpr gint kHeaderButtonSpacing = 6; constexpr gint kHeaderWindowControlsBalanceWidth = kHeaderButtonHeight * 3 + kHeaderButtonSpacing * 2; @@ -38,9 +35,6 @@ constexpr gint kHeaderOnboardingContentWidth = 480; constexpr gint kHeaderOnboardingSideWidth = 120; constexpr gint kHeaderSidebarContentInset = kHeaderButtonSpacing; constexpr gint kHeaderMainContentStartInset = kHeaderSidebarContentInset; -constexpr gint kHeaderTooltipVerticalPadding = 5; -constexpr gint kHeaderTooltipHorizontalPadding = 8; -constexpr gint kHeaderWindowRadius = 8; constexpr gint kMainWindowDefaultWidth = 1280; constexpr gint kMainWindowDefaultHeight = 720; constexpr gint kCompactAgendaPanelWidth = 420; @@ -82,16 +76,6 @@ struct _MyApplication { gchar* header_bar_sidebar_background_color; gchar* header_bar_sidebar_border_color; gchar* header_bar_foreground_color; - gchar* header_bar_muted_foreground_color; - gchar* header_bar_disabled_foreground_color; - gchar* header_bar_control_color; - gchar* header_bar_control_hover_color; - gchar* header_bar_control_active_color; - gchar* header_bar_accent_color; - gchar* header_bar_accent_foreground_color; - gchar* header_bar_popover_background_color; - gchar* header_bar_border_color; - gchar* header_bar_shade_color; gchar* header_bar_modal_barrier_color; gint header_bar_sidebar_width; gboolean header_bar_can_show_sidebar; @@ -151,7 +135,6 @@ struct _MyApplication { gboolean header_navigation_visible; gboolean header_back_visible; gboolean header_onboarding_controls_visible; - gboolean main_window_transparent_backing; }; G_DEFINE_TYPE(MyApplication, my_application, GTK_TYPE_APPLICATION) @@ -461,21 +444,7 @@ static void set_flutter_view_background_color(MyApplication* self, fl_view_set_background_color(FL_VIEW(self->flutter_view), &background_color); } -static void set_flutter_view_background_transparent(MyApplication* self) { - if (self->flutter_view == nullptr || !FL_IS_VIEW(self->flutter_view)) { - return; - } - - GdkRGBA transparent = {0, 0, 0, 0}; - fl_view_set_background_color(FL_VIEW(self->flutter_view), &transparent); -} - static void set_main_flutter_view_background(MyApplication* self) { - if (self->main_window_transparent_backing) { - set_flutter_view_background_transparent(self); - return; - } - set_flutter_view_background_color( self, css_color_or(self->header_bar_window_background_color, self->header_bar_background_color)); @@ -498,9 +467,6 @@ static void refresh_header_bar_css(MyApplication* self) { 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* window_css_background_color = - self->main_window_transparent_backing ? "transparent" - : window_background_color; const gchar* sidebar_background_color = is_css_color_token(self->header_bar_sidebar_background_color) ? self->header_bar_sidebar_background_color @@ -509,29 +475,8 @@ static void refresh_header_bar_css(MyApplication* self) { self->header_bar_sidebar_border_color, "rgba(255,255,255,0.10)"); const gchar* foreground_color = css_color_or( self->header_bar_foreground_color, "rgba(255,255,255,0.86)"); - const gchar* foreground_disabled_color = - css_color_or(self->header_bar_disabled_foreground_color, - "rgba(255,255,255,0.38)"); - const gchar* control_color = - css_color_or(self->header_bar_control_color, "rgba(255,255,255,0.10)"); - const gchar* control_hover_color = css_color_or( - self->header_bar_control_hover_color, "rgba(255,255,255,0.14)"); - const gchar* control_pressed_color = css_color_or( - self->header_bar_control_active_color, "rgba(255,255,255,0.18)"); - const gchar* accent_color = - css_color_or(self->header_bar_accent_color, control_pressed_color); - const gchar* accent_foreground_color = - css_color_or(self->header_bar_accent_foreground_color, foreground_color); - const gchar* popover_background_color = css_color_or( - self->header_bar_popover_background_color, background_color); - const gchar* border_color = - css_color_or(self->header_bar_border_color, "rgba(255,255,255,0.10)"); - const gchar* shade_color = - css_color_or(self->header_bar_shade_color, "rgba(0,0,0,0.28)"); const gchar* modal_barrier_color = css_color_or( self->header_bar_modal_barrier_color, "rgba(0,0,0,0.32)"); - const gint header_bar_left_radius = - header_sidebar_effective_width(self) > 0 ? 0 : kHeaderWindowRadius; 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"); @@ -542,40 +487,25 @@ static void refresh_header_bar_css(MyApplication* self) { "background-color: %s;" "background-image: none;" "}" - "window#busymax-window decoration," - "window#busymax-window decoration:backdrop {" - "background-color: transparent;" - "background-image: none;" - "border: none;" - "box-shadow: 0 3px 18px 2px %s;" - "outline: none;" - "border-radius: %dpx;" - "}" ".busymax-titlebar," ".busymax-titlebar:backdrop," "headerbar.busymax-flat-headerbar," "headerbar.busymax-flat-headerbar:backdrop {" "background-color: %s;" "background-image: none;" + "color: %s;" "border: none;" "box-shadow: none;" - "border-top-left-radius: %dpx;" - "border-top-right-radius: %dpx;" "}" "headerbar.busymax-flat-headerbar," "headerbar.busymax-flat-headerbar:backdrop {" - "border-top-left-radius: %dpx;" - "border-top-right-radius: %dpx;" "padding-left: 0;" "}" ".busymax-titlebar .busymax-header-brand {" "background-color: %s;" "background-image: none;" - "border: none;" + "color: %s;" "border-right: 1px solid %s;" - "box-shadow: none;" - "border-top-left-radius: %dpx;" - "border-top-right-radius: 0;" "}" ".busymax-titlebar .busymax-header-brand label {" "color: %s;" @@ -600,153 +530,13 @@ static void refresh_header_bar_css(MyApplication* self) { "headerbar.busymax-flat-headerbar:backdrop {" "background-color: %s;" "background-image: linear-gradient(%s, %s);" - "}" - ".busymax-titlebar button.busymax-header-button," - ".busymax-titlebar button.busymax-header-view-mode-button {" - "color: %s;" - "background-color: %s;" - "background-image: none;" - "border: none;" - "border-width: 0;" - "border-color: transparent;" - "border-image: none;" - "outline-color: transparent;" - "outline-style: none;" - "outline-width: 0;" - "box-shadow: none;" - "text-shadow: none;" - "-gtk-icon-shadow: none;" - "transition: none;" - "min-height: %dpx;" - "min-width: %dpx;" - "padding: 0 %dpx;" - "border-radius: %dpx;" - "}" - ".busymax-titlebar button.busymax-header-button:hover," - ".busymax-titlebar button.busymax-header-view-mode-button:hover {" - "background-color: %s;" - "}" - ".busymax-titlebar button.busymax-header-button:active," - ".busymax-titlebar button.busymax-header-button:checked," - ".busymax-titlebar button.busymax-header-view-mode-button:checked," - ".busymax-titlebar button.busymax-header-view-mode-button:active {" - "background-color: %s;" - "}" - ".busymax-titlebar button.busymax-header-button:focus," - ".busymax-titlebar button.busymax-header-view-mode-button:focus {" - "box-shadow: inset 0 0 0 2px %s;" - "}" - ".busymax-titlebar button.busymax-header-button:disabled," - ".busymax-titlebar button.busymax-header-view-mode-button:disabled {" - "color: %s;" - "background-color: transparent;" - "}" - ".busymax-titlebar button.busymax-header-primary-button {" - "color: %s;" - "background-color: %s;" - "}" - ".busymax-titlebar button.busymax-header-primary-button:hover," - ".busymax-titlebar button.busymax-header-primary-button:active," - ".busymax-titlebar button.busymax-header-primary-button:checked {" - "color: %s;" - "background-color: %s;" - "}" - ".busymax-titlebar button.busymax-header-primary-button:focus {" - "box-shadow: inset 0 0 0 2px %s;" - "}" - ".busymax-titlebar button.busymax-header-primary-button:disabled {" - "color: %s;" - "background-color: transparent;" - "}" - ".busymax-titlebar .busymax-header-brand " - "button.busymax-brand-action-button," - ".busymax-titlebar .busymax-header-brand " - "button.busymax-brand-action-button:checked," - ".busymax-titlebar .busymax-header-brand " - "button.busymax-brand-action-button:active {" - "background-color: transparent;" - "}" - ".busymax-titlebar .busymax-header-brand " - "button.busymax-brand-action-button:hover {" - "background-color: %s;" - "}" - ".busymax-titlebar button.busymax-header-button.busymax-sidebar-toggle," - ".busymax-titlebar button.busymax-header-button.busymax-sidebar-toggle:checked," - ".busymax-titlebar button.busymax-header-button.busymax-sidebar-toggle:active {" - "background-color: transparent;" - "}" - ".busymax-titlebar button.busymax-header-button.busymax-sidebar-toggle:hover {" - "background-color: %s;" - "}" - ".busymax-titlebar.busymax-modal-barrier label," - ".busymax-titlebar.busymax-modal-barrier button," - ".busymax-titlebar.busymax-modal-barrier button image {" - "color: %s;" - "text-shadow: none;" - "-gtk-icon-shadow: none;" - "}" - ".busymax-titlebar button.busymax-header-icon-button {" - "min-width: %dpx;" - "padding-left: 0;" - "padding-right: 0;" - "}" - "popover.busymax-header-popover," - "popover.background.busymax-header-popover," - ".busymax-header-popover.background," - "popover.busymax-header-popover:backdrop," - "popover.background.busymax-header-popover:backdrop," - ".busymax-header-popover.background:backdrop," - "popover.background.busymax-header-popover > contents," - ".busymax-header-popover.background > contents," - "popover.background.busymax-header-popover arrow," - ".busymax-header-popover.background arrow {" - "background-color: %s;" - "color: %s;" - "}" - "popover.busymax-header-popover > contents," - ".busymax-header-popover.background > contents {" - "border: 1px solid %s;" - "box-shadow: 0 6px 18px %s;" - "}" - "tooltip," - "tooltip.background {" - "margin: 0;" - "padding: 0;" - "min-height: 0;" - "border-radius: %dpx;" - "box-shadow: 0 5px 18px 2px %s;" - "}" - "tooltip > box," - "tooltip.background > box {" - "margin: 0;" - "padding: 0;" - "min-height: 0;" - "}" - "tooltip label {" - "margin: 0;" - "padding: %dpx %dpx;" - "min-height: 0;" - "border-radius: %dpx;" "}", - window_css_background_color, shade_color, kHeaderWindowRadius, - background_color, kHeaderWindowRadius, kHeaderWindowRadius, - header_bar_left_radius, kHeaderWindowRadius, sidebar_background_color, - sidebar_border_color, kHeaderWindowRadius, foreground_color, - foreground_color, + window_background_color, background_color, foreground_color, + sidebar_background_color, foreground_color, sidebar_border_color, + foreground_color, foreground_color, background_color, modal_barrier_color, modal_barrier_color, sidebar_background_color, modal_barrier_color, modal_barrier_color, - background_color, modal_barrier_color, modal_barrier_color, - foreground_color, control_color, kHeaderButtonHeight, - kHeaderButtonHeight, kHeaderButtonHorizontalPadding, kHeaderButtonRadius, - control_hover_color, control_pressed_color, accent_color, - foreground_disabled_color, - accent_foreground_color, accent_color, accent_foreground_color, - accent_color, accent_foreground_color, foreground_disabled_color, - control_hover_color, control_hover_color, foreground_disabled_color, - kHeaderButtonHeight, - popover_background_color, foreground_color, border_color, shade_color, - kHeaderButtonRadius, shade_color, kHeaderTooltipVerticalPadding, - kHeaderTooltipHorizontalPadding, kHeaderButtonRadius); + background_color, modal_barrier_color, modal_barrier_color); g_autoptr(GError) error = nullptr; GtkCssProvider* provider = gtk_css_provider_new(); @@ -795,26 +585,6 @@ static void set_header_bar_theme(MyApplication* self, FlValue* args) { 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_muted_foreground_color, - fl_lookup_string_arg(args, "mutedForegroundColor")); - set_css_color_field(&self->header_bar_disabled_foreground_color, - fl_lookup_string_arg(args, "disabledForegroundColor")); - set_css_color_field(&self->header_bar_control_color, - fl_lookup_string_arg(args, "controlColor")); - set_css_color_field(&self->header_bar_control_hover_color, - fl_lookup_string_arg(args, "controlHoverColor")); - set_css_color_field(&self->header_bar_control_active_color, - fl_lookup_string_arg(args, "controlActiveColor")); - set_css_color_field(&self->header_bar_accent_color, - fl_lookup_string_arg(args, "accentColor")); - set_css_color_field(&self->header_bar_accent_foreground_color, - fl_lookup_string_arg(args, "accentForegroundColor")); - set_css_color_field(&self->header_bar_popover_background_color, - fl_lookup_string_arg(args, "popoverBackgroundColor")); - set_css_color_field(&self->header_bar_border_color, - fl_lookup_string_arg(args, "borderColor")); - set_css_color_field(&self->header_bar_shade_color, - fl_lookup_string_arg(args, "shadeColor")); set_css_color_field(&self->header_bar_modal_barrier_color, fl_lookup_string_arg(args, "modalBarrierColor")); set_main_flutter_view_background(self); @@ -1077,8 +847,6 @@ static void set_header_menu_button_model(GtkWidget* button, } track_widget_pointer(tracked_popover, GTK_WIDGET(popover)); gtk_popover_set_position(popover, GTK_POS_BOTTOM); - gtk_style_context_add_class(gtk_widget_get_style_context(GTK_WIDGET(popover)), - "busymax-header-popover"); } static void append_header_view_mode_item(GMenu* menu, @@ -1209,8 +977,6 @@ static void initialize_header_menu_actions(MyApplication* self) { } static void make_header_icon_button_square(GtkWidget* button) { - gtk_style_context_add_class(gtk_widget_get_style_context(button), - "busymax-header-icon-button"); gtk_widget_set_size_request(button, kHeaderButtonHeight, kHeaderButtonHeight); gtk_widget_set_valign(button, GTK_ALIGN_CENTER); @@ -1226,8 +992,6 @@ static GtkWidget* create_header_icon_button(const gchar* icon_name, gtk_widget_set_valign(button, GTK_ALIGN_CENTER); gtk_style_context_add_class(gtk_widget_get_style_context(button), GTK_STYLE_CLASS_FLAT); - gtk_style_context_add_class(gtk_widget_get_style_context(button), - "busymax-header-button"); make_header_icon_button_square(button); return button; } @@ -1241,8 +1005,6 @@ static GtkWidget* create_header_toggle_icon_button(const gchar* icon_name, gtk_widget_set_tooltip_text(button, tooltip); gtk_style_context_add_class(gtk_widget_get_style_context(button), GTK_STYLE_CLASS_FLAT); - gtk_style_context_add_class(gtk_widget_get_style_context(button), - "busymax-header-button"); make_header_icon_button_square(button); return button; } @@ -1255,8 +1017,6 @@ static GtkWidget* create_header_text_button(const gchar* label, gtk_widget_set_valign(button, GTK_ALIGN_CENTER); gtk_style_context_add_class(gtk_widget_get_style_context(button), GTK_STYLE_CLASS_FLAT); - gtk_style_context_add_class(gtk_widget_get_style_context(button), - "busymax-header-button"); return button; } @@ -1490,6 +1250,8 @@ static void update_header_control_visibility(MyApplication* self) { set_widget_visible(self->search_button, schedule_controls_visible); set_widget_visible(self->create_button, schedule_controls_visible); set_widget_visible(self->refresh_button, schedule_controls_visible); + set_widget_visible(self->settings_menu_button, + schedule_controls_visible || self->header_back_visible); update_header_title_balance_spacer(self); } @@ -1692,10 +1454,6 @@ static GtkWidget* create_busymax_header_bar(MyApplication* self) { track_widget_pointer( &self->search_button, create_header_toggle_icon_button("system-search-symbolic", "")); - gtk_style_context_add_class(gtk_widget_get_style_context(self->search_button), - "busymax-brand-action-button"); - gtk_widget_set_margin_start(self->search_button, - kHeaderSidebarContentInset); connect_header_bar_action(self, self->search_button, "search"); GtkWidget* brand_center_box = @@ -1723,23 +1481,13 @@ static GtkWidget* create_busymax_header_bar(MyApplication* self) { gtk_style_context_add_class( gtk_widget_get_style_context(self->settings_menu_button), GTK_STYLE_CLASS_FLAT); - gtk_style_context_add_class( - gtk_widget_get_style_context(self->settings_menu_button), - "busymax-header-button"); - gtk_style_context_add_class( - gtk_widget_get_style_context(self->settings_menu_button), - "busymax-brand-action-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), - self->search_button, FALSE, FALSE, 0); gtk_box_pack_start(GTK_BOX(self->header_sidebar_brand_box), brand_center_box, TRUE, TRUE, 0); - gtk_box_pack_end(GTK_BOX(self->header_sidebar_brand_box), - self->settings_menu_button, FALSE, FALSE, 0); gtk_box_pack_start(GTK_BOX(self->titlebar_box), self->header_sidebar_brand_box, FALSE, FALSE, 0); @@ -1760,9 +1508,6 @@ static GtkWidget* create_busymax_header_bar(MyApplication* self) { track_widget_pointer( &self->sidebar_collapsed_toggle_button, create_header_toggle_icon_button("sidebar-show-symbolic", "")); - gtk_style_context_add_class( - gtk_widget_get_style_context(self->sidebar_collapsed_toggle_button), - "busymax-sidebar-toggle"); track_widget_pointer(&self->today_button, create_header_text_button("", "")); track_widget_pointer(&self->previous_button, @@ -1861,7 +1606,7 @@ static GtkWidget* create_busymax_header_bar(MyApplication* self) { create_header_text_button("Continue", "Continue")); gtk_style_context_add_class( gtk_widget_get_style_context(self->onboarding_continue_button), - "busymax-header-primary-button"); + GTK_STYLE_CLASS_SUGGESTED_ACTION); connect_header_bar_action(self, self->onboarding_continue_button, "continueSetup"); gtk_widget_set_visible(self->onboarding_continue_button, FALSE); @@ -1886,8 +1631,6 @@ static GtkWidget* create_busymax_header_bar(MyApplication* self) { gtk_button_set_relief(GTK_BUTTON(self->view_mode_button), GTK_RELIEF_NONE); gtk_style_context_add_class(gtk_widget_get_style_context(self->view_mode_button), GTK_STYLE_CLASS_FLAT); - gtk_style_context_add_class(gtk_widget_get_style_context(self->view_mode_button), - "busymax-header-view-mode-button"); GtkWidget* view_mode_button_box = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, kHeaderButtonSpacing); @@ -1907,6 +1650,7 @@ static GtkWidget* create_busymax_header_bar(MyApplication* 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); + gtk_box_pack_start(GTK_BOX(end_box), self->search_button, FALSE, FALSE, 0); track_widget_pointer(&self->create_button, gtk_menu_button_new()); gtk_button_set_relief(GTK_BUTTON(self->create_button), GTK_RELIEF_NONE); @@ -1915,8 +1659,6 @@ static GtkWidget* create_busymax_header_bar(MyApplication* self) { gtk_image_new_from_icon_name("list-add-symbolic", GTK_ICON_SIZE_MENU)); gtk_style_context_add_class(gtk_widget_get_style_context(self->create_button), GTK_STYLE_CLASS_FLAT); - gtk_style_context_add_class(gtk_widget_get_style_context(self->create_button), - "busymax-header-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); @@ -1926,6 +1668,8 @@ static GtkWidget* create_busymax_header_bar(MyApplication* self) { "")); 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->settings_menu_button, FALSE, FALSE, + 0); gtk_header_bar_pack_end(header_bar, end_box); set_header_view_mode(self, "week"); @@ -2736,176 +2480,6 @@ static void register_window_channel(MyApplication* self, FlView* view) { self->window_channel, window_method_call_cb, self, nullptr); } -static gboolean clear_transparent_window_cb(GtkWidget* widget, - cairo_t* cr, - gpointer user_data) { - cairo_save(cr); - cairo_set_operator(cr, CAIRO_OPERATOR_CLEAR); - cairo_paint(cr); - cairo_restore(cr); - return FALSE; -} - -static void rounded_window_realize_cb(GtkWidget* widget, gpointer user_data); - -static gboolean rounded_window_configure_event_cb(GtkWidget* widget, - GdkEventConfigure* event, - gpointer user_data); - -static void rounded_window_size_allocate_cb(GtkWidget* widget, - GtkAllocation* allocation, - gpointer user_data); - -static gboolean rounded_window_state_event_cb(GtkWidget* widget, - GdkEventWindowState* event, - gpointer user_data); - -static gboolean configure_transparent_window_backing(GtkWindow* window) { - GdkScreen* screen = gtk_window_get_screen(window); - GdkVisual* visual = gdk_screen_get_rgba_visual(screen); - if (visual == nullptr) { - return FALSE; - } - - gtk_widget_set_visual(GTK_WIDGET(window), visual); - gtk_widget_set_app_paintable(GTK_WIDGET(window), TRUE); - g_signal_connect(window, "draw", G_CALLBACK(clear_transparent_window_cb), - nullptr); - g_signal_connect_after(window, "realize", G_CALLBACK(rounded_window_realize_cb), - nullptr); - g_signal_connect_after(window, "configure-event", - G_CALLBACK(rounded_window_configure_event_cb), - nullptr); - g_signal_connect_after(window, "size-allocate", - G_CALLBACK(rounded_window_size_allocate_cb), nullptr); - g_signal_connect_after(window, "window-state-event", - G_CALLBACK(rounded_window_state_event_cb), nullptr); - return TRUE; -} - -static cairo_region_t* create_rounded_window_region(gint width, - gint height, - gint radius) { - cairo_region_t* region = cairo_region_create(); - if (width <= 0 || height <= 0) { - return region; - } - - if (radius <= 0 || width < radius * 2 || height < radius * 2) { - const cairo_rectangle_int_t rect = {0, 0, width, height}; - cairo_region_union_rectangle(region, &rect); - return region; - } - - const gdouble radius_squared = radius * radius; - for (gint y = 0; y < height; y++) { - gint inset = 0; - if (y < radius) { - const gdouble dy = radius - y - 1; - inset = radius - static_cast(std::sqrt(radius_squared - dy * dy)); - } else if (y >= height - radius) { - const gdouble dy = y - (height - radius); - inset = radius - static_cast(std::sqrt(radius_squared - dy * dy)); - } - - const gint row_width = width - inset * 2; - if (row_width <= 0) { - continue; - } - - const cairo_rectangle_int_t row = {inset, y, row_width, 1}; - cairo_region_union_rectangle(region, &row); - } - - return region; -} - -static gboolean rounded_window_state_requires_rectangular_shape( - GdkWindowState state) { - if ((state & GDK_WINDOW_STATE_MAXIMIZED) != 0 || - (state & GDK_WINDOW_STATE_FULLSCREEN) != 0) { - return TRUE; - } -#if GTK_CHECK_VERSION(3, 10, 0) - if ((state & GDK_WINDOW_STATE_TILED) != 0) { - return TRUE; - } -#endif -#if GTK_CHECK_VERSION(3, 22, 0) - if ((state & (GDK_WINDOW_STATE_TOP_TILED | GDK_WINDOW_STATE_RIGHT_TILED | - GDK_WINDOW_STATE_BOTTOM_TILED | GDK_WINDOW_STATE_LEFT_TILED)) != - 0) { - return TRUE; - } -#endif - return FALSE; -} - -static void configure_rounded_window_shape(GtkWidget* widget) { - if (widget == nullptr || !GTK_IS_WIDGET(widget) || - !gtk_widget_get_realized(widget)) { - return; - } - - GdkWindow* window = gtk_widget_get_window(widget); - if (window == nullptr || !GDK_IS_WINDOW(window)) { - return; - } - - const GdkWindowState state = gdk_window_get_state(window); - if (rounded_window_state_requires_rectangular_shape(state)) { - gdk_window_shape_combine_region(window, nullptr, 0, 0); - gtk_widget_queue_draw(widget); - return; - } - - const gint width = gtk_widget_get_allocated_width(widget); - const gint height = gtk_widget_get_allocated_height(widget); - if (width <= 0 || height <= 0) { - return; - } - - cairo_region_t* region = - create_rounded_window_region(width, height, kHeaderWindowRadius); - gdk_window_shape_combine_region(window, region, 0, 0); - cairo_region_destroy(region); - gtk_widget_queue_draw(widget); -} - -static void rounded_window_realize_cb(GtkWidget* widget, gpointer user_data) { - configure_rounded_window_shape(widget); -} - -static gboolean rounded_window_configure_event_cb(GtkWidget* widget, - GdkEventConfigure* event, - gpointer user_data) { - configure_rounded_window_shape(widget); - return FALSE; -} - -static void rounded_window_size_allocate_cb(GtkWidget* widget, - GtkAllocation* allocation, - gpointer user_data) { - configure_rounded_window_shape(widget); -} - -static gboolean rounded_window_state_event_cb(GtkWidget* widget, - GdkEventWindowState* event, - gpointer user_data) { - if (event != nullptr && - rounded_window_state_requires_rectangular_shape( - event->new_window_state)) { - GdkWindow* window = gtk_widget_get_window(widget); - if (window != nullptr && GDK_IS_WINDOW(window)) { - gdk_window_shape_combine_region(window, nullptr, 0, 0); - gtk_widget_queue_draw(widget); - return FALSE; - } - } - configure_rounded_window_shape(widget); - return FALSE; -} - static void install_compact_agenda_window_css(GtkWindow* window) { static const gchar* css = "window#busymax-compact-agenda-window," @@ -3178,8 +2752,6 @@ static void my_application_activate(GApplication* application) { GTK_WINDOW(gtk_application_window_new(GTK_APPLICATION(application))); self->main_window = window; gtk_widget_set_name(GTK_WIDGET(window), "busymax-window"); - self->main_window_transparent_backing = - configure_transparent_window_backing(window); // Use a header bar when running in GNOME as this is the common style used // by applications and is the setup most users will be using (e.g. Ubuntu @@ -3351,16 +2923,6 @@ static void my_application_dispose(GObject* object) { 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_muted_foreground_color, g_free); - g_clear_pointer(&self->header_bar_disabled_foreground_color, g_free); - g_clear_pointer(&self->header_bar_control_color, g_free); - g_clear_pointer(&self->header_bar_control_hover_color, g_free); - g_clear_pointer(&self->header_bar_control_active_color, g_free); - g_clear_pointer(&self->header_bar_accent_color, g_free); - g_clear_pointer(&self->header_bar_accent_foreground_color, g_free); - g_clear_pointer(&self->header_bar_popover_background_color, g_free); - g_clear_pointer(&self->header_bar_border_color, g_free); - g_clear_pointer(&self->header_bar_shade_color, g_free); g_clear_pointer(&self->header_bar_modal_barrier_color, g_free); g_clear_pointer(&self->header_view_mode, g_free); g_clear_pointer(&self->header_day_label, g_free); @@ -3404,7 +2966,6 @@ static void my_application_init(MyApplication* self) { self->header_schedule_controls_visible = TRUE; self->header_back_visible = FALSE; self->header_onboarding_controls_visible = FALSE; - self->main_window_transparent_backing = FALSE; self->header_bar_css_provider = nullptr; self->header_bar_window_background_color = g_strdup(kDefaultWindowBackgroundColor); @@ -3414,16 +2975,6 @@ static void my_application_init(MyApplication* self) { g_strdup(kDefaultHeaderBarSidebarBackgroundColor); self->header_bar_sidebar_border_color = nullptr; self->header_bar_foreground_color = nullptr; - self->header_bar_muted_foreground_color = nullptr; - self->header_bar_disabled_foreground_color = nullptr; - self->header_bar_control_color = nullptr; - self->header_bar_control_hover_color = nullptr; - self->header_bar_control_active_color = nullptr; - self->header_bar_accent_color = nullptr; - self->header_bar_accent_foreground_color = nullptr; - self->header_bar_popover_background_color = nullptr; - self->header_bar_border_color = nullptr; - self->header_bar_shade_color = nullptr; self->header_bar_modal_barrier_color = nullptr; self->header_bar_sidebar_width = 300; self->header_bar_can_show_sidebar = TRUE; diff --git a/test/app/about_dialog_test.dart b/test/app/about_dialog_test.dart index b56e88b..b8044ce 100644 --- a/test/app/about_dialog_test.dart +++ b/test/app/about_dialog_test.dart @@ -31,31 +31,22 @@ void main() { expect(source, isNot(contains('https://github.com/albertgee/busymax'))); }); - test( - 'about dialog uses native headerbar dimming and circular close button', - () { - final source = File( - 'lib/src/app/busymax_about_dialog.dart', - ).readAsStringSync(); - final design = File('lib/src/app/busymax_design.dart').readAsStringSync(); - final dialogs = File( - 'lib/src/app/busymax_dialogs.dart', - ).readAsStringSync(); + test('about dialog uses native headerbar dimming and Yaru close button', () { + 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('setModalBarrierVisible(true)')); - expect(dialogs, contains('setModalBarrierVisible(false)')); - expect(source, isNot(contains('barrierColor: Colors.transparent'))); - expect(source, contains('BusyMaxDialogCloseButton')); - expect(design, contains('CircleBorder()')); - expect(design, contains('color: surfaceColors.control')); - expect(design, contains('BusyMaxSizes.aboutCloseButton')); - expect(design, contains('BusyMaxSizes.iconSm')); - }, - ); + expect(source, contains('showBusyMaxModalDialog')); + expect(source, contains('headerBarService: headerBarService')); + expect(dialogs, contains('acquireBusyMaxModalBarrier')); + expect(dialogs, contains('releaseBusyMaxModalBarrier')); + expect(dialogs, contains('setModalBarrierVisible(true)')); + expect(dialogs, contains('setModalBarrierVisible(false)')); + expect(source, isNot(contains('barrierColor: Colors.transparent'))); + expect(source, contains('YaruIconButton(')); + 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 822f7f0..feb2926 100644 --- a/test/app/app_bootstrap_provider_test.dart +++ b/test/app/app_bootstrap_provider_test.dart @@ -269,18 +269,6 @@ class _FakeOAuthGateway implements OAuthGateway { @override Future refreshActiveToken() async => _tokenSet(); - @override - Future signOutAccount(String accountId) async { - if (activeId == accountId) { - activeId = null; - } - } - - @override - Future signOut() async { - activeId = null; - } - @override Future revokeAndSignOutAccount(String accountId) async { if (activeId == accountId) { @@ -289,9 +277,7 @@ class _FakeOAuthGateway implements OAuthGateway { } @override - Future revokeAndSignOut() async { - activeId = null; - } + Future revokeAuthorization(String accountId) async {} @override Future clearLocalSession({String? accountId}) async { diff --git a/test/app/busymax_grouped_surface_test.dart b/test/app/busymax_grouped_surface_test.dart index 95272d3..3688256 100644 --- a/test/app/busymax_grouped_surface_test.dart +++ b/test/app/busymax_grouped_surface_test.dart @@ -241,7 +241,6 @@ void main() { test('all primary sidebars reuse the shared boundary surface', () { for (final path in [ 'lib/src/features/schedule/presentation/schedule_sidebar.dart', - 'lib/src/features/task_lists/presentation/task_lists_sidebar.dart', 'lib/src/features/settings/presentation/settings_screen.dart', ]) { final source = File(path).readAsStringSync(); @@ -376,6 +375,120 @@ void main() { expect(disabledSemantics.properties.value, 'Personal'); }); + testWidgets( + 'combo row uses Yaru geometry and does not focus items on pointer open', + (tester) async { + final selections = []; + await tester.pumpWidget( + _testApp( + Directionality( + textDirection: TextDirection.rtl, + child: BusyMaxComboRow( + title: 'Calendar', + values: const [1, 2], + selected: 1, + labelFor: (value) => 'Calendar $value', + menuItemBuilder: (context, value) => + Text('Choice $value', key: ValueKey('choice-$value')), + selectedBuilder: (context, value) => + Text('Selected $value', key: ValueKey('selected-$value')), + onSelected: selections.add, + ), + ), + ), + ); + + final triggerFinder = find.descendant( + of: find.byType(BusyMaxComboRow), + matching: find.byType(OutlinedButton), + ); + final trigger = tester.widget(triggerFinder); + expect(trigger.style, isNull); + + final selectedRect = tester.getRect( + find.byKey(const ValueKey('selected-1')), + ); + final arrowRect = tester.getRect( + find.descendant( + of: triggerFinder, + matching: find.byIcon(YaruIcons.pan_down), + ), + ); + expect(arrowRect.right, lessThanOrEqualTo(selectedRect.left)); + + await tester.tap(triggerFinder); + await tester.pumpAndSettle(); + + expect(find.byKey(const ValueKey('choice-1')), findsOneWidget); + expect(find.byKey(const ValueKey('choice-2')), findsOneWidget); + expect( + tester.getRect(find.byKey(const ValueKey('choice-1'))).top, + greaterThanOrEqualTo(tester.getRect(triggerFinder).bottom), + ); + final menuItems = tester.widgetList( + find.byType(MenuItemButton), + ); + expect(menuItems, hasLength(2)); + expect( + menuItems.every((item) => item.focusNode?.hasFocus == false), + isTrue, + ); + + await tester.tap(find.byKey(const ValueKey('choice-2'))); + await tester.pumpAndSettle(); + expect(selections, [2]); + }, + ); + + testWidgets('combo row supports keyboard activation and menu navigation', ( + tester, + ) async { + final selections = []; + await tester.pumpWidget( + _testApp( + BusyMaxComboRow( + title: 'Calendar', + values: const ['Personal', 'Work'], + selected: 'Personal', + labelFor: (value) => value, + onSelected: selections.add, + ), + ), + ); + + final triggerFinder = find.descendant( + of: find.byType(BusyMaxComboRow), + matching: find.byType(OutlinedButton), + ); + tester.widget(triggerFinder).focusNode!.requestFocus(); + await tester.pump(); + + await tester.sendKeyEvent(LogicalKeyboardKey.enter); + await tester.pumpAndSettle(); + + var menuItems = tester + .widgetList(find.byType(MenuItemButton)) + .toList(); + expect(menuItems, hasLength(2)); + expect( + tester.getRect(find.text('Personal').last).top, + greaterThanOrEqualTo(tester.getRect(triggerFinder).bottom), + ); + expect(menuItems.first.focusNode?.hasFocus, isTrue); + + await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); + await tester.pump(); + menuItems = tester + .widgetList(find.byType(MenuItemButton)) + .toList(); + expect(menuItems.last.focusNode?.hasFocus, isTrue); + + await tester.sendKeyEvent(LogicalKeyboardKey.enter); + await tester.pumpAndSettle(); + expect(selections, ['Work']); + expect(find.byType(MenuItemButton), findsNothing); + }); + testWidgets('combo row accepts unbounded horizontal constraints', ( tester, ) async { diff --git a/test/app/busymax_search_field_test.dart b/test/app/busymax_search_field_test.dart index b1333a7..f690415 100644 --- a/test/app/busymax_search_field_test.dart +++ b/test/app/busymax_search_field_test.dart @@ -60,16 +60,11 @@ void main() { final schedule = File( 'lib/src/features/schedule/presentation/schedule_workspace.dart', ).readAsStringSync(); - final taskFilters = File( - 'lib/src/features/tasks/presentation/task_filters.dart', - ).readAsStringSync(); expect(design, contains('class BusyMaxSearchField')); expect(RegExp(r'YaruSearchField\(').allMatches(design), hasLength(1)); expect(schedule, contains('BusyMaxSearchField(')); - expect(taskFilters, contains('BusyMaxSearchField(')); expect(schedule, isNot(contains('class _ScheduleSearchField'))); expect(schedule, isNot(contains('YaruSearchField('))); - expect(taskFilters, isNot(contains('YaruSearchField('))); }); } diff --git a/test/app/high_contrast_theme_test.dart b/test/app/high_contrast_theme_test.dart index 4ea599f..e3e7205 100644 --- a/test/app/high_contrast_theme_test.dart +++ b/test/app/high_contrast_theme_test.dart @@ -70,8 +70,8 @@ void main() { expect(elevatedShape.side, isNot(BorderSide.none)); expect(elevatedShape.side.color, surfaces.border); - final popupShape = theme.popupMenuTheme.shape! as RoundedRectangleBorder; - expect(popupShape.side.color, surfaces.border); + final popupShape = theme.popupMenuTheme.shape! as OutlineInputBorder; + expect(popupShape.borderSide.color, surfaces.border); final tooltipDecoration = theme.tooltipTheme.decoration! as BoxDecoration; expect(tooltipDecoration.border, isNotNull); diff --git a/test/app/native_ui_audit_test.dart b/test/app/native_ui_audit_test.dart index b4ad977..a828303 100644 --- a/test/app/native_ui_audit_test.dart +++ b/test/app/native_ui_audit_test.dart @@ -130,8 +130,11 @@ void main() { expect(compactAgenda, contains('leading: _CompactAgendaRowMarker')); expect(dateTimeFields, contains('YaruDateTimeEntry')); - expect(dateTimeFields, contains('_BusyMaxTimeTextEntry')); - expect(dateTimeFields, contains('parseTimeInput')); + expect(dateTimeFields, contains('YaruTimeEntry(')); + expect(dateTimeFields, contains('YaruTimeEntryController')); + expect(dateTimeFields, isNot(contains('_BusyMaxTimeTextEntry'))); + expect(dateTimeFields, isNot(contains('parseTimeInput'))); + expect(dateTimeFields, isNot(contains('fontSize: 0'))); expect(dateTimeFields, isNot(contains('showDatePicker'))); expect(dateTimeFields, isNot(contains('showTimePicker'))); }, @@ -411,6 +414,46 @@ void main() { expect(source, contains('header_brand_label')); expect(source, contains('settings_menu_button')); expect(source, contains('settings_menu')); + expect( + source, + contains( + 'gtk_box_pack_start(GTK_BOX(end_box), self->search_button, ' + 'FALSE, FALSE, 0)', + ), + ); + expect( + source, + contains( + 'gtk_box_pack_start(GTK_BOX(end_box), self->settings_menu_button, ' + 'FALSE, FALSE,', + ), + ); + expect( + source, + contains( + 'set_widget_visible(self->settings_menu_button,\n' + ' schedule_controls_visible || ' + 'self->header_back_visible);', + ), + ); + expect( + headerBarSource, + isNot( + contains( + 'gtk_box_pack_start(GTK_BOX(self->header_sidebar_brand_box),\n' + ' self->search_button', + ), + ), + ); + expect( + headerBarSource, + isNot( + contains( + 'gtk_box_pack_end(GTK_BOX(self->header_sidebar_brand_box),\n' + ' self->settings_menu_button', + ), + ), + ); expect( source, contains( @@ -462,13 +505,6 @@ void main() { 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('const gint header_bar_left_radius')); - expect( - source, - contains( - 'header_sidebar_effective_width(self) > 0 ? 0 : kHeaderWindowRadius', - ), - ); expect(source, contains('kHeaderMainContentStartInset')); expect( source, @@ -491,89 +527,51 @@ void main() { ), ); expect(source, contains('padding-left: 0;')); - expect(source, contains('kHeaderWindowRadius')); - expect(source, contains('border-top-left-radius: %dpx;')); - expect(source, contains('border-top-right-radius: %dpx;')); - final mainWindowCssStart = source.indexOf('"window#busymax-window,"'); - final mainWindowDecorationCssStart = source.indexOf( - '"window#busymax-window decoration,"', - mainWindowCssStart, - ); - final mainWindowCss = source.substring( - mainWindowCssStart, - mainWindowDecorationCssStart, - ); - expect(mainWindowCss, contains('"background-color: %s;"')); + expect(source, contains('"window#busymax-window,"')); + expect(source, contains('"background-color: %s;"')); expect( source, - contains('self->main_window_transparent_backing ? "transparent"'), + 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'))); + 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, - contains( - 'g_signal_connect(window, "draw", G_CALLBACK(clear_transparent_window_cb)', + isNot( + contains('gtk_widget_set_app_paintable(GTK_WIDGET(window), TRUE)'), ), ); - expect(source, contains('clear_transparent_window_cb')); - expect(source, contains('CAIRO_OPERATOR_CLEAR')); - expect( - source, - contains('gtk_widget_set_name(GTK_WIDGET(window), "busymax-window")'), - ); - expect(source, contains('window#busymax-window decoration,')); - expect(source, contains('window#busymax-window decoration:backdrop')); - expect(source, contains('"box-shadow: 0 3px 18px 2px %s;"')); - expect(source, contains('window_css_background_color, shade_color')); - expect(source, contains('"outline: none;"')); - expect( - source, - contains('gtk_widget_set_app_paintable(GTK_WIDGET(window), TRUE)'), - ); - expect(source, contains('configure_rounded_window_shape')); - expect(source, contains('create_rounded_window_region')); - expect(source, contains('gdk_window_shape_combine_region')); - expect(source, contains('rounded_window_size_allocate_cb')); - expect(source, contains('"size-allocate"')); - expect(source, contains('rounded_window_state_event_cb')); - expect(source, contains('"window-state-event"')); - expect(source, contains('GDK_WINDOW_STATE_MAXIMIZED')); - expect(source, contains('GDK_WINDOW_STATE_FULLSCREEN')); - expect(source, contains('GDK_WINDOW_STATE_TILED')); - expect(source, contains('GDK_WINDOW_STATE_RIGHT_TILED')); - expect(source, contains('gtk_widget_queue_draw(widget)')); + 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, contains('constexpr gint kHeaderButtonRadius = 8;')); - expect(source, contains('border-radius: %dpx;')); - expect(source, contains('tooltip.background')); - expect(source, contains('tooltip > box')); - expect(source, contains('tooltip label')); - expect(source, contains('margin: 0;')); - expect(source, contains('min-height: 0;')); - expect(source, contains('"box-shadow: 0 5px 18px 2px %s;"')); - expect(source, contains('kHeaderTooltipVerticalPadding')); - expect(source, contains('kHeaderTooltipHorizontalPadding')); - expect( - source, - contains('constexpr gint kHeaderTooltipVerticalPadding = 5;'), - ); - expect( - source, - contains('constexpr gint kHeaderTooltipHorizontalPadding = 8;'), - ); - expect(source, contains('padding: %dpx %dpx;')); - expect(source, contains('border-color: transparent;')); - expect(source, contains('border-width: 0;')); - expect(source, contains('outline-style: none;')); - expect(source, contains('busymax-brand-action-button')); - expect(source, contains('button.busymax-brand-action-button,')); - expect(source, contains('button.busymax-brand-action-button:hover')); - expect(source, contains('busymax-sidebar-toggle')); - expect(source, contains('busymax-header-button')); - expect(source, contains('busymax-header-icon-button')); + expect(source, isNot(contains('border-radius: %dpx;'))); + expect(source, isNot(contains('kHeaderButtonRadius'))); + expect(source, isNot(contains('tooltip.background'))); + expect(source, isNot(contains('tooltip > box'))); + expect(source, isNot(contains('tooltip label'))); + expect(source, isNot(contains('kHeaderTooltipVerticalPadding'))); + expect(source, isNot(contains('kHeaderTooltipHorizontalPadding'))); + expect(source, isNot(contains('"padding: %dpx %dpx;"'))); + expect(source, isNot(contains('"border-color: transparent;"'))); + 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( @@ -591,11 +589,13 @@ void main() { ); expect( source, - contains( - '.busymax-titlebar button.busymax-header-view-mode-button:hover', + isNot( + contains( + '.busymax-titlebar button.busymax-header-view-mode-button:hover', + ), ), ); - expect(source, contains('transition: none;')); + expect(source, isNot(contains('transition: none;'))); expect(source, contains('gtk_popover_set_position')); expect(source, contains('GTK_POS_BOTTOM')); expect(source, contains('gtk_popover_popdown')); @@ -624,9 +624,9 @@ void main() { 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, contains('"busymax-header-popover"')); - expect(source, contains('header_bar_popover_background_color')); - expect(source, contains('"popoverBackgroundColor"')); + expect(source, isNot(contains('"busymax-header-popover"'))); + expect(source, isNot(contains('header_bar_popover_background_color'))); + expect(source, isNot(contains('"popoverBackgroundColor"'))); expect(source, isNot(contains('"busymax-header-popover-row"'))); expect(source, isNot(contains('kHeaderPopoverRowSpacing'))); expect(source, isNot(contains('busymax-keyboard-focus'))); @@ -687,6 +687,20 @@ void main() { expect(source, contains('args, "searchQuery"')); expect(source, contains('gtk_search_entry_new()')); 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"')); @@ -710,16 +724,17 @@ void main() { expect(source, contains('is_css_rgba_color')); expect(source, contains('is_css_color_token')); expect(source, contains('header_bar_foreground_color')); - expect(source, contains('header_bar_muted_foreground_color')); - expect(source, contains('header_bar_disabled_foreground_color')); - expect(source, contains('header_bar_control_hover_color')); - expect(source, contains('header_bar_popover_background_color')); - expect(source, contains('header_bar_border_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_border_color'))); expect(source, contains('header_bar_sidebar_border_color')); expect(source, contains('border-right: 1px solid %s;')); - expect(source, contains('header_bar_shade_color')); - expect(source, contains('header_bar_accent_color')); - expect(source, contains('header_bar_accent_foreground_color')); + 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')); @@ -733,11 +748,15 @@ void main() { 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, contains('busymax-header-primary-button')); - expect(source, contains('fl_lookup_string_arg(args, "accentColor")')); + expect(source, contains('GTK_STYLE_CLASS_SUGGESTED_ACTION')); + expect(source, isNot(contains('busymax-header-primary-button'))); + expect( + source, + isNot(contains('fl_lookup_string_arg(args, "accentColor")')), + ); expect( source, - contains('fl_lookup_string_arg(args, "accentForegroundColor")'), + isNot(contains('fl_lookup_string_arg(args, "accentForegroundColor")')), ); expect(source, isNot(contains('gtk_menu_new()'))); expect(source, isNot(contains('gtk_menu_popup_at_widget'))); @@ -874,34 +893,38 @@ void main() { expect(source, isNot(contains('button.busymax-header-popover-row'))); expect( source, - contains('button.busymax-header-button.busymax-sidebar-toggle:checked'), + isNot( + contains( + 'button.busymax-header-button.busymax-sidebar-toggle:checked', + ), + ), ); expect(source, isNot(contains('"newItem"'))); expect(source, isNot(contains('"openMenu"'))); }); - test('native headerbar colors use semantic Dart payload fields', () { + test('native headerbar CSS is limited to semantic surfaces', () { final source = File('linux/runner/my_application.cc').readAsStringSync(); - final barrierBackgroundStart = source.indexOf( - '".busymax-titlebar.busymax-modal-barrier,"', - ); - final barrierBackgroundEnd = source.indexOf( - '".busymax-titlebar button.busymax-header-button,"', - barrierBackgroundStart, + final headerCssStart = source.indexOf( + 'g_autofree gchar* css = g_strdup_printf(', ); - final barrierStart = source.indexOf( - '".busymax-titlebar.busymax-modal-barrier label,"', - ); - final barrierEnd = source.indexOf( - '".busymax-titlebar button.busymax-header-icon-button {"', - barrierStart, + final headerCssEnd = source.indexOf( + 'g_autoptr(GError) error = nullptr;', + headerCssStart, ); + expect(headerCssStart, isNonNegative); + expect(headerCssEnd, isNonNegative); + final headerCss = source.substring(headerCssStart, headerCssEnd); expect(source, contains('"busymax-header-title"')); expect(source, contains('".busymax-titlebar .busymax-header-title {"')); - expect(source, contains('"border: 1px solid %s;"')); - expect(source, contains('"box-shadow: 0 6px 18px %s;"')); - expect(source, contains('muted_foreground_color')); + expect(headerCss, contains('"window#busymax-window,"')); + expect( + headerCss, + contains('".busymax-titlebar .busymax-header-brand {"'), + ); + expect(headerCss, contains('"border-right: 1px solid %s;"')); + expect(headerCss, contains('".busymax-titlebar.busymax-modal-barrier,"')); expect(source, contains('kDefaultWindowBackgroundColor[] = "#2C2C2C"')); expect( source, @@ -922,28 +945,40 @@ void main() { source, isNot(contains('gdk_rgba_parse(&background_color, "#00000000")')), ); - expect(barrierBackgroundStart, isNonNegative); - expect(barrierBackgroundEnd, isNonNegative); - final barrierBackgroundCss = source.substring( - barrierBackgroundStart, - barrierBackgroundEnd, + expect(source, contains('"backgroundColor"')); + expect(source, contains('"sidebarBackgroundColor"')); + expect(source, contains('"sidebarBorderColor"')); + expect(source, contains('"foregroundColor"')); + expect(source, isNot(contains('"shadeColor"'))); + expect(source, contains('"modalBarrierColor"')); + expect( + source, + isNot(contains('fl_lookup_string_arg(args, "controlColor")')), ); - expect(barrierBackgroundCss, contains('"background-color: %s;"')); expect( - barrierBackgroundCss, - contains('"headerbar.busymax-flat-headerbar:backdrop {"'), + source, + isNot(contains('fl_lookup_string_arg(args, "controlHoverColor")')), ); expect( source, - contains( - 'sidebar_background_color, modal_barrier_color, modal_barrier_color', - ), + isNot(contains('fl_lookup_string_arg(args, "controlActiveColor")')), ); - expect(barrierStart, isNonNegative); - expect(barrierEnd, isNonNegative); - final barrierCss = source.substring(barrierStart, barrierEnd); - expect(barrierCss, contains('"color: %s;"')); - expect(barrierCss, isNot(contains('rgba(255,255,255,0.38)'))); + expect( + source, + isNot(contains('fl_lookup_string_arg(args, "popoverBackgroundColor")')), + ); + expect(headerCss, isNot(contains('.busymax-titlebar button'))); + expect(headerCss, isNot(contains('popover.busymax'))); + expect(headerCss, isNot(contains('tooltip.background'))); + expect(headerCss, isNot(contains(':hover'))); + expect(headerCss, isNot(contains(':active'))); + expect(headerCss, isNot(contains(':checked'))); + expect(headerCss, isNot(contains(':focus'))); + expect(headerCss, isNot(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('native GTK theme sampling does not export fake disabled colors', () { @@ -1168,7 +1203,7 @@ void main() { expect( _hasRawPopupMenuButton(file, line), isFalse, - reason: '$location should use BusyMaxMenuButton/MenuButtonBuilder.', + reason: '$location should use BusyMaxMenuButton.', ); expect( _hasRawPopupMenuEntry(file, line), @@ -1229,7 +1264,7 @@ bool _hasRawMenuAnchor(File file, String line) { if (file.path.endsWith('lib/src/app/busymax_design.dart')) { return false; } - return line.contains('MenuAnchor'); + return RegExp(r'\bMenuAnchor\s*\(').hasMatch(line); } bool _hasRawCheckbox(String line) { diff --git a/test/app/theme_localization_test.dart b/test/app/theme_localization_test.dart index 32b05ca..4c6bffc 100644 --- a/test/app/theme_localization_test.dart +++ b/test/app/theme_localization_test.dart @@ -6,6 +6,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:system_theme/system_theme.dart'; +import 'package:yaru/constants.dart'; import 'package:yaru/theme.dart'; import 'package:busymax/l10n/generated/app_localizations.dart'; import 'package:busymax/src/app/app_bootstrap.dart'; @@ -93,15 +94,19 @@ void main() { ); expect( light.toggleButtonsTheme.highlightColor, - lightSurfaceColors.controlActive, + yaruBase.toggleButtonsTheme.highlightColor, ); expect( light.toggleButtonsTheme.splashColor, - lightSurfaceColors.controlHover, + yaruBase.toggleButtonsTheme.splashColor, ); expect( light.toggleButtonsTheme.focusColor, - lightSurfaceColors.controlActive, + yaruBase.toggleButtonsTheme.focusColor, + ); + expect( + light.toggleButtonsTheme.hoverColor, + yaruBase.toggleButtonsTheme.hoverColor, ); expect(light.floatingActionButtonTheme.backgroundColor, _testAccentColor); expect(light.progressIndicatorTheme.color, _testAccentColor); @@ -148,6 +153,67 @@ void main() { } }); + test('semantic theme retains Yaru component geometry and interactions', () { + final theme = _buildBusyMaxTheme(brightness: Brightness.light); + final base = createYaruLightTheme(primaryColor: _testAccentColor); + final colors = theme.extension()!; + + expect(theme.inputDecorationTheme.filled, base.inputDecorationTheme.filled); + expect( + theme.inputDecorationTheme.fillColor, + base.inputDecorationTheme.fillColor, + ); + expect( + theme.inputDecorationTheme.contentPadding, + base.inputDecorationTheme.contentPadding, + ); + final inputShape = + theme.inputDecorationTheme.enabledBorder as OutlineInputBorder; + final baseInputShape = + base.inputDecorationTheme.enabledBorder as OutlineInputBorder; + expect(inputShape.borderRadius, baseInputShape.borderRadius); + expect(inputShape.borderSide.width, baseInputShape.borderSide.width); + expect( + inputShape.borderSide.strokeAlign, + baseInputShape.borderSide.strokeAlign, + ); + expect(inputShape.borderSide.color, colors.border); + + expect( + theme.dropdownMenuTheme.inputDecorationTheme?.constraints, + base.dropdownMenuTheme.inputDecorationTheme?.constraints, + ); + + final dialogShape = theme.dialogTheme.shape! as RoundedRectangleBorder; + final baseDialogShape = base.dialogTheme.shape! as RoundedRectangleBorder; + expect(dialogShape.borderRadius, baseDialogShape.borderRadius); + expect(dialogShape.borderRadius, BorderRadius.circular(kYaruWindowRadius)); + expect(dialogShape.side, baseDialogShape.side); + expect(BusyMaxRadius.window, kYaruWindowRadius); + + final checkboxShape = theme.checkboxTheme.shape! as RoundedRectangleBorder; + final baseCheckboxShape = + base.checkboxTheme.shape! as RoundedRectangleBorder; + expect(checkboxShape.borderRadius, baseCheckboxShape.borderRadius); + expect(checkboxShape.borderRadius, BorderRadius.circular(kYaruCheckRadius)); + + final popupShape = theme.popupMenuTheme.shape! as OutlineInputBorder; + final basePopupShape = base.popupMenuTheme.shape! as OutlineInputBorder; + expect(popupShape.borderRadius, basePopupShape.borderRadius); + expect(popupShape.borderSide, basePopupShape.borderSide); + + for (final style in [ + theme.textTheme.titleSmall, + theme.textTheme.bodySmall, + theme.textTheme.labelLarge, + theme.textTheme.labelMedium, + theme.textTheme.labelSmall, + ]) { + expect(style?.color, colors.foreground); + expect(style?.color, isNot(colors.mutedForeground)); + } + }); + test('shared push buttons expose semantic Yaru roles', () { final standard = BusyMaxPushButton.standard( onPressed: () {}, @@ -1168,19 +1234,26 @@ void main() { test('native headerbar receives semantic surface 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 = BusyMaxSurfaceColors.of(context);'), ); - expect(source, contains('await service.setTheme(')); + expect(source, contains('_headerBarConfigurationSynchronizer.schedule(')); + expect(synchronizer, contains('await service.setTheme(')); expect(source, contains('windowBackgroundColor: colors.window')); expect(source, contains('backgroundColor: colors.headerbarFlat')); expect(source, isNot(contains('backgroundColor: colors.headerbar,'))); expect(source, contains('sidebarBackgroundColor: colors.sidebar')); - expect(source, contains('controlHoverColor: colors.controlHover')); - expect(source, contains('accentColor: colorScheme.primary')); - expect(source, contains('accentForegroundColor: colorScheme.onPrimary')); + expect(source, contains('foregroundColor: colors.foreground')); + expect(source, contains('sidebarBorderColor: colors.sidebarBorder')); + expect(source, contains('modalBarrierColor: modalBarrierColor')); + expect(source, isNot(contains('controlHoverColor: colors.controlHover'))); + expect(source, isNot(contains('popoverBackgroundColor: colors.popover'))); + 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')); @@ -1190,21 +1263,13 @@ void main() { expect(source, isNot(contains('setSidebarBackgroundColor('))); }); - test( - 'root window wrapper clips bottom corners over matching native backing', - () { - final source = File('lib/src/app/busymax_app.dart').readAsStringSync(); + test('root window uses semantic backing while GTK owns window geometry', () { + final source = File('lib/src/app/busymax_app.dart').readAsStringSync(); - expect(source, contains('ClipRRect(')); - expect(source, contains('bottom: Radius.circular(BusyMaxRadius.window)')); - expect(source, contains('clipBehavior: Clip.antiAlias')); - expect(source, isNot(contains('Clip.antiAliasWithSaveLayer'))); - expect( - source, - contains('color: BusyMaxSurfaceColors.of(context).window'), - ); - }, - ); + expect(source, isNot(contains('_BusyMaxWindowCornerClip'))); + expect(source, isNot(contains('ClipRRect('))); + expect(source, contains('color: BusyMaxSurfaceColors.of(context).window')); + }); test('signed-out onboarding background matches main content surface', () { final source = File( diff --git a/test/config/build_config_test.dart b/test/config/build_config_test.dart index cf15661..e85754a 100644 --- a/test/config/build_config_test.dart +++ b/test/config/build_config_test.dart @@ -9,6 +9,8 @@ void main() { expect(config.googleApiBaseUrl, 'https://www.googleapis.com'); expect(config.apiBaseUrl, 'https://www.googleapis.com'); expect(config.feedbackEndpoint, 'https://busystack.org/api/feedback'); + expect(config.useFakeProviderData, isFalse); + expect(config.demoTheme, BusyMaxDemoTheme.system); expect( config.oauthAuthorizationEndpoint, 'https://accounts.google.com/o/oauth2/v2/auth', @@ -34,4 +36,38 @@ void main() { expect(config.hasGoogleOAuthClientId, isFalse); expect(config.missingClientIdMessage, contains('flutter run -d linux')); }); + + test('demo mode is never enabled in release builds', () { + expect(busyMaxDemoModeEnabled(requested: true, releaseMode: false), isTrue); + expect(busyMaxDemoModeEnabled(requested: true, releaseMode: true), isFalse); + expect( + busyMaxDemoModeEnabled(requested: false, releaseMode: false), + isFalse, + ); + }); + + test('demo mode exposes only its local Google account flow', () { + const config = BuildConfig( + googleOAuthClientId: '', + googleOAuthClientSecret: '', + microsoftOAuthClientId: 'real-microsoft-client', + oauthAuthorizationEndpoint: 'https://example.test/authorize', + oauthTokenEndpoint: 'https://example.test/token', + oauthRevocationEndpoint: 'https://example.test/revoke', + useFakeProviderData: true, + demoTheme: BusyMaxDemoTheme.dark, + ); + + expect(config.hasGoogleOAuthClientId, isTrue); + expect(config.hasMicrosoftOAuthClientId, isFalse); + expect(config.hasAnyProviderConfigured, isTrue); + expect(config.demoTheme, BusyMaxDemoTheme.dark); + }); + + test('demo theme parser is tolerant and defaults to system', () { + expect(parseBusyMaxDemoTheme('LIGHT'), BusyMaxDemoTheme.light); + expect(parseBusyMaxDemoTheme(' dark '), BusyMaxDemoTheme.dark); + expect(parseBusyMaxDemoTheme('invalid'), BusyMaxDemoTheme.system); + expect(parseBusyMaxDemoTheme(''), BusyMaxDemoTheme.system); + }); } diff --git a/test/core/logging/redacting_logger_test.dart b/test/core/logging/redacting_logger_test.dart index ac77a2e..3ecc5ac 100644 --- a/test/core/logging/redacting_logger_test.dart +++ b/test/core/logging/redacting_logger_test.dart @@ -1,5 +1,6 @@ -import 'package:flutter_test/flutter_test.dart'; import 'package:busymax/src/core/logging/redacting_logger.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; void main() { test('redacts OAuth and bearer secrets', () { @@ -19,4 +20,28 @@ void main() { expect(redacted, isNot(contains('seven'))); expect(redacted, contains('[REDACTED]')); }); + + test('redacts a generic revocation token from a failed request URI', () { + final error = http.ClientException( + 'Connection failed', + Uri.parse( + 'https://oauth.example.test/revoke' + '?token=refresh-secret&reason=account-removal', + ), + ); + + final redacted = redactForLog( + 'Google authorization revocation failed: $error', + ); + + expect(redacted, isNot(contains('refresh-secret'))); + expect(redacted, contains('?token=[REDACTED]')); + expect(redacted, contains('reason=account-removal')); + }); + + test('does not redact generic token text outside a URL query', () { + const message = 'The parser returned token=identifier in normal prose.'; + + expect(redactForLog(message), message); + }); } diff --git a/test/demo/demo_profile_test.dart b/test/demo/demo_profile_test.dart new file mode 100644 index 0000000..45e7322 --- /dev/null +++ b/test/demo/demo_profile_test.dart @@ -0,0 +1,158 @@ +import 'package:busymax/src/app/app_bootstrap.dart'; +import 'package:busymax/src/config/build_config.dart'; +import 'package:busymax/src/demo/demo_profile.dart'; +import 'package:busymax/src/demo/demo_seed.dart'; +import 'package:busymax/src/features/auth/data/auth_repository.dart'; +import 'package:busymax/src/features/feedback/data/feedback_submission.dart'; +import 'package:busymax/src/features/schedule/application/compact_agenda_data.dart'; +import 'package:busymax/src/features/sync/account_sync_operations.dart'; +import 'package:busymax/src/google_tasks/oauth/oauth_token_store.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test('demo settings are isolated and disable background effects', () async { + final settings = busyMaxDemoSettings(BusyMaxDemoTheme.dark); + final store = InMemoryLocalSettingsStore(settings.toJson()); + + expect(settings.themeModePreference, BusyMaxThemeModePreference.dark); + expect(settings.runInBackgroundWhenClosed, isFalse); + expect(settings.showTrayIcon, isFalse); + expect(settings.startMinimizedToTray, isFalse); + expect(settings.notifySyncFailures, isFalse); + expect(settings.notifyConflicts, isFalse); + expect(settings.notifyDueToday, isFalse); + expect(settings.notifyEventReminders, isFalse); + expect(settings.notifyTaskReminders, isFalse); + + final loaded = await loadInitialAppSettings(store); + expect(loaded.themeModePreference, BusyMaxThemeModePreference.dark); + await store.save( + loaded + .copyWith(themeModePreference: BusyMaxThemeModePreference.light) + .toJson(), + ); + expect( + store.snapshot['themeModePreference'], + BusyMaxThemeModePreference.light.name, + ); + }); + + test('demo provider graph remains local and owns its database', () async { + final profile = await BusyMaxDemoProfile.create( + now: DateTime(2026, 7, 23, 10), + ); + final settings = busyMaxDemoSettings(BusyMaxDemoTheme.system); + final settingsStore = InMemoryLocalSettingsStore(settings.toJson()); + final container = ProviderContainer( + overrides: [ + buildConfigProvider.overrideWithValue(_demoConfig), + localSettingsStoreProvider.overrideWithValue(settingsStore), + initialAppSettingsProvider.overrideWithValue(settings), + ...profile.overrides, + ], + ); + + final database = container.read(databaseProvider); + expect(database, same(profile.database)); + expect( + await database + .select(database.accounts) + .getSingle() + .then((account) => account.id), + busyMaxDemoAccountId, + ); + final compactAgenda = await container.read( + compactAgendaDataProvider.future, + ); + expect( + compactAgenda.items.map((item) => item.title), + contains('Product planning'), + ); + + final authState = await container + .read(authRepositoryProvider) + .loadSession(); + expect(authState.status, AuthSessionStatus.signedIn); + expect(authState.accountId, busyMaxDemoAccountId); + expect( + container.read(applicationOAuthGatewayProvider), + isA(), + ); + expect(container.read(applicationMicrosoftOAuthServiceProvider), isNull); + expect( + container.read(oAuthTokenStoreProvider), + isA(), + ); + expect( + container.read( + taskRemoteApiClientForAccountProvider(busyMaxDemoAccountId), + ), + isNull, + ); + + final sync = container.read(accountSyncOperationsProvider); + expect(sync, isA()); + await sync.syncAccount(busyMaxDemoAccountId, full: true); + await sync.syncTasks(busyMaxDemoAccountId, full: false); + await sync.syncCalendar(busyMaxDemoAccountId, full: false); + + expect( + container.read(desktopNotificationBackendProvider), + isA(), + ); + final feedbackReceipt = await container + .read(feedbackSubmissionServiceProvider) + .submit(_feedback); + expect(feedbackReceipt.id, 'demo-demo-submission'); + expect( + container + .read(baseHttpClientProvider) + .get(Uri.parse('https://example.test')), + throwsA(isA()), + ); + + container.dispose(); + await Future.delayed(Duration.zero); + expect( + database.select(database.accounts).get(), + throwsA(isA()), + ); + }); + + test('demo OAuth never needs an external authorization flow', () async { + final gateway = DemoOAuthGateway(); + + final result = await gateway.signIn(); + + expect(result.accountId, busyMaxDemoAccountId); + expect(await gateway.activeAccountId, busyMaxDemoAccountId); + expect(await gateway.readActiveTokenSet(), isNotNull); + expect( + (await gateway.fetchUserInfo(result.tokenSet))?.email, + 'alex@example.com', + ); + + await gateway.revokeAndSignOutAccount(busyMaxDemoAccountId); + expect(await gateway.activeAccountId, isNull); + }); +} + +const _demoConfig = BuildConfig( + googleOAuthClientId: '', + googleOAuthClientSecret: '', + oauthAuthorizationEndpoint: 'https://example.test/authorize', + oauthTokenEndpoint: 'https://example.test/token', + oauthRevocationEndpoint: 'https://example.test/revoke', + useFakeProviderData: true, +); + +const _feedback = FeedbackSubmission( + submissionId: 'demo-submission', + appVersion: '1.0.0', + buildNumber: '1', + category: FeedbackCategory.usability, + subject: 'Demo feedback', + message: 'This remains inside the local demo profile.', + replyEmail: null, +); diff --git a/test/demo/demo_seed_test.dart b/test/demo/demo_seed_test.dart new file mode 100644 index 0000000..5f37eb4 --- /dev/null +++ b/test/demo/demo_seed_test.dart @@ -0,0 +1,53 @@ +import 'package:busymax/src/db/app_database.dart'; +import 'package:busymax/src/demo/demo_seed.dart'; +import 'package:busymax/src/features/accounts/data/accounts_repository.dart'; +import 'package:busymax/src/schedule/schedule_range.dart'; +import 'package:busymax/src/schedule/schedule_repository.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test('demo seed uses the current schema and current calendar date', () async { + final database = AppDatabase.memoryForTests(); + addTearDown(database.close); + final now = DateTime(2026, 7, 23, 10, 15); + + await seedBusyMaxDemoData(database, now: now); + + final account = await database.select(database.accounts).getSingle(); + final sources = await database.select(database.calendarSources).get(); + final events = await database.select(database.calendarEvents).get(); + final lists = await database.select(database.taskLists).get(); + final tasks = await database.select(database.tasks).get(); + + expect(account.id, busyMaxDemoAccountId); + expect(account.authState, accountAuthStateSignedIn); + expect(account.provider, 'google'); + expect(sources, hasLength(2)); + expect(events, hasLength(6)); + expect(lists, hasLength(2)); + expect(tasks, hasLength(6)); + expect( + events.where((event) => event.startDate == '2026-07-23'), + isNotEmpty, + ); + expect(tasks.where((task) => task.dueUtc == '2026-07-23'), hasLength(2)); + expect(await database.select(database.pendingOps).get(), isEmpty); + expect(await database.select(database.notificationSchedule).get(), isEmpty); + }); + + test('demo data is immediately available through schedule queries', () async { + final database = AppDatabase.memoryForTests(); + addTearDown(database.close); + final today = DateTime(2026, 7, 23); + + await seedBusyMaxDemoData(database, now: today); + + final items = await ScheduleRepository( + database, + ).listItems(range: ScheduleRange.day(today)); + + expect(items.map((item) => item.title), contains('Product planning')); + expect(items.map((item) => item.title), contains('Focus day')); + expect(items.map((item) => item.title), contains('Send meeting notes')); + }); +} diff --git a/test/features/auth/data/auth_repository_test.dart b/test/features/auth/data/auth_repository_test.dart index c5f970c..7d70b15 100644 --- a/test/features/auth/data/auth_repository_test.dart +++ b/test/features/auth/data/auth_repository_test.dart @@ -95,6 +95,26 @@ void main() { expect(await database.select(database.accounts).get(), isEmpty); }); + test('revocation failure does not mask missing-scope guidance', () async { + oAuth.nextTokenSet = _tokenSet(scopes: {googleTasksReadOnlyScope}); + oAuth.revokeAndSignOutError = StateError('revocation unavailable'); + + await expectLater( + repository.signIn(), + throwsA( + isA().having( + (error) => error.code, + 'code', + 'OAuthMissingRequiredScope', + ), + ), + ); + + expect(oAuth.revoked, isTrue); + expect(oAuth.revokedAccountId, 'account-1'); + expect(await database.select(database.accounts).get(), isEmpty); + }); + test( 'loadSession does not touch token storage on signed-out startup', () async { @@ -123,29 +143,6 @@ void main() { }, ); - test('revokeAndSignOut marks account signed out', () async { - await repository.signIn(); - - await repository.revokeAndSignOut(); - - final account = await database.select(database.accounts).getSingle(); - expect(account.authState, 'signed_out'); - expect(oAuth.revoked, isTrue); - expect(oAuth.revokedAccountId, 'account-1'); - }); - - test('signOut marks account signed out without revoking', () async { - await repository.signIn(); - - await repository.signOut(); - - final account = await database.select(database.accounts).getSingle(); - expect(account.authState, 'signed_out'); - expect(oAuth.signedOut, isTrue); - expect(oAuth.signedOutAccountId, 'account-1'); - expect(oAuth.revoked, isFalse); - }); - test( 'markReconnectRequired keeps account row visible but not syncable', () async { @@ -161,7 +158,7 @@ void main() { .watchVisibleAccounts() .first; - expect(oAuth.signedOutAccountId, 'google:g'); + expect(oAuth.clearedAccountId, 'google:g'); expect(account.authState, accountAuthStateReauthRequired); expect(signedInAccounts, isEmpty); expect(visibleAccounts.single.needsReconnect, isTrue); @@ -183,7 +180,27 @@ void main() { expect(notifications.single.title, 'Private google-b reminder'); }); - test('signOut Google account does not sign out Microsoft account', () async { + test( + 'markReconnectRequired selects credential storage by provider', + () async { + final microsoftOAuth = _FakeMicrosoftOAuthService(); + repository = AuthRepository( + oAuth: oAuth, + database: database, + microsoftOAuth: microsoftOAuth, + nowUtc: () => DateTime.utc(2026, 6, 4), + ); + const opaqueAccountId = 'opaque-account-id'; + await _insertAccount(database, opaqueAccountId, TaskProvider.microsoft); + + await repository.markReconnectRequired(opaqueAccountId); + + expect(microsoftOAuth.signOutAccountIds, [opaqueAccountId]); + expect(oAuth.clearedAccountId, null); + }, + ); + + test('removeAccount removes only the selected Google account', () async { final microsoftOAuth = _FakeMicrosoftOAuthService(); repository = AuthRepository( oAuth: oAuth, @@ -193,161 +210,150 @@ void main() { ); await _insertAccount(database, 'google-a', TaskProvider.google); await _insertAccount(database, 'microsoft:m', TaskProvider.microsoft); + await _insertNotification(database, 'google-a'); + await _insertNotification(database, 'microsoft:m'); - await repository.signOut(accountId: 'google-a'); + final result = await repository.removeAccount(accountId: 'google-a'); final accounts = await database.select(database.accounts).get(); - expect(oAuth.signedOutAccountId, 'google-a'); + final notifications = await database + .select(database.notificationSchedule) + .get(); + expect( + result.authorizationRevocationStatus, + AccountAuthorizationRevocationStatus.notRequested, + ); + expect(oAuth.clearedAccountId, 'google-a'); + expect(oAuth.revoked, isFalse); expect(microsoftOAuth.signOutAccountIds, isEmpty); - expect(_authState(accounts, 'google-a'), 'signed_out'); - expect(_authState(accounts, 'microsoft:m'), 'signed_in'); + expect(accounts.map((account) => account.id), ['microsoft:m']); + expect(notifications.map((row) => row.accountId), ['microsoft:m']); }); - test('signOut removes only the target account notifications', () async { - await _insertAccount(database, 'google-a', TaskProvider.google); - await _insertAccount(database, 'google-b', TaskProvider.google); - await _insertNotification(database, 'google-a'); - await _insertNotification(database, 'google-b'); + test( + 'removeAccount clears Microsoft credentials without Google revocation', + () async { + final microsoftOAuth = _FakeMicrosoftOAuthService(); + repository = AuthRepository( + oAuth: oAuth, + database: database, + microsoftOAuth: microsoftOAuth, + nowUtc: () => DateTime.utc(2026, 6, 4), + ); + await _insertAccount(database, 'google:g', TaskProvider.google); + await _insertAccount(database, 'microsoft:m', TaskProvider.microsoft); - await repository.signOut(accountId: 'google-a'); + final result = await repository.removeAccount( + accountId: 'microsoft:m', + revokeAuthorization: true, + ); - final notifications = await database - .select(database.notificationSchedule) - .get(); - expect(notifications.map((row) => row.accountId), ['google-b']); - expect(notifications.single.title, 'Private google-b reminder'); - }); + final accounts = await database.select(database.accounts).get(); + expect( + result.authorizationRevocationStatus, + AccountAuthorizationRevocationStatus.notRequested, + ); + expect(oAuth.revoked, isFalse); + expect(oAuth.clearedAccountId, null); + expect(microsoftOAuth.signOutAccountIds, ['microsoft:m']); + expect(accounts.map((account) => account.id), ['google:g']); + }, + ); - test('signOut Microsoft account does not sign out Google account', () async { - final microsoftOAuth = _FakeMicrosoftOAuthService(); - repository = AuthRepository( - oAuth: oAuth, - database: database, - microsoftOAuth: microsoftOAuth, - nowUtc: () => DateTime.utc(2026, 6, 4), - ); - await _insertAccount(database, 'google:g', TaskProvider.google); - await _insertAccount(database, 'microsoft:m', TaskProvider.microsoft); + test( + 'removeAccount can revoke Google authorization before local cleanup', + () async { + await repository.signIn(); - await repository.signOut(accountId: 'microsoft:m'); + final result = await repository.removeAccount( + accountId: 'account-1', + revokeAuthorization: true, + ); - final accounts = await database.select(database.accounts).get(); - expect(oAuth.signedOut, isFalse); - expect(microsoftOAuth.signOutAccountIds, ['microsoft:m']); - expect(_authState(accounts, 'google:g'), 'signed_in'); - expect(_authState(accounts, 'microsoft:m'), 'signed_out'); - }); + expect(await database.select(database.accounts).get(), isEmpty); + expect( + result.authorizationRevocationStatus, + AccountAuthorizationRevocationStatus.succeeded, + ); + expect(oAuth.revokedAccountId, 'account-1'); + expect(oAuth.clearedAccountId, 'account-1'); + }, + ); - test('deleteLocalAccountData removes account row and local tokens', () async { - await repository.signIn(); + test( + 'removeAccount reports revocation failure but still removes locally', + () async { + await repository.signIn(); + oAuth.revocationError = const OAuthException( + 'OAuthRevocationFailed', + 'offline', + ); - await repository.deleteLocalAccountData(accountId: 'account-1'); + final result = await repository.removeAccount( + accountId: 'account-1', + revokeAuthorization: true, + ); - expect(await database.select(database.accounts).get(), isEmpty); - expect(oAuth.signedOutAccountId, 'account-1'); - }); + expect(result.authorizationRevocationFailed, isTrue); + expect(oAuth.clearedAccountId, 'account-1'); + expect(await database.select(database.accounts).get(), isEmpty); + }, + ); - test('deleteLocalAccountData removes only target account row', () async { - await repository.signIn(); + test('removeAccount cascades pending offline operations', () async { + await _insertAccount(database, 'google-a', TaskProvider.google); await database - .into(database.accounts) + .into(database.pendingOps) .insert( - AccountsCompanion.insert( - id: 'account-2', + PendingOpsCompanion.insert( + id: 'pending-1', + accountId: 'google-a', + entityType: 'task', + operation: 'patch_task', + requestJson: '{}', createdAtUtc: '2026-06-04T00:00:00.000Z', updatedAtUtc: '2026-06-04T00:00:00.000Z', - authState: const Value('signed_in'), ), ); - await repository.deleteLocalAccountData(accountId: 'account-1'); + await repository.removeAccount(accountId: 'google-a'); - final accounts = await database.select(database.accounts).get(); - expect(accounts.map((account) => account.id), ['account-2']); - expect(oAuth.signedOutAccountId, 'account-1'); + expect(await database.select(database.pendingOps).get(), isEmpty); }); test( - 'deleteLocalAccountData removes only target account notifications', + 'removeAccount keeps local data when credential cleanup fails', () async { - await _insertAccount(database, 'google-a', TaskProvider.google); - await _insertAccount(database, 'google-b', TaskProvider.google); - await _insertNotification(database, 'google-a'); - await _insertNotification(database, 'google-b'); - - await repository.deleteLocalAccountData(accountId: 'google-a'); - - final notifications = await database - .select(database.notificationSchedule) - .get(); - expect(notifications.map((row) => row.accountId), ['google-b']); - expect(notifications.single.title, 'Private google-b reminder'); - }, - ); - - test('revoking Google account does not sign out Microsoft account', () async { - final microsoftOAuth = _FakeMicrosoftOAuthService(); - repository = AuthRepository( - oAuth: oAuth, - database: database, - microsoftOAuth: microsoftOAuth, - nowUtc: () => DateTime.utc(2026, 6, 4), - ); - await _insertAccount(database, 'google-a', TaskProvider.google); - await _insertAccount(database, 'google-b', TaskProvider.google); - await _insertAccount(database, 'microsoft:m', TaskProvider.microsoft); - - await repository.revokeAndSignOut(accountId: 'google-a'); - - final accounts = await database.select(database.accounts).get(); - expect(oAuth.revokedAccountId, 'google-a'); - expect(microsoftOAuth.signOutAccountIds, isEmpty); - expect(_authState(accounts, 'google-a'), 'signed_out'); - expect(_authState(accounts, 'google-b'), 'signed_in'); - expect(_authState(accounts, 'microsoft:m'), 'signed_in'); - }); - - test('revoke removes only the target account notifications', () async { - await _insertAccount(database, 'google-a', TaskProvider.google); - await _insertAccount(database, 'google-b', TaskProvider.google); - await _insertNotification(database, 'google-a'); - await _insertNotification(database, 'google-b'); - - await repository.revokeAndSignOut(accountId: 'google-a'); + await repository.signIn(); + oAuth.clearError = const OAuthException( + 'SecureStorageFailure', + 'unavailable', + ); - final notifications = await database - .select(database.notificationSchedule) - .get(); - expect(notifications.map((row) => row.accountId), ['google-b']); - expect(notifications.single.title, 'Private google-b reminder'); - }); + await expectLater( + repository.removeAccount(accountId: 'account-1'), + throwsA(isA()), + ); - test('revoking Microsoft account does not revoke Google account', () async { - final microsoftOAuth = _FakeMicrosoftOAuthService(); - repository = AuthRepository( - oAuth: oAuth, - database: database, - microsoftOAuth: microsoftOAuth, - nowUtc: () => DateTime.utc(2026, 6, 4), - ); - await _insertAccount(database, 'google:g', TaskProvider.google); - await _insertAccount(database, 'microsoft:m', TaskProvider.microsoft); + expect(await database.select(database.accounts).get(), hasLength(1)); + }, + ); - await repository.revokeAndSignOut(accountId: 'microsoft:m'); + test('removeAccount is idempotent when the row is already absent', () async { + final result = await repository.removeAccount(accountId: 'missing'); - final accounts = await database.select(database.accounts).get(); - expect(oAuth.revoked, isFalse); - expect(microsoftOAuth.signOutAccountIds, ['microsoft:m']); - expect(_authState(accounts, 'google:g'), 'signed_in'); - expect(_authState(accounts, 'microsoft:m'), 'signed_out'); + expect(result.alreadyRemoved, isTrue); + expect(oAuth.clearedAccountId, null); }); } class FakeOAuthGateway implements OAuthGateway { var revoked = false; - var signedOut = false; String? revokedAccountId; - String? signedOutAccountId; + String? clearedAccountId; + Object? revocationError; + Object? revokeAndSignOutError; + Object? clearError; String? activeId; var activeAccountIdReads = 0; var readActiveTokenSetCalls = 0; @@ -380,21 +386,6 @@ class FakeOAuthGateway implements OAuthGateway { @override Future refreshActiveToken() async => nextTokenSet; - @override - Future signOutAccount(String accountId) async { - signedOut = true; - signedOutAccountId = accountId; - if (activeId == accountId) { - activeId = null; - } - } - - @override - Future signOut() async { - signedOut = true; - activeId = null; - } - @override Future revokeAndSignOutAccount(String accountId) async { revoked = true; @@ -402,17 +393,32 @@ class FakeOAuthGateway implements OAuthGateway { if (activeId == accountId) { activeId = null; } + final error = revokeAndSignOutError; + if (error != null) { + throw error; + } } @override - Future revokeAndSignOut() async { + Future revokeAuthorization(String accountId) async { + final error = revocationError; + if (error != null) { + throw error; + } revoked = true; - activeId = null; + revokedAccountId = accountId; } @override Future clearLocalSession({String? accountId}) async { - activeId = null; + final error = clearError; + if (error != null) { + throw error; + } + clearedAccountId = accountId; + if (accountId == null || activeId == accountId) { + activeId = null; + } } @override @@ -468,10 +474,6 @@ Future _insertNotification(AppDatabase database, String accountId) { ); } -String _authState(List accounts, String id) { - return accounts.singleWhere((account) => account.id == id).authState; -} - class _FakeMicrosoftOAuthService extends MicrosoftOAuthService { _FakeMicrosoftOAuthService() : super( diff --git a/test/features/auth/presentation/auth_routing_test.dart b/test/features/auth/presentation/auth_routing_test.dart index d8ed34b..94d3c60 100644 --- a/test/features/auth/presentation/auth_routing_test.dart +++ b/test/features/auth/presentation/auth_routing_test.dart @@ -17,7 +17,6 @@ import 'package:busymax/src/features/auth/data/auth_repository.dart'; import 'package:busymax/src/features/schedule/presentation/schedule_workspace.dart'; import 'package:busymax/src/features/settings/presentation/settings_screen.dart'; import 'package:busymax/src/features/sync/sync_auth_error.dart'; -import 'package:busymax/src/features/tasks/presentation/tasks_workspace.dart'; import 'package:busymax/src/google_tasks/api/google_tasks_api_surface.dart'; import 'package:busymax/src/google_tasks/oauth/oauth_models.dart'; import 'package:busymax/src/google_tasks/oauth/oauth_service.dart'; @@ -153,13 +152,11 @@ void main() { await _completeOnboardingWithGoogle(tester); expect(find.byType(ScheduleWorkspace), findsOneWidget); - expect(find.byType(TasksWorkspace), findsNothing); GoRouter.of(tester.element(find.byType(ScheduleWorkspace))).go('/tasks'); await tester.pumpAndSettle(); expect(find.byType(ScheduleWorkspace), findsOneWidget); - expect(find.byType(TasksWorkspace), findsNothing); final accountId = (await database.select(database.accounts).getSingle()).id; await database.taskListsDao.upsertTaskList( @@ -629,18 +626,6 @@ class _FakeOAuthGateway implements OAuthGateway { @override Future refreshActiveToken() async => nextTokenSet; - @override - Future signOutAccount(String accountId) async { - if (activeId == accountId) { - activeId = null; - } - } - - @override - Future signOut() async { - activeId = null; - } - @override Future revokeAndSignOutAccount(String accountId) async { if (activeId == accountId) { @@ -649,9 +634,7 @@ class _FakeOAuthGateway implements OAuthGateway { } @override - Future revokeAndSignOut() async { - activeId = null; - } + Future revokeAuthorization(String accountId) async {} @override Future clearLocalSession({String? accountId}) async { diff --git a/test/features/calendar/presentation/event_editor_test.dart b/test/features/calendar/presentation/event_editor_test.dart index 32a549a..3112e78 100644 --- a/test/features/calendar/presentation/event_editor_test.dart +++ b/test/features/calendar/presentation/event_editor_test.dart @@ -12,6 +12,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:ubuntu_widgets/ubuntu_widgets.dart'; +import 'package:yaru/yaru.dart'; import '../../../test_localized_app.dart'; @@ -194,27 +195,37 @@ void main() { await tester.tap(find.text('Start time')); await tester.pumpAndSettle(); - final fieldFinder = _timeTextEntryFinder(); - final entry = tester.widget(fieldFinder); - expect(entry.controller?.text, '09:00'); - expect( - tester - .widgetList(find.byType(EditableText)) - .any((entry) => entry.controller.text.contains('09:00')), - isTrue, - ); + final fieldFinder = _timeEntryFinder(); + final entry = tester.widget(fieldFinder); + expect(entry.controller?.timeOfDay, const TimeOfDay(hour: 9, minute: 0)); - await tester.enterText(fieldFinder, ''); + await tester.tap(find.byIcon(YaruIcons.edit_clear)); await tester.pump(); expect(tester.takeException(), isNull); - expect(tester.widget(fieldFinder).controller?.text, isEmpty); + expect(entry.controller?.timeOfDay, isNull); + expect( + tester + .widget( + find + .ancestor( + of: find.text('OK'), + matching: find.byWidgetPredicate( + (widget) => widget is PushButton, + ), + ) + .first, + ) + .onPressed, + isNull, + ); }); testWidgets('event time popup accepts midnight input', (tester) async { EventEditorDraft? saved; await tester.pumpWidget( localizedTestApp( + alwaysUse24HourFormat: true, child: Scaffold( body: EventEditor( initialDraft: EventEditorDraft.newEvent( @@ -235,7 +246,8 @@ void main() { await tester.ensureVisible(find.text('Start time')); await tester.tap(find.text('Start time')); await tester.pumpAndSettle(); - await tester.enterText(_timeTextEntryFinder(), '00'); + await tester.tap(find.byIcon(YaruIcons.edit_clear)); + await _enterTime(tester, hour: '00', minute: '00'); await tester.tap(find.text('OK')); await tester.pumpAndSettle(); @@ -907,10 +919,60 @@ void main() { expect(find.text('5 minutes before'), findsOneWidget); }); - testWidgets('Microsoft event categories can be selected from suggestions', ( + testWidgets( + 'Microsoft event categories use Yaru autocomplete keyboard selection', + (tester) async { + EventEditorDraft? saved; + await tester.pumpWidget( + localizedTestApp( + child: Scaffold( + body: EventEditor( + initialDraft: EventEditorDraft.existing( + eventId: 'event-1', + accountId: 'microsoft-account', + sourceId: 'microsoft-source', + providerCalendarId: 'ms-cal-1', + title: 'Planning', + allDay: false, + start: DateTime.utc(2026, 6, 8, 9), + end: DateTime.utc(2026, 6, 8, 10), + categories: const ['Home'], + ), + sources: _microsoftSources, + categorySuggestionsByAccount: const { + 'microsoft-account': ['Home', 'Work'], + }, + onCancel: () {}, + onSave: (draft) => saved = draft, + ), + ), + ), + ); + + await tester.ensureVisible(find.text('Add category')); + expect(find.text('Home'), findsOneWidget); + + await tester.tap(find.text('Add category')); + await tester.pumpAndSettle(); + expect(find.byType(YaruAutocomplete), findsOneWidget); + + await tester.enterText( + find.byKey(const Key('event-category-input')), + 'work', + ); + await tester.pumpAndSettle(); + await tester.testTextInput.receiveAction(TextInputAction.done); + await tester.pumpAndSettle(); + await tester.tap(_headerButtonFinder('Save')); + + expect(saved?.categories, ['Home', 'Work']); + }, + ); + + testWidgets('Escape cancels category entry without closing event editor', ( tester, ) async { - EventEditorDraft? saved; + var editorCancelled = false; await tester.pumpWidget( localizedTestApp( child: Scaffold( @@ -924,31 +986,33 @@ void main() { allDay: false, start: DateTime.utc(2026, 6, 8, 9), end: DateTime.utc(2026, 6, 8, 10), - categories: const ['Home'], ), sources: _microsoftSources, - categorySuggestionsByAccount: const { - 'microsoft-account': ['Home', 'Work'], - }, - onCancel: () {}, - onSave: (draft) => saved = draft, + onCancel: () => editorCancelled = true, + onSave: (_) {}, ), ), ), ); await tester.ensureVisible(find.text('Add category')); - expect(find.text('Home'), findsOneWidget); - await tester.tap(find.text('Add category')); await tester.pumpAndSettle(); - await tester.enterText(find.byKey(const Key('event-category-input')), 'wo'); - await tester.pumpAndSettle(); - await tester.tap(find.text('Work').last); + expect(find.byKey(const Key('event-category-input')), findsOneWidget); + expect( + tester + .widget(find.byKey(const Key('event-category-input'))) + .focusNode + ?.hasFocus, + isTrue, + ); + + await tester.sendKeyEvent(LogicalKeyboardKey.escape); await tester.pumpAndSettle(); - await tester.tap(_headerButtonFinder('Save')); - expect(saved?.categories, ['Home', 'Work']); + expect(find.byKey(const Key('event-category-input')), findsNothing); + expect(find.widgetWithText(ActionChip, 'Add category'), findsOneWidget); + expect(editorCancelled, isFalse); }); testWidgets('Google event editor does not show categories', (tester) async { @@ -1275,28 +1339,32 @@ void main() { expect(editor, contains('l10n.deleteEvent')); }); - testWidgets('combo dropdown trigger is transparent in all states', ( + testWidgets('combo dropdown trigger inherits themed Yaru geometry', ( tester, ) async { - late BuildContext capturedContext; await tester.pumpWidget( localizedTestApp( - child: Builder( - builder: (context) { - capturedContext = context; - return const SizedBox.shrink(); - }, + child: SizedBox( + width: 480, + child: BusyMaxComboRow( + title: 'Calendar', + values: const ['Personal', 'Work'], + selected: 'Personal', + labelFor: (value) => value, + onSelected: (_) {}, + ), ), ), ); - final background = busyMaxDropdownButtonStyle( - capturedContext, - ).backgroundColor!; - - expect(background.resolve(const {}), Colors.transparent); - expect(background.resolve({WidgetState.hovered}), Colors.transparent); - expect(background.resolve({WidgetState.pressed}), Colors.transparent); + expect(find.byType(BusyMaxMenuButton), findsOneWidget); + final trigger = tester.widget( + find.descendant( + of: find.byType(BusyMaxComboRow), + matching: find.byType(OutlinedButton), + ), + ); + expect(trigger.style, isNull); }); } @@ -1319,10 +1387,19 @@ BusyMaxComboRow _comboRow(WidgetTester tester, String title) { ); } -Finder _timeTextEntryFinder() { - return find.byWidgetPredicate( - (widget) => widget is TextFormField && widget.controller != null, - ); +Finder _timeEntryFinder() => find.byType(YaruTimeEntry); + +Future _enterTime( + WidgetTester tester, { + required String hour, + required String minute, +}) async { + final entry = _timeEntryFinder(); + await tester.tap(entry); + await tester.enterText(entry, hour); + await tester.pump(); + await tester.enterText(entry, minute); + await tester.pump(); } Finder _plainTextFinder(String label) { diff --git a/test/features/schedule/presentation/schedule_create_menu_test.dart b/test/features/schedule/presentation/schedule_create_menu_test.dart index d913c6b..3c54b5d 100644 --- a/test/features/schedule/presentation/schedule_create_menu_test.dart +++ b/test/features/schedule/presentation/schedule_create_menu_test.dart @@ -1,5 +1,5 @@ +import 'package:busymax/src/app/busymax_design.dart'; import 'package:busymax/src/features/schedule/presentation/schedule_create_menu.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'; @@ -9,92 +9,232 @@ import '../../../test_localized_app.dart'; void main() { TestWidgetsFlutterBinding.ensureInitialized(); - testWidgets('create chooser synchronizes the native modal barrier', ( + testWidgets('create chooser opens as an anchored menu popover', ( tester, ) async { - const channel = MethodChannel('busymax_test/create_chooser_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; + late BuildContext anchorContext; await tester.pumpWidget( localizedTestApp( - child: Builder( - builder: (context) { - hostContext = context; - return const SizedBox(); - }, + child: Scaffold( + body: Builder( + builder: (context) { + hostContext = context; + return Align( + alignment: Alignment.topLeft, + child: Padding( + padding: const EdgeInsets.all(80), + child: Builder( + builder: (context) { + anchorContext = context; + return const SizedBox.square(dimension: 32); + }, + ), + ), + ); + }, + ), ), ), ); final result = showScheduleCreateMenu( context: hostContext, - headerBarService: service, + anchorContext: anchorContext, + anchorPoint: const Offset(96, 96), ); await tester.pumpAndSettle(); - expect(find.text('Create'), findsOneWidget); + expect(find.byType(Dialog), findsNothing); + expect(find.byType(BusyMaxPopoverSurface), findsOneWidget); + expect(find.byType(MenuItemButton), findsNWidgets(2)); expect(find.text('Event'), findsOneWidget); expect(find.text('Task'), findsOneWidget); - final barrierCallsWhileOpen = calls - .where((call) => call.method == 'setModalBarrierVisible') - .toList(); - expect(barrierCallsWhileOpen, hasLength(1)); - expect(barrierCallsWhileOpen.single.arguments, isTrue); + expect( + tester.getRect(find.byType(BusyMaxPopoverSurface)).top, + greaterThan(96), + ); + final eventButton = tester.widget( + find.ancestor( + of: find.text('Event'), + matching: find.byType(MenuItemButton), + ), + ); + expect(eventButton.autofocus, isFalse); await tester.tap(find.text('Task')); await tester.pumpAndSettle(); expect(await result, ScheduleCreateChoice.task); - final barrierCalls = calls - .where((call) => call.method == 'setModalBarrierVisible') - .toList(); - expect(barrierCalls, hasLength(2)); - expect(barrierCalls.first.arguments, isTrue); - expect(barrierCalls.last.arguments, isFalse); }); testWidgets('create chooser disables unavailable creation kinds', ( tester, ) async { late BuildContext hostContext; + late BuildContext anchorContext; await tester.pumpWidget( localizedTestApp( - child: Builder( - builder: (context) { - hostContext = context; - return const SizedBox(); - }, + child: Scaffold( + body: Builder( + builder: (context) { + hostContext = context; + return Center( + child: Builder( + builder: (context) { + anchorContext = context; + return const SizedBox.square(dimension: 32); + }, + ), + ); + }, + ), ), ), ); final result = showScheduleCreateMenu( context: hostContext, + anchorContext: anchorContext, canCreateEvent: false, canCreateTask: true, ); await tester.pumpAndSettle(); + final eventButton = tester.widget( + find.ancestor( + of: find.text('Event'), + matching: find.byType(MenuItemButton), + ), + ); + final taskButton = tester.widget( + find.ancestor( + of: find.text('Task'), + matching: find.byType(MenuItemButton), + ), + ); + expect(eventButton.onPressed, isNull); + expect(eventButton.autofocus, isFalse); + expect(taskButton.onPressed, isNotNull); + expect(taskButton.autofocus, isTrue); + await tester.tap(find.text('Event')); await tester.pump(); - expect(find.text('Create'), findsOneWidget); + expect(find.byType(BusyMaxPopoverSurface), findsOneWidget); await tester.tap(find.text('Task')); await tester.pumpAndSettle(); expect(await result, ScheduleCreateChoice.task); }); + + testWidgets('keyboard chooser supports Escape and restores anchor focus', ( + tester, + ) async { + final focusNode = FocusNode(); + addTearDown(focusNode.dispose); + late BuildContext hostContext; + late BuildContext anchorContext; + await tester.pumpWidget( + localizedTestApp( + child: Scaffold( + body: Builder( + builder: (context) { + hostContext = context; + return Builder( + builder: (context) { + anchorContext = context; + return TextButton( + focusNode: focusNode, + onPressed: () {}, + child: const Text('Anchor'), + ); + }, + ); + }, + ), + ), + ), + ); + focusNode.requestFocus(); + await tester.pump(); + + final result = showScheduleCreateMenu( + context: hostContext, + anchorContext: anchorContext, + ); + await tester.pumpAndSettle(); + + final eventButton = tester.widget( + find.ancestor( + of: find.text('Event'), + matching: find.byType(MenuItemButton), + ), + ); + expect(eventButton.autofocus, isTrue); + expect(focusNode.hasFocus, isFalse); + + await tester.sendKeyEvent(LogicalKeyboardKey.escape); + await tester.pumpAndSettle(); + + expect(await result, isNull); + expect(find.byType(BusyMaxPopoverSurface), findsNothing); + expect(focusNode.hasFocus, isTrue); + }); + + testWidgets('create chooser does not open without an available choice', ( + tester, + ) async { + late BuildContext hostContext; + await tester.pumpWidget( + localizedTestApp( + child: Builder( + builder: (context) { + hostContext = context; + return const SizedBox(); + }, + ), + ), + ); + + final result = await showScheduleCreateMenu( + context: hostContext, + canCreateEvent: false, + canCreateTask: false, + ); + await tester.pump(); + + expect(result, isNull); + expect(find.byType(BusyMaxPopoverSurface), findsNothing); + }); + + test('single available creation kind is resolved for direct creation', () { + expect( + singleAvailableScheduleCreateChoice( + canCreateEvent: true, + canCreateTask: false, + ), + ScheduleCreateChoice.event, + ); + expect( + singleAvailableScheduleCreateChoice( + canCreateEvent: false, + canCreateTask: true, + ), + ScheduleCreateChoice.task, + ); + expect( + singleAvailableScheduleCreateChoice( + canCreateEvent: true, + canCreateTask: true, + ), + isNull, + ); + expect( + singleAvailableScheduleCreateChoice( + canCreateEvent: false, + canCreateTask: false, + ), + isNull, + ); + }); } diff --git a/test/features/schedule/presentation/schedule_toolbar_test.dart b/test/features/schedule/presentation/schedule_toolbar_test.dart index df3b377..826bb32 100644 --- a/test/features/schedule/presentation/schedule_toolbar_test.dart +++ b/test/features/schedule/presentation/schedule_toolbar_test.dart @@ -1,3 +1,4 @@ +import 'package:busymax/src/app/busymax_design.dart'; import 'package:busymax/src/features/schedule/presentation/schedule_toolbar.dart'; import 'package:busymax/src/schedule/schedule_range.dart'; import 'package:busymax/src/schedule/schedule_view_mode.dart'; @@ -187,61 +188,134 @@ void main() { expect(tasks, 1); }); - testWidgets('external controller opens the fallback create menu', ( + testWidgets( + 'keyboard controller opens and focuses the fallback create menu', + (tester) async { + final controller = BusyMaxMenuController(); + + await tester.pumpWidget( + localizedTestApp( + child: Scaffold( + body: SizedBox( + width: 1000, + child: ScheduleToolbar( + mode: ScheduleViewMode.week, + range: ScheduleRange.week(DateTime(2026, 7, 22)), + selectedDate: DateTime(2026, 7, 22), + onToday: () {}, + onPrevious: () {}, + onNext: () {}, + onModeChanged: (_) {}, + canCreateEvent: true, + canCreateTask: true, + onCreateEvent: () {}, + onCreateTask: () {}, + onRefresh: () {}, + createMenuController: controller, + ), + ), + ), + ), + ); + + expect(controller.openForKeyboard(), isTrue); + await tester.pumpAndSettle(); + + expect(find.text('Event'), findsOneWidget); + expect(find.text('Task'), findsOneWidget); + + final trigger = tester.widget( + find.ancestor( + of: find.byTooltip('Create'), + matching: find.byType(YaruIconButton), + ), + ); + final anchor = tester.widget( + find.ancestor( + of: find.byTooltip('Create'), + matching: find.byType(MenuAnchor), + ), + ); + expect(trigger.focusNode, isNotNull); + expect(anchor.childFocusNode, same(trigger.focusNode)); + final menuItems = tester + .widgetList(find.byType(MenuItemButton)) + .toList(); + expect(menuItems.first.focusNode?.hasFocus, isTrue); + + await tester.sendKeyEvent(LogicalKeyboardKey.escape); + await tester.pumpAndSettle(); + expect(find.text('Event'), findsNothing); + expect(find.text('Task'), findsNothing); + }, + ); + + testWidgets('keyboard controller follows a responsive toolbar replacement', ( tester, ) async { - final controller = MenuController(); + final controller = BusyMaxMenuController(); + final nestToolbar = ValueNotifier(false); + addTearDown(nestToolbar.dispose); + + Widget buildToolbar() { + return ScheduleToolbar( + mode: ScheduleViewMode.week, + range: ScheduleRange.week(DateTime(2026, 7, 22)), + selectedDate: DateTime(2026, 7, 22), + onToday: () {}, + onPrevious: () {}, + onNext: () {}, + onModeChanged: (_) {}, + canCreateEvent: true, + canCreateTask: true, + onCreateEvent: () {}, + onCreateTask: () {}, + onRefresh: () {}, + createMenuController: controller, + ); + } await tester.pumpWidget( localizedTestApp( child: Scaffold( body: SizedBox( width: 1000, - child: ScheduleToolbar( - mode: ScheduleViewMode.week, - range: ScheduleRange.week(DateTime(2026, 7, 22)), - selectedDate: DateTime(2026, 7, 22), - onToday: () {}, - onPrevious: () {}, - onNext: () {}, - onModeChanged: (_) {}, - canCreateEvent: true, - canCreateTask: true, - onCreateEvent: () {}, - onCreateTask: () {}, - onRefresh: () {}, - createMenuController: controller, + child: ValueListenableBuilder( + valueListenable: nestToolbar, + builder: (context, nested, child) { + final toolbar = buildToolbar(); + return nested + ? Row(children: [Expanded(child: toolbar)]) + : toolbar; + }, ), ), ), ), ); + expect(controller.isAttached, isTrue); - controller.open(); + nestToolbar.value = true; + await tester.pump(); + + expect(tester.takeException(), isNull); + expect(controller.isAttached, isTrue); + expect(controller.openForKeyboard(), isTrue); await tester.pumpAndSettle(); expect(find.text('Event'), findsOneWidget); expect(find.text('Task'), findsOneWidget); + final menuItems = tester + .widgetList(find.byType(MenuItemButton)) + .toList(); + expect(menuItems.first.focusNode?.hasFocus, isTrue); - final trigger = tester.widget( - find.ancestor( - of: find.byTooltip('Create'), - matching: find.byType(YaruIconButton), - ), - ); - final anchor = tester.widget( - find.ancestor( - of: find.byTooltip('Create'), - matching: find.byType(MenuAnchor), - ), - ); - expect(trigger.focusNode, isNotNull); - expect(anchor.childFocusNode, same(trigger.focusNode)); - - await tester.sendKeyEvent(LogicalKeyboardKey.escape); + controller.close(); await tester.pumpAndSettle(); - expect(find.text('Event'), findsNothing); - expect(find.text('Task'), findsNothing); + await tester.pumpWidget(const SizedBox.shrink()); + + expect(controller.isAttached, isFalse); + expect(controller.openForKeyboard(), isFalse); }); testWidgets('create trigger disables when no creation kind is available', ( diff --git a/test/features/schedule/presentation/schedule_views_test.dart b/test/features/schedule/presentation/schedule_views_test.dart index fb002a0..2d9ccae 100644 --- a/test/features/schedule/presentation/schedule_views_test.dart +++ b/test/features/schedule/presentation/schedule_views_test.dart @@ -1,4 +1,5 @@ import 'dart:io'; +import 'dart:ui' as ui; import 'package:busymax/src/app/busymax_design.dart'; import 'package:busymax/src/app/busymax_surface_colors.dart'; @@ -11,6 +12,7 @@ import 'package:busymax/src/features/schedule/presentation/schedule_item_details import 'package:busymax/src/features/schedule/presentation/schedule_item_exporter.dart'; import 'package:busymax/src/features/schedule/presentation/mini_calendar.dart'; import 'package:busymax/src/features/schedule/presentation/schedule_month_view.dart'; +import 'package:busymax/src/features/schedule/presentation/schedule_year_view.dart'; import 'package:busymax/src/schedule/schedule_item.dart'; import 'package:busymax/src/schedule/schedule_range.dart'; import 'package:busymax/src/task_providers/task_provider.dart'; @@ -373,6 +375,92 @@ void main() { expect(find.text('Submit report'), findsOneWidget); }); + testWidgets('month and year days share accessible date semantics', ( + tester, + ) async { + final semantics = tester.ensureSemantics(); + final selectedDate = DateTime(2026, 1, 15); + DateTime? activatedDay; + + await tester.pumpWidget( + localizedTestApp( + child: Scaffold( + body: SizedBox( + width: 1000, + height: 720, + child: ScheduleMonthView( + range: ScheduleRange.month(selectedDate), + selectedDate: selectedDate, + firstWeekday: DateTime.monday, + items: const [], + onDaySelected: (day) => activatedDay = day, + onCreateAtDay: (_) {}, + onItemSelected: (_, _, [_]) {}, + onTaskCompletionChanged: (_, _) {}, + ), + ), + ), + ), + ); + + var selectedDay = find.text('15'); + var selectedNode = tester.getSemantics(selectedDay); + expect(selectedNode.flagsCollection.isSelected, ui.Tristate.isTrue); + expect(selectedNode.label, contains('January 15, 2026')); + var selectedMarker = tester.widget( + find.byKey( + ValueKey('month-day-marker-${selectedDate.toIso8601String()}'), + ), + ); + expect( + (selectedMarker.decoration! as BoxDecoration).color, + Theme.of(tester.element(selectedDay)).colorScheme.primary, + ); + tester.semantics.tap( + find.semantics.byPredicate((node) => node.id == selectedNode.id), + ); + await tester.pump(); + expect(activatedDay, selectedDate); + + activatedDay = null; + await tester.pumpWidget( + localizedTestApp( + child: Scaffold( + body: SizedBox( + width: 1000, + height: 720, + child: ScheduleYearView( + selectedDate: selectedDate, + firstWeekday: DateTime.monday, + items: const [], + onDaySelected: (day) => activatedDay = day, + onMonthSelected: (_) {}, + onCreateAtDay: (_) {}, + ), + ), + ), + ), + ); + + selectedDay = find.text('15').first; + selectedNode = tester.getSemantics(selectedDay); + expect(selectedNode.flagsCollection.isSelected, ui.Tristate.isTrue); + expect(selectedNode.label, contains('January 15, 2026')); + selectedMarker = tester.widget( + find.byKey(ValueKey('year-day-marker-${selectedDate.toIso8601String()}')), + ); + expect( + (selectedMarker.decoration! as BoxDecoration).color, + Theme.of(tester.element(selectedDay)).colorScheme.primary, + ); + tester.semantics.tap( + find.semantics.byPredicate((node) => node.id == selectedNode.id), + ); + await tester.pump(); + expect(activatedDay, selectedDate); + semantics.dispose(); + }); + testWidgets('month view avoids overflow in very short cells', (tester) async { final selectedDate = DateTime(2026, 1, 15); @@ -1504,7 +1592,7 @@ void main() { expect(source, contains('_requestCalendarMutationSync(draft.accountId)')); expect(source, contains('.deleteLocalEvent(eventId)')); expect(source, contains('_requestCalendarMutationSync(accountId)')); - expect(source, contains('calendarSyncEngineForAccountFactoryProvider')); + expect(source, contains('accountSyncOperationsProvider')); expect(source, isNot(contains('signedInSyncRunnerProvider)(accountId'))); }); @@ -1542,23 +1630,17 @@ void main() { expect(more, isNot(contains('Dialog('))); }); - test('schedule item details actions use shared button styling', () { + test('schedule item details actions use Yaru icon buttons', () { final popover = File( 'lib/src/features/schedule/presentation/schedule_item_details_popover.dart', ).readAsStringSync(); - final design = File('lib/src/app/busymax_design.dart').readAsStringSync(); - expect(popover, contains('BusyMaxCircularAction(')); - expect(popover, contains('destructive: true')); + expect(popover, contains('YaruIconButton(')); + expect(popover, isNot(contains('BusyMaxCircularAction('))); + expect(popover, contains('color: Theme.of(context).colorScheme.error')); expect(popover, isNot(contains('backgroundColor:'))); expect(popover, isNot(contains('foregroundColor:'))); expect(popover, isNot(contains('hoverColor:'))); - expect( - design, - contains('final surfaceColors = BusyMaxSurfaceColors.of(context);'), - ); - expect(design, contains('color: surfaceColors.control')); - expect(design, contains('color: foregroundColor')); }); test( @@ -1685,7 +1767,6 @@ void main() { ); expect(source, contains('DateFormat.E(')); expect(source, contains('_weekdays(firstWeekday)')); - expect(source, contains('DateFormat.yMMMMEEEEd(locale)')); expect(source, contains('ScheduleProjection.colorForItem')); expect(source, contains('height: dayExtent')); expect(source, contains('width: double.infinity')); @@ -1735,19 +1816,54 @@ void main() { expect(source, contains('BoxShape.circle')); expect(source, contains('customBorder: const CircleBorder()')); expect(source, contains('final markerSize = math.min')); - expect(source, contains('final highlightToday = today && currentMonth')); - expect(source, contains('color: highlightToday')); - expect(source, contains('selectedYear == DateTime.now().year')); - expect(source, contains('selectedMonth == DateTime.now().month')); expect( source, - isNot(contains('final selected = _sameDay(day, selectedDate)')), + contains('final highlightToday = today && displayingCurrentMonth'), ); + expect(source, contains('color: selected')); + expect(source, contains('selectedDate.year == DateTime.now().year')); + expect(source, contains('selectedDate.month == DateTime.now().month')); + expect(source, contains('final selected = _sameDay(day, selectedDate)')); expect(source, isNot(contains('YaruIcons.arrow_left'))); expect(source, isNot(contains('YaruIcons.arrow_right'))); expect(source, isNot(contains('BorderRadius.circular(BusyMaxRadius.sm)'))); }); + testWidgets('mini calendar exposes and activates the selected day', ( + tester, + ) async { + final semantics = tester.ensureSemantics(); + DateTime? activatedDay; + + await tester.pumpWidget( + localizedTestApp( + child: Scaffold( + body: SizedBox( + width: 300, + child: MiniCalendar( + selectedDate: DateTime(2026, 1, 15), + firstWeekday: DateTime.monday, + onSelected: (day) => activatedDay = day, + onMonthSelected: (_) {}, + onYearSelected: (_) {}, + onWeekSelected: (_) {}, + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + final selectedDay = find.text('15'); + final selectedSemantics = tester.getSemantics(selectedDay); + expect(selectedSemantics.flagsCollection.isSelected, ui.Tristate.isTrue); + expect(selectedSemantics.label, contains('January 15, 2026')); + + await tester.tap(selectedDay); + expect(activatedDay, DateTime(2026, 1, 15)); + semantics.dispose(); + }); + testWidgets('mini calendar week number selects that week', (tester) async { DateTime? selectedWeek; @@ -2063,6 +2179,7 @@ void main() { expect(workspace, contains('BusyMaxHeaderBarAction.createEvent')); expect(workspace, contains('BusyMaxHeaderBarAction.createTask')); expect(workspace, contains('void _openCreateAtSelectedDate()')); + expect(workspace, contains('_createMenuController.openForKeyboard()')); expect( headerService, isNot(contains("'create' => BusyMaxHeaderBarAction.create")), diff --git a/test/features/schedule/presentation/schedule_workspace_states_test.dart b/test/features/schedule/presentation/schedule_workspace_states_test.dart index ac5dbc6..814fdc9 100644 --- a/test/features/schedule/presentation/schedule_workspace_states_test.dart +++ b/test/features/schedule/presentation/schedule_workspace_states_test.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'package:busymax/src/app/app_bootstrap.dart'; import 'package:busymax/src/app/busymax_design.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'; @@ -25,6 +26,11 @@ void main() { expect(find.byType(ScheduleLoadingState), findsOneWidget); expect(find.text('Loading schedule...'), findsOneWidget); + final loadingContext = tester.element(find.byType(ScheduleLoadingState)); + expect( + tester.widget(find.byType(Scaffold)).backgroundColor, + BusyMaxSurfaceColors.of(loadingContext).view, + ); }); testWidgets('schedule errors are generic and retry the data pipeline', ( diff --git a/test/features/settings/presentation/settings_screen_test.dart b/test/features/settings/presentation/settings_screen_test.dart index 7b46b35..bd0db25 100644 --- a/test/features/settings/presentation/settings_screen_test.dart +++ b/test/features/settings/presentation/settings_screen_test.dart @@ -18,7 +18,6 @@ import 'package:busymax/src/features/sync/sync_auth_error.dart'; import 'package:busymax/src/platform/gtk_font_service.dart'; import 'package:busymax/src/features/task_lists/data/task_lists_repository.dart'; import 'package:busymax/src/features/tasks/presentation/desktop_date_time_fields.dart'; -import 'package:busymax/src/features/tasks/presentation/tasks_selection_state.dart'; import 'package:busymax/src/task_providers/task_provider.dart'; import 'package:busymax/l10n/generated/app_localizations.dart'; import 'package:ubuntu_localizations/ubuntu_localizations.dart'; @@ -26,7 +25,9 @@ import 'package:ubuntu_localizations/ubuntu_localizations.dart'; import '../../../test_localized_app.dart'; void main() { - testWidgets('Settings sign out passes selected account id', (tester) async { + testWidgets('Settings removes the selected Microsoft account', ( + tester, + ) async { final auth = _FakeAuthRepository(); final container = _container( selectedAccountId: 'microsoft:m', @@ -37,16 +38,18 @@ void main() { await _pumpSettings(tester, container); - await tester.tap(find.text('Sign out this account').first); + await _openAccountRemovalDialog(tester); + expect(find.byKey(const Key('revoke-google-authorization')), findsNothing); + await tester.tap(find.byKey(const Key('confirm-account-removal'))); await tester.pumpAndSettle(); - expect(auth.signOutAccountIds, ['microsoft:m']); + expect(auth.removalCalls, [ + const _AccountRemovalCall('microsoft:m', revokeAuthorization: false), + ]); expect(container.read(selectedAccountIdProvider), 'google:g'); - expect(container.read(selectedTaskListIdProvider), isNull); - expect(container.read(selectedTaskIdProvider), isNull); }); - testWidgets('Settings disconnect passes selected account id', (tester) async { + testWidgets('Settings keeps Google revocation opt-in', (tester) async { final auth = _FakeAuthRepository(); final container = _container( selectedAccountId: 'google:g', @@ -57,14 +60,22 @@ void main() { await _pumpSettings(tester, container); - await tester.tap(find.text('Disconnect this account').first); + await _openAccountRemovalDialog(tester); + final revoke = find.byKey(const Key('revoke-google-authorization')); + expect(revoke, findsOneWidget); + expect(tester.widget(revoke).value, isFalse); + await tester.tap(revoke); + await tester.pump(); + await tester.tap(find.byKey(const Key('confirm-account-removal'))); await tester.pumpAndSettle(); - expect(auth.revokedAccountIds, ['google:g']); + expect(auth.removalCalls, [ + const _AccountRemovalCall('google:g', revokeAuthorization: true), + ]); expect(container.read(selectedAccountIdProvider), 'microsoft:m'); }); - testWidgets('Settings delete local data passes selected account id', ( + testWidgets('Settings cancels account removal without mutation', ( tester, ) async { final auth = _FakeAuthRepository(); @@ -77,16 +88,17 @@ void main() { await _pumpSettings(tester, container); - await tester.tap(find.text('Delete local data for this account').first); - await tester.pumpAndSettle(); - await tester.tap(find.text('Delete').last); + await _openAccountRemovalDialog(tester); + await tester.tap(find.text('Cancel')); await tester.pumpAndSettle(); - expect(auth.deletedAccountIds, ['google:g']); - expect(container.read(selectedAccountIdProvider), 'microsoft:m'); + expect(auth.removalCalls, isEmpty); + expect(container.read(selectedAccountIdProvider), 'google:g'); }); - testWidgets('Settings labels are provider-neutral', (tester) async { + testWidgets('Settings exposes one clear account-removal action', ( + tester, + ) async { final container = _container( selectedAccountId: 'google:g', authRepository: _FakeAuthRepository(), @@ -96,10 +108,104 @@ void main() { await _pumpSettings(tester, container); - expect(find.text('Sign out this account'), findsOneWidget); - expect(find.text('Disconnect this account'), findsOneWidget); - expect(find.text('Delete local data for this account'), findsOneWidget); - expect(find.text('Revoke Google authorization'), findsNothing); + expect(find.text('Remove account…'), findsOneWidget); + expect( + find.text( + 'Stop syncing and remove this account’s data from this device.', + ), + findsOneWidget, + ); + 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); + }); + + testWidgets('Settings reports a local account-removal failure in place', ( + tester, + ) async { + final auth = _FakeAuthRepository() + ..removeError = StateError('local cleanup failed'); + final container = _container( + selectedAccountId: 'google:g', + authRepository: auth, + accounts: const [_googleAccount, _microsoftAccount], + ); + addTearDown(container.dispose); + + await _pumpSettings(tester, container); + await _openAccountRemovalDialog(tester); + await tester.tap(find.byKey(const Key('confirm-account-removal'))); + await tester.pumpAndSettle(); + + expect(container.read(selectedAccountIdProvider), 'google:g'); + expect( + find.text('Could not finish removing the account. Try again.'), + findsOneWidget, + ); + }); + + testWidgets('Settings prevents duplicate account-removal submission', ( + tester, + ) async { + final removal = Completer(); + final auth = _FakeAuthRepository()..removalCompleter = removal; + final container = _container( + selectedAccountId: 'google:g', + authRepository: auth, + accounts: const [_googleAccount, _microsoftAccount], + ); + addTearDown(container.dispose); + + await _pumpSettings(tester, container); + await _openAccountRemovalDialog(tester); + await tester.tap(find.byKey(const Key('confirm-account-removal'))); + await tester.pump(); + + expect(auth.removalCalls, hasLength(1)); + expect(find.text('Removing account…'), findsOneWidget); + await tester.tap(find.text('Removing account…'), warnIfMissed: false); + await tester.pump(); + expect(auth.removalCalls, hasLength(1)); + + removal.complete( + const AccountRemovalResult( + authorizationRevocationStatus: + AccountAuthorizationRevocationStatus.notRequested, + ), + ); + await tester.pumpAndSettle(); + expect(container.read(selectedAccountIdProvider), 'microsoft:m'); + }); + + testWidgets('Settings reports partial Google revocation failure', ( + tester, + ) async { + final auth = _FakeAuthRepository() + ..removalResult = const AccountRemovalResult( + authorizationRevocationStatus: + AccountAuthorizationRevocationStatus.failed, + ); + final container = _container( + selectedAccountId: 'google:g', + authRepository: auth, + accounts: const [_googleAccount, _microsoftAccount], + ); + addTearDown(container.dispose); + + await _pumpSettings(tester, container); + await _openAccountRemovalDialog(tester); + await tester.tap(find.byKey(const Key('revoke-google-authorization'))); + await tester.tap(find.byKey(const Key('confirm-account-removal'))); + await tester.pumpAndSettle(); + + expect( + find.text( + 'The account was removed from this device, but BusyMax could not ' + 'revoke Google access. You can revoke it from your Google Account.', + ), + findsOneWidget, + ); + expect(container.read(selectedAccountIdProvider), 'microsoft:m'); }); testWidgets('Settings shows reconnect-required account state', ( @@ -117,7 +223,10 @@ void main() { expect(find.text(accountReconnectRequiredActionLabel), findsOneWidget); expect(find.text(accountReconnectRequiredSyncMessage), findsOneWidget); expect(find.text('New list'), findsNothing); - expect(find.text('Sign out this account'), findsNothing); + expect(find.text('Remove account…'), findsOneWidget); + + await _openAccountRemovalDialog(tester); + expect(find.byKey(const Key('revoke-google-authorization')), findsNothing); }); testWidgets('Settings exposes add account actions', (tester) async { @@ -520,15 +629,24 @@ void main() { await _pumpRoutedSettings(tester, container); - await tester.tap(find.text('Sign out this account').first); + await _openAccountRemovalDialog(tester); + await tester.tap(find.byKey(const Key('confirm-account-removal'))); await tester.pumpAndSettle(); - expect(auth.signOutAccountIds, ['google:g']); + expect(auth.removalCalls, [ + const _AccountRemovalCall('google:g', revokeAuthorization: false), + ]); expect(container.read(selectedAccountIdProvider), isNull); expect(find.text('sign in route'), findsOneWidget); }); } +Future _openAccountRemovalDialog(WidgetTester tester) async { + await tester.tap(find.text('Remove account…').first); + await tester.pumpAndSettle(); + expect(find.textContaining('from BusyMax?'), findsOneWidget); +} + ProviderContainer _container({ required String selectedAccountId, required _FakeAuthRepository authRepository, @@ -629,27 +747,52 @@ Future _pumpRoutedSettings( } class _FakeAuthRepository implements AuthRepository { - final signOutAccountIds = []; - final revokedAccountIds = []; - final deletedAccountIds = []; + final removalCalls = <_AccountRemovalCall>[]; + AccountRemovalResult removalResult = const AccountRemovalResult( + authorizationRevocationStatus: + AccountAuthorizationRevocationStatus.notRequested, + ); + Completer? removalCompleter; + Object? removeError; @override - Future signOut({String? accountId}) async { - signOutAccountIds.add(accountId ?? ''); + Future removeAccount({ + required String accountId, + bool revokeAuthorization = false, + }) async { + removalCalls.add( + _AccountRemovalCall(accountId, revokeAuthorization: revokeAuthorization), + ); + final error = removeError; + if (error != null) { + throw error; + } + final completer = removalCompleter; + return completer == null ? removalResult : completer.future; } @override - Future revokeAndSignOut({String? accountId}) async { - revokedAccountIds.add(accountId ?? ''); - } + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +class _AccountRemovalCall { + const _AccountRemovalCall( + this.accountId, { + required this.revokeAuthorization, + }); + + final String accountId; + final bool revokeAuthorization; @override - Future deleteLocalAccountData({String? accountId}) async { - deletedAccountIds.add(accountId ?? ''); + bool operator ==(Object other) { + return other is _AccountRemovalCall && + other.accountId == accountId && + other.revokeAuthorization == revokeAuthorization; } @override - dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); + int get hashCode => Object.hash(accountId, revokeAuthorization); } class _FakeAccountsRepository implements AccountsRepository { diff --git a/test/features/tasks/presentation/desktop_date_time_fields_test.dart b/test/features/tasks/presentation/desktop_date_time_fields_test.dart index 7e312bd..bb776ae 100644 --- a/test/features/tasks/presentation/desktop_date_time_fields_test.dart +++ b/test/features/tasks/presentation/desktop_date_time_fields_test.dart @@ -2,6 +2,7 @@ import 'package:busymax/src/app/busymax_design.dart'; import 'package:busymax/src/features/tasks/presentation/desktop_date_time_fields.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:yaru/yaru.dart'; import '../../../test_localized_app.dart'; @@ -101,6 +102,58 @@ void main() { await tester.pumpAndSettle(); expect(await result, isNull); }); + + testWidgets('time entry follows locale and explicit 24-hour preference', ( + tester, + ) async { + Future entryText({ + required Locale locale, + required bool alwaysUse24HourFormat, + }) async { + await tester.pumpWidget( + localizedTestApp( + locale: locale, + alwaysUse24HourFormat: alwaysUse24HourFormat, + child: Scaffold( + body: DesktopTimeField( + key: ValueKey('${locale.languageCode}-$alwaysUse24HourFormat'), + label: 'Due time', + time: '14:30', + onChanged: _ignoreNullableString, + ), + ), + ), + ); + + final entry = find.byType(YaruTimeEntry); + await tester.tap(entry); + await tester.pump(); + + return tester + .widget( + find.descendant( + of: entry, + matching: find.byType(TextFormField), + ), + ) + .controller + ?.text ?? + ''; + } + + expect( + await entryText(locale: const Locale('en'), alwaysUse24HourFormat: false), + '02:30 pm', + ); + expect( + await entryText(locale: const Locale('en'), alwaysUse24HourFormat: true), + '14:30', + ); + expect( + await entryText(locale: const Locale('de'), alwaysUse24HourFormat: false), + '14:30', + ); + }); } void _ignoreString(String value) {} diff --git a/test/features/tasks/presentation/task_details_pane_test.dart b/test/features/tasks/presentation/task_details_pane_test.dart index 4ab9238..0163418 100644 --- a/test/features/tasks/presentation/task_details_pane_test.dart +++ b/test/features/tasks/presentation/task_details_pane_test.dart @@ -539,6 +539,88 @@ void main() { expect(find.text('Google Tasks'), findsWidgets); }); + testWidgets( + 'opaque Microsoft account id waits for and preserves stored provider', + (tester) async { + final accounts = StreamController>(); + addTearDown(accounts.close); + const accountId = 'opaque-account-id'; + + await _pumpDetails( + tester, + microsoftTaskProviderCapabilities, + accountIdOverride: accountId, + accountsStream: accounts.stream, + ); + + expect(find.byType(TaskDetailsEditor), findsNothing); + + accounts.add([ + const AccountEntity( + id: accountId, + provider: TaskProvider.microsoft, + authState: 'signed_in', + displayName: 'Microsoft User', + email: 'microsoft@example.com', + ), + ]); + await tester.pumpAndSettle(); + + expect(find.text('Start'), findsOneWidget); + expect(find.text('Reminder'), findsOneWidget); + + await tester.enterText(find.byType(TextField).first, 'Unsaved task'); + await tester.pump(); + accounts.add(const []); + await tester.pumpAndSettle(); + + expect(find.text('Unsaved task'), findsOneWidget); + expect(find.text('Start'), findsOneWidget); + expect(find.text('Reminder'), findsOneWidget); + }, + ); + + testWidgets( + 'account stream errors preserve the pane and definitive removal closes it', + (tester) async { + final accounts = StreamController>(); + addTearDown(accounts.close); + const accountId = 'opaque-account-id'; + var closeCalls = 0; + + await _pumpDetails( + tester, + microsoftTaskProviderCapabilities, + accountIdOverride: accountId, + accountsStream: accounts.stream, + onClose: () => closeCalls += 1, + ); + + expect(closeCalls, 0); + accounts.add([ + const AccountEntity( + id: accountId, + provider: TaskProvider.microsoft, + authState: 'signed_in', + displayName: 'Microsoft User', + email: 'microsoft@example.com', + ), + ]); + await tester.pumpAndSettle(); + expect(find.byType(TaskDetailsEditor), findsOneWidget); + + accounts.addError(StateError('temporary account stream failure')); + await tester.pumpAndSettle(); + expect(find.byType(TaskDetailsEditor), findsOneWidget); + expect(closeCalls, 0); + + accounts.add(const []); + await tester.pumpAndSettle(); + expect(closeCalls, 1); + expect(find.byType(TaskDetailsEditor), findsNothing); + }, + ); + testWidgets('unsupported provider text is not rendered for Google', ( tester, ) async { @@ -577,10 +659,12 @@ void main() { repository: repository, ); - expect(find.text('Home'), findsOneWidget); + expect(find.widgetWithText(InputChip, 'Home'), findsOneWidget); + expect(find.widgetWithText(ActionChip, 'Add category'), findsOneWidget); await tester.tap(find.text('Add category')); await tester.pump(); + expect(find.byType(YaruAutocomplete), findsOneWidget); await tester.enterText( find.byKey(const Key('task-category-input')), 'Work', @@ -602,9 +686,11 @@ void main() { expect(repository.patches.single.fields['categories'], ['Work']); }); - testWidgets('Microsoft category suggestions can be selected', (tester) async { + testWidgets('Microsoft category suggestions use Yaru autocomplete', ( + tester, + ) async { final repository = _FakeTasksRepository( - categorySuggestions: const ['Home', 'Work'], + categorySuggestions: const ['Home', 'Work', 'Workshop'], ); await _pumpDetails( tester, @@ -618,18 +704,23 @@ void main() { await tester.pumpAndSettle(); final input = find.byKey(const Key('task-category-input')); final field = tester.widget(input); - expect(field.decoration?.border, InputBorder.none); - expect(field.decoration?.focusedBorder, InputBorder.none); + expect(find.byType(YaruAutocomplete), findsOneWidget); + expect(field.decoration?.border, isNull); + expect(field.decoration?.focusedBorder, isNull); expect( tester.getTopLeft(find.text('Work').last).dy, greaterThanOrEqualTo(tester.getBottomLeft(input).dy - 1), ); - await tester.tap(find.text('Work').last); + await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); + await tester.testTextInput.receiveAction(TextInputAction.done); await tester.pumpAndSettle(); await tester.tap(find.text('Save')); await tester.pumpAndSettle(); - expect(repository.patches.single.fields['categories'], ['Home', 'Work']); + expect(repository.patches.single.fields['categories'], [ + 'Home', + 'Workshop', + ]); }); testWidgets('Due group appears before separate Start group', (tester) async { @@ -874,10 +965,10 @@ void main() { alwaysUse24HourFormat: false, ); - expect(_timeTextEntryFinder(), findsNothing); + expect(_timeEntryFinder(), findsNothing); await _openRowMenu(tester, 'Due time'); - expect(_timeTextEntryFinder(), findsOneWidget); + expect(_timeEntryFinder(), findsOneWidget); expect(tester.takeException(), isNull); expect(calls, isEmpty); @@ -985,18 +1076,18 @@ void main() { expect(repository.patches, isEmpty); }); - testWidgets('time entries do not show redundant internal input label', ( + testWidgets('time entries suppress the redundant floating label cleanly', ( tester, ) async { await _pumpDetails(tester, microsoftTaskProviderCapabilities); await _openRowMenu(tester, 'Due time'); - final entryContext = tester.element(_timeTextEntryFinder().first); + final entryContext = tester.element(_timeEntryFinder().first); final decorationTheme = Theme.of(entryContext).inputDecorationTheme; expect(decorationTheme.floatingLabelBehavior, FloatingLabelBehavior.never); - expect(decorationTheme.labelStyle?.fontSize, 0); - expect(decorationTheme.floatingLabelStyle?.fontSize, 0); + expect(decorationTheme.labelStyle?.fontSize, isNot(0)); + expect(decorationTheme.floatingLabelStyle?.fontSize, isNot(0)); }); testWidgets( @@ -1004,6 +1095,7 @@ void main() { (tester) async { await tester.pumpWidget( localizedTestApp( + alwaysUse24HourFormat: true, child: Scaffold( body: DesktopTimeField( label: 'Due time', @@ -1016,9 +1108,19 @@ void main() { expect(find.text('Due time'), findsWidgets); expect(find.text('None'), findsNothing); - final entry = tester.widget(_timeTextEntryFinder()); - expect(entry.controller?.text, isEmpty); - expect(find.text('--:--'), findsOneWidget); + final entry = tester.widget(_timeEntryFinder()); + expect(entry.controller?.timeOfDay, isNull); + + await tester.tap(_timeEntryFinder()); + await tester.pump(); + + final textEntry = tester.widget( + find.descendant( + of: _timeEntryFinder(), + matching: find.byType(TextFormField), + ), + ); + expect(textEntry.controller?.text, '--:--'); }, ); @@ -1026,6 +1128,7 @@ void main() { String? changed; await tester.pumpWidget( localizedTestApp( + alwaysUse24HourFormat: true, child: Scaffold( body: DesktopTimeField( label: 'Due time', @@ -1036,17 +1139,20 @@ void main() { ), ); - await tester.enterText(_timeTextEntryFinder(), '00:00'); - await tester.pump(); + await tester.tap(find.byIcon(YaruIcons.edit_clear)); + await _enterTime(tester, hour: '00', minute: '00'); expect(changed, '00:00'); expect(tester.takeException(), isNull); }); - testWidgets('time field formats compact numeric input', (tester) async { + testWidgets('time field uses segmented hour and minute entry', ( + tester, + ) async { String? changed; await tester.pumpWidget( localizedTestApp( + alwaysUse24HourFormat: true, child: Scaffold( body: DesktopTimeField( label: 'Due time', @@ -1057,11 +1163,10 @@ void main() { ), ); - await tester.enterText(_timeTextEntryFinder(), '0517'); - await tester.pump(); + await _enterTime(tester, hour: '05', minute: '17'); - final entry = tester.widget(_timeTextEntryFinder()); - expect(entry.controller?.text, '05:17'); + final entry = tester.widget(_timeEntryFinder()); + expect(entry.controller?.timeOfDay, const TimeOfDay(hour: 5, minute: 17)); expect(changed, '05:17'); expect(tester.takeException(), isNull); }); @@ -1135,6 +1240,7 @@ Future _pumpDetails( bool includeAccountIdentity = true, String? displayName, String? email, + Stream>? accountsStream, }) async { final accountId = accountIdOverride ?? @@ -1172,15 +1278,16 @@ Future _pumpDetails( selectedAccountCapabilitiesProvider.overrideWithValue(capabilities), localTimeZoneProvider.overrideWithValue('UTC'), accountsStreamProvider.overrideWith((ref) { - return Stream.value([ - AccountEntity( - id: accountId, - provider: provider, - authState: 'signed_in', - displayName: accountDisplayName, - email: accountEmail, - ), - ]); + return accountsStream ?? + Stream.value([ + AccountEntity( + id: accountId, + provider: provider, + authState: 'signed_in', + displayName: accountDisplayName, + email: accountEmail, + ), + ]); }), tasksRepositoryForAccountProvider.overrideWith((ref, requestedId) { expect(requestedId, accountId); @@ -1312,10 +1419,19 @@ Future _openRowMenu(WidgetTester tester, String label) async { await tester.pumpAndSettle(); } -Finder _timeTextEntryFinder() { - return find.byWidgetPredicate( - (widget) => widget is TextFormField && widget.controller != null, - ); +Finder _timeEntryFinder() => find.byType(YaruTimeEntry); + +Future _enterTime( + WidgetTester tester, { + required String hour, + required String minute, +}) async { + final entry = _timeEntryFinder(); + await tester.tap(entry); + await tester.enterText(entry, hour); + await tester.pump(); + await tester.enterText(entry, minute); + await tester.pump(); } class _FakeTasksRepository implements TasksRepository { diff --git a/test/features/tasks/presentation/tasks_selection_state_test.dart b/test/features/tasks/presentation/tasks_selection_state_test.dart deleted file mode 100644 index 8dcba6d..0000000 --- a/test/features/tasks/presentation/tasks_selection_state_test.dart +++ /dev/null @@ -1,595 +0,0 @@ -import 'dart:async'; - -import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:busymax/src/app/app_bootstrap.dart'; -import 'package:busymax/src/features/accounts/data/accounts_repository.dart'; -import 'package:busymax/src/features/task_lists/data/task_lists_repository.dart'; -import 'package:busymax/src/features/task_lists/presentation/task_lists_sidebar.dart'; -import 'package:busymax/src/features/tasks/data/tasks_repository.dart'; -import 'package:busymax/src/features/tasks/presentation/task_details_pane.dart'; -import 'package:busymax/src/features/tasks/presentation/tasks_selection_state.dart'; -import 'package:busymax/src/features/tasks/presentation/tasks_workspace.dart'; -import 'package:busymax/src/task_providers/task_provider.dart'; -import 'package:yaru/yaru.dart'; -import '../../../test_localized_app.dart'; - -void main() { - testWidgets('clicking a task list updates selected list provider', ( - tester, - ) async { - final container = await _container(); - await _pumpSidebar(tester, container); - - await tester.tap(find.text('List A')); - await tester.pump(); - - expect(container.read(selectedTaskListIdProvider), 'list-a'); - }); - - testWidgets('clicking a task list does not require GoRouter navigation', ( - tester, - ) async { - final container = await _container(); - await _pumpSidebar(tester, container); - - await tester.tap(find.text('List A')); - await tester.pump(); - - expect(tester.takeException(), isNull); - }); - - testWidgets('sidebar remains mounted after selecting a list', (tester) async { - final container = await _container(); - await _pumpSidebar(tester, container); - - expect(find.byType(TaskListsSidebar), findsOneWidget); - - await tester.tap( - find.descendant( - of: find.byType(TaskListsSidebar), - matching: find.text('List B'), - ), - ); - await tester.pump(); - - expect(find.byType(TaskListsSidebar), findsOneWidget); - expect(container.read(selectedTaskListIdProvider), 'list-b'); - }); - - testWidgets('sidebar renders logo, All tasks row, and accounts header', ( - tester, - ) async { - final container = await _container(); - await _pumpSidebar(tester, container); - - expect(find.byType(Image), findsOneWidget); - expect(find.text('Navigation'), findsNothing); - expect(find.text('All tasks'), findsOneWidget); - expect(find.text('Accounts'), findsOneWidget); - expect(find.byTooltip('New list'), findsOneWidget); - }); - - testWidgets('sidebar header does not overflow when constrained', ( - tester, - ) async { - final container = await _container(); - await _pumpSidebar(tester, container, width: 220); - - expect(tester.takeException(), isNull); - }); - - testWidgets('sidebar renders open account accordion', (tester) async { - final container = await _container( - accounts: const [ - AccountEntity( - id: 'google:a', - provider: TaskProvider.google, - authState: 'signed_in', - displayName: 'Ada', - email: 'ada@example.com', - ), - ], - ); - await _pumpSidebar(tester, container); - - expect(find.text('Google Tasks'), findsOneWidget); - expect(find.text('Ada · ada@example.com'), findsOneWidget); - expect(find.text('List A'), findsOneWidget); - expect(find.text('List B'), findsOneWidget); - expect(find.byType(Card), findsNothing); - expect(find.byTooltip('Switch account'), findsNothing); - expect(find.text('Add Google account'), findsNothing); - }); - - testWidgets( - 'Google account header with no email uses stored account identity', - (tester) async { - final container = await _container( - accounts: const [ - AccountEntity( - id: 'google:a', - provider: TaskProvider.google, - authState: 'signed_in', - providerAccountId: 'ada@gmail.com', - displayName: 'Google', - ), - ], - ); - await _pumpSidebar(tester, container); - - expect(find.text('Google Tasks'), findsOneWidget); - expect(find.text('ada@gmail.com'), findsOneWidget); - expect(find.text('Signed in'), findsNothing); - expect(find.text('Google'), findsNothing); - }, - ); - - testWidgets('Google account header does not show generated account id', ( - tester, - ) async { - final container = await _container( - accounts: const [ - AccountEntity( - id: 'google-abc123', - provider: TaskProvider.google, - authState: 'signed_in', - displayName: 'Google', - ), - ], - ); - await _pumpSidebar(tester, container); - - expect(find.text('Google Tasks'), findsOneWidget); - expect(find.text('google-abc123'), findsNothing); - expect(find.text('Signed in'), findsOneWidget); - expect(find.text('Google'), findsNothing); - }); - - testWidgets('Microsoft account header shows provider and account identity', ( - tester, - ) async { - final container = await _container(accounts: const [_microsoftAccount]); - await _pumpSidebar(tester, container); - - expect(find.text('Microsoft To Do'), findsOneWidget); - expect(find.text('Microsoft User · microsoft@example.com'), findsOneWidget); - expect(find.text('Microsoft'), findsNothing); - }); - - testWidgets('account accordion collapses and expands task lists', ( - tester, - ) async { - final container = await _container(); - await _pumpSidebar(tester, container); - - expect(find.text('List A'), findsOneWidget); - expect(find.text('List B'), findsOneWidget); - - await tester.tap(find.text('Google Tasks')); - await tester.pumpAndSettle(); - - expect(find.text('List A'), findsNothing); - expect(find.text('List B'), findsNothing); - - await tester.tap(find.text('Google Tasks')); - await tester.pumpAndSettle(); - - expect(find.text('List A'), findsOneWidget); - expect(find.text('List B'), findsOneWidget); - }); - - testWidgets('sidebar groups task lists under each account', (tester) async { - final container = await _container( - accounts: const [_googleAccount, _microsoftAccount], - taskListsByAccount: const { - 'google:g': [_TaskListSeed(id: 'google-list', title: 'Google Inbox')], - 'microsoft:m': [ - _TaskListSeed(id: 'microsoft-list', title: 'Microsoft Tasks'), - ], - }, - ); - await _pumpSidebar(tester, container); - - expect(find.text('Google Tasks'), findsOneWidget); - expect(find.text('Google User · google@example.com'), findsOneWidget); - expect(find.text('Microsoft To Do'), findsOneWidget); - expect(find.text('Microsoft User · microsoft@example.com'), findsOneWidget); - expect(find.text('Google Inbox'), findsOneWidget); - expect(find.text('Microsoft Tasks'), findsOneWidget); - - await tester.tap(find.text('Microsoft Tasks')); - await tester.pump(); - - expect(container.read(selectedAccountIdProvider), 'microsoft:m'); - expect(container.read(selectedTaskListIdProvider), 'microsoft-list'); - }); - - testWidgets('clicking All tasks returns to All Tasks mode after a list', ( - tester, - ) async { - final container = await _container(); - await _pumpSidebar(tester, container); - - await tester.tap(find.text('List A')); - await tester.pump(); - - expect(container.read(allTasksModeProvider), isFalse); - expect(container.read(selectedTaskListIdProvider), 'list-a'); - - await tester.tap(find.text('All tasks')); - await tester.pump(); - - expect(container.read(allTasksModeProvider), isTrue); - expect(container.read(selectedTaskListIdProvider), isNull); - expect(container.read(selectedTaskIdProvider), isNull); - }); - - testWidgets('All Tasks row is selected when allTasksMode is true', ( - tester, - ) async { - final container = await _container(); - container.read(allTasksModeProvider.notifier).state = true; - await _pumpSidebar(tester, container); - - final selectedTile = tester.widget( - find.ancestor( - of: find.text('All tasks'), - matching: find.byType(YaruSelectableContainer), - ), - ); - - expect(selectedTile.selected, isTrue); - }); - - testWidgets('entering All Tasks mode clears selected task and list', ( - tester, - ) async { - final container = await _container(); - container.read(allTasksModeProvider.notifier).state = false; - container.read(selectedTaskListIdProvider.notifier).state = 'list-a'; - container.read(selectedTaskIdProvider.notifier).state = 'task-a'; - await _pumpSidebar(tester, container); - - await tester.tap(find.text('All tasks')); - await tester.pump(); - - expect(container.read(allTasksModeProvider), isTrue); - expect(container.read(selectedTaskListIdProvider), isNull); - expect(container.read(selectedTaskIdProvider), isNull); - }); - - testWidgets('sidebar selected list uses theme primary accent', ( - tester, - ) async { - final container = await _container(); - container.read(allTasksModeProvider.notifier).state = false; - container.read(selectedTaskListIdProvider.notifier).state = 'list-a'; - await _pumpSidebar(tester, container); - - final selectedTile = tester.widget( - find.ancestor( - of: find.text('List A'), - matching: find.byType(YaruSelectableContainer), - ), - ); - - expect(selectedTile.selected, isTrue); - }); - - testWidgets('task panel updates for selected list', (tester) async { - final container = await _container(); - await _pumpWorkspace(tester, container); - - expect(find.text('Task for List A'), findsOneWidget); - expect(find.text('Task for List B'), findsOneWidget); - - await tester.tap( - find.descendant( - of: find.byType(TaskListsSidebar), - matching: find.text('List B'), - ), - ); - await tester.pumpAndSettle(); - - expect(find.text('Task for List B'), findsOneWidget); - expect(find.text('Task for List A'), findsNothing); - }); - - testWidgets('selecting a different list clears selected task', ( - tester, - ) async { - final container = await _container(); - container.read(selectedTaskListIdProvider.notifier).state = 'list-a'; - container.read(selectedTaskIdProvider.notifier).state = 'task-a'; - await _pumpSidebar(tester, container); - - await tester.tap(find.text('List B')); - await tester.pump(); - - expect(container.read(selectedTaskListIdProvider), 'list-b'); - expect(container.read(selectedTaskIdProvider), isNull); - }); - - testWidgets( - 'clicking a task updates selected task provider and shows details pane', - (tester) async { - final container = await _container(); - container.read(selectedTaskListIdProvider.notifier).state = 'list-a'; - container.read(allTasksModeProvider.notifier).state = false; - await _pumpWorkspace(tester, container); - - await tester.tap(find.text('Task for List A')); - await tester.pumpAndSettle(); - - expect(container.read(selectedTaskIdProvider), 'task-a'); - expect(find.byType(TaskDetailsPane), findsOneWidget); - expect(tester.takeException(), isNull); - }, - ); -} - -Future _container({ - List accounts = const [_defaultAccount], - Map>? taskListsByAccount, -}) async { - final taskLists = - taskListsByAccount ?? - (accounts.isEmpty - ? const >{} - : _defaultTaskLists(accounts.first.id)); - final selectedAccountId = accounts.isEmpty ? '' : accounts.first.id; - - final container = ProviderContainer( - overrides: [ - accountsStreamProvider.overrideWith((ref) => Stream.value(accounts)), - taskListsRepositoryForAccountProvider.overrideWith((ref, accountId) { - return _FakeTaskListsRepository( - _taskListEntities(accountId, taskLists[accountId] ?? const []), - ); - }), - taskListsRepositoryProvider.overrideWithValue( - _FakeTaskListsRepository( - _taskListEntities( - selectedAccountId, - taskLists[selectedAccountId] ?? const [], - ), - ), - ), - tasksRepositoryProvider.overrideWithValue(_FakeTasksRepository()), - tasksRepositoryForAccountProvider.overrideWith((ref, accountId) { - return _FakeTasksRepository(); - }), - syncEngineProvider.overrideWithValue(null), - signedInSyncRunnerProvider.overrideWithValue((accountId, initial) async { - return; - }), - ], - ); - addTearDown(container.dispose); - return container; -} - -Future _pumpSidebar( - WidgetTester tester, - ProviderContainer container, { - double? width, -}) async { - addTearDown(() async { - await tester.pumpWidget(const SizedBox.shrink()); - await tester.pump(); - }); - await tester.pumpWidget( - UncontrolledProviderScope( - container: container, - child: localizedTestApp( - child: Scaffold( - body: width == null - ? const TaskListsSidebar() - : SizedBox(width: width, child: const TaskListsSidebar()), - ), - ), - ), - ); - await tester.pumpAndSettle(); -} - -Future _pumpWorkspace( - WidgetTester tester, - ProviderContainer container, -) async { - addTearDown(() async { - await tester.pumpWidget(const SizedBox.shrink()); - await tester.pump(); - }); - tester.view.physicalSize = const Size(1280, 800); - tester.view.devicePixelRatio = 1; - addTearDown(() { - tester.view.resetPhysicalSize(); - tester.view.resetDevicePixelRatio(); - }); - await tester.pumpWidget( - UncontrolledProviderScope( - container: container, - child: localizedTestApp(child: const TasksWorkspace()), - ), - ); - await tester.pumpAndSettle(); -} - -class _FakeTaskListsRepository implements TaskListsRepository { - const _FakeTaskListsRepository(this.lists); - - final List lists; - - @override - Stream> watchTaskLists() { - return Stream.value(lists); - } - - @override - Stream watchTaskList(String id) { - return watchTaskLists().map( - (lists) => lists.where((list) => list.id == id).firstOrNull, - ); - } - - @override - dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); -} - -class _FakeTasksRepository implements TasksRepository { - @override - Stream watchTask(String taskListId, String taskId) { - return Stream.value(_taskForList(taskListId)); - } - - @override - Stream> watchAllTaskTreeGroups( - List accountIds, - TaskViewFilter filter, - ) { - return Stream.value(const [ - TaskTreeGroup( - accountId: 'account', - accountLabel: 'Ada - ada@example.com', - provider: TaskProvider.google, - taskListId: 'list-a', - taskListTitle: 'List A', - nodes: [ - TaskTreeNode( - task: TaskEntity( - accountId: 'account', - taskListId: 'list-a', - id: 'task-a', - title: 'Task for List A', - localDirty: false, - pendingDelete: false, - pendingMove: false, - rawJson: '{}', - updatedLocalAtUtc: '2026-06-04T00:00:00.000Z', - status: 'needsAction', - ), - children: [], - ), - ], - ), - TaskTreeGroup( - accountId: 'account', - accountLabel: 'Ada - ada@example.com', - provider: TaskProvider.google, - taskListId: 'list-b', - taskListTitle: 'List B', - nodes: [ - TaskTreeNode( - task: TaskEntity( - accountId: 'account', - taskListId: 'list-b', - id: 'task-b', - title: 'Task for List B', - localDirty: false, - pendingDelete: false, - pendingMove: false, - rawJson: '{}', - updatedLocalAtUtc: '2026-06-04T00:00:00.000Z', - status: 'needsAction', - ), - children: [], - ), - ], - ), - ]); - } - - @override - Stream> watchTaskTree( - String taskListId, - TaskViewFilter filter, - ) { - return Stream.value([ - TaskTreeNode(task: _taskForList(taskListId), children: const []), - ]); - } - - @override - Stream> watchCategorySuggestions() { - return Stream.value(const []); - } - - @override - dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); -} - -List _taskListEntities( - String accountId, - List<_TaskListSeed> seeds, -) { - return [ - for (final seed in seeds) - TaskListEntity( - accountId: accountId, - id: seed.id, - title: seed.title, - localDirty: false, - pendingDelete: false, - rawJson: '{}', - ), - ]; -} - -Map> _defaultTaskLists(String accountId) { - return { - accountId: const [ - _TaskListSeed(id: 'list-a', title: 'List A'), - _TaskListSeed(id: 'list-b', title: 'List B'), - ], - }; -} - -TaskEntity _taskForList(String taskListId) { - final suffix = taskListId == 'list-b' ? 'B' : 'A'; - return TaskEntity( - accountId: 'account', - taskListId: taskListId, - id: 'task-${suffix.toLowerCase()}', - title: 'Task for List $suffix', - localDirty: false, - pendingDelete: false, - pendingMove: false, - rawJson: '{}', - updatedLocalAtUtc: '2026-06-04T00:00:00.000Z', - status: 'needsAction', - ); -} - -class _TaskListSeed { - const _TaskListSeed({required this.id, required this.title}); - - final String id; - final String title; -} - -const _defaultAccount = AccountEntity( - id: 'account', - provider: TaskProvider.google, - authState: 'signed_in', - displayName: 'Ada', - email: 'ada@example.com', -); - -const _googleAccount = AccountEntity( - id: 'google:g', - provider: TaskProvider.google, - authState: 'signed_in', - displayName: 'Google User', - email: 'google@example.com', -); - -const _microsoftAccount = AccountEntity( - id: 'microsoft:m', - provider: TaskProvider.microsoft, - authState: 'signed_in', - displayName: 'Microsoft User', - email: 'microsoft@example.com', -); diff --git a/test/features/tasks/presentation/tasks_workspace_test.dart b/test/features/tasks/presentation/tasks_workspace_test.dart deleted file mode 100644 index b10bfc1..0000000 --- a/test/features/tasks/presentation/tasks_workspace_test.dart +++ /dev/null @@ -1,1731 +0,0 @@ -import 'package:drift/drift.dart'; -import 'package:drift/native.dart'; -import 'package:desktop_notifications/desktop_notifications.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:busymax/src/app/app_bootstrap.dart'; -import 'package:busymax/src/app/busymax_design.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/desktop_notification_service.dart'; -import 'package:busymax/src/features/sync/sync_engine.dart'; -import 'package:busymax/src/features/task_lists/data/task_lists_repository.dart'; -import 'package:busymax/src/features/tasks/data/tasks_repository.dart'; -import 'package:busymax/src/features/tasks/presentation/task_details_editor.dart'; -import 'package:busymax/src/features/tasks/presentation/task_details_pane.dart'; -import 'package:busymax/src/features/tasks/presentation/task_filters.dart'; -import 'package:busymax/src/features/tasks/presentation/tasks_selection_state.dart'; -import 'package:busymax/src/features/tasks/presentation/tasks_workspace.dart'; -import 'package:busymax/src/features/tasks/presentation/task_tree_view.dart'; -import 'package:busymax/src/google_tasks/api/google_tasks_api_client.dart'; -import 'package:busymax/src/google_tasks/api/google_tasks_api_models.dart'; -import 'package:busymax/src/task_providers/task_provider.dart'; -import 'package:yaru/yaru.dart'; -import '../../../test_localized_app.dart'; - -void main() { - test( - 'All Tasks filter capabilities enable Google hidden and assigned filters', - () async { - final container = ProviderContainer( - overrides: [ - accountsStreamProvider.overrideWith((ref) { - return Stream.value([ - _accountEntity(id: 'google:g', provider: TaskProvider.google), - _accountEntity( - id: 'microsoft:m', - provider: TaskProvider.microsoft, - ), - ]); - }), - selectedAccountCapabilitiesProvider.overrideWithValue( - microsoftTaskProviderCapabilities, - ), - ], - ); - addTearDown(container.dispose); - container.read(allTasksModeProvider.notifier).state = true; - await container.read(accountsStreamProvider.future); - - final capabilities = container.read( - visibleTaskFilterCapabilitiesProvider, - ); - - expect(capabilities.supportsHiddenTasks, isTrue); - expect(capabilities.supportsAssignedTasks, isTrue); - }, - ); - - test( - 'Microsoft-only All Tasks keeps hidden and assigned filters disabled', - () async { - final container = ProviderContainer( - overrides: [ - accountsStreamProvider.overrideWith((ref) { - return Stream.value([ - _accountEntity( - id: 'microsoft:m', - provider: TaskProvider.microsoft, - ), - ]); - }), - selectedAccountCapabilitiesProvider.overrideWithValue( - googleTaskProviderCapabilities, - ), - ], - ); - addTearDown(container.dispose); - container.read(allTasksModeProvider.notifier).state = true; - await container.read(accountsStreamProvider.future); - - final capabilities = container.read( - visibleTaskFilterCapabilitiesProvider, - ); - - expect(capabilities.supportsHiddenTasks, isFalse); - expect(capabilities.supportsAssignedTasks, isFalse); - }, - ); - - test( - 'List mode filter capabilities use the selected account capabilities', - () { - final container = ProviderContainer( - overrides: [ - accountsStreamProvider.overrideWith((ref) { - return Stream.value([ - _accountEntity(id: 'google:g', provider: TaskProvider.google), - ]); - }), - selectedAccountCapabilitiesProvider.overrideWithValue( - microsoftTaskProviderCapabilities, - ), - ], - ); - addTearDown(container.dispose); - container.read(allTasksModeProvider.notifier).state = false; - - final capabilities = container.read( - visibleTaskFilterCapabilitiesProvider, - ); - - expect(capabilities.supportsHiddenTasks, isFalse); - expect(capabilities.supportsAssignedTasks, isFalse); - expect(capabilities.supportsDueTime, isTrue); - }, - ); - - testWidgets('All Tasks view shows tasks from Google and Microsoft accounts', ( - tester, - ) async { - _setWideViewport(tester); - final database = AppDatabase(NativeDatabase.memory()); - addTearDown(database.close); - await _insertAccount( - database, - id: 'google:g', - provider: TaskProvider.google, - displayName: 'Google User', - email: 'google@example.com', - ); - await _insertAccount( - database, - id: 'microsoft:m', - provider: TaskProvider.microsoft, - displayName: 'Microsoft User', - email: 'microsoft@example.com', - ); - await database.taskListsDao.upsertTaskList( - _localTaskList( - accountId: 'google:g', - id: 'google-list', - title: 'Google Inbox', - ), - ); - await database.taskListsDao.upsertTaskList( - _localTaskList( - accountId: 'microsoft:m', - id: 'microsoft-list', - title: 'Microsoft Tasks', - ), - ); - await database.tasksDao.upsertTask( - _localTask( - accountId: 'google:g', - taskListId: 'google-list', - id: 'google-task', - title: 'Google task', - dueUtc: Value(_dueUtcForDayOffset(0)), - status: const Value('needsAction'), - ), - ); - await database.tasksDao.upsertTask( - _localTask( - accountId: 'microsoft:m', - taskListId: 'microsoft-list', - id: 'microsoft-task', - title: 'Microsoft task', - dueUtc: Value(_dueUtcForDayOffset(-1)), - status: const Value('needsAction'), - ), - ); - - final container = _workspaceContainer(database); - addTearDown(container.dispose); - await _pumpWorkspaceWithContainer(tester, container); - - final taskTree = find.byType(TaskTreeView); - expect(find.text('All tasks'), findsWidgets); - expect( - find.descendant(of: taskTree, matching: find.text('Google Inbox')), - findsNothing, - ); - expect( - find.descendant(of: taskTree, matching: find.text('Google User')), - findsNothing, - ); - expect( - find.descendant( - of: taskTree, - matching: find.textContaining('Google · Google User · Google Inbox'), - ), - findsOneWidget, - ); - expect( - find.descendant(of: taskTree, matching: find.text('Google task')), - findsOneWidget, - ); - expect( - find.descendant(of: taskTree, matching: find.text('Microsoft Tasks')), - findsNothing, - ); - expect( - find.descendant(of: taskTree, matching: find.text('Microsoft User')), - findsNothing, - ); - expect( - find.descendant( - of: taskTree, - matching: find.textContaining( - 'Microsoft · Microsoft User · Microsoft Tasks', - ), - ), - findsOneWidget, - ); - expect( - find.descendant(of: taskTree, matching: find.text('Microsoft task')), - findsOneWidget, - ); - expect( - tester - .getTopLeft( - find.descendant( - of: taskTree, - matching: find.text('Microsoft task'), - ), - ) - .dy, - lessThan( - tester - .getTopLeft( - find.descendant(of: taskTree, matching: find.text('Google task')), - ) - .dy, - ), - ); - expect(find.text('Select or create a task list to begin.'), findsNothing); - - await _disposeWorkspace(tester); - }); - - testWidgets( - 'checking a Microsoft task queues and syncs the Microsoft account', - (tester) async { - _setWideViewport(tester); - final database = AppDatabase(NativeDatabase.memory()); - addTearDown(database.close); - await _seedTwoAccountWorkspace(database); - final syncEngines = { - 'google:g': _FakeSyncEngine(), - 'microsoft:m': _FakeSyncEngine(), - }; - - final container = _workspaceContainerWithAccountSync( - database, - syncEngines, - ); - addTearDown(container.dispose); - await _pumpWorkspaceWithContainer(tester, container); - - await _tapTaskCheckbox(tester, 'Microsoft task'); - await _waitForQueuedMutationSync(tester); - - expect(syncEngines['microsoft:m']!.incrementalSyncCalls, 1); - expect(syncEngines['google:g']!.incrementalSyncCalls, 0); - expect(container.read(selectedAccountIdProvider), 'google:g'); - - await _disposeWorkspace(tester); - }, - ); - - testWidgets('checking a Google task queues and syncs that Google account', ( - tester, - ) async { - _setWideViewport(tester); - final database = AppDatabase(NativeDatabase.memory()); - addTearDown(database.close); - await _seedTwoAccountWorkspace(database); - final syncEngines = { - 'google:g': _FakeSyncEngine(), - 'microsoft:m': _FakeSyncEngine(), - }; - - final container = _workspaceContainerWithAccountSync(database, syncEngines); - addTearDown(container.dispose); - await _pumpWorkspaceWithContainer(tester, container); - - await _tapTaskCheckbox(tester, 'Google task'); - await _waitForQueuedMutationSync(tester); - - expect(syncEngines['google:g']!.incrementalSyncCalls, 1); - expect(syncEngines['microsoft:m']!.incrementalSyncCalls, 0); - - await _disposeWorkspace(tester); - }); - - testWidgets('Refresh all in All Tasks mode syncs every signed-in account', ( - tester, - ) async { - _setWideViewport(tester); - final database = AppDatabase(NativeDatabase.memory()); - addTearDown(database.close); - await _seedTwoAccountWorkspace(database); - final syncEngines = { - 'google:g': _FakeSyncEngine(), - 'microsoft:m': _FakeSyncEngine(), - }; - - final container = _workspaceContainerWithAccountSync(database, syncEngines); - addTearDown(container.dispose); - await _pumpWorkspaceWithContainer(tester, container); - - await tester.tap(find.byTooltip('Refresh all')); - await tester.pumpAndSettle(); - - expect(syncEngines['google:g']!.incrementalSyncCalls, 1); - expect(syncEngines['microsoft:m']!.incrementalSyncCalls, 1); - expect(find.text('All accounts refreshed.'), findsOneWidget); - - await _disposeWorkspace(tester); - }); - - testWidgets('Refresh all is enabled when signed-in accounts exist', ( - tester, - ) async { - _setWideViewport(tester); - final database = AppDatabase(NativeDatabase.memory()); - addTearDown(database.close); - await _seedTwoAccountWorkspace(database); - final syncEngines = { - 'google:g': _FakeSyncEngine(), - 'microsoft:m': _FakeSyncEngine(), - }; - - final container = _workspaceContainerWithAccountSync(database, syncEngines); - addTearDown(container.dispose); - await _pumpWorkspaceWithContainer(tester, container); - - final onPressed = _toolbarActionOnPressed( - tester, - tooltip: 'Refresh all', - label: 'Refresh all', - ); - expect(onPressed == null, isFalse); - - await _disposeWorkspace(tester); - }); - - testWidgets('New task in All Tasks mode creates in chosen account/list', ( - tester, - ) async { - _setWideViewport(tester); - final database = AppDatabase(NativeDatabase.memory()); - addTearDown(database.close); - await _seedTwoAccountWorkspace(database); - final syncEngines = { - 'google:g': _FakeSyncEngine(), - 'microsoft:m': _FakeSyncEngine(), - }; - - final container = _workspaceContainerWithAccountSync(database, syncEngines); - addTearDown(container.dispose); - await _pumpWorkspaceWithContainer(tester, container); - - final onPressed = _toolbarActionOnPressed( - tester, - tooltip: 'New task', - label: 'New task', - ); - expect(onPressed == null, isFalse); - - await tester.tap(find.byTooltip('New task')); - await tester.pumpAndSettle(); - await tester.enterText(find.widgetWithText(TextField, 'Title'), 'All task'); - await tester.pump(); - await tester.tap(find.text('Create')); - await tester.pumpAndSettle(); - - final created = - await (database.select(database.tasks)..where( - (row) => - row.accountId.equals('google:g') & - row.taskListId.equals('google-list') & - row.title.equals('All task'), - )) - .get(); - expect(created, hasLength(1)); - - await _disposeWorkspace(tester); - }); - - testWidgets('opening an aggregate task keeps all tasks visible', ( - tester, - ) async { - _setWideViewport(tester); - final database = AppDatabase(NativeDatabase.memory()); - addTearDown(database.close); - await _insertAccount( - database, - id: 'google:g', - provider: TaskProvider.google, - displayName: 'Google User', - email: 'google@example.com', - ); - await _insertAccount( - database, - id: 'microsoft:m', - provider: TaskProvider.microsoft, - displayName: 'Microsoft User', - email: 'microsoft@example.com', - ); - await database.taskListsDao.upsertTaskList( - _localTaskList( - accountId: 'google:g', - id: 'google-list', - title: 'Google Inbox', - ), - ); - await database.taskListsDao.upsertTaskList( - _localTaskList( - accountId: 'microsoft:m', - id: 'microsoft-list', - title: 'Microsoft Tasks', - ), - ); - await database.tasksDao.upsertTask( - _localTask( - accountId: 'google:g', - taskListId: 'google-list', - id: 'google-task', - title: 'Google task', - status: const Value('needsAction'), - ), - ); - await database.tasksDao.upsertTask( - _localTask( - accountId: 'microsoft:m', - taskListId: 'microsoft-list', - id: 'microsoft-task', - title: 'Microsoft task', - status: const Value('needsAction'), - ), - ); - - final container = _workspaceContainer(database); - addTearDown(container.dispose); - await _pumpWorkspaceWithContainer(tester, container); - - await tester.tap(find.text('Microsoft task')); - await tester.pumpAndSettle(); - - expect(container.read(selectedAccountIdProvider), 'microsoft:m'); - expect(container.read(selectedTaskListIdProvider), 'microsoft-list'); - expect(container.read(selectedTaskIdProvider), 'microsoft-task'); - expect(container.read(allTasksModeProvider), isTrue); - expect(find.byType(TaskDetailsPane), findsOneWidget); - expect(find.byType(TaskDetailsEditor), findsOneWidget); - final taskTree = find.byType(TaskTreeView); - expect( - find.descendant(of: taskTree, matching: find.text('Google task')), - findsOneWidget, - ); - expect( - find.descendant(of: taskTree, matching: find.text('Microsoft task')), - findsOneWidget, - ); - - await _disposeWorkspace(tester); - }); - - testWidgets('wide workspace does not render persistent Task Details editor', ( - tester, - ) async { - _setWideViewport(tester); - final database = AppDatabase(NativeDatabase.memory()); - addTearDown(database.close); - await _seedTwoAccountWorkspace(database); - - final container = _workspaceContainer(database); - addTearDown(container.dispose); - container.read(selectedAccountIdProvider.notifier).state = 'google:g'; - container.read(selectedTaskListIdProvider.notifier).state = 'google-list'; - container.read(selectedTaskIdProvider.notifier).state = 'google-task'; - await _pumpWorkspaceWithContainer(tester, container); - - expect(find.byType(TaskDetailsPane), findsNothing); - expect(find.byType(TaskDetailsEditor), findsNothing); - - await _disposeWorkspace(tester); - }); - - testWidgets( - 'wide workspace opens Task Details overlay only after task selection', - (tester) async { - _setWideViewport(tester); - final database = AppDatabase(NativeDatabase.memory()); - addTearDown(database.close); - await _seedTwoAccountWorkspace(database); - - final container = _workspaceContainer(database); - addTearDown(container.dispose); - await _pumpWorkspaceWithContainer(tester, container); - - expect(find.byType(TaskDetailsPane), findsNothing); - expect(find.byType(TaskDetailsEditor), findsNothing); - - await tester.tap(find.text('Microsoft task')); - await tester.pumpAndSettle(); - - expect(find.byType(TaskDetailsPane), findsOneWidget); - expect(find.byType(TaskDetailsEditor), findsOneWidget); - expect(find.byType(ModalBarrier), findsWidgets); - - await _disposeWorkspace(tester); - }, - ); - - testWidgets('Task Details overlay is clamped around editor width', ( - tester, - ) async { - _setTallWideViewport(tester); - final database = AppDatabase(NativeDatabase.memory()); - addTearDown(database.close); - await _seedTwoAccountWorkspace(database); - - final container = _workspaceContainer(database); - addTearDown(container.dispose); - await _pumpWorkspaceWithContainer(tester, container); - - await tester.tap(find.text('Microsoft task')); - await tester.pumpAndSettle(); - - final paneSize = tester.getSize(find.byType(TaskDetailsPane)); - expect(paneSize.width, closeTo(BusyMaxSizes.compactDetailsWidth, 1)); - expect(paneSize.width, lessThan(tester.view.physicalSize.width)); - - await _disposeWorkspace(tester); - }); - - testWidgets( - 'medium workspace does not show inspector button for selected task', - (tester) async { - _setMediumViewport(tester); - final database = AppDatabase(NativeDatabase.memory()); - addTearDown(database.close); - await _seedTwoAccountWorkspace(database); - - final container = _workspaceContainer(database); - addTearDown(container.dispose); - container.read(selectedAccountIdProvider.notifier).state = 'google:g'; - container.read(selectedTaskListIdProvider.notifier).state = 'google-list'; - container.read(selectedTaskIdProvider.notifier).state = 'google-task'; - await _pumpWorkspaceWithContainer(tester, container); - - expect(find.byTooltip('Task details'), findsNothing); - expect(find.byType(TaskDetailsPane), findsNothing); - expect(find.byType(TaskDetailsEditor), findsNothing); - - await _disposeWorkspace(tester); - }, - ); - - testWidgets('medium task selection opens Task Details overlay', ( - tester, - ) async { - _setMediumViewport(tester); - final database = AppDatabase(NativeDatabase.memory()); - addTearDown(database.close); - await _seedTwoAccountWorkspace(database); - - final container = _workspaceContainer(database); - addTearDown(container.dispose); - await _pumpWorkspaceWithContainer(tester, container); - - await tester.tap(find.text('Google task')); - await tester.pumpAndSettle(); - - expect(find.byType(TaskDetailsPane), findsOneWidget); - expect(find.byType(TaskDetailsEditor), findsOneWidget); - expect(find.byType(ModalBarrier), findsWidgets); - - await _disposeWorkspace(tester); - }); - - testWidgets('Escape closes medium Task Details overlay', (tester) async { - _setMediumViewport(tester); - final database = AppDatabase(NativeDatabase.memory()); - addTearDown(database.close); - await _seedTwoAccountWorkspace(database); - - final container = _workspaceContainer(database); - addTearDown(container.dispose); - await _pumpWorkspaceWithContainer(tester, container); - - await tester.tap(find.text('Google task')); - await tester.pumpAndSettle(); - expect(find.byType(TaskDetailsEditor), findsOneWidget); - - await tester.sendKeyEvent(LogicalKeyboardKey.escape); - await tester.pumpAndSettle(); - - expect(find.byType(TaskDetailsPane), findsNothing); - expect(find.byType(TaskDetailsEditor), findsNothing); - expect(container.read(selectedTaskIdProvider), 'google-task'); - - await _disposeWorkspace(tester); - }); - - testWidgets( - 'saving All Tasks pane edits task account, not selected account', - (tester) async { - _setTallWideViewport(tester); - final database = AppDatabase(NativeDatabase.memory()); - addTearDown(database.close); - await _seedTwoAccountWorkspace(database); - - final container = _workspaceContainer(database); - addTearDown(container.dispose); - await _pumpWorkspaceWithContainer(tester, container); - - expect(container.read(selectedAccountIdProvider), 'google:g'); - - await tester.tap(find.text('Microsoft task')); - await tester.pumpAndSettle(); - await tester.enterText( - find - .descendant( - of: find.byType(TaskDetailsPane), - matching: find.byType(TextField), - ) - .first, - 'Renamed Microsoft', - ); - await tester.pump(); - await tester.tap(find.text('Save')); - await tester.pumpAndSettle(); - - final microsoftTask = - await (database.select(database.tasks)..where( - (row) => - row.accountId.equals('microsoft:m') & - row.id.equals('microsoft-task'), - )) - .getSingle(); - final googleTask = - await (database.select(database.tasks)..where( - (row) => - row.accountId.equals('google:g') & - row.id.equals('google-task'), - )) - .getSingle(); - - expect(microsoftTask.title, 'Renamed Microsoft'); - expect(googleTask.title, 'Google task'); - - await _disposeWorkspace(tester); - }, - ); - - testWidgets('list-mode task opens details pane scoped to selected account', ( - tester, - ) async { - _setWideViewport(tester); - final database = AppDatabase(NativeDatabase.memory()); - addTearDown(database.close); - await _seedTwoAccountWorkspace(database); - - final container = _workspaceContainer(database); - addTearDown(container.dispose); - container.read(allTasksModeProvider.notifier).state = false; - container.read(selectedAccountIdProvider.notifier).state = 'google:g'; - container.read(selectedTaskListIdProvider.notifier).state = 'google-list'; - await tester.pumpWidget( - UncontrolledProviderScope( - container: container, - child: localizedTestApp( - child: const TasksWorkspace(selectedListId: 'google-list'), - ), - ), - ); - await tester.pumpAndSettle(); - - await tester.tap(find.text('Google task')); - await tester.pumpAndSettle(); - - expect(find.byType(TaskDetailsPane), findsOneWidget); - expect(find.byType(TaskDetailsEditor), findsOneWidget); - expect(container.read(selectedAccountIdProvider), 'google:g'); - expect(container.read(selectedTaskListIdProvider), 'google-list'); - expect(container.read(selectedTaskIdProvider), 'google-task'); - expect(container.read(allTasksModeProvider), isFalse); - - await _disposeWorkspace(tester); - }); - - testWidgets( - 'Refresh list invokes sync instead of task list metadata refresh', - (tester) async { - _setCompactViewport(tester); - final syncEngine = _FakeSyncEngine(); - final listsRepository = _FakeTaskListsRepository(); - - await tester.pumpWidget( - ProviderScope( - overrides: [ - taskListsRepositoryProvider.overrideWithValue(listsRepository), - tasksRepositoryProvider.overrideWithValue(_FakeTasksRepository()), - syncEngineProvider.overrideWithValue(syncEngine), - ], - child: localizedTestApp( - child: const TasksWorkspace(selectedListId: 'list-1'), - ), - ), - ); - await tester.pumpAndSettle(); - - await tester.tap(find.byTooltip('Refresh list')); - await tester.pumpAndSettle(); - - expect(syncEngine.incrementalSyncCalls, 1); - expect(listsRepository.refreshTaskListCalls, 0); - expect(find.text('List refreshed.'), findsOneWidget); - await _disposeWorkspace(tester); - }, - ); - - testWidgets('Refresh list pulls remote completed task into local tree', ( - tester, - ) async { - _setCompactViewport(tester); - final database = AppDatabase(NativeDatabase.memory()); - addTearDown(database.close); - await _insertAccount(database); - await database.taskListsDao.upsertTaskList(_localTaskList()); - await database.tasksDao.upsertTask( - _localTask(status: const Value('needsAction')), - ); - final apiClient = _FakeGoogleTasksApiClient() - ..taskListsPages = [ - TaskListsPageDto(items: [_taskListDto('list-1')], rawJson: const {}), - ] - ..taskPages['list-1'] = [ - TasksPageDto( - items: [ - TaskDto( - id: 'task-2', - title: 'Task 2', - status: 'completed', - completed: DateTime.utc(2026, 6, 4, 12), - rawJson: const { - 'id': 'task-2', - 'title': 'Task 2', - 'status': 'completed', - 'completed': '2026-06-04T12:00:00.000Z', - }, - ), - ], - rawJson: const {}, - ), - ]; - - await _pumpWorkspace( - tester, - database: database, - syncEngine: SyncEngine( - database: database, - apiClient: apiClient, - accountId: 'account', - nowUtc: () => DateTime.utc(2026, 6, 4), - ), - ); - - expect(find.text('Task 2'), findsOneWidget); - expect( - tester.widget(find.byType(YaruCheckbox)).value, - isFalse, - ); - - await tester.tap(find.byTooltip('Refresh list')); - await tester.pumpAndSettle(); - - final task = - await (database.select(database.tasks)..where( - (row) => - row.accountId.equals('account') & - row.taskListId.equals('list-1') & - row.id.equals('task-2'), - )) - .getSingle(); - expect(task.status, 'completed'); - expect(task.localDirty, isFalse); - expect(apiClient.listTasksPageCalls, 1); - expect( - tester.widget(find.byType(YaruCheckbox)).value, - isTrue, - ); - await _disposeWorkspace(tester); - }); - - testWidgets('search filters tasks by title in list mode', (tester) async { - _setCompactViewport(tester); - final database = AppDatabase(NativeDatabase.memory()); - addTearDown(database.close); - await _insertAccount(database); - await database.taskListsDao.upsertTaskList(_localTaskList()); - await database.tasksDao.upsertTask( - _localTask( - id: 'task-alpha', - title: 'Alpha plan', - status: const Value('needsAction'), - ), - ); - await database.tasksDao.upsertTask( - _localTask( - id: 'task-beta', - title: 'Beta task', - status: const Value('needsAction'), - ), - ); - - await _pumpWorkspace( - tester, - database: database, - syncEngine: _FakeSyncEngine(), - ); - - await tester.enterText(find.byType(TextField).first, 'alpha'); - await tester.pumpAndSettle(); - - expect(find.text('Alpha plan'), findsOneWidget); - expect(find.text('Beta task'), findsNothing); - await _disposeWorkspace(tester); - }); - - testWidgets('search filters tasks by notes and body in list mode', ( - tester, - ) async { - _setCompactViewport(tester); - final database = AppDatabase(NativeDatabase.memory()); - addTearDown(database.close); - await _insertAccount(database); - await database.taskListsDao.upsertTaskList(_localTaskList()); - await database.tasksDao.upsertTask( - _localTask( - id: 'task-notes', - title: 'General task', - notes: const Value('Private note marker'), - status: const Value('needsAction'), - ), - ); - await database.tasksDao.upsertTask( - _localTask( - id: 'task-body', - title: 'Body task', - bodyContent: const Value('Body marker'), - status: const Value('needsAction'), - ), - ); - - await _pumpWorkspace( - tester, - database: database, - syncEngine: _FakeSyncEngine(), - ); - - await tester.enterText(find.byType(TextField).first, 'body marker'); - await tester.pumpAndSettle(); - - expect(find.text('Body task'), findsOneWidget); - expect(find.text('General task'), findsNothing); - await _disposeWorkspace(tester); - }); - - testWidgets('search works in All Tasks mode and clearing restores rows', ( - tester, - ) async { - _setWideViewport(tester); - final database = AppDatabase(NativeDatabase.memory()); - addTearDown(database.close); - await _seedTwoAccountWorkspace(database); - - final container = _workspaceContainer(database); - addTearDown(container.dispose); - await _pumpWorkspaceWithContainer(tester, container); - - await tester.enterText(find.byType(TextField).first, 'microsoft'); - await tester.pumpAndSettle(); - - expect(find.text('Microsoft task'), findsOneWidget); - expect(find.text('Google task'), findsNothing); - - await tester.tap(find.byIcon(YaruIcons.edit_clear).first); - await tester.pumpAndSettle(); - - expect(find.text('Microsoft task'), findsOneWidget); - expect(find.text('Google task'), findsOneWidget); - await _disposeWorkspace(tester); - }); - - testWidgets('empty search results do not render boxed call to action', ( - tester, - ) async { - _setWideViewport(tester); - final database = AppDatabase(NativeDatabase.memory()); - addTearDown(database.close); - await _seedTwoAccountWorkspace(database); - - final container = _workspaceContainer(database); - addTearDown(container.dispose); - await _pumpWorkspaceWithContainer(tester, container); - - await tester.enterText(find.byType(TextField).first, 'no matching task'); - await tester.pumpAndSettle(); - - final taskTree = find.byType(TaskTreeView); - expect( - find.descendant(of: taskTree, matching: find.byType(YaruInfoBox)), - findsNothing, - ); - expect( - find.descendant(of: taskTree, matching: find.text('No tasks yet')), - findsNothing, - ); - expect( - find.descendant(of: taskTree, matching: find.text('No tasks.')), - findsOneWidget, - ); - await _disposeWorkspace(tester); - }); - - testWidgets('All Tasks groups tasks by due bucket', (tester) async { - _setWideViewport(tester); - final database = AppDatabase(NativeDatabase.memory()); - addTearDown(database.close); - await _seedTwoAccountWorkspace(database); - - final container = _workspaceContainer(database); - addTearDown(container.dispose); - await _pumpWorkspaceWithContainer(tester, container); - - expect(find.text('Overdue'), findsOneWidget); - expect(find.text('Today'), findsOneWidget); - expect( - tester.getTopLeft(find.text('Overdue')).dy, - lessThan(tester.getTopLeft(find.text('Today')).dy), - ); - await _disposeWorkspace(tester); - }); - - testWidgets( - 'All Tasks orders buckets and rows by due, title, source, and id', - (tester) async { - _setTallWideViewport(tester); - final database = AppDatabase(NativeDatabase.memory()); - addTearDown(database.close); - await _insertAccount( - database, - id: 'google:g', - provider: TaskProvider.google, - displayName: 'Google User', - email: 'google@example.com', - ); - await _insertAccount( - database, - id: 'microsoft:m', - provider: TaskProvider.microsoft, - displayName: 'Microsoft User', - email: 'microsoft@example.com', - ); - await database.taskListsDao.upsertTaskList( - _localTaskList( - accountId: 'google:g', - id: 'google-list', - title: 'Google Inbox', - ), - ); - await database.taskListsDao.upsertTaskList( - _localTaskList( - accountId: 'microsoft:m', - id: 'microsoft-list', - title: 'Microsoft Tasks', - ), - ); - await database.tasksDao.upsertTask( - _localTask( - accountId: 'google:g', - taskListId: 'google-list', - id: 'overdue', - title: 'Overdue task', - dueUtc: Value(_dueUtcForDayOffset(-1)), - status: const Value('needsAction'), - ), - ); - await database.tasksDao.upsertTask( - _localTask( - accountId: 'microsoft:m', - taskListId: 'microsoft-list', - id: 'today-alpha', - title: 'Alpha today', - dueUtc: Value(_dueUtcForDayOffset(0)), - status: const Value('needsAction'), - ), - ); - await database.tasksDao.upsertTask( - _localTask( - accountId: 'google:g', - taskListId: 'google-list', - id: 'today-same-a', - title: 'Same today', - dueUtc: Value(_dueUtcForDayOffset(0)), - status: const Value('needsAction'), - ), - ); - await database.tasksDao.upsertTask( - _localTask( - accountId: 'google:g', - taskListId: 'google-list', - id: 'today-same-b', - title: 'Same today', - dueUtc: Value(_dueUtcForDayOffset(0)), - status: const Value('needsAction'), - ), - ); - await database.tasksDao.upsertTask( - _localTask( - accountId: 'microsoft:m', - taskListId: 'microsoft-list', - id: 'today-same-microsoft', - title: 'Same today', - dueUtc: Value(_dueUtcForDayOffset(0)), - status: const Value('needsAction'), - ), - ); - await database.tasksDao.upsertTask( - _localTask( - accountId: 'google:g', - taskListId: 'google-list', - id: 'tomorrow', - title: 'Tomorrow task', - dueUtc: Value(_dueUtcForDayOffset(1)), - status: const Value('needsAction'), - ), - ); - await database.tasksDao.upsertTask( - _localTask( - accountId: 'microsoft:m', - taskListId: 'microsoft-list', - id: 'upcoming', - title: 'Upcoming task', - dueUtc: Value(_dueUtcForDayOffset(2)), - status: const Value('needsAction'), - ), - ); - await database.tasksDao.upsertTask( - _localTask( - accountId: 'google:g', - taskListId: 'google-list', - id: 'no-date', - title: 'No date task', - status: const Value('needsAction'), - ), - ); - await database.tasksDao.upsertTask( - _localTask( - accountId: 'microsoft:m', - taskListId: 'microsoft-list', - id: 'completed', - title: 'Completed task', - dueUtc: Value(_dueUtcForDayOffset(-1)), - status: const Value('completed'), - ), - ); - - final container = _workspaceContainer(database); - addTearDown(container.dispose); - await _pumpWorkspaceWithContainer(tester, container); - - expect( - _top(tester, find.text('Overdue')), - lessThan(_top(tester, find.text('Today'))), - ); - expect( - _top(tester, find.text('Today')), - lessThan(_top(tester, find.text('Tomorrow'))), - ); - expect( - _top(tester, find.text('Tomorrow')), - lessThan(_top(tester, find.text('Upcoming'))), - ); - expect( - _top(tester, find.text('Upcoming')), - lessThan(_top(tester, find.text('No date'))), - ); - expect( - _top(tester, find.text('No date')), - lessThan(_top(tester, find.text('Completed'))), - ); - expect( - _top(tester, find.text('Completed')), - lessThan( - _top( - tester, - find.byKey( - const ValueKey('task-row-microsoft:m/microsoft-list/completed'), - ), - ), - ), - ); - expect( - find.textContaining('Google · Google User · Google Inbox'), - findsWidgets, - ); - expect( - find.textContaining('Microsoft · Microsoft User · Microsoft Tasks'), - findsWidgets, - ); - expect( - _rowTop(tester, 'microsoft:m', 'microsoft-list', 'today-alpha'), - lessThan(_rowTop(tester, 'google:g', 'google-list', 'today-same-a')), - ); - expect( - _rowTop(tester, 'google:g', 'google-list', 'today-same-a'), - lessThan(_rowTop(tester, 'google:g', 'google-list', 'today-same-b')), - ); - expect( - _rowTop(tester, 'google:g', 'google-list', 'today-same-b'), - lessThan( - _rowTop( - tester, - 'microsoft:m', - 'microsoft-list', - 'today-same-microsoft', - ), - ), - ); - - await _disposeWorkspace(tester); - }, - ); - - testWidgets('Refresh failure is shown in a snackbar', (tester) async { - _setCompactViewport(tester); - final syncEngine = _FakeSyncEngine()..error = StateError('refresh failed'); - - await tester.pumpWidget( - ProviderScope( - overrides: [ - taskListsRepositoryProvider.overrideWithValue( - _FakeTaskListsRepository(), - ), - tasksRepositoryProvider.overrideWithValue(_FakeTasksRepository()), - syncEngineProvider.overrideWithValue(syncEngine), - ], - child: localizedTestApp( - child: const TasksWorkspace(selectedListId: 'list-1'), - ), - ), - ); - await tester.pumpAndSettle(); - - await tester.tap(find.byTooltip('Refresh list')); - await tester.pumpAndSettle(); - - expect(syncEngine.incrementalSyncCalls, 1); - expect(find.textContaining('Refresh failed:'), findsOneWidget); - expect(find.textContaining('refresh failed'), findsOneWidget); - await _disposeWorkspace(tester); - }); - - testWidgets('Clear completed is enabled for Google capabilities', ( - tester, - ) async { - _setCompactViewport(tester); - final tasksRepository = _FakeTasksRepository(); - - await tester.pumpWidget( - ProviderScope( - overrides: [ - selectedAccountCapabilitiesProvider.overrideWithValue( - googleTaskProviderCapabilities, - ), - taskListsRepositoryProvider.overrideWithValue( - _FakeTaskListsRepository(), - ), - tasksRepositoryProvider.overrideWithValue(tasksRepository), - syncEngineProvider.overrideWithValue(_FakeSyncEngine()), - ], - child: localizedTestApp( - child: const TasksWorkspace(selectedListId: 'list-1'), - ), - ), - ); - await tester.pumpAndSettle(); - - await tester.tap(find.byTooltip('Clear completed')); - await tester.pumpAndSettle(); - - expect(tasksRepository.clearCompletedCalls, ['list-1']); - await _disposeWorkspace(tester); - }); - - testWidgets('Clear completed is hidden for Microsoft capabilities', ( - tester, - ) async { - _setCompactViewport(tester); - final tasksRepository = _FakeTasksRepository(); - - await tester.pumpWidget( - ProviderScope( - overrides: [ - selectedAccountCapabilitiesProvider.overrideWithValue( - microsoftTaskProviderCapabilities, - ), - taskListsRepositoryProvider.overrideWithValue( - _FakeTaskListsRepository(), - ), - tasksRepositoryProvider.overrideWithValue(tasksRepository), - syncEngineProvider.overrideWithValue(_FakeSyncEngine()), - ], - child: localizedTestApp( - child: const TasksWorkspace(selectedListId: 'list-1'), - ), - ), - ); - await tester.pumpAndSettle(); - - expect(find.byTooltip('Clear completed'), findsNothing); - - expect(tasksRepository.clearCompletedCalls, isEmpty); - await _disposeWorkspace(tester); - }); -} - -void _setCompactViewport(WidgetTester tester) { - tester.view.physicalSize = const Size(600, 800); - tester.view.devicePixelRatio = 1; - addTearDown(() { - tester.view.resetPhysicalSize(); - tester.view.resetDevicePixelRatio(); - }); -} - -void _setMediumViewport(WidgetTester tester) { - tester.view.physicalSize = const Size(900, 800); - tester.view.devicePixelRatio = 1; - addTearDown(() { - tester.view.resetPhysicalSize(); - tester.view.resetDevicePixelRatio(); - }); -} - -void _setWideViewport(WidgetTester tester) { - tester.view.physicalSize = const Size(1280, 800); - tester.view.devicePixelRatio = 1; - addTearDown(() { - tester.view.resetPhysicalSize(); - tester.view.resetDevicePixelRatio(); - }); -} - -void _setTallWideViewport(WidgetTester tester) { - tester.view.physicalSize = const Size(1280, 1600); - tester.view.devicePixelRatio = 1; - addTearDown(() { - tester.view.resetPhysicalSize(); - tester.view.resetDevicePixelRatio(); - }); -} - -Future _disposeWorkspace(WidgetTester tester) async { - await tester.pumpWidget(const SizedBox.shrink()); - await tester.pump(const Duration(milliseconds: 1)); -} - -Future _pumpWorkspace( - WidgetTester tester, { - required AppDatabase database, - required SyncEngine syncEngine, -}) async { - await tester.pumpWidget( - ProviderScope( - overrides: [ - taskListsRepositoryProvider.overrideWithValue( - TaskListsRepository(database: database, accountId: 'account'), - ), - tasksRepositoryProvider.overrideWithValue( - TasksRepository(database: database, accountId: 'account'), - ), - syncEngineProvider.overrideWithValue(syncEngine), - ], - child: localizedTestApp( - child: const TasksWorkspace(selectedListId: 'list-1'), - ), - ), - ); - await tester.pumpAndSettle(); -} - -ProviderContainer _workspaceContainer(AppDatabase database) { - final container = ProviderContainer( - overrides: [ - databaseProvider.overrideWithValue(database), - accountsStreamProvider.overrideWith((ref) { - final query = database.select(database.accounts) - ..where((row) => row.authState.equals('signed_in')); - return query.watch().map( - (rows) => rows.map(AccountEntity.fromRow).toList(), - ); - }), - taskListsRepositoryProvider.overrideWithValue( - TaskListsRepository(database: database, accountId: 'google:g'), - ), - tasksRepositoryProvider.overrideWithValue( - TasksRepository(database: database, accountId: 'google:g'), - ), - taskListsRepositoryForAccountProvider.overrideWith((ref, accountId) { - return TaskListsRepository(database: database, accountId: accountId); - }), - tasksRepositoryForAccountProvider.overrideWith((ref, accountId) { - return TasksRepository(database: database, accountId: accountId); - }), - syncEngineProvider.overrideWithValue(null), - signedInSyncRunnerProvider.overrideWithValue((accountId, initial) async { - return; - }), - ], - ); - container.read(selectedAccountIdProvider.notifier).state = 'google:g'; - return container; -} - -ProviderContainer _workspaceContainerWithAccountSync( - AppDatabase database, - Map syncEngines, -) { - final apiClients = {}; - final container = ProviderContainer( - overrides: [ - databaseProvider.overrideWithValue(database), - accountsStreamProvider.overrideWith((ref) { - final query = database.select(database.accounts) - ..where((row) => row.authState.equals('signed_in')); - return query.watch().map( - (rows) => rows.map(AccountEntity.fromRow).toList(), - ); - }), - tasksRepositoryProvider.overrideWithValue( - TasksRepository(database: database, accountId: 'google:g'), - ), - tasksRepositoryForAccountProvider.overrideWith((ref, accountId) { - final apiClient = accountId.startsWith('microsoft:') - ? ref.watch( - microsoftAsGoogleTasksApiClientForAccountProvider(accountId), - ) - : ref.watch(googleTasksApiClientForAccountProvider(accountId)); - return TasksRepository( - database: database, - accountId: accountId, - apiClient: apiClient, - onMutationQueued: ref - .watch(pendingMutationSyncRequesterForAccountProvider(accountId)) - .request, - ); - }), - syncEngineProvider.overrideWithValue(null), - syncEngineForAccountFactoryProvider.overrideWithValue((accountId) { - return syncEngines[accountId]!; - }), - googleTasksApiClientForAccountProvider.overrideWith((ref, accountId) { - return apiClients.putIfAbsent(accountId, _FakeGoogleTasksApiClient.new); - }), - microsoftAsGoogleTasksApiClientForAccountProvider.overrideWith(( - ref, - accountId, - ) { - return apiClients.putIfAbsent(accountId, _FakeGoogleTasksApiClient.new); - }), - desktopNotificationBackendProvider.overrideWithValue( - _FakeNotificationBackend(), - ), - signedInSyncRunnerProvider.overrideWithValue((accountId, initial) async { - return; - }), - ], - ); - container.read(selectedAccountIdProvider.notifier).state = 'google:g'; - return container; -} - -Future _pumpWorkspaceWithContainer( - WidgetTester tester, - ProviderContainer container, -) async { - await tester.pumpWidget( - UncontrolledProviderScope( - container: container, - child: localizedTestApp(child: const TasksWorkspace()), - ), - ); - await tester.pumpAndSettle(); -} - -Future _tapTaskCheckbox(WidgetTester tester, String taskTitle) async { - final tile = find.ancestor( - of: find.text(taskTitle), - matching: find.byType(YaruListTile), - ); - await tester.tap( - find.descendant(of: tile, matching: find.byType(YaruCheckbox)), - ); - await tester.pump(); -} - -Future _waitForQueuedMutationSync(WidgetTester tester) async { - await tester.pump(); - await tester.pump(const Duration(milliseconds: 350)); - await tester.pump(); -} - -VoidCallback? _toolbarActionOnPressed( - WidgetTester tester, { - required String tooltip, - required String label, -}) { - final iconButton = find.byWidgetPredicate( - (widget) => widget is YaruIconButton && widget.tooltip == tooltip, - ); - if (iconButton.evaluate().isNotEmpty) { - return tester.widget(iconButton).onPressed; - } - - final button = find.ancestor( - of: find.text(label), - matching: find.byWidgetPredicate((widget) => widget is ButtonStyleButton), - ); - return tester.widget(button).onPressed; -} - -double _top(WidgetTester tester, Finder finder) { - return tester.getTopLeft(finder).dy; -} - -double _rowTop( - WidgetTester tester, - String accountId, - String taskListId, - String taskId, -) { - return _top( - tester, - find.byKey(ValueKey('task-row-$accountId/$taskListId/$taskId')), - ); -} - -Future _seedTwoAccountWorkspace(AppDatabase database) async { - await _insertAccount( - database, - id: 'google:g', - provider: TaskProvider.google, - displayName: 'Google User', - email: 'google@example.com', - ); - await _insertAccount( - database, - id: 'microsoft:m', - provider: TaskProvider.microsoft, - displayName: 'Microsoft User', - email: 'microsoft@example.com', - ); - await database.taskListsDao.upsertTaskList( - _localTaskList( - accountId: 'google:g', - id: 'google-list', - title: 'Google Inbox', - ), - ); - await database.taskListsDao.upsertTaskList( - _localTaskList( - accountId: 'microsoft:m', - id: 'microsoft-list', - title: 'Microsoft Tasks', - ), - ); - await database.tasksDao.upsertTask( - _localTask( - accountId: 'google:g', - taskListId: 'google-list', - id: 'google-task', - title: 'Google task', - dueUtc: Value(_dueUtcForDayOffset(0)), - status: const Value('needsAction'), - ), - ); - await database.tasksDao.upsertTask( - _localTask( - accountId: 'microsoft:m', - taskListId: 'microsoft-list', - id: 'microsoft-task', - title: 'Microsoft task', - dueUtc: Value(_dueUtcForDayOffset(-1)), - status: const Value('needsAction'), - ), - ); -} - -String _dueUtcForDayOffset(int dayOffset) { - final now = DateTime.now(); - final date = DateTime( - now.year, - now.month, - now.day, - ).add(Duration(days: dayOffset)); - final year = date.year.toString().padLeft(4, '0'); - final month = date.month.toString().padLeft(2, '0'); - final day = date.day.toString().padLeft(2, '0'); - return '$year-$month-${day}T00:00:00.000Z'; -} - -Future _insertAccount( - AppDatabase database, { - String id = 'account', - TaskProvider provider = TaskProvider.google, - String? displayName, - String? email, -}) { - return database - .into(database.accounts) - .insert( - AccountsCompanion.insert( - id: id, - provider: Value(provider.storageValue), - displayName: Value(displayName), - email: Value(email), - authState: const Value('signed_in'), - createdAtUtc: _now, - updatedAtUtc: _now, - ), - ); -} - -AccountEntity _accountEntity({ - required String id, - required TaskProvider provider, -}) { - return AccountEntity(id: id, provider: provider, authState: 'signed_in'); -} - -TaskListsCompanion _localTaskList({ - String accountId = 'account', - String id = 'list-1', - String title = 'Inbox', -}) { - return TaskListsCompanion.insert( - accountId: accountId, - id: id, - title: title, - rawJson: '{}', - createdLocalAtUtc: _now, - updatedLocalAtUtc: _now, - ); -} - -TasksCompanion _localTask({ - String accountId = 'account', - String taskListId = 'list-1', - String id = 'task-2', - String title = 'Task 2', - Value notes = const Value.absent(), - Value bodyContent = const Value.absent(), - Value dueUtc = const Value.absent(), - required Value status, -}) { - return TasksCompanion.insert( - accountId: accountId, - taskListId: taskListId, - id: id, - title: title, - notes: notes, - status: status, - dueUtc: dueUtc, - bodyContent: bodyContent, - rawJson: '{"id":"$id","title":"$title"}', - createdLocalAtUtc: _now, - updatedLocalAtUtc: _now, - ); -} - -TaskListDto _taskListDto(String id) { - return TaskListDto( - id: id, - title: 'Inbox', - rawJson: {'id': id, 'title': 'Inbox'}, - ); -} - -const _now = '2026-06-04T00:00:00.000Z'; - -class _FakeSyncEngine implements SyncEngine { - var incrementalSyncCalls = 0; - Object? error; - - @override - Future fullSync() async {} - - @override - Future incrementalSync() async { - incrementalSyncCalls += 1; - final failure = error; - if (failure != null) { - throw failure; - } - } -} - -class _FakeTaskListsRepository implements TaskListsRepository { - var refreshTaskListCalls = 0; - - @override - Stream> watchTaskLists() { - return Stream.value(const [ - TaskListEntity( - accountId: 'account', - id: 'list-1', - title: 'Inbox', - localDirty: false, - pendingDelete: false, - rawJson: '{}', - ), - ]); - } - - @override - Stream watchTaskList(String id) { - return Stream.value( - const TaskListEntity( - accountId: 'account', - id: 'list-1', - title: 'Inbox', - localDirty: false, - pendingDelete: false, - rawJson: '{}', - ), - ); - } - - @override - Future refreshTaskList(String id) async { - refreshTaskListCalls += 1; - } - - @override - dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); -} - -class _FakeTasksRepository implements TasksRepository { - final clearCompletedCalls = []; - - @override - Stream> watchTaskTree( - String taskListId, - TaskViewFilter filter, - ) { - return Stream.value(const []); - } - - @override - Future clearCompleted(String taskListId) async { - clearCompletedCalls.add(taskListId); - } - - @override - dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); -} - -class _FakeGoogleTasksApiClient implements GoogleTasksApiClient { - var taskListsPages = []; - final taskPages = >{}; - var listTasksPageCalls = 0; - var _taskListPageIndex = 0; - final _taskPageIndexes = {}; - - @override - Future listTaskListsPage({ - int maxResults = 1000, - String? pageToken, - }) async { - return taskListsPages[_taskListPageIndex++]; - } - - @override - Future listTasksPage({ - required String taskListId, - DateTime? completedMax, - DateTime? completedMin, - DateTime? dueMax, - DateTime? dueMin, - int maxResults = 100, - String? pageToken, - bool showCompleted = true, - bool showDeleted = false, - bool showHidden = false, - DateTime? updatedMin, - bool showAssigned = false, - }) async { - listTasksPageCalls += 1; - final index = _taskPageIndexes.update( - taskListId, - (value) => value + 1, - ifAbsent: () => 0, - ); - return taskPages[taskListId]![index]; - } - - @override - dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); -} - -class _FakeNotificationBackend implements DesktopNotificationBackend { - @override - Future close() async {} - - @override - Future notify( - String summary, { - String body = '', - List hints = const [], - List actions = const [], - DesktopNotificationActionHandler? onAction, - }) async {} -} diff --git a/test/google_tasks/oauth/token_exchange_test.dart b/test/google_tasks/oauth/token_exchange_test.dart index 7da3b8b..50772bb 100644 --- a/test/google_tasks/oauth/token_exchange_test.dart +++ b/test/google_tasks/oauth/token_exchange_test.dart @@ -626,7 +626,7 @@ void main() { loopbackFlow: OAuthLoopbackFlow(), ); - await service.signOutAccount('google-a'); + await service.clearLocalSession(accountId: 'google-a'); expect(await tokenStore.readTokenSet('google-a'), isNull); expect(await tokenStore.readTokenSet('google-b'), isNotNull); @@ -665,7 +665,8 @@ void main() { await service.revokeAndSignOutAccount('google-a'); - expect(captured.url.queryParameters['token'], 'refresh'); + expect(captured.url.queryParameters, isEmpty); + expect(Uri.splitQueryString(captured.body)['token'], 'refresh'); expect(await tokenStore.readTokenSet('google-a'), isNull); expect(await tokenStore.readTokenSet('google-b'), isNotNull); expect(await tokenStore.readTokenSet('microsoft:m'), isNotNull); @@ -673,6 +674,36 @@ void main() { }, ); + test( + 'revokeAuthorization reports non-success without clearing credentials', + () async { + final tokenStore = InMemoryOAuthTokenStore(); + await tokenStore.saveTokenSet( + 'google-a', + const OAuthTokenSetFixture().tokenSet, + ); + final service = OAuthService( + config: _config, + httpClient: MockClient((request) async => http.Response('', 503)), + tokenStore: tokenStore, + loopbackFlow: OAuthLoopbackFlow(), + ); + + await expectLater( + service.revokeAuthorization('google-a'), + throwsA( + isA().having( + (error) => error.code, + 'code', + 'OAuthRevocationFailed', + ), + ), + ); + + expect(await tokenStore.readTokenSet('google-a'), isNotNull); + }, + ); + test('refresh 400 clears only account being refreshed', () async { final tokenStore = InMemoryOAuthTokenStore(); await tokenStore.saveTokenSet( diff --git a/test/platform/linux_header_bar_configuration_synchronizer_test.dart b/test/platform/linux_header_bar_configuration_synchronizer_test.dart new file mode 100644 index 0000000..122220c --- /dev/null +++ b/test/platform/linux_header_bar_configuration_synchronizer_test.dart @@ -0,0 +1,135 @@ +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', + sidebar: 'Sidebar', + back: 'Back', + settings: 'Settings', + keyboardShortcuts: 'Keyboard shortcuts', + aboutBusyMax: 'About BusyMax', + ), + sidebarWidth: 300, + theme: BusyMaxHeaderBarTheme( + preferDark: dark, + 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, + modalBarrierColor: Colors.black54, + ), + ); +} diff --git a/test/platform/linux_header_bar_service_test.dart b/test/platform/linux_header_bar_service_test.dart index 4d503e0..811f319 100644 --- a/test/platform/linux_header_bar_service_test.dart +++ b/test/platform/linux_header_bar_service_test.dart @@ -100,17 +100,7 @@ void main() { backgroundColor: Color(0xFF1D1D20), sidebarBackgroundColor: Color(0xFF2E2E32), foregroundColor: Color(0xFFFFFFFF), - mutedForegroundColor: Color.fromRGBO(255, 255, 255, 0.70), - disabledForegroundColor: Color.fromRGBO(255, 255, 255, 0.38), - controlColor: Color.fromRGBO(255, 255, 255, 0.10), - controlHoverColor: Color.fromRGBO(255, 255, 255, 0.14), - controlActiveColor: Color.fromRGBO(255, 255, 255, 0.18), - accentColor: Color(0xFF2E7D32), - accentForegroundColor: Color(0xFFFFFFFF), - popoverBackgroundColor: Color(0xFF36363A), - borderColor: Color.fromRGBO(0, 0, 6, 0.75), sidebarBorderColor: Color.fromRGBO(0, 0, 6, 0.75), - shadeColor: Color.fromRGBO(0, 0, 6, 0.25), modalBarrierColor: Color.fromRGBO(0, 0, 0, 0.32), ), ); @@ -145,32 +135,17 @@ void main() { expect(calls[3].arguments, containsPair('visible', true)); expect(calls[3].arguments, containsPair('canContinue', true)); expect(calls[3].arguments, containsPair('continueLabel', 'Continue')); - expect(calls.last.arguments, containsPair('preferDark', true)); - expect(calls.last.arguments, containsPair('backgroundColor', '#1D1D20')); expect( calls.last.arguments, - containsPair('windowBackgroundColor', '#18181B'), - ); - expect( - calls.last.arguments, - containsPair('sidebarBackgroundColor', '#2E2E32'), - ); - expect( - calls.last.arguments, - containsPair('controlHoverColor', 'rgba(255,255,255,0.14)'), - ); - expect( - calls.last.arguments, - containsPair('controlActiveColor', 'rgba(255,255,255,0.18)'), - ); - expect(calls.last.arguments, containsPair('accentColor', '#2E7D32')); - expect( - calls.last.arguments, - containsPair('accentForegroundColor', '#FFFFFF'), - ); - expect( - calls.last.arguments, - containsPair('sidebarBorderColor', 'rgba(0,0,6,0.75)'), + equals({ + 'preferDark': true, + 'windowBackgroundColor': '#18181B', + 'backgroundColor': '#1D1D20', + 'sidebarBackgroundColor': '#2E2E32', + 'foregroundColor': '#FFFFFF', + 'sidebarBorderColor': 'rgba(0,0,6,0.75)', + 'modalBarrierColor': 'rgba(0,0,0,0.32)', + }), ); }); @@ -601,11 +576,21 @@ void main() { test('native header menus delegate row focus modality to GTK', () { final source = File('linux/runner/my_application.cc').readAsStringSync(); - expect(source, contains('button.busymax-header-view-mode-button:focus {"')); - expect(source, contains('"box-shadow: inset 0 0 0 2px %s;"')); + 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, contains('GTK_STYLE_CLASS_SUGGESTED_ACTION')); + 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('tooltip.background'))); 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'))); From d8fdb89211e910ce840d1dae24e665b96d54edf0 Mon Sep 17 00:00:00 2001 From: albert Date: Thu, 23 Jul 2026 14:49:53 -0700 Subject: [PATCH 09/73] Add libhandy dependency and update application to use Handy for window management --- .github/workflows/flutter-linux.yml | 1 + README.md | 2 + docs/beta_snap_release.md | 3 +- linux/CMakeLists.txt | 1 + linux/runner/CMakeLists.txt | 1 + linux/runner/my_application.cc | 78 +++++++++++-------------- snap/snapcraft.yaml | 1 + test/app/native_ui_audit_test.dart | 90 ++++++++++++++++++++++++++++- 8 files changed, 128 insertions(+), 49 deletions(-) diff --git a/.github/workflows/flutter-linux.yml b/.github/workflows/flutter-linux.yml index c305212..25b9da6 100644 --- a/.github/workflows/flutter-linux.yml +++ b/.github/workflows/flutter-linux.yml @@ -23,6 +23,7 @@ jobs: ninja-build \ pkg-config \ libgtk-3-dev \ + libhandy-1-dev \ libstdc++-12-dev \ libsecret-1-dev \ libjsoncpp-dev \ diff --git a/README.md b/README.md index 08cdca8..7540778 100644 --- a/README.md +++ b/README.md @@ -78,6 +78,8 @@ It brings calendar events and tasks into a native-feeling Linux desktop interfac ## Prerequisites - Flutter: https://docs.flutter.dev/install +- GTK 3 and libhandy development packages (`libgtk-3-dev` and + `libhandy-1-dev` on Ubuntu/Debian) - `GOOGLE_OAUTH_CLIENT_ID` and `GOOGLE_OAUTH_CLIENT_SECRET`, see [Google Setup](docs/google_setup.md) - `MICROSOFT_OAUTH_CLIENT_ID`, see [Microsoft Setup](docs/microsoft_setup.md) diff --git a/docs/beta_snap_release.md b/docs/beta_snap_release.md index ef14f09..9c3e6ee 100644 --- a/docs/beta_snap_release.md +++ b/docs/beta_snap_release.md @@ -7,7 +7,8 @@ OAuth-enabled Flutter build must run first. ## Prepare Required: Linux amd64, the Flutter Linux toolchain, snapd, Snapcraft with LXD, -`unsquashfs` from `squashfs-tools`, and BusyMax Store access for publishing. +the `libhandy-1-dev` build package, `unsquashfs` from `squashfs-tools`, and +BusyMax Store access for publishing. Check that the package versions match: diff --git a/linux/CMakeLists.txt b/linux/CMakeLists.txt index 1e0ed75..3756fe6 100644 --- a/linux/CMakeLists.txt +++ b/linux/CMakeLists.txt @@ -53,6 +53,7 @@ add_subdirectory(${FLUTTER_MANAGED_DIR}) # System-level dependencies. find_package(PkgConfig REQUIRED) pkg_check_modules(GTK REQUIRED IMPORTED_TARGET gtk+-3.0) +pkg_check_modules(HANDY REQUIRED IMPORTED_TARGET libhandy-1) # Application build; see runner/CMakeLists.txt. add_subdirectory("runner") diff --git a/linux/runner/CMakeLists.txt b/linux/runner/CMakeLists.txt index e97dabc..ccbb420 100644 --- a/linux/runner/CMakeLists.txt +++ b/linux/runner/CMakeLists.txt @@ -22,5 +22,6 @@ add_definitions(-DAPPLICATION_ID="${APPLICATION_ID}") # Add dependency libraries. Add any application-specific dependencies here. target_link_libraries(${BINARY_NAME} PRIVATE flutter) target_link_libraries(${BINARY_NAME} PRIVATE PkgConfig::GTK) +target_link_libraries(${BINARY_NAME} PRIVATE PkgConfig::HANDY) target_include_directories(${BINARY_NAME} PRIVATE "${CMAKE_SOURCE_DIR}") diff --git a/linux/runner/my_application.cc b/linux/runner/my_application.cc index 8dd7e9e..549dacf 100644 --- a/linux/runner/my_application.cc +++ b/linux/runner/my_application.cc @@ -2,6 +2,7 @@ #include #include +#include #include #include #include @@ -83,6 +84,7 @@ struct _MyApplication { gboolean header_bar_modal_barrier_visible; GtkWindow* main_window; GtkWidget* flutter_view; + GtkWidget* titlebar_handle; GtkWidget* titlebar_box; GtkHeaderBar* header_bar; GtkWidget* header_start_box; @@ -487,8 +489,6 @@ static void refresh_header_bar_css(MyApplication* self) { "background-color: %s;" "background-image: none;" "}" - ".busymax-titlebar," - ".busymax-titlebar:backdrop," "headerbar.busymax-flat-headerbar," "headerbar.busymax-flat-headerbar:backdrop {" "background-color: %s;" @@ -513,11 +513,6 @@ static void refresh_header_bar_css(MyApplication* self) { ".busymax-titlebar .busymax-header-title {" "color: %s;" "}" - ".busymax-titlebar.busymax-modal-barrier," - ".busymax-titlebar.busymax-modal-barrier:backdrop {" - "background-color: %s;" - "background-image: linear-gradient(%s, %s);" - "}" ".busymax-titlebar.busymax-modal-barrier .busymax-header-brand," ".busymax-titlebar.busymax-modal-barrier " ".busymax-header-brand:backdrop {" @@ -534,7 +529,6 @@ static void refresh_header_bar_css(MyApplication* self) { window_background_color, background_color, foreground_color, sidebar_background_color, foreground_color, sidebar_border_color, foreground_color, foreground_color, - background_color, modal_barrier_color, modal_barrier_color, sidebar_background_color, modal_barrier_color, modal_barrier_color, background_color, modal_barrier_color, modal_barrier_color); @@ -594,14 +588,16 @@ static void set_header_bar_theme(MyApplication* self, FlValue* args) { static void set_header_bar_modal_barrier_visible(MyApplication* self, gboolean visible) { self->header_bar_modal_barrier_visible = visible; - if (self->titlebar_box != nullptr && GTK_IS_WIDGET(self->titlebar_box)) { - GtkStyleContext* context = gtk_widget_get_style_context(self->titlebar_box); + 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, "busymax-modal-barrier"); } else { gtk_style_context_remove_class(context, "busymax-modal-barrier"); } - gtk_widget_set_sensitive(self->titlebar_box, !visible); + gtk_widget_set_sensitive(self->titlebar_handle, !visible); } } @@ -1429,13 +1425,11 @@ static void set_header_localized_labels(MyApplication* self, FlValue* args) { rebuild_header_menu_models(self); } -static GtkWidget* create_busymax_header_bar(MyApplication* 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); - gtk_style_context_add_class(gtk_widget_get_style_context(self->titlebar_box), - "busymax-titlebar"); initialize_header_menu_actions(self); GtkHeaderBar* header_bar = GTK_HEADER_BAR(gtk_header_bar_new()); @@ -1681,6 +1675,17 @@ static GtkWidget* create_busymax_header_bar(MyApplication* self) { return self->titlebar_box; } +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"); + gtk_container_add(GTK_CONTAINER(self->titlebar_handle), + create_busymax_titlebar(self)); + return self->titlebar_handle; +} + static gboolean show_header_create_menu(MyApplication* self) { if (self->header_bar_modal_barrier_visible || !self->header_schedule_controls_visible || @@ -2748,35 +2753,13 @@ static void my_application_activate(GApplication* application) { return; } - GtkWindow* window = - GTK_WINDOW(gtk_application_window_new(GTK_APPLICATION(application))); + 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"); - // Use a header bar when running in GNOME as this is the common style used - // by applications and is the setup most users will be using (e.g. Ubuntu - // desktop). - // If running on X and not using GNOME then just use a traditional title bar - // in case the window manager does more exotic layout, e.g. tiling. - // If running on Wayland assume the header bar will work (may need changing - // if future cases occur). - gboolean use_header_bar = TRUE; -#ifdef GDK_WINDOWING_X11 - GdkScreen* screen = gtk_window_get_screen(window); - if (GDK_IS_X11_SCREEN(screen)) { - const gchar* wm_name = gdk_x11_screen_get_window_manager_name(screen); - if (g_strcmp0(wm_name, "GNOME Shell") != 0) { - use_header_bar = FALSE; - } - } -#endif - if (use_header_bar) { - GtkWidget* titlebar = create_busymax_header_bar(self); - gtk_widget_show_all(titlebar); - gtk_window_set_titlebar(window, titlebar); - } else { - gtk_window_set_title(window, kApplicationDisplayName); - } + 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) { @@ -2802,7 +2785,13 @@ static void my_application_activate(GApplication* application) { track_widget_pointer(&self->flutter_view, GTK_WIDGET(view)); set_main_flutter_view_background(self); gtk_widget_show(GTK_WIDGET(view)); - gtk_container_add(GTK_CONTAINER(window), 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); // Show the window when Flutter renders. // Requires the view to be realized so we can start rendering. @@ -2847,11 +2836,8 @@ static gboolean my_application_local_command_line(GApplication* application, // Implements GApplication::startup. static void my_application_startup(GApplication* application) { - // MyApplication* self = MY_APPLICATION(object); - - // Perform any actions required at application startup. - G_APPLICATION_CLASS(my_application_parent_class)->startup(application); + hdy_init(); } // Implements GApplication::shutdown. @@ -2889,6 +2875,7 @@ static void my_application_dispose(GObject* object) { g_clear_object(&self->header_menu_action_group); self->main_window = nullptr; clear_widget_pointer(&self->flutter_view); + clear_widget_pointer(&self->titlebar_handle); clear_widget_pointer(&self->titlebar_box); clear_header_bar_pointer(self); clear_widget_pointer(&self->header_start_box); @@ -2982,6 +2969,7 @@ static void my_application_init(MyApplication* self) { self->header_bar_modal_barrier_visible = FALSE; self->main_window = nullptr; self->flutter_view = nullptr; + self->titlebar_handle = nullptr; self->titlebar_box = nullptr; self->header_bar = nullptr; self->header_start_box = nullptr; diff --git a/snap/snapcraft.yaml b/snap/snapcraft.yaml index d3cdf54..f940cfb 100644 --- a/snap/snapcraft.yaml +++ b/snap/snapcraft.yaml @@ -58,6 +58,7 @@ parts: plugin: dump source: build/linux/x64/release/bundle stage-packages: + - libhandy-1-0 - liblzma5 - libsecret-1-0 override-prime: | diff --git a/test/app/native_ui_audit_test.dart b/test/app/native_ui_audit_test.dart index a828303..79b3ab2 100644 --- a/test/app/native_ui_audit_test.dart +++ b/test/app/native_ui_audit_test.dart @@ -28,6 +28,68 @@ void main() { ); }); + test( + 'main window delegates four-corner clipping and window states to Handy', + () { + final source = File( + 'linux/runner/my_application.cc', + ).readAsStringSync(); + final linuxCmake = File('linux/CMakeLists.txt').readAsStringSync(); + final runnerCmake = File( + 'linux/runner/CMakeLists.txt', + ).readAsStringSync(); + final workflow = File( + '.github/workflows/flutter-linux.yml', + ).readAsStringSync(); + final snapcraft = File('snap/snapcraft.yaml').readAsStringSync(); + final app = File('lib/src/app/busymax_app.dart').readAsStringSync(); + final activateStart = source.indexOf( + 'static void my_application_activate(GApplication* application)', + ); + final activateEnd = source.indexOf( + 'static void my_application_startup(GApplication* application)', + activateStart, + ); + + expect(activateStart, isNonNegative); + expect(activateEnd, greaterThan(activateStart)); + final activateBody = source.substring(activateStart, activateEnd); + + expect(source, contains('#include ')); + 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('gtk_window_set_titlebar('))); + expect( + linuxCmake, + contains( + 'pkg_check_modules(HANDY REQUIRED IMPORTED_TARGET libhandy-1)', + ), + ); + expect( + runnerCmake, + contains( + 'target_link_libraries(\${BINARY_NAME} PRIVATE PkgConfig::HANDY)', + ), + ); + expect(workflow, contains('libhandy-1-dev')); + expect(snapcraft, contains('- libhandy-1-0')); + + // Handy owns the theme radius, state transitions, input region, and + // child crop. BusyMax must not reintroduce a second window-shaping + // implementation in GTK or Flutter. + expect(source, isNot(contains('gdk_window_shape_combine_region'))); + expect(source, isNot(contains('create_rounded_window_region'))); + expect(source, isNot(contains('configure_rounded_window_shape'))); + expect(source, isNot(contains('CAIRO_OPERATOR_CLEAR'))); + expect(source, isNot(contains('kNativeWindowRadius'))); + expect(source, isNot(contains('border-radius: %dpx;'))); + expect(source, isNot(contains('"unified"'))); + expect(app, isNot(contains('_BusyMaxWindowCornerClip'))); + }, + ); + test( 'Task Details, Settings, and Agenda use BusyMax Yaru row patterns', () { @@ -482,7 +544,12 @@ void main() { 'gtk_box_pack_start(GTK_BOX(self->titlebar_box), GTK_WIDGET(header_bar)', ), ); - expect(source, contains('gtk_window_set_titlebar(window, titlebar)')); + 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( @@ -718,7 +785,10 @@ void main() { expect(source, contains('strcmp(method, "showCreateMenu") == 0')); expect(source, contains('setModalBarrierVisible')); expect(source, contains('busymax-modal-barrier')); - expect(source, contains('gtk_widget_set_sensitive(self->titlebar_box')); + expect( + source, + contains('gtk_widget_set_sensitive(self->titlebar_handle'), + ); expect(source, isNot(contains('setBackgroundColor'))); expect(source, isNot(contains('setSidebarBackgroundColor'))); expect(source, contains('is_css_rgba_color')); @@ -924,7 +994,21 @@ void main() { contains('".busymax-titlebar .busymax-header-brand {"'), ); expect(headerCss, contains('"border-right: 1px solid %s;"')); - expect(headerCss, contains('".busymax-titlebar.busymax-modal-barrier,"')); + expect( + headerCss, + contains( + '".busymax-titlebar.busymax-modal-barrier ' + '.busymax-header-brand,"', + ), + ); + expect( + headerCss, + contains( + '".busymax-titlebar.busymax-modal-barrier "\n' + ' "headerbar.busymax-flat-headerbar,"', + ), + ); + expect(headerCss, isNot(contains('".busymax-titlebar,"'))); expect(source, contains('kDefaultWindowBackgroundColor[] = "#2C2C2C"')); expect( source, From 51a31b84b25c6575d9c2083f0c9d7be6adadf68c Mon Sep 17 00:00:00 2001 From: albert Date: Thu, 23 Jul 2026 15:43:16 -0700 Subject: [PATCH 10/73] Add native dialog channel and implement confirmation dialog handling. Refactor color handling in BusyMax components and remove unused button styles for improved clarity and consistency --- lib/src/app/busymax_app.dart | 2 +- lib/src/app/busymax_design.dart | 191 +++++++----------- lib/src/app/busymax_dialogs.dart | 68 ++++++- lib/src/app/busymax_surface_colors.dart | 43 ++-- lib/src/app/busymax_yaru_theme.dart | 42 ++-- lib/src/app/system_accent.dart | 44 ++-- .../event_description_editor.dart | 2 +- .../calendar/presentation/event_editor.dart | 1 - .../presentation/compact_agenda_app.dart | 2 +- .../presentation/schedule_task_chip.dart | 2 +- lib/src/platform/gtk_font_service.dart | 20 +- lib/src/platform/native_dialog_service.dart | 62 ++++++ linux/runner/my_application.cc | 187 ++++++++++++++++- test/app/busymax_dialogs_test.dart | 76 +++++++ test/app/busymax_grouped_surface_test.dart | 89 +++++++- test/app/busymax_menu_button_test.dart | 2 +- test/app/high_contrast_theme_test.dart | 3 +- test/app/native_ui_audit_test.dart | 62 +++++- test/app/system_accent_test.dart | 2 + test/app/theme_localization_test.dart | 96 +++++---- .../auth/presentation/auth_routing_test.dart | 7 + .../presentation/event_editor_test.dart | 30 ++- .../presentation/feedback_dialog_test.dart | 13 ++ .../presentation/schedule_views_test.dart | 2 +- ...chedule_workspace_task_mutations_test.dart | 13 ++ .../presentation/settings_screen_test.dart | 2 +- .../presentation/task_details_pane_test.dart | 19 +- test/platform/gtk_font_service_test.dart | 8 +- test/platform/native_dialog_service_test.dart | 80 ++++++++ 29 files changed, 902 insertions(+), 268 deletions(-) create mode 100644 lib/src/platform/native_dialog_service.dart create mode 100644 test/platform/native_dialog_service_test.dart diff --git a/lib/src/app/busymax_app.dart b/lib/src/app/busymax_app.dart index 22fa507..cf32378 100644 --- a/lib/src/app/busymax_app.dart +++ b/lib/src/app/busymax_app.dart @@ -92,7 +92,7 @@ class _BusyMaxAppState extends ConsumerState { return SystemThemeBuilder( builder: (context, systemColor) { final accentColor = - ubuntuAccentColor ?? gtkThemeColors?.accent ?? systemColor.accent; + gtkThemeColors?.accent ?? ubuntuAccentColor ?? systemColor.accent; return MaterialApp.router( title: 'BusyMax', debugShowCheckedModeBanner: false, diff --git a/lib/src/app/busymax_design.dart b/lib/src/app/busymax_design.dart index 7efe166..09839bc 100644 --- a/lib/src/app/busymax_design.dart +++ b/lib/src/app/busymax_design.dart @@ -36,11 +36,6 @@ abstract final class BusyMaxSizes { static const double detailsWidth = 700; static const double compactDetailsWidth = 700; static const double toolbarHeight = kYaruTitleBarHeight; - static const double pushButtonHeight = kYaruButtonHeight; - static final Size pushButtonSize = Size( - kPushButtonSize.width, - pushButtonHeight, - ); static const double sidebarRowHeight = 36; static const double taskRowMinHeight = 48; static const double iconSm = 16; @@ -206,7 +201,7 @@ class BusyMaxPopoverSurface extends StatelessWidget { child: CustomPaint( foregroundPainter: _BusyMaxPopoverOutlinePainter( clipper: clipper, - color: BusyMaxSurfaceColors.of(context).subtleBorder, + color: BusyMaxSurfaceColors.of(context).floatingBorder, ), child: Padding( padding: EdgeInsets.only( @@ -407,28 +402,6 @@ ButtonStyle busyMaxDropdownMenuItemStyle(BuildContext context) { return Theme.of(context).menuButtonTheme.style ?? const ButtonStyle(); } -ButtonStyle busyMaxPushButtonStyle(ButtonStyle? style) { - return ButtonStyle( - fixedSize: const WidgetStatePropertyAll( - Size.fromHeight(BusyMaxSizes.pushButtonHeight), - ), - minimumSize: WidgetStatePropertyAll(BusyMaxSizes.pushButtonSize), - ).merge(style); -} - -ButtonStyle busyMaxHeaderPushButtonStyle(ButtonStyle? style) { - return ButtonStyle( - minimumSize: const WidgetStatePropertyAll( - Size(BusyMaxSizes.headerIconButton, BusyMaxSizes.headerIconButton), - ), - padding: const WidgetStatePropertyAll( - EdgeInsets.symmetric(horizontal: BusyMaxSpacing.xl), - ), - tapTargetSize: MaterialTapTargetSize.shrinkWrap, - shape: WidgetStatePropertyAll(busyMaxHeaderButtonShape()), - ).merge(style); -} - /// BusyMax's cross-platform fallback for a native desktop search entry. /// /// Linux header bars use `GtkSearchEntry`. Flutter-owned layouts delegate @@ -554,7 +527,7 @@ abstract final class BusyMaxPushButton { onLongPress: onLongPress, onHover: onHover, onFocusChange: onFocusChange, - style: busyMaxPushButtonStyle(style), + style: style, focusNode: focusNode, autofocus: autofocus, clipBehavior: clipBehavior, @@ -584,7 +557,7 @@ abstract final class BusyMaxPushButton { onLongPress: onLongPress, onHover: onHover, onFocusChange: onFocusChange, - style: busyMaxPushButtonStyle(style), + style: style, focusNode: focusNode, autofocus: autofocus, clipBehavior: clipBehavior, @@ -619,70 +592,10 @@ abstract final class BusyMaxPushButton { onLongPress: onLongPress, onHover: onHover, onFocusChange: onFocusChange, - style: busyMaxPushButtonStyle( - ElevatedButton.styleFrom( - backgroundColor: colorScheme.error, - foregroundColor: colorScheme.onError, - ).merge(style), - ), - focusNode: focusNode, - autofocus: autofocus, - clipBehavior: clipBehavior, - statesController: statesController, - child: child, - ); - } -} - -abstract final class BusyMaxHeaderPushButton { - static PushButton standard({ - required Widget child, - required VoidCallback? onPressed, - VoidCallback? onLongPress, - ValueChanged? onHover, - ValueChanged? onFocusChange, - ButtonStyle? style, - FocusNode? focusNode, - bool autofocus = false, - Clip clipBehavior = Clip.none, - WidgetStatesController? statesController, - Key? key, - }) { - return PushButton.filled( - key: key, - onPressed: onPressed, - onLongPress: onLongPress, - onHover: onHover, - onFocusChange: onFocusChange, - style: busyMaxHeaderPushButtonStyle(style), - focusNode: focusNode, - autofocus: autofocus, - clipBehavior: clipBehavior, - statesController: statesController, - child: child, - ); - } - - static PushButton suggested({ - required Widget child, - required VoidCallback? onPressed, - VoidCallback? onLongPress, - ValueChanged? onHover, - ValueChanged? onFocusChange, - ButtonStyle? style, - FocusNode? focusNode, - bool autofocus = false, - Clip clipBehavior = Clip.none, - WidgetStatesController? statesController, - Key? key, - }) { - return PushButton.elevated( - key: key, - onPressed: onPressed, - onLongPress: onLongPress, - onHover: onHover, - onFocusChange: onFocusChange, - style: busyMaxHeaderPushButtonStyle(style), + style: ElevatedButton.styleFrom( + backgroundColor: colorScheme.error, + foregroundColor: colorScheme.onError, + ).merge(style), focusNode: focusNode, autofocus: autofocus, clipBehavior: clipBehavior, @@ -729,6 +642,20 @@ TextStyle? busyMaxSectionHeaderStyle(BuildContext context) { ); } +Widget _busyMaxGroupedRowSubtitle( + BuildContext context, + Widget child, { + bool enabled = true, +}) { + final colors = BusyMaxSurfaceColors.of(context); + return DefaultTextStyle.merge( + style: TextStyle( + color: enabled ? colors.mutedForeground : colors.disabledForeground, + ), + child: child, + ); +} + class BusyMaxClamp extends StatelessWidget { const BusyMaxClamp({ super.key, @@ -868,9 +795,12 @@ class BusyMaxGroupedSurface extends StatelessWidget { @override Widget build(BuildContext context) { final surfaceColors = BusyMaxSurfaceColors.of(context); + final highContrast = MediaQuery.highContrastOf(context); return BusyMaxSurface( color: surfaceColors.groupedSurface, - side: BorderSide(color: surfaceColors.subtleBorder), + side: highContrast + ? BorderSide(color: Theme.of(context).colorScheme.outline) + : BorderSide.none, clipBehavior: clipBehavior, child: child, ); @@ -1000,7 +930,7 @@ class _BusyMaxGroupedListSurface extends StatelessWidget { for (var index = 0; index < children.length; index++) ...[ children[index], if (index < children.length - 1) - Divider(height: 1, thickness: 1, color: surfaceColors.subtleBorder), + Divider(height: 1, thickness: 1, color: surfaceColors.divider), ], ], ); @@ -1068,6 +998,15 @@ class _BusyMaxActionRowState extends State { final titleStyle = widget.destructive ? TextStyle(color: colorScheme.error) : null; + final subtitle = + widget.subtitleWidget ?? + (widget.subtitle == null || widget.subtitle!.isEmpty + ? null + : Text( + widget.subtitle!, + maxLines: 1, + overflow: TextOverflow.ellipsis, + )); final interactive = widget.enabled && (widget.onTap != null || widget.onActivated != null); final row = YaruListTile.square( @@ -1080,15 +1019,13 @@ class _BusyMaxActionRowState extends State { overflow: TextOverflow.ellipsis, style: titleStyle, ), - subtitle: - widget.subtitleWidget ?? - (widget.subtitle == null || widget.subtitle!.isEmpty - ? null - : Text( - widget.subtitle!, - maxLines: 1, - overflow: TextOverflow.ellipsis, - )), + subtitle: subtitle == null + ? null + : _busyMaxGroupedRowSubtitle( + context, + subtitle, + enabled: widget.enabled, + ), trailing: widget.trailing, enabled: widget.enabled, autofocus: widget.autofocus, @@ -1460,7 +1397,11 @@ class BusyMaxCalendarValueRow extends StatelessWidget { final row = YaruListTile.square( leading: leading, title: Text(label, maxLines: 1, overflow: TextOverflow.ellipsis), - subtitle: Text(value, maxLines: 1, overflow: TextOverflow.ellipsis), + subtitle: _busyMaxGroupedRowSubtitle( + context, + Text(value, maxLines: 1, overflow: TextOverflow.ellipsis), + enabled: enabled, + ), trailing: trailingIcons.isEmpty ? null : Row( @@ -1583,6 +1524,13 @@ class BusyMaxComboRow extends StatelessWidget { : subtitle == null ? null : Text(subtitle!); + final styledSubtitle = subtitleWidget == null + ? null + : _busyMaxGroupedRowSubtitle( + context, + subtitleWidget, + enabled: enabled, + ); final textScale = MediaQuery.textScalerOf(context).scale(14) / 14; final actionAllowance = trailingAction == null ? 0.0 @@ -1659,7 +1607,7 @@ class BusyMaxComboRow extends StatelessWidget { YaruListTile.square( leading: leading, titleText: title, - subtitle: subtitleWidget, + subtitle: styledSubtitle, enabled: enabled, ), Padding( @@ -1676,7 +1624,7 @@ class BusyMaxComboRow extends StatelessWidget { : YaruListTile.square( leading: leading, titleText: title, - subtitle: subtitleWidget, + subtitle: styledSubtitle, trailing: trailing, enabled: enabled, ); @@ -1751,7 +1699,13 @@ class BusyMaxSwitchRow extends StatelessWidget { onChanged: enabled ? onChanged : null, secondary: leading, title: Text(title), - subtitle: subtitle == null ? null : Text(subtitle!), + subtitle: subtitle == null + ? null + : _busyMaxGroupedRowSubtitle( + context, + Text(subtitle!), + enabled: enabled, + ), shape: const RoundedRectangleBorder(), hoverColor: busyMaxRowHoverColor(context), ); @@ -2294,7 +2248,7 @@ class BusyMaxEditorHeader extends StatelessWidget { ), child: Row( children: [ - BusyMaxHeaderPushButton.standard( + BusyMaxPushButton.standard( onPressed: cancelEnabled ? onCancel : null, child: Text(cancelLabel, overflow: TextOverflow.ellipsis), ), @@ -2307,7 +2261,7 @@ class BusyMaxEditorHeader extends StatelessWidget { style: Theme.of(context).textTheme.titleMedium, ), ), - BusyMaxHeaderPushButton.suggested( + BusyMaxPushButton.suggested( onPressed: onSave, child: saving ? const SizedBox.square( @@ -2361,7 +2315,10 @@ class BusyMaxTimeModeRow extends StatelessWidget { textScale > 1.2; final label = YaruListTile.square( title: Text(l10n.timeMode), - subtitle: Text(l10n.timeModeDescription), + subtitle: _busyMaxGroupedRowSubtitle( + context, + Text(l10n.timeModeDescription), + ), trailing: stackSelector ? null : selector, ); if (!stackSelector) { @@ -2498,7 +2455,7 @@ class BusyMaxModalEditorSurface extends StatelessWidget { shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(BusyMaxRadius.lg), side: BorderSide( - color: surfaceColors.subtleBorder, + color: surfaceColors.floatingBorder, width: BusyMaxStroke.outline, ), ), @@ -2675,9 +2632,10 @@ class BusyMaxConfirmDialog extends StatelessWidget { @override Widget build(BuildContext context) { - return BusyMaxDialogShell( - title: title, - maxWidth: 460, + return AlertDialog( + titlePadding: EdgeInsets.zero, + title: YaruDialogTitleBar(title: Text(title), centerTitle: true), + content: Text(message), actions: [ BusyMaxPushButton.standard( onPressed: () => Navigator.of(context).pop(false), @@ -2695,7 +2653,6 @@ class BusyMaxConfirmDialog extends StatelessWidget { child: Text(confirmLabel), ), ], - children: [Text(message)], ); } } diff --git a/lib/src/app/busymax_dialogs.dart b/lib/src/app/busymax_dialogs.dart index d1f5cb8..15c3c5d 100644 --- a/lib/src/app/busymax_dialogs.dart +++ b/lib/src/app/busymax_dialogs.dart @@ -1,8 +1,10 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../l10n/l10n.dart'; import '../platform/linux_header_bar_service.dart'; import '../platform/linux_header_bar_provider.dart'; +import '../platform/native_dialog_service.dart'; import 'busymax_design.dart'; import 'busymax_shortcuts.dart'; @@ -39,30 +41,56 @@ Future showBusyMaxModalDialog( }) async { final effectiveHeaderBarService = headerBarService ?? _headerBarServiceFrom(context); + return _coordinateBusyMaxModal( + context, + headerBarService: effectiveHeaderBarService, + showSurface: () => _showBusyMaxFlutterDialog( + context, + builder: builder, + barrierColor: barrierColor, + barrierDismissible: barrierDismissible, + ), + ); +} + +Future _coordinateBusyMaxModal( + BuildContext context, { + required LinuxHeaderBarService? headerBarService, + required Future Function() showSurface, +}) async { final previousFocus = FocusManager.instance.primaryFocus; - await acquireBusyMaxModalBarrier(effectiveHeaderBarService); + await acquireBusyMaxModalBarrier(headerBarService); if (!context.mounted) { - await releaseBusyMaxModalBarrier(effectiveHeaderBarService); + await releaseBusyMaxModalBarrier(headerBarService); return null; } try { - return await showDialog( - context: context, - barrierColor: barrierColor ?? busyMaxModalBarrierColor(context), - barrierDismissible: barrierDismissible, - traversalEdgeBehavior: TraversalEdgeBehavior.closedLoop, - builder: (dialogContext) => - BusyMaxModalShortcutBoundary(child: builder(dialogContext)), - ); + return await showSurface(); } finally { - await releaseBusyMaxModalBarrier(effectiveHeaderBarService); + await releaseBusyMaxModalBarrier(headerBarService); if (previousFocus?.context != null && previousFocus!.canRequestFocus) { previousFocus.requestFocus(); } } } +Future _showBusyMaxFlutterDialog( + BuildContext context, { + required WidgetBuilder builder, + Color? barrierColor, + bool barrierDismissible = true, +}) { + return showDialog( + context: context, + barrierColor: barrierColor ?? busyMaxModalBarrierColor(context), + barrierDismissible: barrierDismissible, + traversalEdgeBehavior: TraversalEdgeBehavior.closedLoop, + builder: (dialogContext) => + BusyMaxModalShortcutBoundary(child: builder(dialogContext)), + ); +} + Future showBusyMaxModalEditorDialog( BuildContext context, { required WidgetBuilder builder, @@ -128,7 +156,25 @@ Future showBusyMaxConfirm( bool destructive = false, Color? barrierColor, LinuxHeaderBarService? headerBarService, + NativeDialogService nativeDialogService = const NativeDialogService(), }) async { + final nativeResult = await nativeDialogService.confirm( + title: title, + message: message, + cancelLabel: context.l10n.cancel, + confirmLabel: confirmLabel, + destructive: destructive, + ); + if (nativeResult.available) { + // GTK owns parent modality for its transient dialog. Adding BusyMax's + // Flutter/header-bar barrier here would dim only part of the native window + // and duplicate the platform's input blocking. + return nativeResult.confirmed; + } + if (!context.mounted) { + return false; + } + final confirmed = await showBusyMaxModalDialog( context, headerBarService: headerBarService, diff --git a/lib/src/app/busymax_surface_colors.dart b/lib/src/app/busymax_surface_colors.dart index ffd79d9..7367c34 100644 --- a/lib/src/app/busymax_surface_colors.dart +++ b/lib/src/app/busymax_surface_colors.dart @@ -1,5 +1,7 @@ import 'package:flutter/material.dart'; +const _dimLabelOpacity = 0.55; + @immutable class BusyMaxSurfaceColors extends ThemeExtension { const BusyMaxSurfaceColors({ @@ -22,7 +24,8 @@ class BusyMaxSurfaceColors extends ThemeExtension { required this.disabledForeground, required this.disabledControl, required this.border, - required this.subtleBorder, + required this.divider, + required this.floatingBorder, required this.sidebarBorder, required this.shade, }); @@ -46,7 +49,8 @@ class BusyMaxSurfaceColors extends ThemeExtension { final Color disabledForeground; final Color disabledControl; final Color border; - final Color subtleBorder; + final Color divider; + final Color floatingBorder; final Color sidebarBorder; final Color shade; @@ -77,7 +81,8 @@ class BusyMaxSurfaceColors extends ThemeExtension { Color? disabledForeground, Color? disabledControl, Color? border, - Color? subtleBorder, + Color? divider, + Color? floatingBorder, Color? sidebarBorder, Color? shade, }) { @@ -101,7 +106,8 @@ class BusyMaxSurfaceColors extends ThemeExtension { disabledForeground: disabledForeground ?? this.disabledForeground, disabledControl: disabledControl ?? this.disabledControl, border: border ?? this.border, - subtleBorder: subtleBorder ?? this.subtleBorder, + divider: divider ?? this.divider, + floatingBorder: floatingBorder ?? this.floatingBorder, sidebarBorder: sidebarBorder ?? this.sidebarBorder, shade: shade ?? this.shade, ); @@ -140,7 +146,8 @@ class BusyMaxSurfaceColors extends ThemeExtension { )!, disabledControl: Color.lerp(disabledControl, other.disabledControl, t)!, border: Color.lerp(border, other.border, t)!, - subtleBorder: Color.lerp(subtleBorder, other.subtleBorder, t)!, + divider: Color.lerp(divider, other.divider, t)!, + floatingBorder: Color.lerp(floatingBorder, other.floatingBorder, t)!, sidebarBorder: Color.lerp(sidebarBorder, other.sidebarBorder, t)!, shade: Color.lerp(shade, other.shade, t)!, ); @@ -148,8 +155,16 @@ class BusyMaxSurfaceColors extends ThemeExtension { } BusyMaxSurfaceColors busyMaxFallbackSurfaceColors(Brightness brightness) { + final foreground = switch (brightness) { + Brightness.light => const Color.fromRGBO(0, 0, 6, 0.80), + Brightness.dark => const Color(0xFFF6F5F4), + }; + final mutedForeground = foreground.withValues( + alpha: foreground.a * _dimLabelOpacity, + ); + return switch (brightness) { - Brightness.light => const BusyMaxSurfaceColors( + Brightness.light => BusyMaxSurfaceColors( window: Color(0xFFFAFAFB), view: Color(0xFFFFFFFF), sidebar: Color(0xFFEBEBED), @@ -164,16 +179,17 @@ BusyMaxSurfaceColors busyMaxFallbackSurfaceColors(Brightness brightness) { controlHover: Color.fromRGBO(0, 0, 0, 0.10), controlActive: Color.fromRGBO(0, 0, 0, 0.16), activeToggle: Color(0xFFFFFFFF), - foreground: Color.fromRGBO(0, 0, 6, 0.80), - mutedForeground: Color.fromRGBO(0, 0, 6, 0.56), + foreground: foreground, + mutedForeground: mutedForeground, disabledForeground: Color.fromRGBO(0, 0, 6, 0.38), disabledControl: Color.fromRGBO(0, 0, 0, 0.04), border: Color.fromRGBO(0, 0, 6, 0.18), - subtleBorder: Color.fromRGBO(0, 0, 6, 0.10), + divider: Color.fromRGBO(0, 0, 6, 0.10), + floatingBorder: Color.fromRGBO(0, 0, 6, 0.10), sidebarBorder: Color.fromRGBO(0, 0, 6, 0.07), shade: Color.fromRGBO(0, 0, 6, 0.07), ), - Brightness.dark => const BusyMaxSurfaceColors( + Brightness.dark => BusyMaxSurfaceColors( // Current Yaru/libadwaita semantic surface ladder. These values are the // fallback when GTK 3 cannot expose a compatible role; flat or recessed // legacy `.sidebar` and `popover.background` samples must not replace @@ -192,12 +208,13 @@ BusyMaxSurfaceColors busyMaxFallbackSurfaceColors(Brightness brightness) { controlHover: Color.fromRGBO(255, 255, 255, 0.14), controlActive: Color.fromRGBO(255, 255, 255, 0.18), activeToggle: Color.fromRGBO(255, 255, 255, 0.20), - foreground: Color(0xFFFFFFFF), - mutedForeground: Color.fromRGBO(255, 255, 255, 0.70), + foreground: foreground, + mutedForeground: mutedForeground, disabledForeground: Color.fromRGBO(255, 255, 255, 0.38), disabledControl: Color.fromRGBO(255, 255, 255, 0.06), border: Color.fromRGBO(0, 0, 6, 0.75), - subtleBorder: Color.fromRGBO(255, 255, 255, 0.10), + divider: Color.fromRGBO(255, 255, 255, 0.10), + floatingBorder: Color.fromRGBO(255, 255, 255, 0.10), sidebarBorder: Color.fromRGBO(255, 255, 255, 0.10), shade: Color.fromRGBO(0, 0, 6, 0.25), ), diff --git a/lib/src/app/busymax_yaru_theme.dart b/lib/src/app/busymax_yaru_theme.dart index b8cdd62..490df8d 100644 --- a/lib/src/app/busymax_yaru_theme.dart +++ b/lib/src/app/busymax_yaru_theme.dart @@ -11,22 +11,6 @@ export 'busymax_surface_colors.dart'; const _minimumRaisedSurfaceContrast = 1.08; abstract final class BusyMaxLinuxPalette { - static const blueAccent = Color(0xFF3584E4); - static const ubuntuBlueAccent = Color(0xFF0073E5); - static const ubuntuTealAccent = Color(0xFF2190A4); - static const ubuntuGreenAccent = Color(0xFF3A944A); - static const ubuntuYellowAccent = Color(0xFFC88800); - static const ubuntuOrangeAccent = Color(0xFFED5B00); - static const ubuntuRedAccent = Color(0xFFDA3450); - static const ubuntuPinkAccent = Color(0xFFD56199); - static const ubuntuPurpleAccent = Color(0xFF7764D8); - static const ubuntuSlateAccent = Color(0xFF6F8396); - static const ubuntuBrownAccent = Color(0xFF986A44); - static const ubuntuMagentaAccent = Color(0xFFB34CB3); - static const ubuntuOliveAccent = Color(0xFF4B8501); - static const ubuntuPrussianGreenAccent = Color(0xFF308280); - static const ubuntuSageAccent = Color(0xFF657B69); - static const ubuntuWartyBrownAccent = Color(0xFFB39169); static const red3 = Color(0xFFE01B24); static const red5 = Color(0xFFA51D2D); static const light2 = Color(0xFFF6F5F4); @@ -59,7 +43,16 @@ class BusyMaxYaruTheme { final colors = highContrast ? _highContrastSurfaceColors(brightness) : resolvedColors; - final onAccent = contrastColor(accentColor); + final sampledAccentForeground = + gtkThemeColors?.brightness == brightness && + gtkThemeColors?.accent == accentColor + ? gtkThemeColors?.accentForeground + : null; + final onAccent = + sampledAccentForeground != null && + _contrastRatio(sampledAccentForeground, accentColor) >= 4.5 + ? sampledAccentForeground + : contrastColor(accentColor); final accentContainer = Color.alphaBlend( accentColor.withValues( alpha: brightness == Brightness.dark ? 0.24 : 0.14, @@ -87,7 +80,7 @@ class BusyMaxYaruTheme { surfaceContainerHigh: colors.control, surfaceContainerHighest: colors.controlHover, outline: colors.border, - outlineVariant: colors.subtleBorder, + outlineVariant: colors.divider, scrim: BusyMaxLinuxPalette.dark5, ); final normalizer = _TextStyleNormalizer( @@ -196,7 +189,7 @@ class BusyMaxYaruTheme { if (extension is! BusyMaxSurfaceColors) extension, colors, ], - dividerColor: colors.subtleBorder, + dividerColor: colors.divider, appBarTheme: base.appBarTheme.copyWith( elevation: 0, scrolledUnderElevation: 0, @@ -506,7 +499,8 @@ BusyMaxSurfaceColors _highContrastSurfaceColors(Brightness brightness) { disabledForeground: layer(0.55), disabledControl: layer(0.06), border: foreground, - subtleBorder: foreground, + divider: foreground, + floatingBorder: foreground, sidebarBorder: foreground, shade: Colors.black, ); @@ -714,6 +708,11 @@ class _BusyMaxResolvedSurfaceColors { sidebar: sidebar, fallback: fallback.sidebarBorder, ); + // Inset separators and floating-surface outlines are distinct native + // roles. Resolve each GTK sample directly rather than evaluating a shared + // color against an unrelated card surface. + final runtimeDivider = _runtimeColor(runtime.divider); + final runtimeFloatingBorder = _runtimeColor(runtime.floatingBorder); final readableBackgrounds = [ window, view, @@ -760,7 +759,8 @@ class _BusyMaxResolvedSurfaceColors { disabledForeground: disabledForeground, disabledControl: _runtimeOverlayColor(runtime.disabledControl), border: _runtimeColor(runtime.border), - subtleBorder: _runtimeColor(runtime.subtleBorder), + divider: runtimeDivider, + floatingBorder: runtimeFloatingBorder, sidebarBorder: sidebarBorder, shade: _runtimeShadeColor(runtime.shade, over: popover), ); diff --git a/lib/src/app/system_accent.dart b/lib/src/app/system_accent.dart index 1fc9d1b..3c42aa5 100644 --- a/lib/src/app/system_accent.dart +++ b/lib/src/app/system_accent.dart @@ -3,30 +3,20 @@ import 'dart:io'; import 'package:dbus/dbus.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; - -import 'busymax_yaru_theme.dart'; - -const busyMaxDefaultAccentColor = BusyMaxLinuxPalette.blueAccent; - -final initialSystemAccentColorProvider = Provider( - (ref) => busyMaxDefaultAccentColor, -); +import 'package:yaru/theme.dart'; final linuxPortalAppearanceProvider = Provider( (ref) => const LinuxPortalAppearance(), ); final ubuntuSystemAccentColorProvider = StreamProvider((ref) async* { - final fallback = ref.watch(initialSystemAccentColorProvider); - yield fallback; - if (!Platform.isLinux) { return; } final appearance = ref.watch(linuxPortalAppearanceProvider); final initial = await appearance.readAccentColor(); - if (initial != null && initial != fallback) { + if (initial != null) { yield initial; } yield* appearance.accentColorChanges().distinct(); @@ -152,21 +142,21 @@ Color? colorFromUbuntuAccentNameValue(DBusValue value) { Color? ubuntuAccentNameColor(String name) { return switch (name) { - 'blue' => BusyMaxLinuxPalette.ubuntuBlueAccent, - 'teal' => BusyMaxLinuxPalette.ubuntuTealAccent, - 'green' => BusyMaxLinuxPalette.ubuntuGreenAccent, - 'yellow' => BusyMaxLinuxPalette.ubuntuYellowAccent, - 'orange' => BusyMaxLinuxPalette.ubuntuOrangeAccent, - 'red' => BusyMaxLinuxPalette.ubuntuRedAccent, - 'pink' => BusyMaxLinuxPalette.ubuntuPinkAccent, - 'purple' => BusyMaxLinuxPalette.ubuntuPurpleAccent, - 'slate' => BusyMaxLinuxPalette.ubuntuSlateAccent, - 'brown' => BusyMaxLinuxPalette.ubuntuBrownAccent, - 'magenta' => BusyMaxLinuxPalette.ubuntuMagentaAccent, - 'olive' => BusyMaxLinuxPalette.ubuntuOliveAccent, - 'prussiangreen' => BusyMaxLinuxPalette.ubuntuPrussianGreenAccent, - 'sage' => BusyMaxLinuxPalette.ubuntuSageAccent, - 'wartybrown' => BusyMaxLinuxPalette.ubuntuWartyBrownAccent, + 'blue' => YaruVariant.blue.color, + 'teal' => YaruVariant.adwaitaTeal.color, + 'green' => YaruVariant.adwaitaGreen.color, + 'yellow' => YaruVariant.adwaitaYellow.color, + 'orange' => YaruVariant.orange.color, + 'red' => YaruVariant.red.color, + 'pink' => YaruVariant.magenta.color, + 'purple' => YaruVariant.purple.color, + 'slate' => YaruVariant.adwaitaSlate.color, + 'brown' => YaruVariant.wartyBrown.color, + 'magenta' => YaruVariant.magenta.color, + 'olive' => YaruVariant.olive.color, + 'prussiangreen' => YaruVariant.prussianGreen.color, + 'sage' => YaruVariant.sage.color, + 'wartybrown' => YaruVariant.wartyBrown.color, _ => null, }; } diff --git a/lib/src/features/calendar/presentation/event_description_editor.dart b/lib/src/features/calendar/presentation/event_description_editor.dart index b923f51..467e681 100644 --- a/lib/src/features/calendar/presentation/event_description_editor.dart +++ b/lib/src/features/calendar/presentation/event_description_editor.dart @@ -181,7 +181,7 @@ class _FormatButton extends StatelessWidget { return Tooltip( message: tooltip, child: SizedBox.square( - dimension: BusyMaxSizes.pushButtonHeight, + dimension: BusyMaxSizes.headerIconButton, child: YaruIconButton( tooltip: tooltip, onPressed: onPressed, diff --git a/lib/src/features/calendar/presentation/event_editor.dart b/lib/src/features/calendar/presentation/event_editor.dart index ca1809b..8c48b82 100644 --- a/lib/src/features/calendar/presentation/event_editor.dart +++ b/lib/src/features/calendar/presentation/event_editor.dart @@ -338,7 +338,6 @@ class _EventEditorState extends State { message: context.l10n.discardChangesConfirmation, confirmLabel: context.l10n.discard, destructive: true, - barrierColor: Colors.transparent, headerBarService: widget.headerBarService, ); if (discard && mounted) { diff --git a/lib/src/features/schedule/presentation/compact_agenda_app.dart b/lib/src/features/schedule/presentation/compact_agenda_app.dart index bf3431d..23818bd 100644 --- a/lib/src/features/schedule/presentation/compact_agenda_app.dart +++ b/lib/src/features/schedule/presentation/compact_agenda_app.dart @@ -284,7 +284,7 @@ class _BusyMaxCompactAgendaAppState return SystemThemeBuilder( builder: (context, systemColor) { final accentColor = - ubuntuAccentColor ?? gtkThemeColors?.accent ?? systemColor.accent; + gtkThemeColors?.accent ?? ubuntuAccentColor ?? systemColor.accent; return MaterialApp( title: 'BusyMax Agenda', debugShowCheckedModeBanner: false, diff --git a/lib/src/features/schedule/presentation/schedule_task_chip.dart b/lib/src/features/schedule/presentation/schedule_task_chip.dart index 7ccd2af..e3ac2eb 100644 --- a/lib/src/features/schedule/presentation/schedule_task_chip.dart +++ b/lib/src/features/schedule/presentation/schedule_task_chip.dart @@ -83,7 +83,7 @@ class ScheduleTaskChip extends StatelessWidget { color: surfaceColors.control, borderRadius: BorderRadius.circular(BusyMaxRadius.sm), border: Border( - left: BorderSide(color: surfaceColors.subtleBorder, width: 3), + left: BorderSide(color: surfaceColors.divider, width: 3), ), ), child: showContent diff --git a/lib/src/platform/gtk_font_service.dart b/lib/src/platform/gtk_font_service.dart index 2e1e276..0ba2b84 100644 --- a/lib/src/platform/gtk_font_service.dart +++ b/lib/src/platform/gtk_font_service.dart @@ -154,13 +154,15 @@ class GtkThemeColors { this.controlHover, this.controlActive, this.accent, + this.accentForeground, this.activeToggle, this.foreground, this.mutedForeground, this.disabledForeground, this.disabledControl, this.border, - this.subtleBorder, + this.divider, + this.floatingBorder, this.sidebarBorder, this.shade, }); @@ -179,13 +181,15 @@ class GtkThemeColors { final Color? controlHover; final Color? controlActive; final Color? accent; + final Color? accentForeground; final Color? activeToggle; final Color? foreground; final Color? mutedForeground; final Color? disabledForeground; final Color? disabledControl; final Color? border; - final Color? subtleBorder; + final Color? divider; + final Color? floatingBorder; final Color? sidebarBorder; final Color? shade; @@ -207,13 +211,15 @@ class GtkThemeColors { other.controlHover == controlHover && other.controlActive == controlActive && other.accent == accent && + other.accentForeground == accentForeground && other.activeToggle == activeToggle && other.foreground == foreground && other.mutedForeground == mutedForeground && other.disabledForeground == disabledForeground && other.disabledControl == disabledControl && other.border == border && - other.subtleBorder == subtleBorder && + other.divider == divider && + other.floatingBorder == floatingBorder && other.sidebarBorder == sidebarBorder && other.shade == shade; } @@ -234,13 +240,15 @@ class GtkThemeColors { controlHover, controlActive, accent, + accentForeground, activeToggle, foreground, mutedForeground, disabledForeground, disabledControl, border, - subtleBorder, + divider, + floatingBorder, sidebarBorder, shade, ]); @@ -320,13 +328,15 @@ GtkThemeColors? _parseThemeColors(Object? value) { controlHover: _parseColor(value['controlHover']), controlActive: _parseColor(value['controlActive']), accent: _parseColor(value['accent']), + accentForeground: _parseColor(value['accentForeground']), activeToggle: _parseColor(value['activeToggle']), foreground: _parseColor(value['foreground']), mutedForeground: _parseColor(value['mutedForeground']), disabledForeground: _parseColor(value['disabledForeground']), disabledControl: _parseColor(value['disabledControl']), border: _parseColor(value['border']), - subtleBorder: _parseColor(value['subtleBorder']), + divider: _parseColor(value['divider']), + floatingBorder: _parseColor(value['floatingBorder']), sidebarBorder: _parseColor(value['sidebarBorder']), shade: _parseColor(value['shade']), ); diff --git a/lib/src/platform/native_dialog_service.dart b/lib/src/platform/native_dialog_service.dart new file mode 100644 index 0000000..50fbec1 --- /dev/null +++ b/lib/src/platform/native_dialog_service.dart @@ -0,0 +1,62 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; + +@visibleForTesting +const nativeDialogChannelName = 'busymax/native_dialogs'; + +/// Result of asking the platform to present a native confirmation dialog. +/// +/// [available] distinguishes a user cancellation from a platform that does +/// not implement the native dialog bridge. +@immutable +class NativeConfirmationResult { + const NativeConfirmationResult({ + required this.available, + this.confirmed = false, + }); + + const NativeConfirmationResult.unavailable() + : available = false, + confirmed = false; + + final bool available; + final bool confirmed; +} + +/// Presents confirmation UI owned by the host desktop toolkit. +/// +/// Linux implements this with a transient `GtkMessageDialog`. Other hosts can +/// omit the channel; callers then use their themed Flutter fallback. +class NativeDialogService { + const NativeDialogService({ + MethodChannel channel = const MethodChannel(nativeDialogChannelName), + }) : _channel = channel; + + final MethodChannel _channel; + + Future confirm({ + required String title, + required String message, + required String cancelLabel, + required String confirmLabel, + required bool destructive, + }) async { + try { + final confirmed = await _channel.invokeMethod('confirm', { + 'title': title, + 'message': message, + 'cancelLabel': cancelLabel, + 'confirmLabel': confirmLabel, + 'destructive': destructive, + }); + if (confirmed == null) { + return const NativeConfirmationResult.unavailable(); + } + return NativeConfirmationResult(available: true, confirmed: confirmed); + } on MissingPluginException { + return const NativeConfirmationResult.unavailable(); + } on PlatformException { + return const NativeConfirmationResult.unavailable(); + } + } +} diff --git a/linux/runner/my_application.cc b/linux/runner/my_application.cc index 549dacf..d829e8b 100644 --- a/linux/runner/my_application.cc +++ b/linux/runner/my_application.cc @@ -17,6 +17,7 @@ constexpr char kApplicationDisplayName[] = "BusyMax"; constexpr char kNativeDateTimePickerChannel[] = "busymax/native_date_time_picker"; +constexpr char kNativeDialogChannel[] = "busymax/native_dialogs"; constexpr char kWindowChannel[] = "io.busystack.busymax/window"; constexpr char kHeaderBarChannel[] = "io.busystack.busymax/headerbar"; constexpr char kGtkSettingsChannel[] = "io.busystack.busymax/gtk_settings"; @@ -61,6 +62,7 @@ struct _MyApplication { GtkApplication parent_instance; char** dart_entrypoint_arguments; FlMethodChannel* native_date_time_picker_channel; + FlMethodChannel* native_dialog_channel; FlMethodChannel* window_channel; FlMethodChannel* header_bar_channel; FlMethodChannel* gtk_settings_channel; @@ -347,6 +349,113 @@ static void respond_bool(FlMethodCall* method_call, gboolean value) { fl_method_call_respond_success(method_call, result, nullptr); } +static void handle_native_confirmation(FlMethodCall* method_call, + FlValue* args, + GtkWindow* parent) { + const gchar* title = fl_lookup_string_arg(args, "title"); + const gchar* message = fl_lookup_string_arg(args, "message"); + const gchar* cancel_label = fl_lookup_string_arg(args, "cancelLabel"); + const gchar* confirm_label = fl_lookup_string_arg(args, "confirmLabel"); + const gboolean destructive = + fl_lookup_bool_arg(args, "destructive", FALSE); + + GtkWidget* dialog = gtk_message_dialog_new( + parent, + static_cast(GTK_DIALOG_MODAL | + GTK_DIALOG_DESTROY_WITH_PARENT), + destructive ? GTK_MESSAGE_WARNING : GTK_MESSAGE_QUESTION, + GTK_BUTTONS_NONE, "%s", title != nullptr ? title : ""); + if (message != nullptr && message[0] != '\0') { + gtk_message_dialog_format_secondary_text(GTK_MESSAGE_DIALOG(dialog), "%s", + message); + } + gtk_window_set_resizable(GTK_WINDOW(dialog), FALSE); + + GtkWidget* cancel_button = gtk_dialog_add_button( + GTK_DIALOG(dialog), cancel_label != nullptr ? cancel_label : "_Cancel", + GTK_RESPONSE_CANCEL); + GtkWidget* confirm_button = gtk_dialog_add_button( + GTK_DIALOG(dialog), confirm_label != nullptr ? confirm_label : "_OK", + GTK_RESPONSE_ACCEPT); + GtkStyleContext* confirm_context = + gtk_widget_get_style_context(confirm_button); + gtk_style_context_add_class( + confirm_context, destructive ? GTK_STYLE_CLASS_DESTRUCTIVE_ACTION + : GTK_STYLE_CLASS_SUGGESTED_ACTION); + + // Confirmation dialogs default to the safe action. GTK still owns focus + // rendering, keyboard behavior, button order, typography, and accent use. + gtk_widget_set_can_default(cancel_button, TRUE); + gtk_dialog_set_default_response(GTK_DIALOG(dialog), GTK_RESPONSE_CANCEL); + gtk_widget_grab_focus(cancel_button); + + gtk_widget_show_all(dialog); + const gint response = gtk_dialog_run(GTK_DIALOG(dialog)); + respond_bool(method_call, response == GTK_RESPONSE_ACCEPT); + gtk_widget_destroy(dialog); +} + +struct NativeDialogHandlerData { + GtkWindow* window; +}; + +static void native_dialog_handler_data_free(gpointer user_data) { + auto* data = static_cast(user_data); + if (data->window != nullptr) { + g_object_remove_weak_pointer( + G_OBJECT(data->window), + reinterpret_cast(&data->window)); + } + g_free(data); +} + +static void native_dialog_method_call_cb(FlMethodChannel* channel, + FlMethodCall* method_call, + gpointer user_data) { + auto* data = static_cast(user_data); + GtkWindow* parent = data->window; + if (parent == nullptr) { + fl_method_call_respond_not_implemented(method_call, nullptr); + return; + } + const gchar* method = fl_method_call_get_name(method_call); + if (strcmp(method, "confirm") == 0) { + handle_native_confirmation(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) { + 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; + g_object_add_weak_pointer(G_OBJECT(window), + reinterpret_cast(&data->window)); + fl_method_channel_set_method_call_handler( + channel, native_dialog_method_call_cb, data, + native_dialog_handler_data_free); + return channel; +} + +static void register_native_dialogs(MyApplication* self, + FlView* view, + GtkWindow* window) { + self->native_dialog_channel = create_native_dialog_channel(view, window); +} + +static void register_native_dialogs_for_subwindow(FlView* view, + GtkWindow* window) { + FlMethodChannel* channel = create_native_dialog_channel(view, window); + g_object_set_data_full(G_OBJECT(window), "busymax-native-dialogs", channel, + g_object_unref); +} + static void respond_success(FlMethodCall* method_call) { g_autoptr(FlValue) result = fl_value_new_null(); fl_method_call_respond_success(method_call, result, nullptr); @@ -1897,6 +2006,32 @@ static gboolean sample_widget_background(GtkWidget* widget, return color_is_visible(color); } +static gboolean sample_widget_border_color(GtkWidget* widget, + const gchar* style_class, + GtkStateFlags state, + GdkRGBA* color) { + if (widget == nullptr || color == nullptr) { + return FALSE; + } + GtkStyleContext* context = gtk_widget_get_style_context(widget); + if (context == nullptr) { + return FALSE; + } + if (style_class != nullptr) { + gtk_style_context_add_class(context, style_class); + } + gtk_style_context_set_state(context, state); + GValue value = G_VALUE_INIT; + gtk_style_context_get_property(context, "border-color", state, &value); + const GdkRGBA* border = + static_cast(g_value_get_boxed(&value)); + if (border != nullptr) { + *color = *border; + } + g_value_unset(&value); + return color_is_visible(color); +} + static gboolean sample_widget_color(GtkWidget* widget, const gchar* style_class, GtkStateFlags state, @@ -1916,6 +2051,24 @@ static gboolean sample_widget_color(GtkWidget* widget, return color_is_visible(color); } +static gboolean sample_widget_color_with_opacity( + GtkWidget* widget, + const gchar* style_class, + GtkStateFlags state, + GdkRGBA* color) { + if (!sample_widget_color(widget, style_class, state, color)) { + return FALSE; + } + GtkStyleContext* context = gtk_widget_get_style_context(widget); + gdouble opacity = 1.0; + gtk_style_context_get(context, state, "opacity", &opacity, nullptr); + if (!std::isfinite(opacity)) { + opacity = 1.0; + } + color->alpha *= CLAMP(opacity, 0.0, 1.0); + return color_is_visible(color); +} + static const gchar* brightness_for_color(const GdkRGBA* color) { if (color == nullptr) { return "light"; @@ -1935,6 +2088,8 @@ static FlValue* get_gtk_theme_colors() { GtkWidget* dialog = gtk_dialog_new(); GtkWidget* popover = gtk_popover_new(nullptr); GtkWidget* control = gtk_button_new(); + GtkWidget* separator = gtk_separator_new(GTK_ORIENTATION_HORIZONTAL); + GtkWidget* dim_label = gtk_label_new(nullptr); GdkRGBA window_color = {0, 0, 0, 0}; GdkRGBA view_color = {0, 0, 0, 0}; @@ -1948,10 +2103,12 @@ static FlValue* get_gtk_theme_colors() { GdkRGBA control_hover_color = {0, 0, 0, 0}; GdkRGBA control_active_color = {0, 0, 0, 0}; GdkRGBA accent_color = {0, 0, 0, 0}; + GdkRGBA accent_foreground_color = {0, 0, 0, 0}; GdkRGBA foreground_color = {0, 0, 0, 0}; GdkRGBA muted_foreground_color = {0, 0, 0, 0}; GdkRGBA border_color = {0, 0, 0, 0}; - GdkRGBA subtle_border_color = {0, 0, 0, 0}; + GdkRGBA divider_color = {0, 0, 0, 0}; + GdkRGBA floating_border_color = {0, 0, 0, 0}; GdkRGBA sidebar_border_color = {0, 0, 0, 0}; GdkRGBA shade_color = {0, 0, 0, 0}; @@ -1970,8 +2127,9 @@ static FlValue* get_gtk_theme_colors() { &foreground_color) || sample_widget_color(window, GTK_STYLE_CLASS_BACKGROUND, GTK_STATE_FLAG_NORMAL, &foreground_color); - lookup_context_color(window_context, "theme_unfocused_fg_color", - &muted_foreground_color); + sample_widget_color_with_opacity( + dim_label, GTK_STYLE_CLASS_DIM_LABEL, GTK_STATE_FLAG_NORMAL, + &muted_foreground_color); lookup_context_color(window_context, "borders", &border_color); lookup_context_color(window_context, "sidebar_border_color", &sidebar_border_color); @@ -1980,6 +2138,10 @@ static FlValue* get_gtk_theme_colors() { lookup_context_color(window_context, "accent_bg_color", &accent_color) || lookup_context_color(window_context, "theme_selected_bg_color", &accent_color); + lookup_context_color(window_context, "accent_fg_color", + &accent_foreground_color) || + lookup_context_color(window_context, "theme_selected_fg_color", + &accent_foreground_color); // Prefer public semantic roles. Classic GTK 3 themes often expose only // widget-class styling, so retain those samples as compatibility input; @@ -2005,6 +2167,10 @@ static FlValue* get_gtk_theme_colors() { lookup_context_color(window_context, "popover_bg_color", &popover_color) || sample_widget_background(popover, GTK_STYLE_CLASS_BACKGROUND, GTK_STATE_FLAG_NORMAL, &popover_color); + sample_widget_border_color(popover, GTK_STYLE_CLASS_BACKGROUND, + GTK_STATE_FLAG_NORMAL, &floating_border_color); + sample_widget_background(separator, GTK_STYLE_CLASS_SEPARATOR, + GTK_STATE_FLAG_NORMAL, ÷r_color); sample_widget_background(control, nullptr, GTK_STATE_FLAG_NORMAL, &control_color); @@ -2013,11 +2179,6 @@ static FlValue* get_gtk_theme_colors() { sample_widget_background(control, nullptr, GTK_STATE_FLAG_ACTIVE, &control_active_color); - if (color_is_visible(&border_color)) { - subtle_border_color = border_color; - subtle_border_color.alpha *= 0.56; - } - FlValue* result = fl_value_new_map(); fl_value_set_string_take( result, "brightness", @@ -2034,14 +2195,18 @@ static FlValue* get_gtk_theme_colors() { set_theme_color(result, "controlHover", &control_hover_color); set_theme_color(result, "controlActive", &control_active_color); set_theme_color(result, "accent", &accent_color); + set_theme_color(result, "accentForeground", &accent_foreground_color); set_theme_color(result, "activeToggle", &control_active_color); set_theme_color(result, "foreground", &foreground_color); set_theme_color(result, "mutedForeground", &muted_foreground_color); set_theme_color(result, "border", &border_color); - set_theme_color(result, "subtleBorder", &subtle_border_color); + set_theme_color(result, "divider", ÷r_color); + set_theme_color(result, "floatingBorder", &floating_border_color); set_theme_color(result, "sidebarBorder", &sidebar_border_color); set_theme_color(result, "shade", &shade_color); + gtk_widget_destroy(dim_label); + gtk_widget_destroy(separator); gtk_widget_destroy(control); gtk_widget_destroy(popover); gtk_widget_destroy(dialog); @@ -2738,6 +2903,7 @@ static void configure_compact_agenda_subwindow(FlPluginRegistry* registry) { register_compact_agenda_window_channel(view, window); register_compact_gtk_settings_channel(view, window); register_native_date_time_picker_for_subwindow(view, window); + register_native_dialogs_for_subwindow(view, window); } // Called when first Flutter frame received. @@ -2806,6 +2972,7 @@ static void my_application_activate(GApplication* application) { fl_register_plugins(registry); }); register_native_date_time_picker(self, view, window); + register_native_dialogs(self, view, window); register_window_channel(self, view); register_header_bar_channel(self, view); register_gtk_settings_channel(self, view); @@ -2858,6 +3025,7 @@ static void my_application_dispose(GObject* object) { g_clear_object(&self->header_bar_css_provider); } g_clear_object(&self->native_date_time_picker_channel); + g_clear_object(&self->native_dialog_channel); g_clear_object(&self->window_channel); g_clear_object(&self->header_bar_channel); g_clear_object(&self->gtk_settings_channel); @@ -2938,6 +3106,7 @@ static void my_application_class_init(MyApplicationClass* klass) { static void my_application_init(MyApplication* self) { self->native_date_time_picker_channel = nullptr; + self->native_dialog_channel = nullptr; self->window_channel = nullptr; self->header_bar_channel = nullptr; self->gtk_settings_channel = nullptr; diff --git a/test/app/busymax_dialogs_test.dart b/test/app/busymax_dialogs_test.dart index 35a23be..0846b0e 100644 --- a/test/app/busymax_dialogs_test.dart +++ b/test/app/busymax_dialogs_test.dart @@ -3,6 +3,7 @@ import 'package:busymax/src/app/busymax_dialogs.dart'; import 'package:busymax/src/app/busymax_shortcuts.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/native_dialog_service.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter/services.dart'; @@ -12,6 +13,81 @@ import '../test_localized_app.dart'; void main() { TestWidgetsFlutterBinding.ensureInitialized(); + const nativeDialogChannel = MethodChannel(nativeDialogChannelName); + + setUp(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(nativeDialogChannel, (_) async => null); + }); + + tearDown(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(nativeDialogChannel, null); + }); + + testWidgets('confirmation uses the native host when available', ( + tester, + ) async { + const channel = MethodChannel('busymax_test/native_confirmation'); + const headerChannel = MethodChannel( + 'busymax_test/native_confirmation_header', + ); + final calls = []; + final headerCalls = []; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + calls.add(call); + return true; + }); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(headerChannel, (call) async { + headerCalls.add(call); + return call.method == 'initialize' ? true : null; + }); + addTearDown(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, null); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(headerChannel, null); + }); + final headerBarService = LinuxHeaderBarService( + channel: headerChannel, + isLinux: true, + ); + addTearDown(headerBarService.dispose); + await headerBarService.initialize(); + + late BuildContext hostContext; + await tester.pumpWidget( + localizedTestApp( + child: Builder( + builder: (context) { + hostContext = context; + return const SizedBox(); + }, + ), + ), + ); + + final result = showBusyMaxConfirm( + hostContext, + title: 'Discard changes?', + message: 'Unsaved changes will be lost.', + confirmLabel: 'Discard', + destructive: true, + headerBarService: headerBarService, + nativeDialogService: const NativeDialogService(channel: channel), + ); + await tester.pump(); + + expect(await result, isTrue); + expect(find.byType(BusyMaxConfirmDialog), findsNothing); + expect(calls.single.method, 'confirm'); + expect( + headerCalls.where((call) => call.method == 'setModalBarrierVisible'), + isEmpty, + ); + }); testWidgets('modal coordinator synchronizes the native barrier', ( tester, diff --git a/test/app/busymax_grouped_surface_test.dart b/test/app/busymax_grouped_surface_test.dart index 3688256..65a4488 100644 --- a/test/app/busymax_grouped_surface_test.dart +++ b/test/app/busymax_grouped_surface_test.dart @@ -28,9 +28,14 @@ void main() { body: BusyMaxGroupedList( filled: true, children: [ - BusyMaxActionRow(title: 'Calendar', onTap: () {}), + BusyMaxActionRow( + title: 'Calendar', + subtitle: 'Personal account', + onTap: () {}, + ), const BusyMaxSwitchRow( title: 'Notifications', + subtitle: 'Sync changes automatically', value: true, onChanged: _ignoreBool, ), @@ -55,7 +60,7 @@ void main() { expect(materialSurface.elevation, BusyMaxElevation.card); expect(materialSurface.shadowColor, theme.colorScheme.shadow); final shape = materialSurface.shape! as RoundedRectangleBorder; - expect(shape.side.color, colors.subtleBorder); + expect(shape.side, BorderSide.none); expect( find.descendant( of: groupedSurface, @@ -71,10 +76,82 @@ void main() { materialLayers.where((material) => material.color == colors.control), isEmpty, ); + expect( + DefaultTextStyle.of( + tester.element(find.text('Personal account')), + ).style.color, + colors.mutedForeground, + ); + expect( + DefaultTextStyle.of( + tester.element(find.text('Sync changes automatically')), + ).style.color, + colors.mutedForeground, + ); }, ); } + testWidgets('disabled grouped subtitles use the semantic disabled role', ( + tester, + ) async { + final theme = BusyMaxYaruTheme.build( + brightness: Brightness.dark, + accentColor: const Color(0xFF3584E4), + ); + final colors = theme.extension()!; + + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: const Scaffold( + body: BusyMaxActionRow( + title: 'Calendar', + subtitle: 'Account unavailable', + enabled: false, + ), + ), + ), + ); + + expect( + DefaultTextStyle.of( + tester.element(find.text('Account unavailable')), + ).style.color, + colors.disabledForeground, + ); + }); + + testWidgets('grouped cards add a semantic outline in high contrast', ( + tester, + ) async { + final theme = BusyMaxYaruTheme.build( + brightness: Brightness.dark, + accentColor: const Color(0xFF3584E4), + highContrast: true, + ); + + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: const MediaQuery( + data: MediaQueryData(highContrast: true), + child: BusyMaxGroupedSurface(child: SizedBox(height: 48)), + ), + ), + ); + + final materialSurface = tester.widget( + find.descendant( + of: find.byType(BusyMaxGroupedSurface), + matching: find.byType(Material), + ), + ); + final shape = materialSurface.shape! as RoundedRectangleBorder; + expect(shape.side.color, theme.colorScheme.outline); + expect(shape.side.width, BusyMaxStroke.outline); + }); + for (final brightness in Brightness.values) { testWidgets('rows use the subtle Yaru $brightness hover role', ( tester, @@ -619,7 +696,7 @@ void main() { final modalShape = modalMaterial.shape! as RoundedRectangleBorder; expect(modalMaterial.elevation, BusyMaxElevation.window); expect(modalMaterial.shadowColor, theme.colorScheme.shadow); - expect(modalShape.side.color, colors.subtleBorder); + expect(modalShape.side.color, colors.floatingBorder); expect(modalShape.side.width, BusyMaxStroke.outline); final physicalShape = tester.widget( @@ -704,6 +781,12 @@ void main() { expect(control.isSelected, [isTrue, isFalse]); final theme = Theme.of(tester.element(find.byType(ToggleButtons))); final colors = theme.extension()!; + expect( + DefaultTextStyle.of( + tester.element(find.text('Use dates only or set specific times.')), + ).style.color, + colors.mutedForeground, + ); expect(theme.toggleButtonsTheme.fillColor, colors.controlActive); expect(theme.toggleButtonsTheme.fillColor, isNot(accentColor)); expect(theme.toggleButtonsTheme.selectedColor, colors.foreground); diff --git a/test/app/busymax_menu_button_test.dart b/test/app/busymax_menu_button_test.dart index 6f778e2..dc67f40 100644 --- a/test/app/busymax_menu_button_test.dart +++ b/test/app/busymax_menu_button_test.dart @@ -13,7 +13,7 @@ void main() { String? selected; final theme = BusyMaxYaruTheme.build( brightness: Brightness.dark, - accentColor: BusyMaxLinuxPalette.ubuntuOrangeAccent, + accentColor: YaruColors.orange, ); await tester.pumpWidget( diff --git a/test/app/high_contrast_theme_test.dart b/test/app/high_contrast_theme_test.dart index e3e7205..626cc3d 100644 --- a/test/app/high_contrast_theme_test.dart +++ b/test/app/high_contrast_theme_test.dart @@ -51,7 +51,8 @@ void main() { expect(surfaces.mutedForeground, surfaces.foreground); expect(surfaces.disabledForeground, isNot(surfaces.foreground)); expect(surfaces.border, surfaces.foreground); - expect(surfaces.subtleBorder, surfaces.foreground); + expect(surfaces.divider, surfaces.foreground); + expect(surfaces.floatingBorder, surfaces.foreground); expect(surfaces.sidebarBorder, surfaces.foreground); expect(theme.colorScheme.outline, surfaces.foreground); expect(theme.colorScheme.outlineVariant, surfaces.foreground); diff --git a/test/app/native_ui_audit_test.dart b/test/app/native_ui_audit_test.dart index 79b3ab2..8cf8a4c 100644 --- a/test/app/native_ui_audit_test.dart +++ b/test/app/native_ui_audit_test.dart @@ -321,6 +321,7 @@ void main() { runner, contains('register_native_date_time_picker_for_subwindow'), ); + expect(runner, contains('register_native_dialogs_for_subwindow')); expect(runner, contains('window#busymax-compact-agenda-window')); expect(runner, contains('gtk_window_get_titlebar(window)')); expect(runner, contains('gtk_widget_hide(titlebar)')); @@ -973,6 +974,43 @@ void main() { expect(source, isNot(contains('"openMenu"'))); }); + test('Linux confirmations are native GTK dialogs with a Yaru fallback', () { + final runner = File('linux/runner/my_application.cc').readAsStringSync(); + final dialogs = File( + 'lib/src/app/busymax_dialogs.dart', + ).readAsStringSync(); + final design = File('lib/src/app/busymax_design.dart').readAsStringSync(); + final confirmStart = design.indexOf('class BusyMaxConfirmDialog'); + final confirmBody = design.substring(confirmStart); + + expect(runner, contains('"busymax/native_dialogs"')); + expect(runner, contains('gtk_message_dialog_new(')); + expect(runner, contains('GTK_DIALOG_DESTROY_WITH_PARENT')); + expect(runner, contains('GTK_STYLE_CLASS_DESTRUCTIVE_ACTION')); + expect(runner, contains('GTK_STYLE_CLASS_SUGGESTED_ACTION')); + expect( + runner, + contains( + 'gtk_dialog_set_default_response(GTK_DIALOG(dialog), ' + 'GTK_RESPONSE_CANCEL)', + ), + ); + expect(runner, contains('register_native_dialogs(self, view, window)')); + expect( + runner, + contains('register_native_dialogs_for_subwindow(view, window)'), + ); + expect(runner, contains('g_object_add_weak_pointer')); + expect(runner, contains('native_dialog_handler_data_free')); + expect( + runner, + isNot(contains('native_dialog_method_call_cb, g_object_ref(window)')), + ); + expect(dialogs, contains('NativeDialogService nativeDialogService')); + expect(confirmBody, contains('return AlertDialog(')); + expect(confirmBody, isNot(contains('return BusyMaxDialogShell('))); + }); + test('native headerbar CSS is limited to semantic surfaces', () { final source = File('linux/runner/my_application.cc').readAsStringSync(); final headerCssStart = source.indexOf( @@ -1163,20 +1201,29 @@ void main() { 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, "floatingBorder"')); + 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( - 'ubuntuAccentColor ?? gtkThemeColors?.accent ?? systemColor.accent', + 'gtkThemeColors?.accent ?? ubuntuAccentColor ?? systemColor.accent', ), ); expect( compactApp, contains( - 'ubuntuAccentColor ?? gtkThemeColors?.accent ?? systemColor.accent', + 'gtkThemeColors?.accent ?? ubuntuAccentColor ?? systemColor.accent', ), ); expect(source, contains('fl_lookup_optional_bool_arg')); @@ -1239,7 +1286,7 @@ void main() { 'ButtonStyle busyMaxDropdownMenuItemStyle', ); final itemEnd = source.indexOf( - 'ButtonStyle busyMaxPushButtonStyle', + "/// BusyMax's cross-platform fallback for a native desktop search entry.", itemStart, ); final menuBody = source.substring(menuStart, itemStart); @@ -1277,7 +1324,14 @@ void main() { for (var index = 0; index < lines.length; index++) { final line = lines[index]; final location = '${file.path}:${index + 1}'; - expect(line, isNot(contains('AlertDialog')), reason: location); + final isSharedConfirmationFallback = + file.path.endsWith('lib/src/app/busymax_design.dart') && + line.contains('return AlertDialog('); + expect( + line.contains('AlertDialog'), + isSharedConfirmationFallback, + reason: location, + ); expect( line, isNot(contains('DropdownButtonFormField')), diff --git a/test/app/system_accent_test.dart b/test/app/system_accent_test.dart index 3db300f..dfb3c1a 100644 --- a/test/app/system_accent_test.dart +++ b/test/app/system_accent_test.dart @@ -2,6 +2,7 @@ import 'package:busymax/src/app/system_accent.dart'; import 'package:dbus/dbus.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:yaru/theme.dart'; void main() { test('reads RGB portal accent color values', () { @@ -24,6 +25,7 @@ void main() { colorFromUbuntuAccentNameValue(const DBusString('purple')), const Color(0xff7764d8), ); + expect(ubuntuAccentNameColor('orange'), YaruColors.orange); expect(colorFromUbuntuAccentNameValue(const DBusString('unknown')), isNull); }); } diff --git a/test/app/theme_localization_test.dart b/test/app/theme_localization_test.dart index 4c6bffc..0c481a6 100644 --- a/test/app/theme_localization_test.dart +++ b/test/app/theme_localization_test.dart @@ -121,15 +121,6 @@ void main() { as RoundedRectangleBorder; expect(outlinedShape.borderRadius, BorderRadius.circular(BusyMaxRadius.sm)); - final pushButtonStyle = busyMaxPushButtonStyle(null); - expect( - pushButtonStyle.minimumSize?.resolve({}), - BusyMaxSizes.pushButtonSize, - ); - expect( - pushButtonStyle.fixedSize?.resolve({}), - const Size.fromHeight(BusyMaxSizes.pushButtonHeight), - ); expect( light.outlinedButtonTheme.style?.side?.resolve({}), yaruBase.outlinedButtonTheme.style?.side?.resolve({}), @@ -223,19 +214,11 @@ void main() { onPressed: () {}, child: const Text('Suggested'), ); - final headerStandard = BusyMaxHeaderPushButton.standard( - onPressed: () {}, - child: const Text('Cancel'), - ); - final headerSuggested = BusyMaxHeaderPushButton.suggested( - onPressed: () {}, - child: const Text('Save'), - ); expect(standard, isA()); expect(suggested, isA()); - expect(headerStandard, isA()); - expect(headerSuggested, isA()); + expect(standard.style, isNull); + expect(suggested.style, isNull); }); test('BusyMax Yaru theme exposes semantic fallback surfaces', () { @@ -636,7 +619,8 @@ void main() { disabledForeground: Color(0x61FFFFFF), disabledControl: Color(0x0FFFFFFF), border: Color(0x66000000), - subtleBorder: Color(0x1AFFFFFF), + divider: Color(0x1AFFFFFF), + floatingBorder: Color(0x24000000), sidebarBorder: Color(0x33000000), shade: Color(0x55000000), ); @@ -659,6 +643,27 @@ void main() { expect(colors.groupedSurface, gtkColors.card); }); + test('BusyMax theme uses GTK accent foreground when it is readable', () { + const accent = Color(0xFF006B50); + const accentForeground = Color(0xFFF5FFF9); + const gtkColors = GtkThemeColors( + brightness: Brightness.light, + accent: accent, + accentForeground: accentForeground, + ); + final theme = _buildBusyMaxTheme( + brightness: Brightness.light, + accentColor: accent, + gtkThemeColors: gtkColors, + ); + + expect(theme.colorScheme.onPrimary, accentForeground); + expect( + theme.elevatedButtonTheme.style?.foregroundColor?.resolve({}), + accentForeground, + ); + }); + test('BusyMax theme ignores light GTK runtime shade samples', () { const gtkColors = GtkThemeColors( brightness: Brightness.light, @@ -863,6 +868,23 @@ void main() { ); }); + test('BusyMax keeps native divider and floating outline roles separate', () { + const gtkColors = GtkThemeColors( + brightness: Brightness.dark, + window: Color(0xFF2C2C2C), + card: Color(0xFF3D3D3D), + divider: Color.fromRGBO(0, 0, 6, 0.56), + floatingBorder: Color.fromRGBO(255, 255, 255, 0.14), + ); + final colors = _buildBusyMaxTheme( + brightness: Brightness.dark, + gtkThemeColors: gtkColors, + ).extension()!; + + expect(colors.divider, gtkColors.divider); + expect(colors.floatingBorder, gtkColors.floatingBorder); + }); + test('BusyMax theme preserves chromatic GTK dark surface samples', () { const gtkColors = GtkThemeColors( brightness: Brightness.dark, @@ -1079,9 +1101,14 @@ void main() { gtkThemeColors: gtkColors, ); final colors = theme.extension()!; + final fallback = busyMaxFallbackSurfaceColors(Brightness.dark); - expect(colors.foreground, const Color(0xFFFFFFFF)); - expect(colors.mutedForeground, const Color.fromRGBO(255, 255, 255, 0.70)); + expect(colors.foreground, fallback.foreground); + expect(colors.mutedForeground, fallback.mutedForeground); + expect( + colors.mutedForeground.a, + closeTo(colors.foreground.a * 0.55, 0.001), + ); expect(theme.colorScheme.onSurface, colors.foreground); expect(theme.colorScheme.onSurfaceVariant, colors.mutedForeground); }); @@ -1381,7 +1408,7 @@ void main() { expect(windowService.hideWindowCalls, 1); }); - test('app sources avoid forbidden hardcoded accent colors', () { + test('production sources avoid forbidden hardcoded accent colors', () { final disallowedHue = String.fromCharCodes([111, 114, 97, 110, 103, 101]); final forbidden = [ '0xFF0D6E' @@ -1391,20 +1418,21 @@ void main() { 'Colors.' 'red', 'Colors.$disallowedHue', - 'YaruVariant.$disallowedHue', ]; final matches = []; + const centralizedAccentMapping = 'lib/src/app/system_accent.dart'; - for (final root in [Directory('lib'), Directory('test')]) { - for (final entry in root.listSync(recursive: true)) { - if (entry is! File) { - continue; - } - final text = utf8.decode(entry.readAsBytesSync(), allowMalformed: true); - for (final token in forbidden) { - if (text.contains(token)) { - matches.add('${entry.path}: $token'); - } + for (final entry in Directory('lib').listSync(recursive: true)) { + if (entry is! File) { + continue; + } + final text = utf8.decode(entry.readAsBytesSync(), allowMalformed: true); + final fileForbidden = entry.path == centralizedAccentMapping + ? forbidden + : [...forbidden, 'YaruVariant.', 'YaruColors.']; + for (final token in fileForbidden) { + if (text.contains(token)) { + matches.add('${entry.path}: $token'); } } } diff --git a/test/features/auth/presentation/auth_routing_test.dart b/test/features/auth/presentation/auth_routing_test.dart index 94d3c60..3dc7ace 100644 --- a/test/features/auth/presentation/auth_routing_test.dart +++ b/test/features/auth/presentation/auth_routing_test.dart @@ -22,9 +22,12 @@ import 'package:busymax/src/google_tasks/oauth/oauth_models.dart'; import 'package:busymax/src/google_tasks/oauth/oauth_service.dart'; import 'package:busymax/src/google_tasks/oauth/oauth_token_store.dart'; import 'package:busymax/src/platform/linux_header_bar_service.dart'; +import 'package:busymax/src/platform/native_dialog_service.dart'; import 'package:busymax/src/schedule/schedule_scope.dart'; import 'package:busymax/src/task_providers/task_provider.dart'; +const _nativeDialogChannel = MethodChannel(nativeDialogChannelName); + void main() { late AppDatabase database; late _FakeOAuthGateway oAuth; @@ -32,9 +35,13 @@ void main() { setUp(() { database = AppDatabase(NativeDatabase.memory()); oAuth = _FakeOAuthGateway(); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(_nativeDialogChannel, (_) async => null); }); tearDown(() async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(_nativeDialogChannel, null); await database.close(); }); diff --git a/test/features/calendar/presentation/event_editor_test.dart b/test/features/calendar/presentation/event_editor_test.dart index 3112e78..c5751c2 100644 --- a/test/features/calendar/presentation/event_editor_test.dart +++ b/test/features/calendar/presentation/event_editor_test.dart @@ -7,6 +7,7 @@ import 'package:busymax/src/features/calendar/presentation/event_editor_draft.da import 'package:busymax/src/features/tasks/presentation/desktop_date_time_fields.dart'; import 'package:busymax/src/app/busymax_design.dart'; import 'package:busymax/src/microsoft_calendar/microsoft_calendar_mapper.dart'; +import 'package:busymax/src/platform/native_dialog_service.dart'; import 'package:busymax/src/task_providers/task_provider.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; @@ -16,8 +17,22 @@ import 'package:yaru/yaru.dart'; import '../../../test_localized_app.dart'; +const _nativeDialogChannel = MethodChannel(nativeDialogChannelName); + void main() { - testWidgets('header buttons use compact headerbar sizing', (tester) async { + setUp(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(_nativeDialogChannel, (_) async => null); + }); + + tearDown(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(_nativeDialogChannel, null); + }); + + testWidgets('editor actions use standard desktop push-button sizing', ( + tester, + ) async { await tester.pumpWidget( localizedTestApp( child: Scaffold( @@ -47,11 +62,11 @@ void main() { ); expect( tester.getSize(_headerButtonFinder('Cancel')).height, - BusyMaxSizes.headerIconButton, + inInclusiveRange(kPushButtonSize.height, kMinInteractiveDimension), ); expect( tester.getSize(_headerButtonFinder('Save')).height, - BusyMaxSizes.headerIconButton, + inInclusiveRange(kPushButtonSize.height, kMinInteractiveDimension), ); expect( find.ancestor( @@ -1145,12 +1160,9 @@ void main() { expect(design, contains('class BusyMaxModalEditorScaffold')); expect(design, contains('BusyMaxEditorHeader(')); expect(design, contains('SingleChildScrollView')); - expect(design, contains('BusyMaxHeaderPushButton.standard')); - expect(design, contains('BusyMaxHeaderPushButton.suggested')); - expect( - design, - contains('EdgeInsets.symmetric(horizontal: BusyMaxSpacing.xl)'), - ); + expect(design, contains('BusyMaxPushButton.standard')); + expect(design, contains('BusyMaxPushButton.suggested')); + expect(design, isNot(contains('BusyMaxHeaderPushButton'))); expect(design, contains('textAlign: TextAlign.center')); expect(design, contains('textTheme.titleMedium')); expect(editor, isNot(contains('BusyMaxDialogCloseButton'))); diff --git a/test/features/feedback/presentation/feedback_dialog_test.dart b/test/features/feedback/presentation/feedback_dialog_test.dart index 8bb6422..6c5186e 100644 --- a/test/features/feedback/presentation/feedback_dialog_test.dart +++ b/test/features/feedback/presentation/feedback_dialog_test.dart @@ -3,6 +3,7 @@ import 'dart:async'; import 'package:busymax/src/features/feedback/data/feedback_api_client.dart'; import 'package:busymax/src/features/feedback/data/feedback_submission.dart'; import 'package:busymax/src/features/feedback/presentation/feedback_dialog.dart'; +import 'package:busymax/src/platform/native_dialog_service.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -10,7 +11,19 @@ import 'package:yaru/yaru.dart'; import '../../../test_localized_app.dart'; +const _nativeDialogChannel = MethodChannel(nativeDialogChannelName); + void main() { + setUp(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(_nativeDialogChannel, (_) async => null); + }); + + tearDown(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(_nativeDialogChannel, null); + }); + testWidgets('shows required-field validation without sending', ( tester, ) async { diff --git a/test/features/schedule/presentation/schedule_views_test.dart b/test/features/schedule/presentation/schedule_views_test.dart index 2d9ccae..f6280bb 100644 --- a/test/features/schedule/presentation/schedule_views_test.dart +++ b/test/features/schedule/presentation/schedule_views_test.dart @@ -2282,7 +2282,7 @@ void main() { expect(eventBlock, contains('color: surfaceColors.control')); expect(eventBlock, contains('sourceAccent')); expect(taskChip, contains('color: surfaceColors.control')); - expect(taskChip, contains('color: surfaceColors.subtleBorder')); + expect(taskChip, contains('color: surfaceColors.divider')); expect(taskChip, contains('YaruCheckbox(')); expect(taskChip, isNot(contains('selectedColor:'))); expect(taskChip, isNot(contains('checkmarkColor:'))); 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 8c63db3..a844314 100644 --- a/test/features/schedule/presentation/schedule_workspace_task_mutations_test.dart +++ b/test/features/schedule/presentation/schedule_workspace_task_mutations_test.dart @@ -6,6 +6,7 @@ import 'package:busymax/src/features/schedule/presentation/schedule_workspace.da import 'package:busymax/src/features/task_lists/data/task_lists_repository.dart'; import 'package:busymax/src/features/tasks/data/tasks_repository.dart'; import 'package:busymax/src/platform/linux_header_bar_service.dart'; +import 'package:busymax/src/platform/native_dialog_service.dart'; import 'package:busymax/src/schedule/schedule_scope.dart'; import 'package:busymax/src/task_providers/task_provider.dart'; import 'package:drift/drift.dart'; @@ -17,7 +18,19 @@ import 'package:yaru/yaru.dart'; import '../../../test_localized_app.dart'; +const _nativeDialogChannel = MethodChannel(nativeDialogChannelName); + void main() { + setUp(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(_nativeDialogChannel, (_) async => null); + }); + + tearDown(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(_nativeDialogChannel, null); + }); + testWidgets('creating a task from Schedule refreshes the visible items', ( tester, ) async { diff --git a/test/features/settings/presentation/settings_screen_test.dart b/test/features/settings/presentation/settings_screen_test.dart index bd0db25..4d9f9c4 100644 --- a/test/features/settings/presentation/settings_screen_test.dart +++ b/test/features/settings/presentation/settings_screen_test.dart @@ -297,7 +297,7 @@ void main() { ); final theme = BusyMaxYaruTheme.build( brightness: Brightness.light, - accentColor: BusyMaxLinuxPalette.ubuntuOrangeAccent, + accentColor: YaruColors.orange, gtkThemeColors: gtkColors, ); diff --git a/test/features/tasks/presentation/task_details_pane_test.dart b/test/features/tasks/presentation/task_details_pane_test.dart index 0163418..916a94e 100644 --- a/test/features/tasks/presentation/task_details_pane_test.dart +++ b/test/features/tasks/presentation/task_details_pane_test.dart @@ -14,6 +14,7 @@ import 'package:busymax/src/features/tasks/data/tasks_repository.dart'; import 'package:busymax/src/features/tasks/presentation/desktop_date_time_fields.dart'; import 'package:busymax/src/features/tasks/presentation/task_details_editor.dart'; import 'package:busymax/src/features/tasks/presentation/task_details_pane.dart'; +import 'package:busymax/src/platform/native_dialog_service.dart'; import 'package:busymax/src/task_providers/task_provider.dart'; import 'package:ubuntu_widgets/ubuntu_widgets.dart'; import 'package:yaru/yaru.dart'; @@ -21,13 +22,21 @@ import 'package:yaru/yaru.dart'; import '../../../test_localized_app.dart'; const _nativePickerChannel = MethodChannel(nativeDateTimePickerChannelName); +const _nativeDialogChannel = MethodChannel(nativeDialogChannelName); void main() { + setUp(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(_nativeDialogChannel, (_) async => null); + }); + tearDown(() { TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger .setMockMethodCallHandler(_nativePickerChannel, (_) async { throw MissingPluginException(); }); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(_nativeDialogChannel, null); }); testWidgets('Task Details header shows Cancel and Save', (tester) async { @@ -39,7 +48,7 @@ void main() { expect(find.text('Save'), findsOneWidget); }); - testWidgets('Cancel and Save are compact PushButtons', (tester) async { + testWidgets('Cancel and Save are desktop PushButtons', (tester) async { await _pumpDetails(tester, microsoftTaskProviderCapabilities); expect( @@ -58,7 +67,9 @@ void main() { ); }); - testWidgets('header buttons use compact headerbar sizing', (tester) async { + testWidgets('editor actions use standard desktop push-button sizing', ( + tester, + ) async { await _pumpDetails(tester, microsoftTaskProviderCapabilities); expect( @@ -71,11 +82,11 @@ void main() { ); expect( tester.getSize(_headerButtonFinder(tester, 'Cancel')).height, - BusyMaxSizes.headerIconButton, + inInclusiveRange(kPushButtonSize.height, kMinInteractiveDimension), ); expect( tester.getSize(_headerButtonFinder(tester, 'Save')).height, - BusyMaxSizes.headerIconButton, + inInclusiveRange(kPushButtonSize.height, kMinInteractiveDimension), ); }); diff --git a/test/platform/gtk_font_service_test.dart b/test/platform/gtk_font_service_test.dart index 0f8879c..de5aefd 100644 --- a/test/platform/gtk_font_service_test.dart +++ b/test/platform/gtk_font_service_test.dart @@ -276,13 +276,15 @@ void main() { 'controlHover': '#2EFFFFFF', 'controlActive': '#33FFFFFF', 'accent': '#C061CB', + 'accentForeground': '#FFFFFFFF', 'activeToggle': '#44FFFFFF', 'foreground': '#FFFFFF', 'mutedForeground': '#C0C0C0', 'disabledForeground': '#61FFFFFF', 'disabledControl': '#0FFFFFFF', 'border': '#99000000', - 'subtleBorder': '#1AFFFFFF', + 'divider': '#1AFFFFFF', + 'floatingBorder': '#24000000', 'sidebarBorder': '#33000000', 'shade': '#55000000', }; @@ -304,7 +306,9 @@ void main() { expect(colors?.control, const Color(0x1AFFFFFF)); expect(colors?.controlActive, const Color(0x33FFFFFF)); expect(colors?.accent, const Color(0xFFC061CB)); - expect(colors?.subtleBorder, const Color(0x1AFFFFFF)); + expect(colors?.accentForeground, const Color(0xFFFFFFFF)); + expect(colors?.divider, const Color(0x1AFFFFFF)); + expect(colors?.floatingBorder, const Color(0x24000000)); }); test('missing native GTK theme color channel falls back to null', () async { diff --git a/test/platform/native_dialog_service_test.dart b/test/platform/native_dialog_service_test.dart new file mode 100644 index 0000000..7deec4d --- /dev/null +++ b/test/platform/native_dialog_service_test.dart @@ -0,0 +1,80 @@ +import 'package:busymax/src/platform/native_dialog_service.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + const channel = MethodChannel('busymax_test/native_dialogs'); + + tearDown(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, null); + }); + + test('passes semantic confirmation data to the native host', () async { + MethodCall? receivedCall; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + receivedCall = call; + return true; + }); + const service = NativeDialogService(channel: channel); + + final result = await service.confirm( + title: 'Discard changes?', + message: 'Unsaved changes will be lost.', + cancelLabel: 'Cancel', + confirmLabel: 'Discard', + destructive: true, + ); + + expect(result.available, isTrue); + expect(result.confirmed, isTrue); + expect(receivedCall?.method, 'confirm'); + expect(receivedCall?.arguments, { + 'title': 'Discard changes?', + 'message': 'Unsaved changes will be lost.', + 'cancelLabel': 'Cancel', + 'confirmLabel': 'Discard', + 'destructive': true, + }); + }); + + test('distinguishes native cancellation from an unavailable host', () async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (_) async => false); + const service = NativeDialogService(channel: channel); + + final result = await service.confirm( + title: 'Continue?', + message: 'Confirm this action.', + cancelLabel: 'Cancel', + confirmLabel: 'Continue', + destructive: false, + ); + + expect(result.available, isTrue); + expect(result.confirmed, isFalse); + }); + + test('reports unavailable when the native channel is missing', () async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler( + channel, + (_) async => throw MissingPluginException(), + ); + const service = NativeDialogService(channel: channel); + + final result = await service.confirm( + title: 'Continue?', + message: 'Confirm this action.', + cancelLabel: 'Cancel', + confirmLabel: 'Continue', + destructive: false, + ); + + expect(result.available, isFalse); + expect(result.confirmed, isFalse); + }); +} From f35c23d3e6cb4e4b86ecffc38d16cc7544844efa Mon Sep 17 00:00:00 2001 From: albert Date: Thu, 23 Jul 2026 17:28:11 -0700 Subject: [PATCH 11/73] Remove unused localization keys for time mode and time mode description. Refactor event editor and task details editor to simplify widget structure and improve clarity. --- lib/l10n/app_de.arb | 2 - lib/l10n/app_en.arb | 2 - lib/l10n/app_es.arb | 2 - lib/l10n/app_fr.arb | 2 - lib/l10n/generated/app_localizations.dart | 12 - lib/l10n/generated/app_localizations_de.dart | 7 - lib/l10n/generated/app_localizations_en.dart | 6 - lib/l10n/generated/app_localizations_es.dart | 6 - lib/l10n/generated/app_localizations_fr.dart | 7 - lib/src/app/busymax_design.dart | 291 +++++++++------ lib/src/app/busymax_dialogs.dart | 18 +- lib/src/app/busymax_yaru_theme.dart | 59 ++- .../calendar/presentation/event_editor.dart | 79 +--- .../presentation/task_details_editor.dart | 84 +---- test/app/busymax_grouped_surface_test.dart | 351 ++++++++++++------ test/app/native_ui_audit_test.dart | 73 ++++ test/app/theme_localization_test.dart | 38 ++ .../presentation/event_editor_test.dart | 127 +++++-- .../presentation/feedback_dialog_test.dart | 29 +- .../presentation/task_details_pane_test.dart | 83 ++++- test/test_localized_app.dart | 2 + 21 files changed, 777 insertions(+), 503 deletions(-) diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index ad78a50..31c924f 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -91,8 +91,6 @@ "eventTitle": "Termintitel", "location": "Ort", "timeSlot": "Zeitfenster", - "timeMode": "Zeit", - "timeModeDescription": "Nur Daten verwenden oder genaue Uhrzeiten festlegen.", "startDateTime": "Startdatum/-zeit", "endDateTime": "Enddatum/-zeit", "doesNotRepeat": "Wiederholt sich nicht", diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 2b73f86..037913b 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -93,8 +93,6 @@ "eventTitle": "Event title", "location": "Location", "timeSlot": "Time slot", - "timeMode": "Time", - "timeModeDescription": "Use dates only or set specific times.", "startDateTime": "Start date/time", "endDateTime": "End date/time", "doesNotRepeat": "Does not repeat", diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index 7dcf019..ffcfc7b 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -91,8 +91,6 @@ "eventTitle": "Título del evento", "location": "Ubicación", "timeSlot": "Franja horaria", - "timeMode": "Hora", - "timeModeDescription": "Usa solo fechas o define horas concretas.", "startDateTime": "Fecha/hora de inicio", "endDateTime": "Fecha/hora de fin", "doesNotRepeat": "No se repite", diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index 4eaddeb..a1fde37 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -91,8 +91,6 @@ "eventTitle": "Titre de l’événement", "location": "Lieu", "timeSlot": "Créneau", - "timeMode": "Horaire", - "timeModeDescription": "Utilisez uniquement les dates ou définissez des heures précises.", "startDateTime": "Date/heure de début", "endDateTime": "Date/heure de fin", "doesNotRepeat": "Ne se répète pas", diff --git a/lib/l10n/generated/app_localizations.dart b/lib/l10n/generated/app_localizations.dart index bfc693b..0f8d4c1 100644 --- a/lib/l10n/generated/app_localizations.dart +++ b/lib/l10n/generated/app_localizations.dart @@ -648,18 +648,6 @@ abstract class AppLocalizations { /// **'Time slot'** String get timeSlot; - /// No description provided for @timeMode. - /// - /// In en, this message translates to: - /// **'Time'** - String get timeMode; - - /// No description provided for @timeModeDescription. - /// - /// In en, this message translates to: - /// **'Use dates only or set specific times.'** - String get timeModeDescription; - /// No description provided for @startDateTime. /// /// In en, this message translates to: diff --git a/lib/l10n/generated/app_localizations_de.dart b/lib/l10n/generated/app_localizations_de.dart index 50cd8af..31e99e6 100644 --- a/lib/l10n/generated/app_localizations_de.dart +++ b/lib/l10n/generated/app_localizations_de.dart @@ -298,13 +298,6 @@ class AppLocalizationsDe extends AppLocalizations { @override String get timeSlot => 'Zeitfenster'; - @override - String get timeMode => 'Zeit'; - - @override - String get timeModeDescription => - 'Nur Daten verwenden oder genaue Uhrzeiten festlegen.'; - @override String get startDateTime => 'Startdatum/-zeit'; diff --git a/lib/l10n/generated/app_localizations_en.dart b/lib/l10n/generated/app_localizations_en.dart index 5dd1f1e..c14edfa 100644 --- a/lib/l10n/generated/app_localizations_en.dart +++ b/lib/l10n/generated/app_localizations_en.dart @@ -295,12 +295,6 @@ class AppLocalizationsEn extends AppLocalizations { @override String get timeSlot => 'Time slot'; - @override - String get timeMode => 'Time'; - - @override - String get timeModeDescription => 'Use dates only or set specific times.'; - @override String get startDateTime => 'Start date/time'; diff --git a/lib/l10n/generated/app_localizations_es.dart b/lib/l10n/generated/app_localizations_es.dart index c689210..fbd9671 100644 --- a/lib/l10n/generated/app_localizations_es.dart +++ b/lib/l10n/generated/app_localizations_es.dart @@ -300,12 +300,6 @@ class AppLocalizationsEs extends AppLocalizations { @override String get timeSlot => 'Franja horaria'; - @override - String get timeMode => 'Hora'; - - @override - String get timeModeDescription => 'Usa solo fechas o define horas concretas.'; - @override String get startDateTime => 'Fecha/hora de inicio'; diff --git a/lib/l10n/generated/app_localizations_fr.dart b/lib/l10n/generated/app_localizations_fr.dart index 9d6f09f..c1a1bbd 100644 --- a/lib/l10n/generated/app_localizations_fr.dart +++ b/lib/l10n/generated/app_localizations_fr.dart @@ -299,13 +299,6 @@ class AppLocalizationsFr extends AppLocalizations { @override String get timeSlot => 'Créneau'; - @override - String get timeMode => 'Horaire'; - - @override - String get timeModeDescription => - 'Utilisez uniquement les dates ou définissez des heures précises.'; - @override String get startDateTime => 'Date/heure de début'; diff --git a/lib/src/app/busymax_design.dart b/lib/src/app/busymax_design.dart index 09839bc..73aae72 100644 --- a/lib/src/app/busymax_design.dart +++ b/lib/src/app/busymax_design.dart @@ -52,9 +52,7 @@ abstract final class BusyMaxSizes { abstract final class BusyMaxElevation { static const double card = 2; - static const double popover = 6; static const double tooltip = 10; - static const double window = 12; } abstract final class BusyMaxStroke { @@ -1472,6 +1470,91 @@ class BusyMaxCalendarNotesCard extends StatelessWidget { } } +typedef _BusyMaxComboOption = ({int index, String label}); + +/// A theme-owned single-selection control for Flutter form content. +/// +/// Action menus use [BusyMaxMenuButton] because they expose commands. Form +/// selectors instead use [DropdownMenu] in select-only mode, whose geometry, +/// popup surface, typography, and interaction states are explicitly supplied +/// by Yaru's [DropdownMenuThemeData]. +class BusyMaxComboBox extends StatelessWidget { + const BusyMaxComboBox({ + super.key, + required this.values, + required this.selected, + required this.labelFor, + required this.onSelected, + required this.width, + this.enabled = true, + this.tooltip, + this.leadingBuilder, + }) : assert(values.length > 0, 'A combo box requires at least one value.'); + + final List values; + final T selected; + final String Function(T value) labelFor; + final ValueChanged onSelected; + final double width; + final bool enabled; + final String? tooltip; + final Widget Function(BuildContext context, T value)? leadingBuilder; + + @override + Widget build(BuildContext context) { + final selectedIndex = values.indexWhere((value) => value == selected); + assert( + selectedIndex >= 0, + 'The selected combo-box value must be present in values.', + ); + final options = [ + for (var index = 0; index < values.length; index += 1) + (index: index, label: labelFor(values[index])), + ]; + final selectedOption = options[selectedIndex]; + final selector = DropdownMenu<_BusyMaxComboOption>( + width: width, + enabled: enabled, + initialSelection: selectedOption, + selectOnly: true, + enableSearch: false, + trailingIcon: const Icon(YaruIcons.pan_down), + selectedTrailingIcon: const Icon(YaruIcons.pan_up), + leadingIcon: leadingBuilder?.call(context, selected), + dropdownMenuEntries: [ + for (final option in options) + DropdownMenuEntry<_BusyMaxComboOption>( + value: option, + label: option.label, + leadingIcon: leadingBuilder?.call(context, values[option.index]), + labelWidget: Semantics( + selected: option.index == selectedIndex, + child: Text( + option.label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ), + ], + onSelected: enabled + ? (option) { + if (option != null) { + onSelected(values[option.index]); + } + } + : null, + ); + return tooltip == null + ? selector + : Tooltip( + message: tooltip!, + excludeFromSemantics: true, + child: selector, + ); + } +} + class BusyMaxComboRow extends StatelessWidget { const BusyMaxComboRow({ super.key, @@ -1487,8 +1570,7 @@ class BusyMaxComboRow extends StatelessWidget { this.tooltip, this.width = 220, this.trailingAction, - this.menuItemBuilder, - this.selectedBuilder, + this.selectorLeadingBuilder, }); final String title; @@ -1503,8 +1585,7 @@ class BusyMaxComboRow extends StatelessWidget { final String? tooltip; final double width; final Widget? trailingAction; - final Widget Function(BuildContext context, T value)? menuItemBuilder; - final Widget Function(BuildContext context, T value)? selectedBuilder; + final Widget Function(BuildContext context, T value)? selectorLeadingBuilder; @override Widget build(BuildContext context) { @@ -1552,43 +1633,15 @@ class BusyMaxComboRow extends StatelessWidget { : constraints.hasBoundedWidth ? width.clamp(120.0, maximumInlineSelectorWidth).toDouble() : width.clamp(120.0, double.infinity).toDouble(); - final selector = SizedBox( + final selector = BusyMaxComboBox( width: selectorWidth, - child: BusyMaxMenuButton( - tooltip: tooltip ?? title, - entries: [ - for (final value in values) - BusyMaxMenuEntry( - value: value, - label: labelFor(value), - child: menuItemBuilder?.call(context, value), - ), - ], - onSelected: onSelected, - minMenuWidth: selectorWidth, - menuPosition: null, - enabled: enabled, - triggerBuilder: (context, onPressed, focusNode) { - return OutlinedButton( - focusNode: focusNode, - onPressed: onPressed, - child: Row( - children: [ - Expanded( - child: - selectedBuilder?.call(context, selected) ?? - Text( - labelFor(selected), - overflow: TextOverflow.ellipsis, - ), - ), - const SizedBox(width: BusyMaxSpacing.sm), - const Icon(YaruIcons.pan_down), - ], - ), - ); - }, - ), + tooltip: tooltip ?? title, + values: values, + selected: selected, + labelFor: labelFor, + onSelected: onSelected, + enabled: enabled, + leadingBuilder: selectorLeadingBuilder, ); final trailing = Row( mainAxisSize: MainAxisSize.min, @@ -1656,10 +1709,7 @@ class BusyMaxComboRow extends StatelessWidget { : '$title, $subtitle', value: labelFor(selected), child: ExcludeSemantics( - child: Opacity( - opacity: 0.6, - child: ExcludeFocus(child: IgnorePointer(child: validatedRow)), - ), + child: ExcludeFocus(child: IgnorePointer(child: validatedRow)), ), ); return tooltip == null @@ -2239,6 +2289,9 @@ 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, @@ -2247,10 +2300,18 @@ class BusyMaxEditorHeader extends StatelessWidget { 0, ), child: Row( + crossAxisAlignment: CrossAxisAlignment.center, children: [ - BusyMaxPushButton.standard( - onPressed: cancelEnabled ? onCancel : null, - child: Text(cancelLabel, overflow: TextOverflow.ellipsis), + Expanded( + child: Align( + alignment: AlignmentDirectional.centerStart, + heightFactor: 1, + child: FilledButton( + onPressed: cancelEnabled ? onCancel : null, + style: actionStyle, + child: Text(cancelLabel, overflow: TextOverflow.ellipsis), + ), + ), ), Expanded( child: Text( @@ -2261,14 +2322,23 @@ class BusyMaxEditorHeader extends StatelessWidget { style: Theme.of(context).textTheme.titleMedium, ), ), - BusyMaxPushButton.suggested( - onPressed: onSave, - child: saving - ? const SizedBox.square( - dimension: 16, - child: CircularProgressIndicator(strokeWidth: 2), - ) - : Text(saveLabel, overflow: TextOverflow.ellipsis), + Expanded( + child: Align( + alignment: AlignmentDirectional.centerEnd, + heightFactor: 1, + child: ElevatedButton( + onPressed: onSave, + style: actionStyle, + child: saving + ? const ExcludeSemantics( + child: SizedBox.square( + dimension: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ), + ) + : Text(saveLabel, overflow: TextOverflow.ellipsis), + ), + ), ), ], ), @@ -2289,56 +2359,36 @@ class BusyMaxTimeModeRow extends StatelessWidget { @override Widget build(BuildContext context) { final l10n = context.l10n; - final selector = ToggleButtons( - isSelected: [allDay, !allDay], - onPressed: (index) { - final value = index == 0; - if (value != allDay) { - onChanged(value); - } - }, - children: [ - for (final label in [l10n.allDay, l10n.timeSlot]) - Padding( - padding: const EdgeInsets.symmetric(horizontal: BusyMaxSpacing.md), - child: Text(label), - ), - ], - ); - + final labels = [l10n.allDay, l10n.timeSlot]; return LayoutBuilder( builder: (context, constraints) { - final textScale = MediaQuery.textScalerOf(context).scale(14) / 14; - final stackSelector = - !constraints.hasBoundedWidth || - constraints.maxWidth < 480 || - textScale > 1.2; - final label = YaruListTile.square( - title: Text(l10n.timeMode), - subtitle: _busyMaxGroupedRowSubtitle( - context, - Text(l10n.timeModeDescription), - ), - trailing: stackSelector ? null : selector, - ); - if (!stackSelector) { - return label; - } - return Column( - crossAxisAlignment: CrossAxisAlignment.stretch, + final toggleTheme = ToggleButtonsTheme.of(context); + final themeConstraints = toggleTheme.constraints; + final borderWidth = toggleTheme.borderWidth ?? BusyMaxStroke.outline; + final boundedSegmentWidth = constraints.hasBoundedWidth + ? ((constraints.maxWidth - borderWidth * (labels.length + 1)) / + labels.length) + .clamp(0.0, double.infinity) + .toDouble() + : null; + final segmentConstraints = constraints.hasBoundedWidth + ? (themeConstraints ?? const BoxConstraints()).copyWith( + minWidth: boundedSegmentWidth, + maxWidth: boundedSegmentWidth, + ) + : themeConstraints; + return ToggleButtons( + constraints: segmentConstraints, + isSelected: [allDay, !allDay], + onPressed: (index) { + final value = index == 0; + if (value != allDay) { + onChanged(value); + } + }, children: [ - label, - Padding( - padding: const EdgeInsetsDirectional.only( - start: BusyMaxSpacing.md, - end: BusyMaxSpacing.md, - bottom: BusyMaxSpacing.md, - ), - child: Align( - alignment: AlignmentDirectional.centerEnd, - child: selector, - ), - ), + for (final label in labels) + Text(label, maxLines: 1, overflow: TextOverflow.ellipsis), ], ); }, @@ -2418,16 +2468,17 @@ class BusyMaxModalEditorSurface extends StatelessWidget { this.minWidth = 0, this.maxWidth = BusyMaxSizes.compactDetailsWidth, this.maxHeight, + this.insetPadding = EdgeInsets.zero, }); final Widget child; final double minWidth; final double maxWidth; final double? maxHeight; + final EdgeInsets insetPadding; @override Widget build(BuildContext context) { - final surfaceColors = BusyMaxSurfaceColors.of(context); final effectiveMaxWidth = maxWidth.isFinite ? maxWidth.clamp(0.0, double.infinity).toDouble() : maxWidth; @@ -2441,25 +2492,19 @@ class BusyMaxModalEditorSurface extends StatelessWidget { ? double.infinity : maxHeight!.clamp(0.0, double.infinity).toDouble(); - return ConstrainedBox( - constraints: BoxConstraints( - minWidth: effectiveMinWidth, - maxWidth: effectiveMaxWidth, - maxHeight: effectiveMaxHeight, - ), - child: Material( - color: surfaceColors.dialog, - surfaceTintColor: Colors.transparent, - elevation: BusyMaxElevation.window, - shadowColor: BusyMaxShadow.physicalColor(context), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(BusyMaxRadius.lg), - side: BorderSide( - color: surfaceColors.floatingBorder, - width: BusyMaxStroke.outline, - ), + return Dialog( + insetPadding: insetPadding, + insetAnimationDuration: MediaQuery.disableAnimationsOf(context) + ? Duration.zero + : BusyMaxMotion.dialogInsets, + insetAnimationCurve: BusyMaxMotion.dialogInsetsCurve, + clipBehavior: Clip.antiAlias, + child: ConstrainedBox( + constraints: BoxConstraints( + minWidth: effectiveMinWidth, + maxWidth: effectiveMaxWidth, + maxHeight: effectiveMaxHeight, ), - clipBehavior: Clip.antiAlias, child: child, ), ); diff --git a/lib/src/app/busymax_dialogs.dart b/lib/src/app/busymax_dialogs.dart index 15c3c5d..7d5c8a5 100644 --- a/lib/src/app/busymax_dialogs.dart +++ b/lib/src/app/busymax_dialogs.dart @@ -103,21 +103,11 @@ Future showBusyMaxModalEditorDialog( headerBarService: headerBarService, barrierDismissible: false, builder: (dialogContext) { - final reduceMotion = MediaQuery.disableAnimationsOf(dialogContext); - return Dialog( - backgroundColor: Colors.transparent, - surfaceTintColor: Colors.transparent, - elevation: 0, + return BusyMaxModalEditorSurface( + maxWidth: maxWidth, + maxHeight: maxHeight, insetPadding: const EdgeInsets.all(BusyMaxSpacing.lg), - insetAnimationDuration: reduceMotion - ? Duration.zero - : BusyMaxMotion.dialogInsets, - insetAnimationCurve: BusyMaxMotion.dialogInsetsCurve, - child: BusyMaxModalEditorSurface( - maxWidth: maxWidth, - maxHeight: maxHeight, - child: builder(dialogContext), - ), + child: builder(dialogContext), ); }, ); diff --git a/lib/src/app/busymax_yaru_theme.dart b/lib/src/app/busymax_yaru_theme.dart index 490df8d..5f91ec9 100644 --- a/lib/src/app/busymax_yaru_theme.dart +++ b/lib/src/app/busymax_yaru_theme.dart @@ -109,7 +109,7 @@ class BusyMaxYaruTheme { textTheme: textTheme, ); final outlinedButtonStyle = _semanticButtonStyle( - base.outlinedButtonTheme.style, + _yaruDesktopButtonStyle(base.outlinedButtonTheme.style), foreground: colors.foreground, background: Colors.transparent, disabledForeground: colors.disabledForeground, @@ -121,7 +121,7 @@ class BusyMaxYaruTheme { ), ); final filledButtonStyle = _semanticButtonStyle( - base.filledButtonTheme.style, + _yaruDesktopButtonStyle(base.filledButtonTheme.style), foreground: colors.foreground, background: colors.control, disabledForeground: colors.disabledForeground, @@ -133,7 +133,7 @@ class BusyMaxYaruTheme { ), ); final elevatedButtonStyle = _semanticButtonStyle( - base.elevatedButtonTheme.style, + _yaruDesktopButtonStyle(base.elevatedButtonTheme.style), foreground: onAccent, background: accentColor, disabledForeground: colors.disabledForeground, @@ -145,7 +145,7 @@ class BusyMaxYaruTheme { ), ); final textButtonStyle = _semanticButtonStyle( - base.textButtonTheme.style, + _yaruDesktopButtonStyle(base.textButtonTheme.style), foreground: accentColor, background: Colors.transparent, disabledForeground: colors.disabledForeground, @@ -315,16 +315,21 @@ class BusyMaxYaruTheme { popupMenuTheme: base.popupMenuTheme.copyWith( color: colors.popover, surfaceTintColor: colors.popover, - elevation: BusyMaxElevation.popover, shadowColor: colorScheme.shadow, - menuPadding: const EdgeInsets.symmetric(vertical: 4), - iconColor: colors.mutedForeground, - iconSize: 16, textStyle: normalizer.apply( base.popupMenuTheme.textStyle, fallback: textTheme.bodyMedium, color: colors.foreground, ), + labelTextStyle: WidgetStateProperty.resolveWith((states) { + return normalizer.apply( + base.popupMenuTheme.labelTextStyle?.resolve(states), + fallback: textTheme.bodyMedium, + color: states.contains(WidgetState.disabled) + ? colors.disabledForeground + : colors.foreground, + ); + }), shape: highContrast ? _withOutlineSide( base.popupMenuTheme.shape, @@ -1009,6 +1014,44 @@ MenuStyle _semanticMenuSurfaceStyle( ); } +/// Keeps Yaru's horizontal breathing room while allowing its own minimum +/// button height to remain the control height. +/// +/// 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( + // 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. + visualDensity: VisualDensity.standard, + // BusyMax is a desktop app. This is also Flutter's Linux default, but + // 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, + }; + }), + ); +} + /// Applies runtime semantic colors and typography without replacing Yaru's /// geometry, focus treatment, hover/press overlays, or motion defaults. ButtonStyle _semanticButtonStyle( diff --git a/lib/src/features/calendar/presentation/event_editor.dart b/lib/src/features/calendar/presentation/event_editor.dart index 8c48b82..c05fb77 100644 --- a/lib/src/features/calendar/presentation/event_editor.dart +++ b/lib/src/features/calendar/presentation/event_editor.dart @@ -398,10 +398,6 @@ class _EventEditorState extends State { values: labels.keys.toList(), selected: _recurrenceType(_draft.recurrence), labelFor: (value) => labels[value] ?? l10n.doesNotRepeat, - selectedBuilder: (context, value) => _eventEditorSelectedValue( - context, - labels[value] ?? l10n.doesNotRepeat, - ), onSelected: (value) { setState(() { _draft = value == 'none' @@ -442,17 +438,9 @@ class _EventEditorState extends State { enabled: existingSourceId == null, labelFor: (value) => sources.firstWhere((source) => source.id == value).summary, - menuItemBuilder: (context, value) { - return _calendarSourceChoice( - context, - sources.firstWhere((source) => source.id == value), - ); - }, - selectedBuilder: (context, value) { - return _calendarSourceSelectedChoice( - context, - sources.firstWhere((source) => source.id == value), - ); + selectorLeadingBuilder: (context, value) { + final source = sources.firstWhere((source) => source.id == value); + return _CalendarSourceDot(color: _calendarSourceColor(context, source)); }, onSelected: (value) { final source = sources.firstWhere((source) => source.id == value); @@ -661,10 +649,6 @@ class _EventEditorState extends State { values: values, selected: selected, labelFor: (value) => _availabilityLabel(context, value), - selectedBuilder: (context, value) => _eventEditorSelectedValue( - context, - _availabilityLabel(context, value), - ), onSelected: (value) { setState(() { _draft = _draft.copyWith(showAs: value); @@ -686,8 +670,6 @@ class _EventEditorState extends State { values: values, selected: selected, labelFor: (value) => _visibilityLabel(context, value), - selectedBuilder: (context, value) => - _eventEditorSelectedValue(context, _visibilityLabel(context, value)), onSelected: (value) { setState(() { _draft = _draft.copyWith(visibilityOrSensitivity: value); @@ -1104,61 +1086,6 @@ bool _looksLikeEmail(String value) { return RegExp(r'^[^@\s]+@[^@\s]+\.[^@\s]+$').hasMatch(value); } -Widget _calendarSourceChoice( - BuildContext context, - CalendarSourceEntity source, -) { - return Row( - mainAxisSize: MainAxisSize.min, - children: [ - _CalendarSourceDot(color: _calendarSourceColor(context, source)), - const SizedBox(width: BusyMaxSpacing.sm), - Expanded( - child: Text( - source.summary, - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ), - ], - ); -} - -Widget _calendarSourceSelectedChoice( - BuildContext context, - CalendarSourceEntity source, -) { - return Row( - mainAxisAlignment: MainAxisAlignment.end, - mainAxisSize: MainAxisSize.max, - children: [ - _CalendarSourceDot(color: _calendarSourceColor(context, source)), - const SizedBox(width: BusyMaxSpacing.sm), - Flexible( - fit: FlexFit.loose, - child: Text( - source.summary, - maxLines: 1, - overflow: TextOverflow.ellipsis, - textAlign: TextAlign.end, - ), - ), - ], - ); -} - -Widget _eventEditorSelectedValue(BuildContext context, String value) { - return Align( - alignment: Alignment.centerRight, - child: Text( - value, - maxLines: 1, - overflow: TextOverflow.ellipsis, - textAlign: TextAlign.end, - ), - ); -} - class _CalendarSourceDot extends StatelessWidget { const _CalendarSourceDot({required this.color}); diff --git a/lib/src/features/tasks/presentation/task_details_editor.dart b/lib/src/features/tasks/presentation/task_details_editor.dart index 7a201a1..f882b1d 100644 --- a/lib/src/features/tasks/presentation/task_details_editor.dart +++ b/lib/src/features/tasks/presentation/task_details_editor.dart @@ -276,7 +276,6 @@ class _TaskDetailsEditorState extends State { ), if (widget.capabilities.supportsRecurrence) BusyMaxGroupedList( - title: l10n.repeat, filled: true, children: [_repeatRow(draft)], ), @@ -402,17 +401,10 @@ class _TaskDetailsEditorState extends State { return BusyMaxComboRow( title: l10n.account, leading: const Icon(YaruIcons.user), + subtitle: secondaryLabelFor?.call(widget.selectedAccountId!), values: widget.accountIds, selected: widget.selectedAccountId!, labelFor: labelFor, - menuItemBuilder: (context, value) => _TaskEditorAccountIdentity( - label: labelFor(value), - secondaryLabel: secondaryLabelFor?.call(value), - ), - selectedBuilder: (context, value) => _TaskEditorAccountIdentity( - label: labelFor(value), - secondaryLabel: secondaryLabelFor?.call(value), - ), onSelected: widget.onAccountSelected!, ); } @@ -422,7 +414,7 @@ class _TaskDetailsEditorState extends State { if (widget.taskLists.isEmpty) { return BusyMaxActionRow( title: l10n.list, - leading: const Icon(Icons.drive_file_move_outline), + leading: const Icon(YaruIcons.task_list), subtitle: listValue.isEmpty ? null : listValue, enabled: false, ); @@ -433,7 +425,7 @@ class _TaskDetailsEditorState extends State { if (!canSelectList) { return BusyMaxActionRow( title: l10n.list, - leading: const Icon(Icons.drive_file_move_outline), + leading: const Icon(YaruIcons.task_list), subtitle: listValue.isEmpty ? null : listValue, enabled: false, tooltip: widget.capabilities.supportsCrossListMove @@ -443,15 +435,11 @@ class _TaskDetailsEditorState extends State { } return BusyMaxComboRow( title: l10n.list, - leading: const Icon(Icons.drive_file_move_outline), + leading: const Icon(YaruIcons.task_list), subtitle: widget.accountLabel, values: [for (final list in widget.taskLists) list.id], selected: draft.taskListId, labelFor: (value) => _listTitle(value) ?? l10n.noneValue, - selectedBuilder: (context, value) => _taskEditorSelectedValue( - context, - _listTitle(value) ?? l10n.noneValue, - ), onSelected: (value) => _updateDraft(draft.copyWith(taskListId: value)), ); } @@ -537,12 +525,10 @@ class _TaskDetailsEditorState extends State { final options = _repeatOptions(context); return BusyMaxComboRow( title: l10n.repeat, - leading: const Icon(Icons.repeat), + leading: const Icon(YaruIcons.repeat), values: options.keys.toList(), selected: type, labelFor: (value) => options[value] ?? l10n.repeatNone, - selectedBuilder: (context, value) => - _taskEditorSelectedValue(context, options[value] ?? l10n.repeatNone), onSelected: (value) => _updateDraft( draft.copyWith(recurrenceJson: _recurrenceJsonFor(value, draft)), ), @@ -558,14 +544,10 @@ class _TaskDetailsEditorState extends State { }; return BusyMaxComboRow( title: l10n.importance, - leading: const Icon(Icons.priority_high_outlined), + leading: const Icon(YaruIcons.task_important), values: labels.keys.toList(), selected: draft.importance, labelFor: (value) => labels[value] ?? l10n.importanceNormal, - selectedBuilder: (context, value) => _taskEditorSelectedValue( - context, - labels[value] ?? l10n.importanceNormal, - ), onSelected: (value) => _updateDraft(draft.copyWith(importance: value)), ); } @@ -884,60 +866,6 @@ InputDecoration _plainTaskFieldDecoration( ); } -Widget _taskEditorSelectedValue(BuildContext context, String value) { - return Align( - alignment: Alignment.centerRight, - child: Text( - value, - maxLines: 1, - overflow: TextOverflow.ellipsis, - textAlign: TextAlign.end, - ), - ); -} - -class _TaskEditorAccountIdentity extends StatelessWidget { - const _TaskEditorAccountIdentity({required this.label, this.secondaryLabel}); - - final String label; - final String? secondaryLabel; - - @override - Widget build(BuildContext context) { - final colorScheme = Theme.of(context).colorScheme; - final secondary = secondaryLabel?.trim(); - final primaryStyle = Theme.of( - context, - ).textTheme.bodyMedium?.copyWith(height: 1.05); - final secondaryStyle = Theme.of(context).textTheme.bodySmall?.copyWith( - color: colorScheme.onSurfaceVariant, - height: 1.05, - ); - return Align( - alignment: Alignment.centerLeft, - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - label, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: primaryStyle, - ), - if (secondary != null && secondary.isNotEmpty) - Text( - secondary, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: secondaryStyle, - ), - ], - ), - ); - } -} - TextStyle? _taskEditorProminentActionStyle( BuildContext context, { Color? color, diff --git a/test/app/busymax_grouped_surface_test.dart b/test/app/busymax_grouped_surface_test.dart index 65a4488..c10df78 100644 --- a/test/app/busymax_grouped_surface_test.dart +++ b/test/app/busymax_grouped_surface_test.dart @@ -3,6 +3,7 @@ import 'dart:ui' as ui; import 'package:busymax/src/app/busymax_design.dart'; import 'package:busymax/src/app/busymax_yaru_theme.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -424,10 +425,7 @@ void main() { ); final combo = find.byType(BusyMaxComboRow); - final trigger = find.descendant( - of: combo, - matching: find.byType(OutlinedButton), - ); + final trigger = find.descendant(of: combo, matching: _dropdownMenuFinder()); expect(trigger, findsOneWidget); await tester.tap(trigger, warnIfMissed: false); @@ -436,7 +434,7 @@ void main() { await tester.sendKeyEvent(LogicalKeyboardKey.enter); await tester.pumpAndSettle(); - expect(find.byType(MenuItemButton), findsNothing); + expect(find.byType(MenuItemButton).hitTestable(), findsNothing); expect(selected, isEmpty); final disabledSemantics = tester.widget( find.descendant( @@ -452,70 +450,142 @@ void main() { expect(disabledSemantics.properties.value, 'Personal'); }); - testWidgets( - 'combo row uses Yaru geometry and does not focus items on pointer open', - (tester) async { - final selections = []; - await tester.pumpWidget( - _testApp( - Directionality( - textDirection: TextDirection.rtl, - child: BusyMaxComboRow( - title: 'Calendar', - values: const [1, 2], - selected: 1, - labelFor: (value) => 'Calendar $value', - menuItemBuilder: (context, value) => - Text('Choice $value', key: ValueKey('choice-$value')), - selectedBuilder: (context, value) => - Text('Selected $value', key: ValueKey('selected-$value')), - onSelected: selections.add, - ), + testWidgets('combo row delegates form selection and geometry to Yaru', ( + tester, + ) async { + final selections = []; + await tester.pumpWidget( + _testApp( + Directionality( + textDirection: TextDirection.rtl, + child: BusyMaxComboRow( + title: 'Calendar', + values: const [1, 2], + selected: 1, + labelFor: (value) => 'Calendar $value', + onSelected: selections.add, ), ), - ); + ), + ); - final triggerFinder = find.descendant( - of: find.byType(BusyMaxComboRow), - matching: find.byType(OutlinedButton), - ); - final trigger = tester.widget(triggerFinder); - expect(trigger.style, isNull); + final triggerFinder = find.descendant( + of: find.byType(BusyMaxComboRow), + matching: _dropdownMenuFinder(), + ); + final trigger = tester.widget(triggerFinder); + expect(trigger.selectOnly, isTrue); + expect(trigger.enableSearch, isFalse); + expect(trigger.width, tester.getSize(triggerFinder).width); + expect(trigger.inputDecorationTheme, isNull); + expect(trigger.menuStyle, isNull); + expect(trigger.initialSelection, isNotNull); + + final selectedRect = tester.getRect(find.text('Calendar 1').first); + final arrowRect = tester.getRect( + find + .descendant( + of: triggerFinder, + matching: find.byIcon(YaruIcons.pan_down), + ) + .hitTestable(), + ); + expect(arrowRect.right, lessThanOrEqualTo(selectedRect.left)); - final selectedRect = tester.getRect( - find.byKey(const ValueKey('selected-1')), - ); - final arrowRect = tester.getRect( - find.descendant( - of: triggerFinder, - matching: find.byIcon(YaruIcons.pan_down), + await tester.tap(triggerFinder); + await tester.pumpAndSettle(); + + final firstChoice = find + .byWidgetPredicate( + (widget) => widget is Text && widget.data == 'Calendar 1', + ) + .hitTestable(); + final secondChoice = find + .byWidgetPredicate( + (widget) => widget is Text && widget.data == 'Calendar 2', + ) + .hitTestable(); + expect(firstChoice, findsOneWidget); + expect(secondChoice, findsOneWidget); + expect( + tester.getRect(firstChoice).top, + greaterThanOrEqualTo(tester.getRect(triggerFinder).bottom), + ); + final visibleMenuItems = find.byType(MenuItemButton).hitTestable(); + expect(visibleMenuItems, findsNWidgets(2)); + expect(find.byType(YaruFocusBorder), findsNothing); + final selectedSemantics = tester + .widgetList( + find.descendant( + of: visibleMenuItems, + matching: find.byType(Semantics), + ), + ) + .where((semantics) => semantics.properties.selected == true); + expect(selectedSemantics, hasLength(1)); + + await tester.tap(secondChoice); + await tester.pumpAndSettle(); + expect(selections, [2]); + }); + + testWidgets('combo row maps a nullable domain choice through the popup', ( + tester, + ) async { + final selections = []; + await tester.pumpWidget( + _testApp( + BusyMaxComboRow( + title: 'Category', + values: const [null, 'Problem'], + selected: 'Problem', + labelFor: (value) => value ?? 'Select a category', + onSelected: selections.add, ), - ); - expect(arrowRect.right, lessThanOrEqualTo(selectedRect.left)); + ), + ); - await tester.tap(triggerFinder); - await tester.pumpAndSettle(); + await tester.tap(_dropdownMenuFinder()); + await tester.pumpAndSettle(); + await tester.tap(find.text('Select a category').last); + await tester.pumpAndSettle(); - expect(find.byKey(const ValueKey('choice-1')), findsOneWidget); - expect(find.byKey(const ValueKey('choice-2')), findsOneWidget); - expect( - tester.getRect(find.byKey(const ValueKey('choice-1'))).top, - greaterThanOrEqualTo(tester.getRect(triggerFinder).bottom), - ); - final menuItems = tester.widgetList( - find.byType(MenuItemButton), - ); - expect(menuItems, hasLength(2)); - expect( - menuItems.every((item) => item.focusNode?.hasFocus == false), - isTrue, - ); + expect(selections, [isNull]); + }); - await tester.tap(find.byKey(const ValueKey('choice-2'))); - await tester.pumpAndSettle(); - expect(selections, [2]); - }, - ); + testWidgets('combo popup stays constrained to its trigger width', ( + tester, + ) async { + const selectorWidth = 220.0; + await tester.pumpWidget( + _testApp( + SizedBox( + width: 640, + child: BusyMaxComboRow( + title: 'Calendar', + width: selectorWidth, + values: const [ + 'Personal', + 'A provider-controlled calendar name that is intentionally long', + ], + selected: 'Personal', + labelFor: (value) => value, + onSelected: (_) {}, + ), + ), + ), + ); + + final trigger = _dropdownMenuFinder(); + await tester.tap(trigger); + await tester.pumpAndSettle(); + + final triggerWidth = tester.getSize(trigger).width; + for (final item in find.byType(MenuItemButton).hitTestable().evaluate()) { + expect(tester.getSize(find.byWidget(item.widget)).width, triggerWidth); + } + expect(tester.takeException(), isNull); + }); testWidgets('combo row supports keyboard activation and menu navigation', ( tester, @@ -535,35 +605,26 @@ void main() { final triggerFinder = find.descendant( of: find.byType(BusyMaxComboRow), - matching: find.byType(OutlinedButton), + matching: _dropdownMenuFinder(), ); - tester.widget(triggerFinder).focusNode!.requestFocus(); - await tester.pump(); - + await tester.sendKeyEvent(LogicalKeyboardKey.tab); await tester.sendKeyEvent(LogicalKeyboardKey.enter); await tester.pumpAndSettle(); - var menuItems = tester - .widgetList(find.byType(MenuItemButton)) - .toList(); - expect(menuItems, hasLength(2)); + expect(find.byType(MenuItemButton).hitTestable(), findsNWidgets(2)); expect( tester.getRect(find.text('Personal').last).top, greaterThanOrEqualTo(tester.getRect(triggerFinder).bottom), ); - expect(menuItems.first.focusNode?.hasFocus, isTrue); await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); await tester.pump(); - menuItems = tester - .widgetList(find.byType(MenuItemButton)) - .toList(); - expect(menuItems.last.focusNode?.hasFocus, isTrue); - + await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); + await tester.pump(); await tester.sendKeyEvent(LogicalKeyboardKey.enter); await tester.pumpAndSettle(); expect(selections, ['Work']); - expect(find.byType(MenuItemButton), findsNothing); + expect(find.byType(MenuItemButton).hitTestable(), findsNothing); }); testWidgets('combo row accepts unbounded horizontal constraints', ( @@ -583,8 +644,8 @@ void main() { ), ); - expect(find.byType(OutlinedButton), findsOneWidget); - expect(tester.getSize(find.byType(OutlinedButton)).width, 220); + expect(_dropdownMenuFinder(), findsOneWidget); + expect(tester.getSize(_dropdownMenuFinder()).width, 220); expect(tester.takeException(), isNull); }); @@ -657,7 +718,7 @@ void main() { }); for (final brightness in Brightness.values) { - testWidgets('floating surfaces use semantic $brightness separation', ( + testWidgets('dialogs and popovers use native $brightness surface roles', ( tester, ) async { final theme = BusyMaxYaruTheme.build( @@ -670,14 +731,17 @@ void main() { MaterialApp( theme: theme, home: Scaffold( - body: Column( + body: Stack( children: [ BusyMaxModalEditorSurface( child: const SizedBox(width: 240, height: 120), ), - BusyMaxPopoverSurface( - color: colors.popover, - child: const SizedBox(width: 180, height: 80), + Align( + alignment: Alignment.bottomCenter, + child: BusyMaxPopoverSurface( + color: colors.popover, + child: const SizedBox(width: 180, height: 80), + ), ), ], ), @@ -693,11 +757,18 @@ void main() { ), ), ); - final modalShape = modalMaterial.shape! as RoundedRectangleBorder; - expect(modalMaterial.elevation, BusyMaxElevation.window); - expect(modalMaterial.shadowColor, theme.colorScheme.shadow); - expect(modalShape.side.color, colors.floatingBorder); - expect(modalShape.side.width, BusyMaxStroke.outline); + final modalDialog = tester.widget( + find.descendant( + of: find.byType(BusyMaxModalEditorSurface), + matching: find.byType(Dialog), + ), + ); + expect(modalDialog.backgroundColor, isNull); + expect(modalDialog.surfaceTintColor, isNull); + expect(modalDialog.elevation, isNull); + expect(modalDialog.shadowColor, isNull); + expect(modalDialog.shape, isNull); + expect(modalMaterial.shape, theme.dialogTheme.shape); final physicalShape = tester.widget( find.descendant( @@ -749,12 +820,12 @@ void main() { final titleRect = tester.getRect( find.text('Calendar account with a long label'), ); - final triggerRect = tester.getRect(find.byType(OutlinedButton)); + final triggerRect = tester.getRect(_dropdownMenuFinder()); expect(triggerRect.top, greaterThanOrEqualTo(titleRect.bottom)); expect(tester.takeException(), isNull); }); - testWidgets('time mode uses a labeled row and neutral Yaru toggle group', ( + testWidgets('time mode is a full-width neutral Yaru toggle group', ( tester, ) async { const accentColor = Color(0xFF3584E4); @@ -773,20 +844,14 @@ void main() { ), ); - expect(find.text('Time'), findsOneWidget); - expect(find.text('Use dates only or set specific times.'), findsOneWidget); - expect(find.byType(YaruListTile), findsOneWidget); + expect(find.text('Time'), findsNothing); + expect(find.text('Use dates only or set specific times.'), findsNothing); + expect(find.byType(YaruListTile), findsNothing); final control = tester.widget(find.byType(ToggleButtons)); expect(control.isSelected, [isTrue, isFalse]); final theme = Theme.of(tester.element(find.byType(ToggleButtons))); final colors = theme.extension()!; - expect( - DefaultTextStyle.of( - tester.element(find.text('Use dates only or set specific times.')), - ).style.color, - colors.mutedForeground, - ); expect(theme.toggleButtonsTheme.fillColor, colors.controlActive); expect(theme.toggleButtonsTheme.fillColor, isNot(accentColor)); expect(theme.toggleButtonsTheme.selectedColor, colors.foreground); @@ -796,20 +861,27 @@ void main() { BorderRadius.circular(BusyMaxRadius.sm), ); - final titleRect = tester.getRect(find.text('Time')); - final descriptionRect = tester.getRect( - find.text('Use dates only or set specific times.'), - ); + final rowRect = tester.getRect(find.byType(BusyMaxTimeModeRow)); final controlRect = tester.getRect(find.byType(ToggleButtons)); - expect(descriptionRect.top, greaterThan(titleRect.top)); - expect(controlRect.left, greaterThan(titleRect.right)); + expect(controlRect.width, rowRect.width); + final segmentWidths = tester + .widgetList( + find.descendant( + of: find.byType(ToggleButtons), + matching: find.byType(TextButton), + ), + ) + .map((button) => tester.getSize(find.byWidget(button)).width) + .toList(); + expect(segmentWidths, hasLength(2)); + expect(segmentWidths.first, segmentWidths.last); await tester.tap(find.text('Time slot')); await tester.pump(); expect(changes, [isFalse]); }); - testWidgets('time mode stacks cleanly when its form section is narrow', ( + testWidgets('time mode remains full width when its section is narrow', ( tester, ) async { await tester.pumpWidget( @@ -831,14 +903,59 @@ void main() { ), ); - final descriptionRect = tester.getRect( - find.text('Use dates only or set specific times.'), - ); + final rowRect = tester.getRect(find.byType(BusyMaxTimeModeRow)); final controlRect = tester.getRect(find.byType(ToggleButtons)); - expect(controlRect.top, greaterThanOrEqualTo(descriptionRect.bottom)); + expect(controlRect.width, rowRect.width); + expect(controlRect.width, 420); expect(tester.takeException(), isNull); }); + testWidgets('editor header actions are natural width with native loading', ( + tester, + ) async { + Widget header({required bool saving}) { + return BusyMaxEditorHeader( + title: 'Edit event', + cancelLabel: 'Cancel', + saveLabel: 'Save', + onCancel: () {}, + onSave: () {}, + saving: saving, + ); + } + + await tester.pumpWidget(_linuxTestApp(header(saving: false))); + + final cancel = find.byType(FilledButton); + final save = find.byType(ElevatedButton); + final slotWidth = + tester.getSize(find.byType(BusyMaxEditorHeader)).width / 3; + expect(tester.getSize(cancel).width, lessThan(slotWidth)); + expect(tester.getSize(save).width, lessThan(slotWidth)); + expect(tester.getSize(cancel).height, kYaruButtonHeight); + expect(tester.getSize(save).height, kYaruButtonHeight); + 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); + for (final style in [cancelButton.style, saveButton.style]) { + expect(style?.minimumSize, isNull); + expect(style?.fixedSize, isNull); + expect(style?.maximumSize, isNull); + expect(style?.padding, isNull); + expect(style?.tapTargetSize, isNull); + expect(style?.visualDensity, isNull); + } + + await tester.pumpWidget(_linuxTestApp(header(saving: true))); + await tester.pump(); + + expect(tester.getSize(save).width, lessThan(slotWidth)); + expect(tester.getSize(save).height, kYaruButtonHeight); + expect(find.byType(CircularProgressIndicator), findsOneWidget); + expect(find.text('Save'), findsNothing); + }); + testWidgets('custom dialogs announce their title as route semantics', ( tester, ) async { @@ -884,6 +1001,10 @@ void main() { void _ignoreBool(bool value) {} +Finder _dropdownMenuFinder() { + return find.byWidgetPredicate((widget) => widget is DropdownMenu); +} + Widget _testApp(Widget child) { return MaterialApp( theme: BusyMaxYaruTheme.build( @@ -895,3 +1016,13 @@ Widget _testApp(Widget child) { ), ); } + +Widget _linuxTestApp(Widget child) { + final previousPlatform = debugDefaultTargetPlatformOverride; + debugDefaultTargetPlatformOverride = TargetPlatform.linux; + try { + return _testApp(child); + } finally { + debugDefaultTargetPlatformOverride = previousPlatform; + } +} diff --git a/test/app/native_ui_audit_test.dart b/test/app/native_ui_audit_test.dart index 8cf8a4c..220736e 100644 --- a/test/app/native_ui_audit_test.dart +++ b/test/app/native_ui_audit_test.dart @@ -1011,6 +1011,20 @@ void main() { expect(confirmBody, isNot(contains('return BusyMaxDialogShell('))); }); + test('modal editors delegate their surface styling to DialogTheme', () { + final design = File('lib/src/app/busymax_design.dart').readAsStringSync(); + final start = design.indexOf('class BusyMaxModalEditorSurface'); + final end = design.indexOf('class BusyMaxInlineBadge', start); + expect(start, isNonNegative); + expect(end, greaterThan(start)); + final surface = design.substring(start, end); + + expect(surface, contains('return Dialog(')); + expect(surface, isNot(contains('floatingBorder'))); + expect(surface, isNot(contains('BorderSide('))); + expect(surface, isNot(contains('BusyMaxElevation'))); + }); + test('native headerbar CSS is limited to semantic surfaces', () { final source = File('linux/runner/my_application.cc').readAsStringSync(); final headerCssStart = source.indexOf( @@ -1313,6 +1327,65 @@ void main() { ); }); + test('form combo delegates selection geometry to Yaru dropdown theme', () { + final source = File('lib/src/app/busymax_design.dart').readAsStringSync(); + final comboStart = source.indexOf('class BusyMaxComboBox'); + final rowStart = source.indexOf('class BusyMaxComboRow'); + final rowEnd = source.indexOf('class BusyMaxSwitchRow'); + + expect(comboStart, isNonNegative); + expect(rowStart, greaterThan(comboStart)); + expect(rowEnd, greaterThan(rowStart)); + + final comboBody = source.substring(comboStart, rowStart); + final rowBody = source.substring(rowStart, rowEnd); + expect(comboBody, contains('DropdownMenu<_BusyMaxComboOption>(')); + expect(comboBody, contains('selectOnly: true')); + expect(comboBody, contains('enableSearch: false')); + expect(comboBody, contains('DropdownMenuEntry<_BusyMaxComboOption>(')); + expect(comboBody, contains('selected: option.index == selectedIndex')); + expect( + comboBody, + contains('trailingIcon: const Icon(YaruIcons.pan_down)'), + ); + expect( + comboBody, + contains('selectedTrailingIcon: const Icon(YaruIcons.pan_up)'), + ); + expect(comboBody, isNot(contains('YaruPopupMenuButton'))); + expect(comboBody, isNot(contains('PopupMenuItem'))); + expect(comboBody, isNot(contains('inputDecorationTheme:'))); + expect(comboBody, isNot(contains('menuStyle:'))); + expect(comboBody, isNot(contains('MenuAnchor('))); + expect(comboBody, isNot(contains('OutlinedButton('))); + expect(comboBody, isNot(contains('RoundedRectangleBorder'))); + expect(comboBody, isNot(contains('BusyMaxElevation'))); + expect(rowBody, contains('BusyMaxComboBox(')); + expect(rowBody, isNot(contains('BusyMaxMenuButton('))); + expect(rowBody, isNot(contains('OutlinedButton('))); + expect(rowBody, isNot(contains('opacity: 0.6'))); + }); + + test('time mode delegates linked-button visuals to the Yaru theme', () { + final source = File('lib/src/app/busymax_design.dart').readAsStringSync(); + final start = source.indexOf('class BusyMaxTimeModeRow'); + final end = source.indexOf('class BusyMaxModalEditorScaffold'); + + expect(start, isNonNegative); + expect(end, greaterThan(start)); + final body = source.substring(start, end); + expect(body, contains('ToggleButtonsTheme.of(context)')); + expect(body, contains('ToggleButtons(')); + expect(body, contains('constraints.maxWidth')); + expect(body, isNot(contains('YaruListTile'))); + expect(body, isNot(contains('timeModeDescription'))); + expect(body, isNot(contains('MediaQuery'))); + expect(body, isNot(contains('SegmentedButton'))); + expect(body, isNot(contains('Padding('))); + expect(body, isNot(contains('borderRadius:'))); + expect(body, isNot(contains('fillColor:'))); + }); + test('feature code avoids raw Material controls with Yaru replacements', () { final files = [ ..._dartFilesIn('lib/src/app'), diff --git a/test/app/theme_localization_test.dart b/test/app/theme_localization_test.dart index 0c481a6..13b9d9e 100644 --- a/test/app/theme_localization_test.dart +++ b/test/app/theme_localization_test.dart @@ -182,6 +182,24 @@ void main() { expect(dialogShape.side, baseDialogShape.side); expect(BusyMaxRadius.window, kYaruWindowRadius); + 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), + ]) { + 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(pair.$1?.visualDensity, VisualDensity.standard); + expect(pair.$1?.tapTargetSize, MaterialTapTargetSize.shrinkWrap); + } + final checkboxShape = theme.checkboxTheme.shape! as RoundedRectangleBorder; final baseCheckboxShape = base.checkboxTheme.shape! as RoundedRectangleBorder; @@ -192,6 +210,9 @@ void main() { final basePopupShape = base.popupMenuTheme.shape! as OutlineInputBorder; expect(popupShape.borderRadius, basePopupShape.borderRadius); expect(popupShape.borderSide, basePopupShape.borderSide); + expect(theme.popupMenuTheme.elevation, base.popupMenuTheme.elevation); + expect(theme.popupMenuTheme.menuPadding, base.popupMenuTheme.menuPadding); + expect(theme.popupMenuTheme.position, base.popupMenuTheme.position); for (final style in [ theme.textTheme.titleSmall, @@ -254,6 +275,16 @@ void main() { expect(dark.dialogTheme.backgroundColor, darkColors.dialog); expect(light.popupMenuTheme.color, lightColors.popover); expect(dark.popupMenuTheme.color, darkColors.popover); + expect( + light.popupMenuTheme.labelTextStyle?.resolve(const {})?.color, + lightColors.foreground, + ); + expect( + light.popupMenuTheme.labelTextStyle?.resolve(const { + WidgetState.disabled, + })?.color, + lightColors.disabledForeground, + ); expect( dark.menuTheme.style?.backgroundColor?.resolve(const {}), darkColors.popover, @@ -462,6 +493,13 @@ void main() { family: gtkFamily, scale: scale, ); + _expectComponentStyleUsesTypography( + theme.popupMenuTheme.labelTextStyle?.resolve(buttonStates), + baseStyle: base.popupMenuTheme.labelTextStyle?.resolve(buttonStates), + fallback: textTheme.bodyMedium, + family: gtkFamily, + scale: scale, + ); _expectComponentStyleUsesTypography( theme.dialogTheme.titleTextStyle, baseStyle: base.dialogTheme.titleTextStyle, diff --git a/test/features/calendar/presentation/event_editor_test.dart b/test/features/calendar/presentation/event_editor_test.dart index c5751c2..63b689a 100644 --- a/test/features/calendar/presentation/event_editor_test.dart +++ b/test/features/calendar/presentation/event_editor_test.dart @@ -6,6 +6,7 @@ import 'package:busymax/src/features/calendar/presentation/event_editor.dart'; import 'package:busymax/src/features/calendar/presentation/event_editor_draft.dart'; import 'package:busymax/src/features/tasks/presentation/desktop_date_time_fields.dart'; import 'package:busymax/src/app/busymax_design.dart'; +import 'package:busymax/src/app/busymax_yaru_theme.dart'; import 'package:busymax/src/microsoft_calendar/microsoft_calendar_mapper.dart'; import 'package:busymax/src/platform/native_dialog_service.dart'; import 'package:busymax/src/task_providers/task_provider.dart'; @@ -30,11 +31,15 @@ void main() { .setMockMethodCallHandler(_nativeDialogChannel, null); }); - testWidgets('editor actions use standard desktop push-button sizing', ( + testWidgets('editor actions use natural-width themed controls', ( tester, ) async { await tester.pumpWidget( localizedTestApp( + theme: BusyMaxYaruTheme.build( + brightness: Brightness.light, + accentColor: const Color(0xFF3584E4), + ), child: Scaffold( body: EventEditor( initialDraft: EventEditorDraft.newEvent( @@ -54,34 +59,52 @@ void main() { expect( tester.getSize(_headerButtonFinder('Cancel')).width, - inInclusiveRange(100, 180), + lessThan(kPushButtonSize.width), ); expect( tester.getSize(_headerButtonFinder('Save')).width, - inInclusiveRange(100, 180), + lessThan(kPushButtonSize.width), ); expect( tester.getSize(_headerButtonFinder('Cancel')).height, - inInclusiveRange(kPushButtonSize.height, kMinInteractiveDimension), + kYaruButtonHeight, ); expect( tester.getSize(_headerButtonFinder('Save')).height, - inInclusiveRange(kPushButtonSize.height, kMinInteractiveDimension), + kYaruButtonHeight, ); expect( find.ancestor( of: find.text('Cancel'), - matching: find.byWidgetPredicate((widget) => widget is PushButton), + matching: find.byType(FilledButton), ), findsOneWidget, ); expect( find.ancestor( of: find.text('Save'), - matching: find.byWidgetPredicate((widget) => widget is PushButton), + matching: find.byType(ElevatedButton), ), findsOneWidget, ); + expect( + find.descendant( + of: find.byType(BusyMaxEditorHeader), + matching: find.byWidgetPredicate((widget) => widget is PushButton), + ), + findsNothing, + ); + final cancelButton = tester.widget( + find.ancestor( + of: find.text('Cancel'), + matching: find.byType(FilledButton), + ), + ); + final cancelContext = tester.element(find.text('Cancel')); + expect( + cancelButton.style?.textStyle?.resolve(const {})?.fontWeight, + Theme.of(cancelContext).textTheme.titleSmall?.fontWeight, + ); }); testWidgets('all-day event hides time rows and conference placeholder', ( @@ -931,7 +954,14 @@ void main() { await tester.pumpAndSettle(); expect(find.text('Add Reminder'), findsNothing); - expect(find.text('5 minutes before'), findsOneWidget); + expect( + find.byWidgetPredicate( + (widget) => + widget is EditableText && + widget.controller.text == '5 minutes before', + ), + findsOneWidget, + ); }); testWidgets( @@ -1160,8 +1190,21 @@ void main() { expect(design, contains('class BusyMaxModalEditorScaffold')); expect(design, contains('BusyMaxEditorHeader(')); expect(design, contains('SingleChildScrollView')); - expect(design, contains('BusyMaxPushButton.standard')); - expect(design, contains('BusyMaxPushButton.suggested')); + final headerStart = design.indexOf('class BusyMaxEditorHeader'); + final headerEnd = design.indexOf('class BusyMaxTimeModeRow'); + final header = design.substring(headerStart, headerEnd); + expect(header, contains('child: Row(')); + expect(header, contains('AlignmentDirectional.centerStart')); + expect(header, contains('child: FilledButton(')); + expect(header, contains('AlignmentDirectional.centerEnd')); + expect(header, contains('child: ElevatedButton(')); + expect(header, contains('heightFactor: 1')); + expect(header, contains('textTheme.titleSmall')); + expect(header, isNot(contains('BusyMaxPushButton'))); + expect(header, isNot(contains('NavigationToolbar('))); + expect(header, isNot(contains('ConstrainedBox('))); + expect(header, isNot(contains('kPushButtonSize'))); + expect(header, isNot(contains('kYaruButtonHeight'))); expect(design, isNot(contains('BusyMaxHeaderPushButton'))); expect(design, contains('textAlign: TextAlign.center')); expect(design, contains('textTheme.titleMedium')); @@ -1270,11 +1313,10 @@ void main() { expect(editor, contains('return BusyMaxComboRow')); expect(editor, contains('title: context.l10n.calendar')); expect(editor, contains('leading: const Icon(YaruIcons.calendar)')); - expect(editor, contains('menuItemBuilder: (context, value)')); - expect(editor, contains('selectedBuilder: (context, value)')); - expect(editor, contains('_calendarSourceSelectedChoice')); - expect(editor, contains('mainAxisAlignment: MainAxisAlignment.end')); - expect(editor, contains('textAlign: TextAlign.end')); + expect(editor, contains('selectorLeadingBuilder: (context, value)')); + expect(editor, isNot(contains('menuItemBuilder:'))); + expect(editor, isNot(contains('selectedBuilder:'))); + expect(editor, isNot(contains('_calendarSourceSelectedChoice'))); expect(editor, contains('class _CalendarSourceDot')); expect(editor, contains('source.backgroundColor')); expect(editor, contains('ScheduleProjection.deterministicSourceColor')); @@ -1325,18 +1367,14 @@ void main() { expect(editor, isNot(contains('title: l10n.delete,'))); }); - test('event combo selected values are right aligned', () { + test('event combos do not overlay custom selected-value rendering', () { final editor = File( 'lib/src/features/calendar/presentation/event_editor.dart', ).readAsStringSync(); - expect(editor, contains('Widget _eventEditorSelectedValue')); - expect(editor, contains('alignment: Alignment.centerRight')); - expect(editor, contains('textAlign: TextAlign.end')); - expect( - '_eventEditorSelectedValue'.allMatches(editor).length, - greaterThanOrEqualTo(4), - ); + expect(editor, isNot(contains('_eventEditorSelectedValue'))); + expect(editor, isNot(contains('selectedBuilder:'))); + expect(editor, contains('selectorLeadingBuilder:')); }); test('event editor prominent actions use semibold action style', () { @@ -1351,32 +1389,45 @@ void main() { expect(editor, contains('l10n.deleteEvent')); }); - testWidgets('combo dropdown trigger inherits themed Yaru geometry', ( - tester, - ) async { + testWidgets('combo selector inherits Yaru dropdown geometry', (tester) async { await tester.pumpWidget( localizedTestApp( - child: SizedBox( - width: 480, - child: BusyMaxComboRow( - title: 'Calendar', - values: const ['Personal', 'Work'], - selected: 'Personal', - labelFor: (value) => value, - onSelected: (_) {}, + child: Theme( + data: BusyMaxYaruTheme.build( + brightness: Brightness.light, + accentColor: const Color(0xFF3584E4), + ), + child: SizedBox( + width: 480, + child: BusyMaxComboRow( + title: 'Calendar', + values: const ['Personal', 'Work'], + selected: 'Personal', + labelFor: (value) => value, + onSelected: (_) {}, + ), ), ), ), ); - expect(find.byType(BusyMaxMenuButton), findsOneWidget); - final trigger = tester.widget( + expect(find.byType(BusyMaxComboBox), findsOneWidget); + final trigger = tester.widget( find.descendant( of: find.byType(BusyMaxComboRow), - matching: find.byType(OutlinedButton), + matching: find.byWidgetPredicate((widget) => widget is DropdownMenu), ), ); - expect(trigger.style, isNull); + expect(trigger.selectOnly, isTrue); + expect(trigger.enableSearch, isFalse); + expect(trigger.inputDecorationTheme, isNull); + expect(trigger.menuStyle, isNull); + expect( + tester + .getSize(find.byWidgetPredicate((widget) => widget is DropdownMenu)) + .height, + kYaruButtonHeight, + ); }); } diff --git a/test/features/feedback/presentation/feedback_dialog_test.dart b/test/features/feedback/presentation/feedback_dialog_test.dart index 6c5186e..23b5728 100644 --- a/test/features/feedback/presentation/feedback_dialog_test.dart +++ b/test/features/feedback/presentation/feedback_dialog_test.dart @@ -47,6 +47,33 @@ void main() { expect(service.submissions, isEmpty); }); + testWidgets('category can return to the unselected placeholder', ( + tester, + ) async { + final service = _FakeFeedbackService((_) async { + return const FeedbackReceipt(id: 'unexpected'); + }); + await _pumpDialog(tester, service); + final selector = find.descendant( + of: find.byKey(const Key('feedback-category')), + matching: find.byWidgetPredicate((widget) => widget is DropdownMenu), + ); + + await tester.tap(selector); + await tester.pumpAndSettle(); + await tester.tap(find.text('Problem or bug').last); + await tester.pumpAndSettle(); + await tester.tap(selector); + await tester.pumpAndSettle(); + await tester.tap(find.text('Select a category').last); + await tester.pumpAndSettle(); + await tester.tap(find.text('Submit')); + await tester.pump(); + + expect(find.text('Select a category.'), findsOneWidget); + expect(service.submissions, isEmpty); + }); + testWidgets('rejects an invalid optional reply email', (tester) async { final service = _FakeFeedbackService((_) async { return const FeedbackReceipt(id: 'unexpected'); @@ -390,7 +417,7 @@ Future _enterValidRequiredFields(WidgetTester tester) async { await tester.tap( find.descendant( of: find.byKey(const Key('feedback-category')), - matching: find.byType(OutlinedButton), + matching: find.byWidgetPredicate((widget) => widget is DropdownMenu), ), ); await tester.pumpAndSettle(); diff --git a/test/features/tasks/presentation/task_details_pane_test.dart b/test/features/tasks/presentation/task_details_pane_test.dart index 916a94e..dca6e0d 100644 --- a/test/features/tasks/presentation/task_details_pane_test.dart +++ b/test/features/tasks/presentation/task_details_pane_test.dart @@ -8,6 +8,7 @@ import 'package:flutter/services.dart'; 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/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'; @@ -48,45 +49,107 @@ void main() { expect(find.text('Save'), findsOneWidget); }); - testWidgets('Cancel and Save are desktop PushButtons', (tester) async { + testWidgets('Cancel and Save use natural-width themed controls', ( + tester, + ) async { await _pumpDetails(tester, microsoftTaskProviderCapabilities); expect( find.ancestor( of: find.text('Cancel'), - matching: find.byWidgetPredicate((widget) => widget is PushButton), + matching: find.byType(FilledButton), ), findsOneWidget, ); expect( find.ancestor( of: find.text('Save'), - matching: find.byWidgetPredicate((widget) => widget is PushButton), + matching: find.byType(ElevatedButton), ), findsOneWidget, ); + expect( + find.descendant( + of: find.byType(BusyMaxEditorHeader), + matching: find.byWidgetPredicate((widget) => widget is PushButton), + ), + findsNothing, + ); }); - testWidgets('editor actions use standard desktop push-button sizing', ( + testWidgets('task selectors use the shared Yaru form selector', ( tester, ) async { await _pumpDetails(tester, microsoftTaskProviderCapabilities); + final comboRows = find.byType(BusyMaxComboRow); + final comboCount = comboRows.evaluate().length; + expect(comboCount, greaterThanOrEqualTo(2)); + expect( + find.descendant( + of: comboRows, + matching: find.byWidgetPredicate((widget) => widget is DropdownMenu), + ), + findsNWidgets(comboCount), + ); + expect( + find.descendant( + of: comboRows, + matching: find.byType(BusyMaxMenuButton), + ), + findsNothing, + ); + expect( + find.descendant(of: comboRows, matching: find.byType(OutlinedButton)), + findsNothing, + ); + expect( + find.descendant( + of: comboRows, + matching: find.byType(YaruPopupMenuButton), + ), + findsNothing, + ); + }); + + test('task selector content mirrors with text direction', () { + final source = File( + 'lib/src/features/tasks/presentation/task_details_editor.dart', + ).readAsStringSync(); + + expect(source, isNot(contains('_taskEditorSelectedValue'))); + expect(source, isNot(contains('_TaskEditorAccountIdentity'))); + expect(source, isNot(contains('alignment: Alignment.centerRight'))); + expect(source, isNot(contains('alignment: Alignment.centerLeft'))); + }); + + testWidgets('editor actions keep native height without forced width', ( + tester, + ) async { + await _pumpDetails( + tester, + microsoftTaskProviderCapabilities, + theme: BusyMaxYaruTheme.build( + brightness: Brightness.light, + accentColor: const Color(0xFF3584E4), + ), + ); + expect( tester.getSize(_headerButtonFinder(tester, 'Cancel')).width, - inInclusiveRange(100, 180), + lessThan(kPushButtonSize.width), ); expect( tester.getSize(_headerButtonFinder(tester, 'Save')).width, - inInclusiveRange(100, 180), + lessThan(kPushButtonSize.width), ); expect( tester.getSize(_headerButtonFinder(tester, 'Cancel')).height, - inInclusiveRange(kPushButtonSize.height, kMinInteractiveDimension), + kYaruButtonHeight, ); expect( tester.getSize(_headerButtonFinder(tester, 'Save')).height, - inInclusiveRange(kPushButtonSize.height, kMinInteractiveDimension), + kYaruButtonHeight, ); }); @@ -654,7 +717,7 @@ void main() { expect(find.text('Due'), findsOneWidget); expect(find.text('Start'), findsOneWidget); expect(find.text('Reminder'), findsOneWidget); - expect(find.text('Repeat'), findsNWidgets(2)); + expect(find.text('Repeat'), findsOneWidget); expect(find.text('Organization'), findsOneWidget); expect(find.text('Provider features'), findsNothing); }, @@ -1252,6 +1315,7 @@ Future _pumpDetails( String? displayName, String? email, Stream>? accountsStream, + ThemeData? theme, }) async { final accountId = accountIdOverride ?? @@ -1316,6 +1380,7 @@ Future _pumpDetails( child: localizedTestApp( locale: locale, alwaysUse24HourFormat: alwaysUse24HourFormat, + theme: theme, child: Scaffold( body: TaskDetailsPane( accountId: accountId, diff --git a/test/test_localized_app.dart b/test/test_localized_app.dart index bae9532..897746f 100644 --- a/test/test_localized_app.dart +++ b/test/test_localized_app.dart @@ -6,9 +6,11 @@ Widget localizedTestApp({ required Widget child, Locale locale = const Locale('en'), bool? alwaysUse24HourFormat, + ThemeData? theme, }) { return MaterialApp( locale: locale, + theme: theme, localizationsDelegates: const [ ...AppLocalizations.localizationsDelegates, ...GlobalUbuntuLocalizations.delegates, From 00c1e346bf68b31b7114619c1257d9f67e9c50db Mon Sep 17 00:00:00 2001 From: albert Date: Thu, 23 Jul 2026 20:46:04 -0700 Subject: [PATCH 12/73] Refactor BusyMax components to enhance menu handling and UI consistency. Introduce native menu service for improved integration with desktop environments. Update button styles and layout for better accessibility and user experience. Add native menu support with session management and action handling. Implement methods for showing and dismissing menus, and integrate with existing UI components for improved user interaction. --- lib/src/app/busymax_design.dart | 1064 ++++++++++++----- lib/src/app/busymax_surface_colors.dart | 35 +- lib/src/app/busymax_yaru_theme.dart | 129 +- .../presentation/schedule_create_menu.dart | 100 +- .../schedule_item_details_popover.dart | 21 +- .../presentation/schedule_toolbar.dart | 2 +- .../presentation/schedule_workspace.dart | 29 +- .../presentation/settings_screen.dart | 4 +- lib/src/platform/native_menu_service.dart | 121 ++ linux/runner/my_application.cc | 528 +++++++- pubspec.lock | 24 - pubspec.yaml | 1 - test/app/busymax_grouped_surface_test.dart | 439 +++++-- test/app/busymax_menu_button_test.dart | 265 +++- test/app/native_ui_audit_test.dart | 163 ++- test/app/theme_localization_test.dart | 145 ++- .../presentation/event_editor_test.dart | 95 +- .../presentation/feedback_dialog_test.dart | 41 +- .../compact_agenda_panel_test.dart | 2 +- .../schedule_create_menu_test.dart | 157 ++- .../presentation/schedule_toolbar_test.dart | 204 +++- .../presentation/schedule_views_test.dart | 180 ++- ...chedule_workspace_task_mutations_test.dart | 9 + .../presentation/settings_screen_test.dart | 27 +- .../presentation/task_details_pane_test.dart | 52 +- test/platform/native_menu_service_test.dart | 179 +++ 26 files changed, 3196 insertions(+), 820 deletions(-) create mode 100644 lib/src/platform/native_menu_service.dart create mode 100644 test/platform/native_menu_service_test.dart diff --git a/lib/src/app/busymax_design.dart b/lib/src/app/busymax_design.dart index 73aae72..bc14725 100644 --- a/lib/src/app/busymax_design.dart +++ b/lib/src/app/busymax_design.dart @@ -4,10 +4,10 @@ import 'dart:ui' as ui; import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; -import 'package:ubuntu_widgets/ubuntu_widgets.dart'; import 'package:yaru/yaru.dart'; import '../l10n/l10n.dart'; +import '../platform/native_menu_service.dart'; import 'busymax_surface_colors.dart'; abstract final class BusyMaxSpacing { @@ -35,6 +35,8 @@ abstract final class BusyMaxSizes { static const double sidebarWidth = 300; static const double detailsWidth = 700; static const double compactDetailsWidth = 700; + static const double comboWidth = 220; + static const double comboMinWidth = 120; static const double toolbarHeight = kYaruTitleBarHeight; static const double sidebarRowHeight = 36; static const double taskRowMinHeight = 48; @@ -50,6 +52,12 @@ abstract final class BusyMaxSizes { static const double popoverArrowHeight = 10; } +abstract final class BusyMaxFormLayout { + static const double comboStackBreakpoint = 560; + static const double comboInlineMaxFraction = 0.46; + static const double comboLargeTextScale = 1.2; +} + abstract final class BusyMaxElevation { static const double card = 2; static const double tooltip = 10; @@ -387,19 +395,6 @@ WidgetStateProperty busyMaxHeaderButtonBackground( }); } -MenuStyle busyMaxDropdownMenuStyle(BuildContext context, {double? minWidth}) { - final base = Theme.of(context).menuTheme.style ?? const MenuStyle(); - return base.copyWith( - minimumSize: minWidth == null - ? null - : WidgetStatePropertyAll(Size(minWidth, 0)), - ); -} - -ButtonStyle busyMaxDropdownMenuItemStyle(BuildContext context) { - return Theme.of(context).menuButtonTheme.style ?? const ButtonStyle(); -} - /// BusyMax's cross-platform fallback for a native desktop search entry. /// /// Linux header bars use `GtkSearchEntry`. Flutter-owned layouts delegate @@ -506,7 +501,7 @@ class _BusyMaxSearchFieldState extends State { abstract final class BusyMaxPushButton { /// A neutral desktop action. Yaru renders this with its standard filled /// control surface and native interaction geometry. - static PushButton standard({ + static FilledButton standard({ required Widget child, required VoidCallback? onPressed, VoidCallback? onLongPress, @@ -519,7 +514,7 @@ abstract final class BusyMaxPushButton { WidgetStatesController? statesController, Key? key, }) { - return PushButton.filled( + return FilledButton( key: key, onPressed: onPressed, onLongPress: onLongPress, @@ -536,7 +531,7 @@ abstract final class BusyMaxPushButton { /// A suggested action. Yaru reserves the accent-filled elevated role for /// the single preferred action in a group. - static PushButton suggested({ + static ElevatedButton suggested({ required Widget child, required VoidCallback? onPressed, VoidCallback? onLongPress, @@ -549,7 +544,7 @@ abstract final class BusyMaxPushButton { WidgetStatesController? statesController, Key? key, }) { - return PushButton.elevated( + return ElevatedButton( key: key, onPressed: onPressed, onLongPress: onLongPress, @@ -569,7 +564,7 @@ abstract final class BusyMaxPushButton { /// Keep destructive emphasis on the final action in a confirmation dialog; /// ordinary destructive rows should continue to use semantic error /// foregrounds without becoming accent-filled buttons. - static PushButton destructive({ + static ElevatedButton destructive({ required BuildContext context, required Widget child, required VoidCallback? onPressed, @@ -584,7 +579,7 @@ abstract final class BusyMaxPushButton { Key? key, }) { final colorScheme = Theme.of(context).colorScheme; - return PushButton.elevated( + return ElevatedButton( key: key, onPressed: onPressed, onLongPress: onLongPress, @@ -593,6 +588,7 @@ abstract final class BusyMaxPushButton { style: ElevatedButton.styleFrom( backgroundColor: colorScheme.error, foregroundColor: colorScheme.onError, + iconColor: colorScheme.onError, ).merge(style), focusNode: focusNode, autofocus: autofocus, @@ -603,6 +599,51 @@ abstract final class BusyMaxPushButton { } } +/// A contained circular action for compact popover toolbars. +/// +/// [YaruIconButton] continues to own focus treatment, hover and press feedback, +/// and desktop control metrics. Its built-in style is intentionally flat and +/// cannot be overridden through its `style` argument, so this adapter supplies +/// the semantic contained surface around it once for every popover action. +class BusyMaxPopoverIconButton extends StatelessWidget { + const BusyMaxPopoverIconButton({ + super.key, + required this.icon, + required this.tooltip, + required this.onPressed, + this.destructive = false, + }); + + final IconData icon; + final String tooltip; + final VoidCallback? onPressed; + final bool destructive; + + @override + Widget build(BuildContext context) { + final colors = BusyMaxSurfaceColors.of(context); + final foreground = destructive + ? Theme.of(context).colorScheme.error + : colors.foreground; + final enabled = onPressed != null; + return Material( + color: enabled ? colors.control : colors.disabledControl, + shape: const CircleBorder(), + clipBehavior: Clip.antiAlias, + child: YaruIconButton( + icon: Icon( + icon, + size: kYaruIconSize, + color: enabled ? foreground : colors.disabledForeground, + ), + iconSize: kYaruTitleBarItemHeight, + tooltip: tooltip, + onPressed: onPressed, + ), + ); + } +} + Color busyMaxSelectedBackground(BuildContext context) { return BusyMaxSurfaceColors.of(context).controlActive; } @@ -1470,18 +1511,14 @@ class BusyMaxCalendarNotesCard extends StatelessWidget { } } -typedef _BusyMaxComboOption = ({int index, String label}); - -/// A theme-owned single-selection control for Flutter form content. +/// A controlled single-selection trigger backed by the host toolkit menu. /// -/// Action menus use [BusyMaxMenuButton] because they expose commands. Form -/// selectors instead use [DropdownMenu] in select-only mode, whose geometry, -/// popup surface, typography, and interaction states are explicitly supplied -/// by Yaru's [DropdownMenuThemeData]. -class BusyMaxComboBox extends StatelessWidget { - const BusyMaxComboBox({ +/// Linux presents a real GTK menu. If the native bridge is unavailable, the +/// centralized Yaru-themed fallback is used without changing domain behavior. +class BusyMaxComboBox extends StatefulWidget { + BusyMaxComboBox({ super.key, - required this.values, + required List values, required this.selected, required this.labelFor, required this.onSelected, @@ -1489,7 +1526,30 @@ class BusyMaxComboBox extends StatelessWidget { this.enabled = true, this.tooltip, this.leadingBuilder, - }) : assert(values.length > 0, 'A combo box requires at least one value.'); + this.nativeMenuService = const NativeMenuService(), + }) : values = List.unmodifiable(values) { + if (this.values.isEmpty) { + throw ArgumentError.value( + values, + 'values', + 'A combo box requires at least one value.', + ); + } + if (this.values.toSet().length != this.values.length) { + throw ArgumentError.value( + values, + 'values', + 'A combo box requires unique values.', + ); + } + if (!this.values.contains(selected)) { + throw ArgumentError.value( + selected, + 'selected', + 'The selected value must be present in values.', + ); + } + } final List values; final T selected; @@ -1499,59 +1559,161 @@ class BusyMaxComboBox extends StatelessWidget { final bool enabled; final String? tooltip; final Widget Function(BuildContext context, T value)? leadingBuilder; + final NativeMenuService nativeMenuService; @override - Widget build(BuildContext context) { - final selectedIndex = values.indexWhere((value) => value == selected); - assert( - selectedIndex >= 0, - 'The selected combo-box value must be present in values.', + State> createState() => _BusyMaxComboBoxState(); +} + +class _BusyMaxComboBoxState extends State> { + final _triggerKey = GlobalKey(); + late final FocusNode _triggerFocusNode; + BusyMaxMenuSession? _activeMenuSession; + bool _menuOpen = false; + + @override + void initState() { + super.initState(); + _triggerFocusNode = FocusNode( + debugLabel: 'BusyMax combo trigger', + onKeyEvent: _handleTriggerKeyEvent, ); - final options = [ - for (var index = 0; index < values.length; index += 1) - (index: index, label: labelFor(values[index])), - ]; - final selectedOption = options[selectedIndex]; - final selector = DropdownMenu<_BusyMaxComboOption>( - width: width, - enabled: enabled, - initialSelection: selectedOption, - selectOnly: true, - enableSearch: false, - trailingIcon: const Icon(YaruIcons.pan_down), - selectedTrailingIcon: const Icon(YaruIcons.pan_up), - leadingIcon: leadingBuilder?.call(context, selected), - dropdownMenuEntries: [ - for (final option in options) - DropdownMenuEntry<_BusyMaxComboOption>( - value: option, - label: option.label, - leadingIcon: leadingBuilder?.call(context, values[option.index]), - labelWidget: Semantics( - selected: option.index == selectedIndex, - child: Text( - option.label, - maxLines: 1, - overflow: TextOverflow.ellipsis, + } + + @override + void didUpdateWidget(covariant BusyMaxComboBox oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.enabled && !widget.enabled) { + _dismissMenu(); + } + } + + @override + void dispose() { + final session = _activeMenuSession; + _activeMenuSession = null; + if (session != null) { + unawaited(session.dismiss()); + } + _triggerFocusNode.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Builder( + builder: (triggerContext) { + final selector = SizedBox( + key: _triggerKey, + width: widget.width, + child: Semantics( + expanded: _menuOpen, + child: BusyMaxPushButton.standard( + onPressed: widget.enabled + ? () => _openMenu(triggerContext, focusFirst: false) + : null, + focusNode: _triggerFocusNode, + child: Row( + children: [ + if (widget.leadingBuilder?.call(context, widget.selected) + case final leading?) ...[ + leading, + const SizedBox(width: BusyMaxSpacing.sm), + ], + Expanded( + child: Text( + widget.labelFor(widget.selected), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + Icon(_menuOpen ? YaruIcons.pan_up : YaruIcons.pan_down), + ], ), ), ), - ], - onSelected: enabled - ? (option) { - if (option != null) { - onSelected(values[option.index]); - } - } - : null, + ); + return widget.tooltip == null + ? selector + : Tooltip( + message: widget.tooltip!, + excludeFromSemantics: true, + child: selector, + ); + }, ); - return tooltip == null - ? selector - : Tooltip( - message: tooltip!, - excludeFromSemantics: true, - child: selector, - ); + } + + KeyEventResult _handleTriggerKeyEvent(FocusNode node, KeyEvent event) { + if (!widget.enabled || event is! KeyDownEvent) { + return KeyEventResult.ignored; + } + final key = event.logicalKey; + if (key != LogicalKeyboardKey.arrowDown && + key != LogicalKeyboardKey.enter && + key != LogicalKeyboardKey.space) { + return KeyEventResult.ignored; + } + final triggerContext = _triggerKey.currentContext; + if (!_menuOpen && triggerContext != null) { + unawaited(_openMenu(triggerContext, focusFirst: true)); + } + return KeyEventResult.handled; + } + + Future _openMenu( + BuildContext triggerContext, { + required bool focusFirst, + }) async { + if (!widget.enabled || _menuOpen) { + return; + } + final values = List.unmodifiable(widget.values); + final selected = widget.selected; + final labelFor = widget.labelFor; + final onSelected = widget.onSelected; + final nativeMenuService = widget.nativeMenuService; + final session = BusyMaxMenuSession(); + _activeMenuSession = session; + setState(() => _menuOpen = true); + BusyMaxMenuSelection? selection; + try { + selection = await showBusyMaxMenu( + context: context, + anchorContext: triggerContext, + entries: [ + for (final value in values) + BusyMaxMenuEntry( + value: value, + label: labelFor(value), + selected: value == selected, + ), + ], + nativeMenuService: nativeMenuService, + session: session, + focusFirst: focusFirst, + ); + } finally { + if (mounted && identical(_activeMenuSession, session)) { + setState(() { + _activeMenuSession = null; + _menuOpen = false; + }); + } + } + if (mounted && + !session._isDismissed && + selection != null && + selection.value != selected) { + onSelected(selection.value); + } + } + + void _dismissMenu() { + final session = _activeMenuSession; + if (session != null) { + unawaited(session.dismiss()); + } } } @@ -1568,7 +1730,7 @@ class BusyMaxComboRow extends StatelessWidget { this.leading, this.enabled = true, this.tooltip, - this.width = 220, + this.width = BusyMaxSizes.comboWidth, this.trailingAction, this.selectorLeadingBuilder, }); @@ -1612,27 +1774,36 @@ class BusyMaxComboRow extends StatelessWidget { subtitleWidget, enabled: enabled, ); - final textScale = MediaQuery.textScalerOf(context).scale(14) / 14; + final bodyFontSize = Theme.of(context).textTheme.bodyMedium?.fontSize; + final textScale = bodyFontSize == null + ? 1.0 + : MediaQuery.textScalerOf(context).scale(bodyFontSize) / + bodyFontSize; final actionAllowance = trailingAction == null ? 0.0 : BusyMaxSizes.headerIconButton + BusyMaxSpacing.xs; final stackControl = !constraints.hasBoundedWidth || - constraints.maxWidth < 560 || - textScale > 1.2; + constraints.maxWidth < BusyMaxFormLayout.comboStackBreakpoint || + textScale > BusyMaxFormLayout.comboLargeTextScale; final availableWidth = constraints.hasBoundedWidth ? constraints.maxWidth : width + BusyMaxSpacing.md * 2 + actionAllowance; - final maximumInlineSelectorWidth = (availableWidth * 0.46) - .clamp(120.0, double.infinity) - .toDouble(); + final maximumInlineSelectorWidth = + (availableWidth * BusyMaxFormLayout.comboInlineMaxFraction) + .clamp(BusyMaxSizes.comboMinWidth, double.infinity) + .toDouble(); final selectorWidth = stackControl ? (availableWidth - BusyMaxSpacing.md * 2 - actionAllowance) - .clamp(120.0, double.infinity) + .clamp(BusyMaxSizes.comboMinWidth, double.infinity) .toDouble() : constraints.hasBoundedWidth - ? width.clamp(120.0, maximumInlineSelectorWidth).toDouble() - : width.clamp(120.0, double.infinity).toDouble(); + ? width + .clamp(BusyMaxSizes.comboMinWidth, maximumInlineSelectorWidth) + .toDouble() + : width + .clamp(BusyMaxSizes.comboMinWidth, double.infinity) + .toDouble(); final selector = BusyMaxComboBox( width: selectorWidth, tooltip: tooltip ?? title, @@ -1769,7 +1940,7 @@ class BusyMaxMenuEntry { this.icon, this.child, this.enabled = true, - this.checked = false, + this.selected = false, this.tooltip, this.destructive = false, }); @@ -1779,11 +1950,329 @@ class BusyMaxMenuEntry { final IconData? icon; final Widget? child; final bool enabled; - final bool checked; + final bool selected; final String? tooltip; final bool destructive; } +@immutable +final class BusyMaxMenuSelection { + const BusyMaxMenuSelection(this.value); + + final T value; +} + +/// Owns one native or Flutter fallback menu presentation. +/// +/// A session may be dismissed safely after its owner is disposed. Native +/// dismissal is identity-checked by the host, while fallback dismissal +/// removes only the exact popup route captured for this presentation. +final class BusyMaxMenuSession { + BusyMaxMenuSession() : _nativeSession = NativeMenuSession(); + + final NativeMenuSession _nativeSession; + final GlobalKey _fallbackRouteKey = GlobalKey(); + NativeMenuService _nativeMenuService = const NativeMenuService(); + Route? _fallbackRoute; + bool _started = false; + bool _dismissRequested = false; + + bool get _isDismissed => _dismissRequested; + + Future dismiss() async { + if (_dismissRequested) { + return; + } + _dismissRequested = true; + _removeFallbackRoute(); + await _nativeMenuService.dismiss(_nativeSession); + } + + void _beginPresentation(NativeMenuService nativeMenuService) { + if (_started) { + throw StateError('A BusyMaxMenuSession can present only one menu.'); + } + _started = true; + _nativeMenuService = nativeMenuService; + } + + void _captureFallbackRoute() { + final itemContext = _fallbackRouteKey.currentContext; + if (itemContext == null) { + return; + } + final route = ModalRoute.of(itemContext); + if (route == null) { + return; + } + _fallbackRoute = route; + if (_dismissRequested) { + _removeFallbackRoute(); + } + } + + void _releaseFallbackRoute() { + _fallbackRoute = null; + } + + void _removeFallbackRoute() { + final route = _fallbackRoute; + final navigator = route?.navigator; + if (route != null && navigator != null && route.isActive) { + navigator.removeRoute(route); + } + _fallbackRoute = null; + } +} + +/// Presents a semantic menu at [anchorContext] or [anchorPoint]. +/// +/// Linux delegates the menu surface, rows, focus, keyboard navigation, and +/// dismissal to GTK. The Flutter route exists only for hosts where that +/// bridge is unavailable and inherits Yaru's popup-menu theme unchanged. +Future?> showBusyMaxMenu({ + required BuildContext context, + required List> entries, + BuildContext? anchorContext, + Offset? anchorPoint, + NativeMenuService nativeMenuService = const NativeMenuService(), + BusyMaxMenuSession? session, + bool focusFirst = false, +}) async { + if (entries.isEmpty) { + return null; + } + final entrySnapshot = List>.unmodifiable(entries); + _validateBusyMaxMenuEntries(entrySnapshot); + final presentation = session ?? BusyMaxMenuSession(); + if (presentation._isDismissed) { + return null; + } + presentation._beginPresentation(nativeMenuService); + final anchor = _busyMaxMenuAnchorRect( + anchorContext ?? context, + anchorPoint: anchorPoint, + ); + final nativeResult = await nativeMenuService.show( + session: presentation._nativeSession, + anchor: anchor, + entries: _nativeMenuEntries(entrySnapshot), + focusFirst: focusFirst, + ); + if (presentation._isDismissed) { + return null; + } + if (nativeResult.available) { + return _busyMaxMenuValueAt(entrySnapshot, nativeResult.selectedIndex); + } + if (!context.mounted) { + return null; + } + final selectedIndex = await _showBusyMaxFlutterMenu( + context: context, + anchor: anchor, + entries: entrySnapshot, + session: presentation, + focusFirst: focusFirst, + ); + if (presentation._isDismissed) { + return null; + } + return _busyMaxMenuValueAt(entrySnapshot, selectedIndex); +} + +void _validateBusyMaxMenuEntries(List> entries) { + final selectedCount = entries.where((entry) => entry.selected).length; + if (selectedCount > 1) { + throw ArgumentError.value( + entries, + 'entries', + 'A single-choice menu can have only one selected entry.', + ); + } + if (selectedCount == 1 && entries.any((entry) => !entry.enabled)) { + throw ArgumentError.value( + entries, + 'entries', + 'Single-choice menu entries must all be enabled.', + ); + } +} + +Rect _busyMaxMenuAnchorRect(BuildContext anchorContext, {Offset? anchorPoint}) { + if (anchorPoint != null) { + return Rect.fromLTWH(anchorPoint.dx, anchorPoint.dy, 0, 0); + } + final renderObject = anchorContext.findRenderObject(); + if (renderObject is! RenderBox || !renderObject.hasSize) { + return Rect.zero; + } + return renderObject.localToGlobal(Offset.zero) & renderObject.size; +} + +List _nativeMenuEntries(List> entries) { + return [ + for (final entry in entries) + NativeMenuEntry( + label: entry.label, + enabled: entry.enabled, + selected: entry.selected, + ), + ]; +} + +BusyMaxMenuSelection? _busyMaxMenuValueAt( + List> entries, + int? index, +) { + if (index == null || index < 0 || index >= entries.length) { + return null; + } + final entry = entries[index]; + return entry.enabled ? BusyMaxMenuSelection(entry.value) : null; +} + +Future _showBusyMaxFlutterMenu({ + required BuildContext context, + required Rect anchor, + required List> entries, + required BusyMaxMenuSession session, + required bool focusFirst, +}) async { + final navigator = Navigator.of(context); + final overlay = navigator.overlay?.context.findRenderObject(); + if (overlay is! RenderBox || !overlay.hasSize) { + return null; + } + final localAnchor = Rect.fromPoints( + overlay.globalToLocal(anchor.topLeft), + overlay.globalToLocal(anchor.bottomRight), + ); + final menuAnchor = Rect.fromLTWH( + localAnchor.left, + localAnchor.bottom, + localAnchor.width, + 0, + ); + final hasSelectedEntry = entries.any((entry) => entry.selected); + final selectedIndex = entries.indexWhere((entry) => entry.selected); + final firstEnabledIndex = entries.indexWhere((entry) => entry.enabled); + final firstEnabledKey = focusFirst && firstEnabledIndex >= 0 + ? GlobalKey() + : null; + final routeKey = session._fallbackRouteKey; + final selection = showMenu( + context: context, + position: RelativeRect.fromRect(menuAnchor, Offset.zero & overlay.size), + items: [ + for (var index = 0; index < entries.length; index += 1) + PopupMenuItem( + value: index, + enabled: entries[index].enabled, + child: _busyMaxFocusableFallbackEntry( + context, + entries[index], + selectionIndicator: hasSelectedEntry + ? ExcludeSemantics( + child: IgnorePointer( + child: YaruRadio( + value: index, + groupValue: selectedIndex, + onChanged: (_) {}, + hasFocusBorder: false, + ), + ), + ) + : null, + focusKey: index == firstEnabledIndex ? firstEnabledKey : null, + routeKey: index == 0 ? routeKey : null, + ), + ), + ], + requestFocus: true, + ); + WidgetsBinding.instance.addPostFrameCallback((_) { + session._captureFallbackRoute(); + if (firstEnabledKey != null && !session._isDismissed) { + final itemContext = firstEnabledKey.currentContext; + if (itemContext != null) { + Focus.of(itemContext).requestFocus(); + } + } + }); + try { + return await selection; + } finally { + session._releaseFallbackRoute(); + } +} + +Widget _busyMaxFocusableFallbackEntry( + BuildContext context, + BusyMaxMenuEntry entry, { + required Widget? selectionIndicator, + required GlobalKey? focusKey, + required GlobalKey? routeKey, +}) { + Widget child = _busyMaxFallbackMenuEntry( + context, + entry, + selectionIndicator: selectionIndicator, + ); + if (selectionIndicator != null) { + child = Semantics( + selected: entry.selected, + inMutuallyExclusiveGroup: true, + child: child, + ); + } + if (focusKey != null) { + child = KeyedSubtree(key: focusKey, child: child); + } + if (routeKey != null) { + child = KeyedSubtree(key: routeKey, child: child); + } + return child; +} + +Widget _busyMaxFallbackMenuEntry( + BuildContext context, + BusyMaxMenuEntry entry, { + required Widget? selectionIndicator, +}) { + final foreground = entry.destructive + ? Theme.of(context).colorScheme.error + : null; + final label = + entry.child ?? + Text( + entry.label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: foreground == null ? null : TextStyle(color: foreground), + ); + final content = entry.icon == null && selectionIndicator == null + ? label + : Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (selectionIndicator != null) ...[ + selectionIndicator, + const SizedBox(width: BusyMaxSpacing.sm), + ], + if (entry.icon != null) ...[ + Icon(entry.icon, color: foreground), + const SizedBox(width: BusyMaxSpacing.sm), + ], + Flexible(child: label), + ], + ); + if (entry.enabled || entry.tooltip == null) { + return content; + } + return Tooltip(message: entry.tooltip!, child: content); +} + typedef BusyMaxMenuTriggerBuilder = Widget Function( BuildContext context, @@ -1853,31 +2342,30 @@ class BusyMaxMenuButton extends StatefulWidget { required this.entries, required this.onSelected, this.icon = const Icon(YaruIcons.view_more), - this.minMenuWidth = 180, - this.menuPosition = const Offset(0, BusyMaxSizes.headerIconButton), this.triggerBuilder, this.controller, this.enabled = true, + this.nativeMenuService = const NativeMenuService(), }); final String tooltip; final Widget icon; final List> entries; final ValueChanged onSelected; - final double minMenuWidth; - final Offset? menuPosition; final BusyMaxMenuTriggerBuilder? triggerBuilder; final BusyMaxMenuController? controller; final bool enabled; + final NativeMenuService nativeMenuService; @override State> createState() => _BusyMaxMenuButtonState(); } class _BusyMaxMenuButtonState extends State> { - final _menuController = MenuController(); + final _triggerKey = GlobalKey(); late final FocusNode _triggerFocusNode; - final List _entryFocusNodes = []; + BusyMaxMenuSession? _activeMenuSession; + bool _menuOpen = false; @override void initState() { @@ -1886,7 +2374,6 @@ class _BusyMaxMenuButtonState extends State> { debugLabel: 'BusyMax menu trigger', onKeyEvent: _handleTriggerKeyEvent, ); - _synchronizeEntryFocusNodes(); _attachExternalController(); } @@ -1897,73 +2384,41 @@ class _BusyMaxMenuButtonState extends State> { oldWidget.controller?._detach(this); _attachExternalController(); } - _synchronizeEntryFocusNodes(); - if (oldWidget.enabled && !widget.enabled && _menuController.isOpen) { - _menuController.close(); + if (oldWidget.enabled && !widget.enabled && _menuOpen) { + _closeMenu(); } } @override void dispose() { widget.controller?._detach(this); - _triggerFocusNode.dispose(); - for (final focusNode in _entryFocusNodes) { - focusNode.dispose(); + final session = _activeMenuSession; + _activeMenuSession = null; + if (session != null) { + unawaited(session.dismiss()); } + _triggerFocusNode.dispose(); super.dispose(); } @override Widget build(BuildContext context) { - final colorScheme = Theme.of(context).colorScheme; - final reservesLeadingSpace = widget.entries.any( - (entry) => entry.checked || entry.icon != null, - ); - return MenuAnchor( - controller: _menuController, - childFocusNode: _triggerFocusNode, - crossAxisUnconstrained: false, - style: busyMaxDropdownMenuStyle(context, minWidth: widget.minMenuWidth), - builder: (context, controller, child) { - final triggerBuilder = widget.triggerBuilder; - if (triggerBuilder != null) { - return triggerBuilder( + final triggerBuilder = widget.triggerBuilder; + final trigger = triggerBuilder != null + ? triggerBuilder( context, - widget.enabled ? () => _toggleMenu(controller) : null, + widget.enabled ? _toggleMenu : null, _triggerFocusNode, + ) + : YaruIconButton( + tooltip: widget.tooltip, + icon: widget.icon, + focusNode: _triggerFocusNode, + onPressed: widget.enabled ? _toggleMenu : null, ); - } - return YaruIconButton( - tooltip: widget.tooltip, - iconSize: BusyMaxSizes.headerIcon, - icon: IconTheme.merge( - data: IconThemeData( - color: colorScheme.onSurfaceVariant, - size: BusyMaxSizes.headerIcon, - ), - child: widget.icon, - ), - focusNode: _triggerFocusNode, - onPressed: widget.enabled ? () => _toggleMenu(controller) : null, - style: busyMaxHeaderIconButtonStyle( - foregroundColor: colorScheme.onSurfaceVariant, - backgroundColor: busyMaxSubtleButtonBackground(context), - overlayColor: const WidgetStatePropertyAll(Colors.transparent), - ), - ); - }, - menuChildren: [ - for (var index = 0; index < widget.entries.length; index += 1) - _BusyMaxMenuEntryButton( - entry: widget.entries[index], - focusNode: _entryFocusNodes[index], - reserveLeadingSpace: reservesLeadingSpace, - onSelected: (value) { - widget.onSelected(value); - _menuController.close(); - }, - ), - ], + return KeyedSubtree( + key: _triggerKey, + child: Semantics(expanded: _menuOpen, child: trigger), ); } @@ -1972,136 +2427,91 @@ class _BusyMaxMenuButtonState extends State> { return KeyEventResult.ignored; } final key = event.logicalKey; - if (key == LogicalKeyboardKey.enter || - key == LogicalKeyboardKey.space || - key == LogicalKeyboardKey.arrowDown) { - if (_menuController.isOpen) { - if (key == LogicalKeyboardKey.arrowDown) { - _focusFirstEnabledEntry(); - } else { - _menuController.close(); - } - } else { + if (key == LogicalKeyboardKey.arrowDown || + key == LogicalKeyboardKey.enter || + key == LogicalKeyboardKey.space) { + if (!_menuOpen) { _openForKeyboard(); } return KeyEventResult.handled; } - if (key == LogicalKeyboardKey.escape && _menuController.isOpen) { - _menuController.close(); + if (key == LogicalKeyboardKey.escape && _menuOpen) { + _closeMenu(); return KeyEventResult.handled; } return KeyEventResult.ignored; } - void _toggleMenu(MenuController controller) { - if (controller.isOpen) { - controller.close(); + void _toggleMenu() { + if (_menuOpen) { + _closeMenu(); return; } - _openMenu(controller); + unawaited(_openMenu()); } - void _openMenu(MenuController controller) { - final position = widget.menuPosition; - if (position == null) { - controller.open(); - } else { - controller.open(position: position); + Future _openMenu({bool focusFirst = false}) async { + final triggerContext = _triggerKey.currentContext; + if (!widget.enabled || + _menuOpen || + triggerContext == null || + widget.entries.isEmpty) { + return; + } + final entries = List>.unmodifiable(widget.entries); + final onSelected = widget.onSelected; + final nativeMenuService = widget.nativeMenuService; + final session = BusyMaxMenuSession(); + _activeMenuSession = session; + setState(() { + _menuOpen = true; + }); + + BusyMaxMenuSelection? selection; + try { + selection = await showBusyMaxMenu( + context: context, + anchorContext: triggerContext, + entries: entries, + nativeMenuService: nativeMenuService, + session: session, + focusFirst: focusFirst, + ); + } finally { + if (mounted && identical(_activeMenuSession, session)) { + setState(() { + _activeMenuSession = null; + _menuOpen = false; + }); + } + } + if (mounted && !session._isDismissed && selection != null) { + onSelected(selection.value); } } bool _openForKeyboard() { - if (!widget.enabled || _menuController.isOpen) { + if (!widget.enabled || _menuOpen || widget.entries.isEmpty) { return false; } _triggerFocusNode.requestFocus(); - _openMenu(_menuController); - _focusFirstEnabledEntry(); + unawaited(_openMenu(focusFirst: true)); return true; } - void _focusFirstEnabledEntry() { - final index = widget.entries.indexWhere((entry) => entry.enabled); - if (index < 0) { - return; + void _closeMenu() { + final session = _activeMenuSession; + if (session != null) { + unawaited(session.dismiss()); } - WidgetsBinding.instance.addPostFrameCallback((_) { - if (mounted && _menuController.isOpen) { - _entryFocusNodes[index].requestFocus(); - } - }); } void _attachExternalController() { widget.controller?._attach( owner: this, openForKeyboard: _openForKeyboard, - close: _menuController.close, - isOpen: () => _menuController.isOpen, - ); - } - - void _synchronizeEntryFocusNodes() { - while (_entryFocusNodes.length < widget.entries.length) { - _entryFocusNodes.add( - FocusNode(debugLabel: 'BusyMax menu entry ${_entryFocusNodes.length}'), - ); - } - while (_entryFocusNodes.length > widget.entries.length) { - _entryFocusNodes.removeLast().dispose(); - } - } -} - -class _BusyMaxMenuEntryButton extends StatelessWidget { - const _BusyMaxMenuEntryButton({ - required this.entry, - required this.focusNode, - required this.reserveLeadingSpace, - required this.onSelected, - }); - - final BusyMaxMenuEntry entry; - final FocusNode focusNode; - final bool reserveLeadingSpace; - final ValueChanged onSelected; - - @override - Widget build(BuildContext context) { - final colorScheme = Theme.of(context).colorScheme; - final foreground = entry.destructive ? colorScheme.error : null; - final iconData = entry.checked ? YaruIcons.checkmark : entry.icon; - final icon = iconData == null - ? reserveLeadingSpace - ? const SizedBox.square(dimension: BusyMaxSizes.iconSm) - : null - : Icon(iconData, size: BusyMaxSizes.iconSm, color: foreground); - final row = MenuItemButton( - focusNode: focusNode, - leadingIcon: icon, - onPressed: entry.enabled ? () => onSelected(entry.value) : null, - style: busyMaxDropdownMenuItemStyle(context).copyWith( - foregroundColor: foreground == null - ? null - : WidgetStatePropertyAll(foreground), - ), - child: - entry.child ?? - Text( - entry.label, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: foreground == null ? null : TextStyle(color: foreground), - ), - ); - - if (entry.enabled || entry.tooltip == null) { - return row; - } - - return Tooltip( - message: entry.tooltip!, - child: Opacity(opacity: 0.55, child: row), + close: _closeMenu, + isOpen: () => _menuOpen, ); } } @@ -2306,7 +2716,7 @@ class BusyMaxEditorHeader extends StatelessWidget { child: Align( alignment: AlignmentDirectional.centerStart, heightFactor: 1, - child: FilledButton( + child: BusyMaxPushButton.standard( onPressed: cancelEnabled ? onCancel : null, style: actionStyle, child: Text(cancelLabel, overflow: TextOverflow.ellipsis), @@ -2326,7 +2736,7 @@ class BusyMaxEditorHeader extends StatelessWidget { child: Align( alignment: AlignmentDirectional.centerEnd, heightFactor: 1, - child: ElevatedButton( + child: BusyMaxPushButton.suggested( onPressed: onSave, style: actionStyle, child: saving @@ -2346,6 +2756,118 @@ class BusyMaxEditorHeader extends StatelessWidget { } } +/// A controlled, theme-owned selector for mutually exclusive content modes. +/// +/// [YaruTabBar] owns the visual geometry and interaction treatment. This +/// adapter only validates the domain choices and keeps its controller +/// synchronized with the selected value. +class BusyMaxModeSwitcher extends StatefulWidget { + BusyMaxModeSwitcher({ + super.key, + required List values, + required this.selected, + required this.labelFor, + required this.onSelected, + }) : values = List.unmodifiable(values) { + if (this.values.length < 2) { + throw ArgumentError.value( + values, + 'values', + 'A mode switcher requires multiple values.', + ); + } + if (this.values.toSet().length != this.values.length) { + throw ArgumentError.value( + values, + 'values', + 'A mode switcher requires unique values.', + ); + } + if (!this.values.contains(selected)) { + throw ArgumentError.value( + selected, + 'selected', + 'The selected mode must be present in values.', + ); + } + } + + final List values; + final T selected; + final String Function(T value) labelFor; + final ValueChanged onSelected; + + @override + State> createState() => _BusyMaxModeSwitcherState(); +} + +class _BusyMaxModeSwitcherState extends State> + with TickerProviderStateMixin { + late TabController _controller; + + int get _selectedIndex => widget.values.indexOf(widget.selected); + + @override + void initState() { + super.initState(); + _controller = _newController(); + } + + @override + void didUpdateWidget(covariant BusyMaxModeSwitcher oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.values.length != widget.values.length) { + _controller.dispose(); + _controller = _newController(); + return; + } + if (_controller.index != _selectedIndex) { + _controller.index = _selectedIndex; + } + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + TabController _newController() { + return TabController( + length: widget.values.length, + initialIndex: _selectedIndex, + vsync: this, + ); + } + + void _restoreExternalSelectionAfterInteraction() { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted || _controller.index == _selectedIndex) { + return; + } + _controller.index = _selectedIndex; + }); + } + + @override + Widget build(BuildContext context) { + return YaruTabBar( + tabController: _controller, + tabs: [ + for (final value in widget.values) + YaruTab(label: widget.labelFor(value)), + ], + onTap: (index) { + final value = widget.values[index]; + if (value != widget.selected) { + _restoreExternalSelectionAfterInteraction(); + widget.onSelected(value); + } + }, + ); + } +} + class BusyMaxTimeModeRow extends StatelessWidget { const BusyMaxTimeModeRow({ super.key, @@ -2359,39 +2881,11 @@ class BusyMaxTimeModeRow extends StatelessWidget { @override Widget build(BuildContext context) { final l10n = context.l10n; - final labels = [l10n.allDay, l10n.timeSlot]; - return LayoutBuilder( - builder: (context, constraints) { - final toggleTheme = ToggleButtonsTheme.of(context); - final themeConstraints = toggleTheme.constraints; - final borderWidth = toggleTheme.borderWidth ?? BusyMaxStroke.outline; - final boundedSegmentWidth = constraints.hasBoundedWidth - ? ((constraints.maxWidth - borderWidth * (labels.length + 1)) / - labels.length) - .clamp(0.0, double.infinity) - .toDouble() - : null; - final segmentConstraints = constraints.hasBoundedWidth - ? (themeConstraints ?? const BoxConstraints()).copyWith( - minWidth: boundedSegmentWidth, - maxWidth: boundedSegmentWidth, - ) - : themeConstraints; - return ToggleButtons( - constraints: segmentConstraints, - isSelected: [allDay, !allDay], - onPressed: (index) { - final value = index == 0; - if (value != allDay) { - onChanged(value); - } - }, - children: [ - for (final label in labels) - Text(label, maxLines: 1, overflow: TextOverflow.ellipsis), - ], - ); - }, + return BusyMaxModeSwitcher( + values: const [true, false], + selected: allDay, + labelFor: (value) => value ? l10n.allDay : l10n.timeSlot, + onSelected: onChanged, ); } } @@ -2649,10 +3143,10 @@ class _BusyMaxPromptDialogState extends State { Text(widget.message!), const SizedBox(height: BusyMaxSpacing.lg), ], - ValidatedFormField( + TextFormField( initialValue: widget.initialValue, autofocus: true, - labelText: widget.label, + decoration: InputDecoration(labelText: widget.label), onChanged: (value) => _value = value, onEditingComplete: () => Navigator.of(context).pop(_value), ), diff --git a/lib/src/app/busymax_surface_colors.dart b/lib/src/app/busymax_surface_colors.dart index 7367c34..885bb5c 100644 --- a/lib/src/app/busymax_surface_colors.dart +++ b/lib/src/app/busymax_surface_colors.dart @@ -175,9 +175,11 @@ BusyMaxSurfaceColors busyMaxFallbackSurfaceColors(Brightness brightness) { groupedSurface: Color(0xFFFFFFFF), dialog: Color(0xFFFAFAFB), popover: Color(0xFFFFFFFF), - control: Color.fromRGBO(0, 0, 0, 0.06), - controlHover: Color.fromRGBO(0, 0, 0, 0.10), - controlActive: Color.fromRGBO(0, 0, 0, 0.16), + // Match Yaru's contained-button ladder. A weaker resting layer makes + // standard controls look flat until their hover overlay appears. + control: Color.fromRGBO(0, 0, 0, 0.10), + controlHover: Color.fromRGBO(0, 0, 0, 0.14), + controlActive: Color.fromRGBO(0, 0, 0, 0.18), activeToggle: Color(0xFFFFFFFF), foreground: foreground, mutedForeground: mutedForeground, @@ -190,20 +192,19 @@ BusyMaxSurfaceColors busyMaxFallbackSurfaceColors(Brightness brightness) { shade: Color.fromRGBO(0, 0, 6, 0.07), ), Brightness.dark => BusyMaxSurfaceColors( - // Current Yaru/libadwaita semantic surface ladder. These values are the - // fallback when GTK 3 cannot expose a compatible role; flat or recessed - // legacy `.sidebar` and `popover.background` samples must not replace - // these raised roles. - window: Color(0xFF2C2C2C), - view: Color(0xFF1D1D20), - sidebar: Color(0xFF393939), - secondarySidebar: Color(0xFF323232), - headerbar: Color(0xFF393939), - headerbarFlat: Color(0xFF1D1D20), - card: Color(0xFF3D3D3D), - groupedSurface: Color(0xFF3D3D3D), - dialog: Color(0xFF3E3E3E), - popover: Color(0xFF3E3E3E), + // Current Yaru/libadwaita semantic surface ladder. GTK 3 cannot expose + // every libadwaita role reliably, so incompatible legacy samples fall + // back to the matching modern surface rather than a hand-tuned shade. + window: Color(0xFF222226), + view: Color(0xFF222226), + sidebar: Color(0xFF2E2E32), + secondarySidebar: Color(0xFF28282C), + headerbar: Color(0xFF2E2E32), + headerbarFlat: Color(0xFF222226), + card: Color(0xFF36363A), + groupedSurface: Color(0xFF36363A), + dialog: Color(0xFF36363A), + popover: Color(0xFF36363A), control: Color.fromRGBO(255, 255, 255, 0.10), controlHover: Color.fromRGBO(255, 255, 255, 0.14), controlActive: Color.fromRGBO(255, 255, 255, 0.18), diff --git a/lib/src/app/busymax_yaru_theme.dart b/lib/src/app/busymax_yaru_theme.dart index 5f91ec9..ab0a18e 100644 --- a/lib/src/app/busymax_yaru_theme.dart +++ b/lib/src/app/busymax_yaru_theme.dart @@ -9,6 +9,7 @@ import 'busymax_surface_colors.dart'; export 'busymax_surface_colors.dart'; const _minimumRaisedSurfaceContrast = 1.08; +const _minimumControlSurfaceContrast = 1.02; abstract final class BusyMaxLinuxPalette { static const red3 = Color(0xFFE01B24); @@ -156,15 +157,6 @@ class BusyMaxYaruTheme { fallback: textTheme.labelLarge, ), ); - final toggleButtonsTheme = base.toggleButtonsTheme.copyWith( - color: colors.foreground, - selectedColor: colors.foreground, - disabledColor: colors.disabledForeground, - fillColor: colors.controlActive, - borderColor: colors.border, - selectedBorderColor: colors.border, - disabledBorderColor: colors.disabledForeground, - ); final menuStyle = _semanticMenuSurfaceStyle( base.menuTheme.style, color: colors.popover, @@ -311,7 +303,6 @@ class BusyMaxYaruTheme { return colors.mutedForeground; }), ), - toggleButtonsTheme: toggleButtonsTheme, popupMenuTheme: base.popupMenuTheme.copyWith( color: colors.popover, surfaceTintColor: colors.popover, @@ -658,10 +649,11 @@ class _BusyMaxResolvedSurfaceColors { // the additional hierarchy validation below. final window = readableSurface(sampledWindow, fallback.window); final view = readableSurface(sampledView, fallback.view); - final sidebar = _resolvedRaisedSurface( + final sidebar = _resolvedSidebarSurface( runtimeSidebar, brightness: brightness, parent: window, + adjacent: view, foreground: foreground, fallback: fallback.sidebar, ); @@ -743,6 +735,13 @@ class _BusyMaxResolvedSurfaceColors { backgrounds: readableBackgrounds, minContrast: 1.5, ); + final controlLadder = _resolvedControlLadder( + runtimeControl: runtime.control, + runtimeHover: runtime.controlHover, + runtimeActive: runtime.controlActive, + fallback: fallback, + backgrounds: [view, sidebar, groupedSurface, dialog, popover], + ); return fallback.copyWith( window: window, @@ -755,9 +754,9 @@ class _BusyMaxResolvedSurfaceColors { groupedSurface: groupedSurface, dialog: dialog, popover: popover, - control: _runtimeOverlayColor(runtime.control), - controlHover: _runtimeOverlayColor(runtime.controlHover), - controlActive: _runtimeOverlayColor(runtime.controlActive), + control: controlLadder.control, + controlHover: controlLadder.hover, + controlActive: controlLadder.active, activeToggle: _runtimeOverlayColor(runtime.activeToggle), foreground: foreground, mutedForeground: mutedForeground, @@ -772,6 +771,42 @@ class _BusyMaxResolvedSurfaceColors { } } +Color _resolvedSidebarSurface( + Color? runtimeSurface, { + required Brightness brightness, + required Color parent, + required Color adjacent, + required Color foreground, + required Color fallback, +}) { + bool isReadable(Color color) => _contrastRatio(foreground, color) >= 4.5; + + bool hasExpectedHierarchy(Color color) { + final surfaceLuminance = color.computeLuminance(); + final isOnRaisedSideOfBoth = [parent, adjacent].every((background) { + final backgroundLuminance = background.computeLuminance(); + return brightness == Brightness.dark + ? surfaceLuminance > backgroundLuminance + : surfaceLuminance < backgroundLuminance; + }); + return isOnRaisedSideOfBoth; + } + + if (runtimeSurface != null && + isReadable(runtimeSurface) && + hasExpectedHierarchy(runtimeSurface)) { + return runtimeSurface; + } + if (isReadable(fallback) && hasExpectedHierarchy(fallback)) { + return fallback; + } + + // A custom palette can invert the fixed fallback hierarchy. Matching the + // adjacent content is safer than drawing the sidebar on the wrong side of + // either parent surface. + return adjacent; +} + Color _resolvedRaisedSurface( Color? runtimeSurface, { required Brightness brightness, @@ -839,6 +874,66 @@ Color? _runtimeOverlayColor(Color? color) { return candidate; } +({Color control, Color hover, Color active}) _resolvedControlLadder({ + required Color? runtimeControl, + required Color? runtimeHover, + required Color? runtimeActive, + required BusyMaxSurfaceColors fallback, + required Iterable backgrounds, +}) { + final control = _runtimeOverlayColor(runtimeControl); + final hover = _runtimeOverlayColor(runtimeHover); + final active = _runtimeOverlayColor(runtimeActive); + if (control == null || hover == null || active == null) { + return ( + control: fallback.control, + hover: fallback.controlHover, + active: fallback.controlActive, + ); + } + + // GTK 3 themes can report only a nearly transparent background-color while + // painting the actual button through a background image. Such samples are + // not usable as Flutter control roles. Require at least Yaru's semantic + // strength and validate the complete state ladder against every surface on + // which a shared control may appear. + if (control.a < fallback.control.a || + hover.a < fallback.controlHover.a || + active.a < fallback.controlActive.a) { + return ( + control: fallback.control, + hover: fallback.controlHover, + active: fallback.controlActive, + ); + } + + for (final background in backgrounds) { + final controlContrast = _contrastRatio( + Color.alphaBlend(control, background), + background, + ); + final hoverContrast = _contrastRatio( + Color.alphaBlend(hover, background), + background, + ); + final activeContrast = _contrastRatio( + Color.alphaBlend(active, background), + background, + ); + if (controlContrast < _minimumControlSurfaceContrast || + hoverContrast < controlContrast || + activeContrast < hoverContrast) { + return ( + control: fallback.control, + hover: fallback.controlHover, + active: fallback.controlActive, + ); + } + } + + return (control: control, hover: hover, active: active); +} + Color? _runtimeShadeColor(Color? color, {required Color over}) { final runtime = _runtimeColor(color); if (runtime == null) { @@ -1070,6 +1165,12 @@ ButtonStyle _semanticButtonStyle( } return foreground; }), + iconColor: WidgetStateProperty.resolveWith((states) { + if (states.contains(WidgetState.disabled)) { + return disabledForeground; + } + return foreground; + }), backgroundColor: WidgetStateProperty.resolveWith((states) { if (states.contains(WidgetState.disabled)) { return disabledBackground; diff --git a/lib/src/features/schedule/presentation/schedule_create_menu.dart b/lib/src/features/schedule/presentation/schedule_create_menu.dart index 9ffd801..e3dd56b 100644 --- a/lib/src/features/schedule/presentation/schedule_create_menu.dart +++ b/lib/src/features/schedule/presentation/schedule_create_menu.dart @@ -1,9 +1,7 @@ import 'package:flutter/material.dart'; import '../../../app/busymax_design.dart'; -import '../../../app/busymax_surface_colors.dart'; import '../../../l10n/l10n.dart'; -import 'schedule_anchored_popover.dart'; enum ScheduleCreateChoice { event, task } @@ -25,90 +23,30 @@ Future showScheduleCreateMenu({ Offset? anchorPoint, bool canCreateEvent = true, bool canCreateTask = true, -}) { + BusyMaxMenuSession? session, +}) async { if (!canCreateEvent && !canCreateTask) { - return Future.value(); + return null; } - return showScheduleAnchoredPopover( + final selection = await showBusyMaxMenu( context: context, anchorContext: anchorContext ?? context, anchorPoint: anchorPoint, - semanticLabel: context.l10n.createChoiceTitle, - preferredWidth: 220, - minimumWidth: 180, - preferredMinimumHeight: 120, - builder: (context, arrowSide, arrowAlignment) { - return BusyMaxPopoverSurface( - color: BusyMaxSurfaceColors.of(context).popover, - arrowSide: arrowSide, - arrowAlignment: arrowAlignment, - padding: const EdgeInsets.symmetric(vertical: BusyMaxSpacing.xs), - child: _ScheduleCreateMenuItems( - canCreateEvent: canCreateEvent, - canCreateTask: canCreateTask, - autofocusFirstItem: anchorPoint == null, - ), - ); - }, + session: session, + focusFirst: anchorPoint == null, + entries: [ + BusyMaxMenuEntry( + value: ScheduleCreateChoice.event, + label: context.l10n.createEventAtTime, + enabled: canCreateEvent, + ), + BusyMaxMenuEntry( + value: ScheduleCreateChoice.task, + label: context.l10n.createTaskAtDate, + enabled: canCreateTask, + ), + ], ); -} - -class _ScheduleCreateMenuItems extends StatelessWidget { - const _ScheduleCreateMenuItems({ - required this.canCreateEvent, - required this.canCreateTask, - required this.autofocusFirstItem, - }); - - final bool canCreateEvent; - final bool canCreateTask; - final bool autofocusFirstItem; - - @override - Widget build(BuildContext context) { - return Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - _ScheduleCreateMenuItem( - choice: ScheduleCreateChoice.event, - label: context.l10n.createEventAtTime, - enabled: canCreateEvent, - autofocus: autofocusFirstItem && canCreateEvent, - ), - _ScheduleCreateMenuItem( - choice: ScheduleCreateChoice.task, - label: context.l10n.createTaskAtDate, - enabled: canCreateTask, - autofocus: autofocusFirstItem && !canCreateEvent && canCreateTask, - ), - ], - ); - } -} - -class _ScheduleCreateMenuItem extends StatelessWidget { - const _ScheduleCreateMenuItem({ - required this.choice, - required this.label, - required this.enabled, - required this.autofocus, - }); - - final ScheduleCreateChoice choice; - final String label; - final bool enabled; - final bool autofocus; - - @override - Widget build(BuildContext context) { - return MenuItemButton( - autofocus: autofocus, - leadingIcon: const SizedBox.square(dimension: BusyMaxSizes.iconSm), - onPressed: enabled ? () => Navigator.of(context).pop(choice) : null, - style: busyMaxDropdownMenuItemStyle(context), - child: Text(label, maxLines: 1, overflow: TextOverflow.ellipsis), - ); - } + return selection?.value; } diff --git a/lib/src/features/schedule/presentation/schedule_item_details_popover.dart b/lib/src/features/schedule/presentation/schedule_item_details_popover.dart index 7d03017..09d8cfe 100644 --- a/lib/src/features/schedule/presentation/schedule_item_details_popover.dart +++ b/lib/src/features/schedule/presentation/schedule_item_details_popover.dart @@ -120,32 +120,29 @@ class _PopoverActions extends StatelessWidget { @override Widget build(BuildContext context) { final actions = [ - YaruIconButton( - icon: const Icon(Icons.download_outlined, size: BusyMaxSizes.iconSm), + BusyMaxPopoverIconButton( + icon: YaruIcons.share, tooltip: context.l10n.export, onPressed: () => Navigator.of(context).pop(ScheduleItemDetailsAction.export), ), if (item.capabilities.canEdit) - YaruIconButton( - icon: const Icon(Icons.edit_outlined, size: BusyMaxSizes.iconSm), + BusyMaxPopoverIconButton( + icon: Icons.edit_outlined, tooltip: _editLabel(context, item), onPressed: () => Navigator.of(context).pop(ScheduleItemDetailsAction.edit), ), if (item.capabilities.canDelete) - YaruIconButton( - icon: Icon( - Icons.delete_outline, - size: BusyMaxSizes.iconSm, - color: Theme.of(context).colorScheme.error, - ), + BusyMaxPopoverIconButton( + icon: YaruIcons.trash, tooltip: context.l10n.delete, + destructive: true, onPressed: () => Navigator.of(context).pop(ScheduleItemDetailsAction.delete), ), - YaruIconButton( - icon: const Icon(Icons.close, size: BusyMaxSizes.iconSm), + BusyMaxPopoverIconButton( + icon: YaruIcons.window_close, tooltip: MaterialLocalizations.of(context).closeButtonTooltip, onPressed: () => Navigator.of(context).pop(), ), diff --git a/lib/src/features/schedule/presentation/schedule_toolbar.dart b/lib/src/features/schedule/presentation/schedule_toolbar.dart index 9668a95..d7ac066 100644 --- a/lib/src/features/schedule/presentation/schedule_toolbar.dart +++ b/lib/src/features/schedule/presentation/schedule_toolbar.dart @@ -113,7 +113,7 @@ class ScheduleToolbar extends StatelessWidget { value: value, label: _modeLabel(context, value), icon: _modeIcon(value), - checked: mode == value, + selected: mode == value, ), ], onSelected: onModeChanged, diff --git a/lib/src/features/schedule/presentation/schedule_workspace.dart b/lib/src/features/schedule/presentation/schedule_workspace.dart index 4c63ed9..5979b4f 100644 --- a/lib/src/features/schedule/presentation/schedule_workspace.dart +++ b/lib/src/features/schedule/presentation/schedule_workspace.dart @@ -188,6 +188,7 @@ class _ScheduleWorkspaceState extends ConsumerState { var _latestItems = const []; final _itemAnchorContexts = {}; final _createMenuController = BusyMaxMenuController(); + BusyMaxMenuSession? _createChoiceMenuSession; final _pointerAnchorClock = Stopwatch()..start(); Offset? _recentSchedulePointerPosition; Duration? _recentSchedulePointerTime; @@ -224,6 +225,11 @@ class _ScheduleWorkspaceState extends ConsumerState { @override void dispose() { + final createChoiceMenuSession = _createChoiceMenuSession; + _createChoiceMenuSession = null; + if (createChoiceMenuSession != null) { + unawaited(createChoiceMenuSession.dismiss()); + } _headerBarSession.dispose(); if (_taskDetailsTarget != null) { unawaited( @@ -1361,6 +1367,9 @@ class _ScheduleWorkspaceState extends ConsumerState { DateTime start, { required bool canCreateTask, }) async { + if (_createChoiceMenuSession != null) { + return; + } final writableSources = writableCalendarSources(sources); final anchorPoint = _takeRecentSchedulePointerPosition(); final canCreateEvent = writableSources.isNotEmpty; @@ -1380,11 +1389,21 @@ class _ScheduleWorkspaceState extends ConsumerState { ); return; } - final choice = await showScheduleCreateMenu( - context: context, - anchorContext: _createChoiceAnchorContext(), - anchorPoint: anchorPoint, - ); + final menuSession = BusyMaxMenuSession(); + _createChoiceMenuSession = menuSession; + ScheduleCreateChoice? choice; + try { + choice = await showScheduleCreateMenu( + context: context, + anchorContext: _createChoiceAnchorContext(), + anchorPoint: anchorPoint, + session: menuSession, + ); + } finally { + if (identical(_createChoiceMenuSession, menuSession)) { + _createChoiceMenuSession = null; + } + } if (!mounted || choice == null) { return; } diff --git a/lib/src/features/settings/presentation/settings_screen.dart b/lib/src/features/settings/presentation/settings_screen.dart index 85ffe16..1605092 100644 --- a/lib/src/features/settings/presentation/settings_screen.dart +++ b/lib/src/features/settings/presentation/settings_screen.dart @@ -638,15 +638,13 @@ class _SettingsPageSelector extends StatelessWidget { width: double.infinity, child: BusyMaxMenuButton( tooltip: _settingsPageLabel(context, selected), - minMenuWidth: BusyMaxSizes.sidebarWidth, - menuPosition: null, entries: [ for (final page in SettingsPage.values) BusyMaxMenuEntry( value: page, label: _settingsPageLabel(context, page), icon: _settingsPageIcon(page), - checked: page == selected, + selected: page == selected, ), ], onSelected: onSelected, diff --git a/lib/src/platform/native_menu_service.dart b/lib/src/platform/native_menu_service.dart new file mode 100644 index 0000000..bd0661d --- /dev/null +++ b/lib/src/platform/native_menu_service.dart @@ -0,0 +1,121 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; + +@visibleForTesting +const nativeMenuChannelName = 'busymax/native_menus'; + +/// Identifies one native menu presentation. +/// +/// The host compares this identity when dismissing a menu, so a retiring +/// widget can never close a newer caller's popover. +@immutable +final class NativeMenuSession { + NativeMenuSession() : id = _nextId++; + + static int _nextId = 1; + + final int id; +} + +/// One command exposed by a host-toolkit menu. +/// +/// Entries intentionally contain presentation state only. The selected index +/// is mapped back to the caller's domain value after the native menu closes. +@immutable +final class NativeMenuEntry { + const NativeMenuEntry({ + required this.label, + this.enabled = true, + this.selected = false, + }); + + final String label; + final bool enabled; + final bool selected; + + Map _toPlatformMap() { + return { + 'label': label, + 'enabled': enabled, + 'selected': selected, + }; + } +} + +/// Result of asking the host toolkit to present a native menu. +/// +/// [available] distinguishes a dismissed native menu from a host that does not +/// implement the bridge. When available, a null [selectedIndex] means that the +/// user dismissed the menu without choosing an entry. +@immutable +final class NativeMenuResult { + const NativeMenuResult.available({this.selectedIndex}) : available = true; + + const NativeMenuResult.unavailable() + : available = false, + selectedIndex = null; + + final bool available; + final int? selectedIndex; +} + +/// Presents an anchored menu owned by the host desktop toolkit. +/// +/// Hosts that do not implement the channel are reported as unavailable so the +/// caller can use its themed Flutter fallback. +class NativeMenuService { + const NativeMenuService({ + MethodChannel channel = const MethodChannel(nativeMenuChannelName), + }) : _channel = channel; + + final MethodChannel _channel; + + Future show({ + required NativeMenuSession session, + required Rect anchor, + required List entries, + bool focusFirst = false, + }) async { + try { + final selectedIndex = await _channel.invokeMethod('show', { + 'sessionId': session.id, + 'anchor': { + 'x': anchor.left, + 'y': anchor.top, + 'width': anchor.width, + 'height': anchor.height, + }, + 'entries': [for (final entry in entries) entry._toPlatformMap()], + 'focusFirst': focusFirst, + }); + return NativeMenuResult.available(selectedIndex: selectedIndex); + } on MissingPluginException { + return const NativeMenuResult.unavailable(); + } on PlatformException catch (error) { + if (error.code == 'unavailable') { + return const NativeMenuResult.unavailable(); + } + rethrow; + } + } + + /// Dismisses [session] if it is still owned by the native host. + /// + /// Returns false when no menu was dismissed or the host does not implement + /// the native menu bridge. + Future dismiss(NativeMenuSession session) async { + try { + return await _channel.invokeMethod('dismiss', { + 'sessionId': session.id, + }) ?? + false; + } on MissingPluginException { + return false; + } on PlatformException catch (error) { + if (error.code == 'unavailable') { + return false; + } + rethrow; + } + } +} diff --git a/linux/runner/my_application.cc b/linux/runner/my_application.cc index d829e8b..e9243bb 100644 --- a/linux/runner/my_application.cc +++ b/linux/runner/my_application.cc @@ -4,6 +4,7 @@ #include #include #include +#include #include #include #include @@ -18,6 +19,7 @@ constexpr char kApplicationDisplayName[] = "BusyMax"; constexpr char kNativeDateTimePickerChannel[] = "busymax/native_date_time_picker"; 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"; @@ -63,6 +65,7 @@ struct _MyApplication { char** dart_entrypoint_arguments; FlMethodChannel* native_date_time_picker_channel; FlMethodChannel* native_dialog_channel; + FlMethodChannel* native_menu_channel; FlMethodChannel* window_channel; FlMethodChannel* header_bar_channel; FlMethodChannel* gtk_settings_channel; @@ -456,6 +459,472 @@ static void register_native_dialogs_for_subwindow(FlView* view, g_object_unref); } +constexpr char kNativeMenuActionNamespace[] = "busymax-native-menu"; +constexpr char kNativeMenuActionIndexKey[] = "busymax-native-menu-index"; + +struct NativeMenuHandlerData; + +struct NativeMenuSession { + NativeMenuHandlerData* owner; + gint64 id; + size_t entry_count; + GtkWidget* popover; + GMenu* model; + GSimpleActionGroup* action_group; + FlMethodCall* method_call; + gulong closed_signal_id; + guint cleanup_source_id; +}; + +struct NativeMenuHandlerData { + GtkWidget* view; + NativeMenuSession* active; +}; + +static void native_menu_session_respond(NativeMenuSession* session, + gint selected_index) { + if (session->method_call == nullptr) { + return; + } + g_autoptr(FlValue) result = selected_index < 0 + ? fl_value_new_null() + : fl_value_new_int(selected_index); + fl_method_call_respond_success(session->method_call, result, nullptr); + g_clear_object(&session->method_call); +} + +static void native_menu_session_dispose(NativeMenuSession* session) { + if (session == nullptr) { + return; + } + + NativeMenuHandlerData* owner = session->owner; + if (owner != nullptr && owner->active == session) { + owner->active = nullptr; + } + + if (session->cleanup_source_id != 0) { + g_source_remove(session->cleanup_source_id); + session->cleanup_source_id = 0; + } + native_menu_session_respond(session, -1); + + if (session->popover != nullptr) { + if (session->closed_signal_id != 0) { + g_signal_handler_disconnect(session->popover, + session->closed_signal_id); + session->closed_signal_id = 0; + } + gtk_popover_bind_model(GTK_POPOVER(session->popover), nullptr, nullptr); + gtk_widget_destroy(session->popover); + g_clear_object(&session->popover); + } + + if (owner != nullptr && owner->view != nullptr) { + gtk_widget_insert_action_group(owner->view, kNativeMenuActionNamespace, + nullptr); + if (gtk_widget_get_realized(owner->view)) { + gtk_widget_grab_focus(owner->view); + } + } + g_clear_object(&session->model); + g_clear_object(&session->action_group); + g_free(session); +} + +static gboolean native_menu_cleanup_idle_cb(gpointer user_data) { + auto* session = static_cast(user_data); + session->cleanup_source_id = 0; + native_menu_session_dispose(session); + return G_SOURCE_REMOVE; +} + +static void native_menu_closed_cb(GtkPopover*, gpointer user_data) { + auto* session = static_cast(user_data); + if (session->cleanup_source_id == 0) { + // GtkModelButton normally activates its GAction before closing the + // popover. Deferring final cleanup also covers themes/backends that emit + // "closed" first, so the action can still win with a selected index. + session->cleanup_source_id = g_idle_add_full( + G_PRIORITY_DEFAULT_IDLE, native_menu_cleanup_idle_cb, session, nullptr); + } +} + +static void native_menu_action_activated_cb(GSimpleAction* action, + GVariant*, + gpointer user_data) { + auto* session = static_cast(user_data); + const gint selected_index = + GPOINTER_TO_INT( + g_object_get_data(G_OBJECT(action), kNativeMenuActionIndexKey)) - + 1; + native_menu_session_respond(session, selected_index); + if (session->popover != nullptr) { + gtk_popover_popdown(GTK_POPOVER(session->popover)); + } +} + +static void native_menu_selection_activated_cb(GSimpleAction* action, + GVariant* parameter, + gpointer user_data) { + if (parameter == nullptr || + !g_variant_is_of_type(parameter, G_VARIANT_TYPE_STRING)) { + return; + } + const gchar* target = g_variant_get_string(parameter, nullptr); + gchar* end = nullptr; + const guint64 parsed = g_ascii_strtoull(target, &end, 10); + auto* session = static_cast(user_data); + if (target[0] == '\0' || end == nullptr || *end != '\0' || + parsed > static_cast(G_MAXINT) || + parsed >= session->entry_count) { + return; + } + + g_simple_action_set_state(action, parameter); + native_menu_session_respond(session, static_cast(parsed)); + if (session->popover != nullptr) { + gtk_popover_popdown(GTK_POPOVER(session->popover)); + } +} + +static gboolean native_menu_dismiss_active(NativeMenuHandlerData* data, + gint64 session_id) { + NativeMenuSession* session = data->active; + if (session == nullptr || session->id != session_id) { + return FALSE; + } + + native_menu_session_respond(session, -1); + if (session->popover != nullptr && + gtk_widget_get_visible(session->popover)) { + gtk_popover_popdown(GTK_POPOVER(session->popover)); + } else { + native_menu_session_dispose(session); + } + return TRUE; +} + +static gboolean fl_lookup_number_arg(FlValue* args, + const gchar* key, + gdouble* value_out) { + if (args == nullptr || fl_value_get_type(args) != FL_VALUE_TYPE_MAP) { + return FALSE; + } + FlValue* value = fl_value_lookup_string(args, key); + if (value == nullptr) { + return FALSE; + } + switch (fl_value_get_type(value)) { + case FL_VALUE_TYPE_FLOAT: + *value_out = fl_value_get_float(value); + return std::isfinite(*value_out); + case FL_VALUE_TYPE_INT: + *value_out = static_cast(fl_value_get_int(value)); + return TRUE; + default: + return FALSE; + } +} + +static gboolean fl_lookup_optional_bool_arg(FlValue* args, + const gchar* key, + gboolean fallback, + gboolean* value_out) { + if (args == nullptr || fl_value_get_type(args) != FL_VALUE_TYPE_MAP) { + return FALSE; + } + FlValue* value = fl_value_lookup_string(args, key); + if (value == nullptr) { + *value_out = fallback; + return TRUE; + } + if (fl_value_get_type(value) != FL_VALUE_TYPE_BOOL) { + return FALSE; + } + *value_out = fl_value_get_bool(value); + return TRUE; +} + +static gboolean fl_lookup_positive_int64_arg(FlValue* args, + const gchar* key, + gint64* value_out) { + if (args == nullptr || fl_value_get_type(args) != FL_VALUE_TYPE_MAP) { + return FALSE; + } + FlValue* value = fl_value_lookup_string(args, key); + if (value == nullptr || fl_value_get_type(value) != FL_VALUE_TYPE_INT) { + return FALSE; + } + const gint64 parsed = fl_value_get_int(value); + if (parsed <= 0) { + return FALSE; + } + *value_out = parsed; + return TRUE; +} + +static void respond_native_menu_argument_error(FlMethodCall* method_call, + const gchar* message) { + fl_method_call_respond_error(method_call, "invalid-arguments", message, + nullptr, nullptr); +} + +static gboolean parse_native_menu_anchor(FlValue* args, + GdkRectangle* rectangle_out) { + if (args == nullptr || fl_value_get_type(args) != FL_VALUE_TYPE_MAP) { + return FALSE; + } + FlValue* anchor = fl_value_lookup_string(args, "anchor"); + if (anchor == nullptr || fl_value_get_type(anchor) != FL_VALUE_TYPE_MAP) { + return FALSE; + } + gdouble x = 0; + gdouble y = 0; + gdouble width = 0; + gdouble height = 0; + const gboolean has_x = fl_lookup_number_arg(anchor, "x", &x) || + fl_lookup_number_arg(anchor, "left", &x); + const gboolean has_y = fl_lookup_number_arg(anchor, "y", &y) || + fl_lookup_number_arg(anchor, "top", &y); + if (!has_x || !has_y || + !fl_lookup_number_arg(anchor, "width", &width) || + !fl_lookup_number_arg(anchor, "height", &height) || width < 0 || + height < 0 || !std::isfinite(x + width) || + !std::isfinite(y + height)) { + return FALSE; + } + + const gdouble left = std::floor(x); + const gdouble top = std::floor(y); + const gdouble right = std::ceil(x + width); + const gdouble bottom = std::ceil(y + height); + const gdouble pixel_width = std::max(1.0, right - left); + const gdouble pixel_height = std::max(1.0, bottom - top); + if (left < G_MININT || left > G_MAXINT || top < G_MININT || + top > G_MAXINT || right < G_MININT || right > G_MAXINT || + bottom < G_MININT || bottom > G_MAXINT || pixel_width > G_MAXINT || + pixel_height > G_MAXINT) { + return FALSE; + } + + rectangle_out->x = static_cast(left); + rectangle_out->y = static_cast(top); + rectangle_out->width = static_cast(pixel_width); + rectangle_out->height = static_cast(pixel_height); + return TRUE; +} + +static void show_native_menu(NativeMenuHandlerData* data, + FlMethodCall* method_call, + FlValue* args) { + if (data->view == nullptr || !gtk_widget_get_realized(data->view)) { + fl_method_call_respond_error(method_call, "unavailable", + "The native menu host is unavailable.", + nullptr, nullptr); + return; + } + + GdkRectangle anchor = {}; + gint64 session_id = 0; + if (!fl_lookup_positive_int64_arg(args, "sessionId", &session_id) || + !parse_native_menu_anchor(args, &anchor)) { + respond_native_menu_argument_error( + method_call, + "sessionId must be a positive integer and anchor must contain finite " + "x, y, width, and height."); + return; + } + + FlValue* entries = fl_value_lookup_string(args, "entries"); + gboolean focus_first = FALSE; + if (entries == nullptr || + fl_value_get_type(entries) != FL_VALUE_TYPE_LIST || + fl_value_get_length(entries) == 0 || + fl_value_get_length(entries) > static_cast(G_MAXINT) || + !fl_lookup_optional_bool_arg(args, "focusFirst", FALSE, + &focus_first)) { + respond_native_menu_argument_error( + method_call, + "entries must be a non-empty list and focusFirst must be boolean."); + return; + } + + size_t selected_entry_count = 0; + size_t selected_entry_index = 0; + gboolean has_disabled_entry = FALSE; + for (size_t index = 0; index < fl_value_get_length(entries); index++) { + FlValue* entry = fl_value_get_list_value(entries, index); + const gchar* label = fl_lookup_string_arg(entry, "label"); + gboolean enabled = TRUE; + gboolean selected = FALSE; + if (label == nullptr || + !fl_lookup_optional_bool_arg(entry, "enabled", TRUE, &enabled) || + !fl_lookup_optional_bool_arg(entry, "selected", FALSE, &selected)) { + respond_native_menu_argument_error( + method_call, + "each entry must contain a label and optional boolean enabled and " + "selected values."); + return; + } + if (selected) { + selected_entry_count++; + selected_entry_index = index; + } + has_disabled_entry = has_disabled_entry || !enabled; + } + if (selected_entry_count > 1 || + (selected_entry_count == 1 && has_disabled_entry)) { + respond_native_menu_argument_error( + method_call, + "single-choice menus require exactly one selected entry and all " + "entries enabled."); + return; + } + + if (data->active != nullptr) { + native_menu_session_dispose(data->active); + } + + auto* session = g_new0(NativeMenuSession, 1); + session->owner = data; + session->id = session_id; + session->entry_count = fl_value_get_length(entries); + session->method_call = + FL_METHOD_CALL(g_object_ref(G_OBJECT(method_call))); + session->action_group = g_simple_action_group_new(); + session->model = g_menu_new(); + data->active = session; + + if (selected_entry_count == 1) { + g_autofree gchar* selected_target = + g_strdup_printf("%zu", selected_entry_index); + GSimpleAction* selection_action = g_simple_action_new_stateful( + "select", G_VARIANT_TYPE_STRING, + g_variant_new_string(selected_target)); + g_signal_connect(selection_action, "activate", + G_CALLBACK(native_menu_selection_activated_cb), session); + g_action_map_add_action(G_ACTION_MAP(session->action_group), + G_ACTION(selection_action)); + g_object_unref(selection_action); + } + + g_autofree gchar* selection_action_name = + g_strdup_printf("%s.select", kNativeMenuActionNamespace); + for (size_t index = 0; index < fl_value_get_length(entries); index++) { + FlValue* entry = fl_value_get_list_value(entries, index); + const gchar* label = fl_lookup_string_arg(entry, "label"); + gboolean enabled = TRUE; + fl_lookup_optional_bool_arg(entry, "enabled", TRUE, &enabled); + + g_autoptr(GMenuItem) item = g_menu_item_new(label, nullptr); + if (selected_entry_count == 1) { + g_autofree gchar* target = g_strdup_printf("%zu", index); + g_menu_item_set_action_and_target_value( + item, selection_action_name, g_variant_new_string(target)); + } else { + g_autofree gchar* action_name = g_strdup_printf("select-%zu", index); + GSimpleAction* action = g_simple_action_new(action_name, nullptr); + g_simple_action_set_enabled(action, enabled); + g_object_set_data(G_OBJECT(action), kNativeMenuActionIndexKey, + GINT_TO_POINTER(static_cast(index) + 1)); + g_signal_connect(action, "activate", + G_CALLBACK(native_menu_action_activated_cb), session); + g_action_map_add_action(G_ACTION_MAP(session->action_group), + G_ACTION(action)); + g_autofree gchar* detailed_action = + g_strdup_printf("%s.%s", kNativeMenuActionNamespace, action_name); + g_menu_item_set_detailed_action(item, detailed_action); + g_object_unref(action); + } + g_menu_append_item(session->model, item); + } + + gtk_widget_insert_action_group( + data->view, kNativeMenuActionNamespace, + G_ACTION_GROUP(session->action_group)); + session->popover = gtk_popover_new_from_model( + data->view, G_MENU_MODEL(session->model)); + g_object_ref_sink(session->popover); + gtk_popover_set_pointing_to(GTK_POPOVER(session->popover), &anchor); + gtk_popover_set_position(GTK_POPOVER(session->popover), GTK_POS_BOTTOM); + gtk_popover_set_constrain_to(GTK_POPOVER(session->popover), + GTK_POPOVER_CONSTRAINT_WINDOW); + gtk_popover_set_modal(GTK_POPOVER(session->popover), TRUE); + gtk_widget_set_can_focus(session->popover, TRUE); + session->closed_signal_id = + g_signal_connect(session->popover, "closed", + G_CALLBACK(native_menu_closed_cb), session); + gtk_widget_show_all(session->popover); + gtk_popover_popup(GTK_POPOVER(session->popover)); + if (focus_first) { + gtk_widget_child_focus(session->popover, GTK_DIR_TAB_FORWARD); + } else { + gtk_widget_grab_focus(session->popover); + } +} + +static void native_menu_handler_data_free(gpointer user_data) { + auto* data = static_cast(user_data); + if (data->active != nullptr) { + native_menu_session_dispose(data->active); + } + if (data->view != nullptr) { + g_object_remove_weak_pointer( + G_OBJECT(data->view), + reinterpret_cast(&data->view)); + } + g_free(data); +} + +static void native_menu_method_call_cb(FlMethodChannel*, + FlMethodCall* method_call, + gpointer user_data) { + auto* data = static_cast(user_data); + const gchar* method = fl_method_call_get_name(method_call); + if (strcmp(method, "show") == 0) { + show_native_menu(data, method_call, fl_method_call_get_args(method_call)); + } else if (strcmp(method, "dismiss") == 0) { + gint64 session_id = 0; + if (!fl_lookup_positive_int64_arg(fl_method_call_get_args(method_call), + "sessionId", &session_id)) { + respond_native_menu_argument_error( + method_call, "sessionId must be a positive integer."); + return; + } + respond_bool(method_call, + native_menu_dismiss_active(data, session_id)); + } else { + fl_method_call_respond_not_implemented(method_call, nullptr); + } +} + +static FlMethodChannel* create_native_menu_channel(FlView* view) { + 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)), + kNativeMenuChannel, FL_METHOD_CODEC(codec)); + auto* data = g_new0(NativeMenuHandlerData, 1); + data->view = GTK_WIDGET(view); + g_object_add_weak_pointer(G_OBJECT(data->view), + reinterpret_cast(&data->view)); + fl_method_channel_set_method_call_handler( + channel, native_menu_method_call_cb, data, + native_menu_handler_data_free); + return channel; +} + +static void register_native_menus(MyApplication* self, FlView* view) { + self->native_menu_channel = create_native_menu_channel(view); +} + +static void register_native_menus_for_subwindow(FlView* view, + GtkWindow* window) { + FlMethodChannel* channel = create_native_menu_channel(view); + g_object_set_data_full(G_OBJECT(window), "busymax-native-menus", channel, + g_object_unref); +} + static void respond_success(FlMethodCall* method_call) { g_autoptr(FlValue) result = fl_value_new_null(); fl_method_call_respond_success(method_call, result, nullptr); @@ -541,6 +1010,45 @@ static const gchar* css_color_or(const gchar* value, const gchar* fallback) { return is_css_color_token(value) ? value : fallback; } +static GdkRGBA composite_rgba(const GdkRGBA& foreground, + const GdkRGBA& background) { + const gdouble inverse_foreground_alpha = 1.0 - foreground.alpha; + const gdouble alpha = + foreground.alpha + background.alpha * inverse_foreground_alpha; + if (alpha <= 0) { + return GdkRGBA{0, 0, 0, 0}; + } + return GdkRGBA{ + (foreground.red * foreground.alpha + + background.red * background.alpha * inverse_foreground_alpha) / + alpha, + (foreground.green * foreground.alpha + + background.green * background.alpha * inverse_foreground_alpha) / + alpha, + (foreground.blue * foreground.alpha + + background.blue * background.alpha * inverse_foreground_alpha) / + alpha, + alpha, + }; +} + +static gchar* modal_sidebar_border_css_color(const gchar* border_color, + const gchar* sidebar_color, + const gchar* barrier_color) { + GdkRGBA border; + GdkRGBA sidebar; + GdkRGBA barrier; + if (!gdk_rgba_parse(&border, border_color) || + !gdk_rgba_parse(&sidebar, sidebar_color) || + !gdk_rgba_parse(&barrier, barrier_color)) { + return g_strdup(border_color); + } + + const GdkRGBA visible_border = composite_rgba(border, sidebar); + const GdkRGBA dimmed_border = composite_rgba(barrier, visible_border); + return gdk_rgba_to_string(&dimmed_border); +} + static void set_flutter_view_background_color(MyApplication* self, const gchar* color) { if (self->flutter_view == nullptr || !FL_IS_VIEW(self->flutter_view) || @@ -588,6 +1096,10 @@ static void refresh_header_bar_css(MyApplication* self) { self->header_bar_foreground_color, "rgba(255,255,255,0.86)"); const gchar* modal_barrier_color = css_color_or( self->header_bar_modal_barrier_color, "rgba(0,0,0,0.32)"); + g_autofree gchar* modal_sidebar_border_color = + modal_sidebar_border_css_color(sidebar_border_color, + sidebar_background_color, + modal_barrier_color); 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"); @@ -627,6 +1139,7 @@ static void refresh_header_bar_css(MyApplication* self) { ".busymax-header-brand:backdrop {" "background-color: %s;" "background-image: linear-gradient(%s, %s);" + "border-right-color: %s;" "}" ".busymax-titlebar.busymax-modal-barrier " "headerbar.busymax-flat-headerbar," @@ -639,7 +1152,8 @@ static void refresh_header_bar_css(MyApplication* self) { sidebar_background_color, foreground_color, sidebar_border_color, foreground_color, foreground_color, sidebar_background_color, modal_barrier_color, modal_barrier_color, - background_color, modal_barrier_color, modal_barrier_color); + modal_sidebar_border_color, background_color, modal_barrier_color, + modal_barrier_color); g_autoptr(GError) error = nullptr; GtkCssProvider* provider = gtk_css_provider_new(); @@ -941,6 +1455,9 @@ static void set_header_menu_button_model(GtkWidget* button, 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_popover != nullptr) { clear_widget_pointer(tracked_popover); } @@ -1805,9 +2322,8 @@ static gboolean show_header_create_menu(MyApplication* self) { return FALSE; } - // A pointer-triggered Flutter command should behave like clicking the native - // menu button: retain focus on the trigger and let GTK move into the model - // only when the user starts keyboard navigation. + // 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)); @@ -2904,6 +3420,7 @@ static void configure_compact_agenda_subwindow(FlPluginRegistry* registry) { register_compact_gtk_settings_channel(view, window); register_native_date_time_picker_for_subwindow(view, window); register_native_dialogs_for_subwindow(view, window); + register_native_menus_for_subwindow(view, window); } // Called when first Flutter frame received. @@ -2973,6 +3490,7 @@ static void my_application_activate(GApplication* application) { }); register_native_date_time_picker(self, view, window); register_native_dialogs(self, view, window); + register_native_menus(self, view); register_window_channel(self, view); register_header_bar_channel(self, view); register_gtk_settings_channel(self, view); @@ -3026,6 +3544,7 @@ static void my_application_dispose(GObject* object) { } g_clear_object(&self->native_date_time_picker_channel); g_clear_object(&self->native_dialog_channel); + g_clear_object(&self->native_menu_channel); g_clear_object(&self->window_channel); g_clear_object(&self->header_bar_channel); g_clear_object(&self->gtk_settings_channel); @@ -3107,6 +3626,7 @@ static void my_application_class_init(MyApplicationClass* klass) { static void my_application_init(MyApplication* self) { self->native_date_time_picker_channel = nullptr; self->native_dialog_channel = nullptr; + self->native_menu_channel = nullptr; self->window_channel = nullptr; self->header_bar_channel = nullptr; self->gtk_settings_channel = nullptr; diff --git a/pubspec.lock b/pubspec.lock index 2d67497..ec035c7 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -474,14 +474,6 @@ packages: description: flutter source: sdk version: "0.0.0" - form_field_validator: - dependency: transitive - description: - name: form_field_validator - sha256: c1fc6c89b5525288c02fe989a88c6f30bb6072da4f9dded17a04cf68c4abfb87 - url: "https://pub.dev" - source: hosted - version: "1.1.0" fuchsia_remote_debug_protocol: dependency: transitive description: flutter @@ -756,14 +748,6 @@ packages: url: "https://pub.dev" source: hosted version: "4.1.0" - password_strength: - dependency: transitive - description: - name: password_strength - sha256: "0e51e3d864e37873a1347e658147f88b66e141ee36c58e19828dc5637961e1ce" - url: "https://pub.dev" - source: hosted - version: "0.2.0" path: dependency: "direct main" description: @@ -1121,14 +1105,6 @@ packages: url: "https://pub.dev" source: hosted version: "0.5.2+3" - ubuntu_widgets: - dependency: "direct main" - description: - name: ubuntu_widgets - sha256: "7eaa3bcfde7197c8ae18e4546339316fa95d445e3fa8e1fdb2d3c2ea569a5d38" - url: "https://pub.dev" - source: hosted - version: "0.8.1" url_launcher: dependency: "direct main" description: diff --git a/pubspec.yaml b/pubspec.yaml index 24725f4..62287a8 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -35,7 +35,6 @@ dependencies: sqlite3_flutter_libs: ^0.6.0 system_theme: ^3.3.0 ubuntu_localizations: ^0.5.2+3 - ubuntu_widgets: ^0.8.1 url_launcher: ^6.3.0 uuid: ^4.5.0 window_manager: ^0.5.1 diff --git a/test/app/busymax_grouped_surface_test.dart b/test/app/busymax_grouped_surface_test.dart index c10df78..e7bcc6a 100644 --- a/test/app/busymax_grouped_surface_test.dart +++ b/test/app/busymax_grouped_surface_test.dart @@ -1,8 +1,10 @@ +import 'dart:async'; import 'dart:io'; 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/platform/native_menu_service.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; @@ -11,7 +13,22 @@ import 'package:yaru/yaru.dart'; import '../test_localized_app.dart'; +const _nativeMenuChannel = MethodChannel(nativeMenuChannelName); + void main() { + setUp(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler( + _nativeMenuChannel, + (_) async => throw MissingPluginException(), + ); + }); + + tearDown(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(_nativeMenuChannel, null); + }); + for (final brightness in Brightness.values) { testWidgets( 'grouped list uses the semantic $brightness surface and Yaru rows', @@ -425,7 +442,7 @@ void main() { ); final combo = find.byType(BusyMaxComboRow); - final trigger = find.descendant(of: combo, matching: _dropdownMenuFinder()); + final trigger = find.descendant(of: combo, matching: _comboTriggerFinder()); expect(trigger, findsOneWidget); await tester.tap(trigger, warnIfMissed: false); @@ -434,7 +451,8 @@ void main() { await tester.sendKeyEvent(LogicalKeyboardKey.enter); await tester.pumpAndSettle(); - expect(find.byType(MenuItemButton).hitTestable(), findsNothing); + expect(find.byType(PopupMenuItem).hitTestable(), findsNothing); + expect(find.byType(YaruRadio).hitTestable(), findsNothing); expect(selected, isEmpty); final disabledSemantics = tester.widget( find.descendant( @@ -450,7 +468,7 @@ void main() { expect(disabledSemantics.properties.value, 'Personal'); }); - testWidgets('combo row delegates form selection and geometry to Yaru', ( + testWidgets('combo row delegates selection to the shared native menu path', ( tester, ) async { final selections = []; @@ -471,15 +489,15 @@ void main() { final triggerFinder = find.descendant( of: find.byType(BusyMaxComboRow), - matching: _dropdownMenuFinder(), + matching: _comboTriggerFinder(), + ); + final trigger = tester.widget(triggerFinder); + final comboBox = tester.widget>( + find.byType(BusyMaxComboBox), ); - final trigger = tester.widget(triggerFinder); - expect(trigger.selectOnly, isTrue); - expect(trigger.enableSearch, isFalse); - expect(trigger.width, tester.getSize(triggerFinder).width); - expect(trigger.inputDecorationTheme, isNull); - expect(trigger.menuStyle, isNull); - expect(trigger.initialSelection, isNotNull); + expect(trigger.onPressed, isNotNull); + expect(trigger.style, isNull); + expect(tester.getSize(triggerFinder).width, comboBox.width); final selectedRect = tester.getRect(find.text('Calendar 1').first); final arrowRect = tester.getRect( @@ -495,40 +513,74 @@ void main() { await tester.tap(triggerFinder); await tester.pumpAndSettle(); - final firstChoice = find - .byWidgetPredicate( - (widget) => widget is Text && widget.data == 'Calendar 1', - ) - .hitTestable(); - final secondChoice = find - .byWidgetPredicate( - (widget) => widget is Text && widget.data == 'Calendar 2', - ) - .hitTestable(); + final firstChoice = _menuItemWithLabel('Calendar 1'); + final secondChoice = _menuItemWithLabel('Calendar 2'); expect(firstChoice, findsOneWidget); expect(secondChoice, findsOneWidget); expect( tester.getRect(firstChoice).top, greaterThanOrEqualTo(tester.getRect(triggerFinder).bottom), ); - final visibleMenuItems = find.byType(MenuItemButton).hitTestable(); + final visibleMenuItems = find.byType(PopupMenuItem).hitTestable(); expect(visibleMenuItems, findsNWidgets(2)); expect(find.byType(YaruFocusBorder), findsNothing); - final selectedSemantics = tester - .widgetList( - find.descendant( - of: visibleMenuItems, - matching: find.byType(Semantics), - ), - ) - .where((semantics) => semantics.properties.selected == true); - expect(selectedSemantics, hasLength(1)); + final radioItems = tester.widgetList>( + find.byType(YaruRadio), + ); + expect(radioItems.map((item) => item.value), [0, 1]); + expect(radioItems.map((item) => item.groupValue), everyElement(0)); await tester.tap(secondChoice); await tester.pumpAndSettle(); expect(selections, [2]); }); + testWidgets('disposing a combo dismisses only its native menu session', ( + tester, + ) async { + final calls = []; + final nativeSelection = Completer(); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(_nativeMenuChannel, (call) async { + calls.add(call); + if (call.method == 'show') { + return await nativeSelection.future; + } + if (call.method == 'dismiss') { + return true; + } + throw MissingPluginException(); + }); + + await tester.pumpWidget( + _testApp( + BusyMaxComboRow( + title: 'Calendar', + values: const ['Personal', 'Work'], + selected: 'Personal', + labelFor: (value) => value, + onSelected: (_) {}, + ), + ), + ); + await tester.tap(_comboTriggerFinder()); + await tester.pump(); + await tester.pumpWidget(const SizedBox()); + await tester.pump(); + + final showArguments = + calls.singleWhere((call) => call.method == 'show').arguments! + as Map; + final dismissArguments = + calls.singleWhere((call) => call.method == 'dismiss').arguments! + as Map; + expect(dismissArguments['sessionId'], showArguments['sessionId']); + + nativeSelection.complete(); + await tester.pump(); + expect(tester.takeException(), isNull); + }); + testWidgets('combo row maps a nullable domain choice through the popup', ( tester, ) async { @@ -545,15 +597,15 @@ void main() { ), ); - await tester.tap(_dropdownMenuFinder()); + await tester.tap(_comboTriggerFinder()); await tester.pumpAndSettle(); - await tester.tap(find.text('Select a category').last); + await tester.tap(_menuItemWithLabel('Select a category')); await tester.pumpAndSettle(); expect(selections, [isNull]); }); - testWidgets('combo popup stays constrained to its trigger width', ( + testWidgets('combo trigger keeps its layout width with long menu content', ( tester, ) async { const selectorWidth = 220.0; @@ -576,14 +628,15 @@ void main() { ), ); - final trigger = _dropdownMenuFinder(); + final trigger = _comboTriggerFinder(); + final comboBox = tester.widget>( + find.byType(BusyMaxComboBox), + ); + expect(tester.getSize(trigger).width, comboBox.width); await tester.tap(trigger); await tester.pumpAndSettle(); - final triggerWidth = tester.getSize(trigger).width; - for (final item in find.byType(MenuItemButton).hitTestable().evaluate()) { - expect(tester.getSize(find.byWidget(item.widget)).width, triggerWidth); - } + expect(find.byType(PopupMenuItem).hitTestable(), findsNWidgets(2)); expect(tester.takeException(), isNull); }); @@ -605,26 +658,24 @@ void main() { final triggerFinder = find.descendant( of: find.byType(BusyMaxComboRow), - matching: _dropdownMenuFinder(), + matching: _comboTriggerFinder(), ); await tester.sendKeyEvent(LogicalKeyboardKey.tab); await tester.sendKeyEvent(LogicalKeyboardKey.enter); await tester.pumpAndSettle(); - expect(find.byType(MenuItemButton).hitTestable(), findsNWidgets(2)); + expect(find.byType(PopupMenuItem).hitTestable(), findsNWidgets(2)); expect( tester.getRect(find.text('Personal').last).top, greaterThanOrEqualTo(tester.getRect(triggerFinder).bottom), ); - await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); - await tester.pump(); await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); await tester.pump(); await tester.sendKeyEvent(LogicalKeyboardKey.enter); await tester.pumpAndSettle(); expect(selections, ['Work']); - expect(find.byType(MenuItemButton).hitTestable(), findsNothing); + expect(find.byType(PopupMenuItem).hitTestable(), findsNothing); }); testWidgets('combo row accepts unbounded horizontal constraints', ( @@ -644,8 +695,11 @@ void main() { ), ); - expect(_dropdownMenuFinder(), findsOneWidget); - expect(tester.getSize(_dropdownMenuFinder()).width, 220); + expect(_comboTriggerFinder(), findsOneWidget); + expect( + tester.getSize(_comboTriggerFinder()).width, + BusyMaxSizes.comboWidth, + ); expect(tester.takeException(), isNull); }); @@ -820,26 +874,28 @@ void main() { final titleRect = tester.getRect( find.text('Calendar account with a long label'), ); - final triggerRect = tester.getRect(_dropdownMenuFinder()); + final triggerRect = tester.getRect(_comboTriggerFinder()); expect(triggerRect.top, greaterThanOrEqualTo(titleRect.bottom)); expect(tester.takeException(), isNull); }); - testWidgets('time mode is a full-width neutral Yaru toggle group', ( + testWidgets('time mode delegates full-width neutral selection to Yaru tabs', ( tester, ) async { - const accentColor = Color(0xFF3584E4); final changes = []; + var allDay = true; await tester.pumpWidget( - localizedTestApp( - child: Theme( - data: BusyMaxYaruTheme.build( - brightness: Brightness.light, - accentColor: accentColor, - ), - child: Scaffold( - body: BusyMaxTimeModeRow(allDay: true, onChanged: changes.add), - ), + _timeModeTestApp( + StatefulBuilder( + builder: (context, setState) { + return BusyMaxTimeModeRow( + allDay: allDay, + onChanged: (value) { + changes.add(value); + setState(() => allDay = value); + }, + ); + }, ), ), ); @@ -847,69 +903,238 @@ void main() { expect(find.text('Time'), findsNothing); expect(find.text('Use dates only or set specific times.'), findsNothing); expect(find.byType(YaruListTile), findsNothing); - - final control = tester.widget(find.byType(ToggleButtons)); - expect(control.isSelected, [isTrue, isFalse]); - final theme = Theme.of(tester.element(find.byType(ToggleButtons))); - final colors = theme.extension()!; - expect(theme.toggleButtonsTheme.fillColor, colors.controlActive); - expect(theme.toggleButtonsTheme.fillColor, isNot(accentColor)); - expect(theme.toggleButtonsTheme.selectedColor, colors.foreground); - expect(theme.toggleButtonsTheme.selectedBorderColor, colors.border); + expect(find.byType(ToggleButtons), findsNothing); + expect(find.byType(BusyMaxModeSwitcher), findsOneWidget); + + final control = tester.widget(find.byType(YaruTabBar)); + expect(control.height, isNull); + expect(control.labelColor, isNull); + expect(control.unselectedLabelColor, isNull); + expect(control.tabController?.index, 0); expect( - theme.toggleButtonsTheme.borderRadius, - BorderRadius.circular(BusyMaxRadius.sm), + Theme.of(tester.element(find.byType(YaruTabBar))).platform, + TargetPlatform.linux, ); final rowRect = tester.getRect(find.byType(BusyMaxTimeModeRow)); - final controlRect = tester.getRect(find.byType(ToggleButtons)); + final controlRect = tester.getRect(find.byType(YaruTabBar)); expect(controlRect.width, rowRect.width); - final segmentWidths = tester - .widgetList( - find.descendant( - of: find.byType(ToggleButtons), - matching: find.byType(TextButton), - ), - ) - .map((button) => tester.getSize(find.byWidget(button)).width) - .toList(); - expect(segmentWidths, hasLength(2)); - expect(segmentWidths.first, segmentWidths.last); + final optionWidths = [ + for (final label in ['All day', 'Time slot']) + tester + .getSize( + find + .ancestor( + of: find.text(label), + matching: find.byType(InkWell), + ) + .first, + ) + .width, + ]; + expect(optionWidths, hasLength(2)); + expect(optionWidths.first, optionWidths.last); + + await tester.tap(find.text('All day')); + await tester.pump(); + expect(changes, isEmpty); await tester.tap(find.text('Time slot')); await tester.pump(); expect(changes, [isFalse]); + expect(control.tabController?.index, 1); }); testWidgets('time mode remains full width when its section is narrow', ( tester, ) async { await tester.pumpWidget( - localizedTestApp( - child: Theme( - data: BusyMaxYaruTheme.build( - brightness: Brightness.light, - accentColor: const Color(0xFF3584E4), - ), - child: const Scaffold( - body: Center( - child: SizedBox( - width: 420, - child: BusyMaxTimeModeRow(allDay: true, onChanged: _ignoreBool), - ), - ), + _timeModeTestApp( + const Center( + child: SizedBox( + width: 420, + child: BusyMaxTimeModeRow(allDay: true, onChanged: _ignoreBool), ), ), ), ); final rowRect = tester.getRect(find.byType(BusyMaxTimeModeRow)); - final controlRect = tester.getRect(find.byType(ToggleButtons)); + final controlRect = tester.getRect(find.byType(YaruTabBar)); expect(controlRect.width, rowRect.width); expect(controlRect.width, 420); expect(tester.takeException(), isNull); }); + testWidgets('mode switcher follows external selection changes', ( + tester, + ) async { + Widget switcher(bool allDay) { + return BusyMaxTimeModeRow(allDay: allDay, onChanged: _ignoreBool); + } + + await tester.pumpWidget(_timeModeTestApp(switcher(true))); + expect( + tester.widget(find.byType(YaruTabBar)).tabController?.index, + 0, + ); + + await tester.pumpWidget(_timeModeTestApp(switcher(false))); + await tester.pump(); + + expect( + tester.widget(find.byType(YaruTabBar)).tabController?.index, + 1, + ); + }); + + testWidgets('mode switcher restores a rejected external selection', ( + tester, + ) async { + final changes = []; + await tester.pumpWidget( + _timeModeTestApp( + BusyMaxTimeModeRow(allDay: true, onChanged: changes.add), + ), + ); + + await tester.tap(find.text('Time slot')); + await tester.pump(); + + expect(changes, [isFalse]); + expect( + tester.widget(find.byType(YaruTabBar)).tabController?.index, + 0, + ); + }); + + testWidgets('mode switcher safely accepts a different choice count', ( + tester, + ) async { + Widget switcher(List values) { + return BusyMaxModeSwitcher( + values: values, + selected: 1, + labelFor: (value) => 'Mode $value', + onSelected: (_) {}, + ); + } + + await tester.pumpWidget(_timeModeTestApp(switcher([1, 2]))); + await tester.pumpWidget(_timeModeTestApp(switcher([1, 2, 3]))); + + final control = tester.widget(find.byType(YaruTabBar)); + expect(control.tabs, hasLength(3)); + expect(control.tabController?.length, 3); + expect(tester.takeException(), isNull); + }); + + test('mode switcher snapshots and validates domain choices', () { + final values = [1, 2]; + final switcher = BusyMaxModeSwitcher( + values: values, + selected: 1, + labelFor: (value) => '$value', + onSelected: (_) {}, + ); + values.add(3); + + expect(switcher.values, [1, 2]); + expect( + () => BusyMaxModeSwitcher( + values: const [1], + selected: 1, + labelFor: (value) => '$value', + onSelected: (_) {}, + ), + throwsArgumentError, + ); + expect( + () => BusyMaxModeSwitcher( + values: const [1, 1], + selected: 1, + labelFor: (value) => '$value', + onSelected: (_) {}, + ), + throwsArgumentError, + ); + expect( + () => BusyMaxModeSwitcher( + values: const [1, 2], + selected: 3, + labelFor: (value) => '$value', + onSelected: (_) {}, + ), + throwsArgumentError, + ); + }); + + testWidgets('mode switcher supports desktop keyboard selection', ( + tester, + ) async { + final changes = []; + var allDay = true; + await tester.pumpWidget( + _timeModeTestApp( + StatefulBuilder( + builder: (context, setState) { + return BusyMaxTimeModeRow( + allDay: allDay, + onChanged: (value) { + changes.add(value); + setState(() => allDay = value); + }, + ); + }, + ), + ), + ); + + await tester.sendKeyEvent(LogicalKeyboardKey.tab); + await tester.pump(); + await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight); + await tester.pump(); + await tester.sendKeyEvent(LogicalKeyboardKey.enter); + await tester.pump(); + + expect(changes, [isFalse]); + }); + + testWidgets('mode switcher announces one selected localized mode', ( + tester, + ) async { + final semantics = tester.ensureSemantics(); + var allDay = true; + await tester.pumpWidget( + _timeModeTestApp( + StatefulBuilder( + builder: (context, setState) { + return BusyMaxTimeModeRow( + allDay: allDay, + onChanged: (value) => setState(() => allDay = value), + ); + }, + ), + ), + ); + + var allDayNode = tester.getSemantics(find.text('All day')); + var timeSlotNode = tester.getSemantics(find.text('Time slot')); + expect(allDayNode.role, ui.SemanticsRole.tab); + expect(timeSlotNode.role, ui.SemanticsRole.tab); + expect(allDayNode.flagsCollection.isSelected, ui.Tristate.isTrue); + expect(timeSlotNode.flagsCollection.isSelected, ui.Tristate.isFalse); + + await tester.tap(find.text('Time slot')); + await tester.pump(); + + allDayNode = tester.getSemantics(find.text('All day')); + timeSlotNode = tester.getSemantics(find.text('Time slot')); + expect(allDayNode.flagsCollection.isSelected, ui.Tristate.isFalse); + expect(timeSlotNode.flagsCollection.isSelected, ui.Tristate.isTrue); + semantics.dispose(); + }); + testWidgets('editor header actions are natural width with native loading', ( tester, ) async { @@ -1001,8 +1226,16 @@ void main() { void _ignoreBool(bool value) {} -Finder _dropdownMenuFinder() { - return find.byWidgetPredicate((widget) => widget is DropdownMenu); +Finder _comboTriggerFinder() { + return find.byWidgetPredicate( + (widget) => widget is ButtonStyleButton && widget is! IconButton, + ); +} + +Finder _menuItemWithLabel(String label) { + return find + .ancestor(of: find.text(label), matching: find.byType(PopupMenuItem)) + .hitTestable(); } Widget _testApp(Widget child) { @@ -1017,6 +1250,18 @@ Widget _testApp(Widget child) { ); } +Widget _timeModeTestApp(Widget child) { + return localizedTestApp( + child: Theme( + data: BusyMaxYaruTheme.build( + brightness: Brightness.light, + accentColor: const Color(0xFF3584E4), + ).copyWith(platform: TargetPlatform.linux), + child: Scaffold(body: child), + ), + ); +} + Widget _linuxTestApp(Widget child) { final previousPlatform = debugDefaultTargetPlatformOverride; debugDefaultTargetPlatformOverride = TargetPlatform.linux; diff --git a/test/app/busymax_menu_button_test.dart b/test/app/busymax_menu_button_test.dart index dc67f40..53477ad 100644 --- a/test/app/busymax_menu_button_test.dart +++ b/test/app/busymax_menu_button_test.dart @@ -1,20 +1,39 @@ +import 'dart:async'; + import 'package:busymax/src/app/busymax_design.dart'; import 'package:busymax/src/app/busymax_yaru_theme.dart'; +import 'package:busymax/src/platform/native_menu_service.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:yaru/yaru.dart'; import '../test_localized_app.dart'; void main() { - testWidgets('BusyMaxMenuButton opens, closes, and reopens with entries', ( + TestWidgetsFlutterBinding.ensureInitialized(); + + const channel = MethodChannel('busymax_test/menu_button'); + + tearDown(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, null); + }); + + testWidgets('menu button uses the centralized themed fallback', ( tester, ) async { String? selected; + final controller = BusyMaxMenuController(); final theme = BusyMaxYaruTheme.build( brightness: Brightness.dark, accentColor: YaruColors.orange, ); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler( + channel, + (_) async => throw MissingPluginException(), + ); await tester.pumpWidget( localizedTestApp( @@ -24,6 +43,8 @@ void main() { body: Center( child: BusyMaxMenuButton( tooltip: 'Options', + controller: controller, + nativeMenuService: const NativeMenuService(channel: channel), onSelected: (value) => selected = value, entries: const [ BusyMaxMenuEntry( @@ -50,15 +71,11 @@ void main() { expect(find.text('Refresh calendar'), findsOneWidget); expect(find.text('Open in provider'), findsOneWidget); final colors = theme.extension()!; - final anchor = tester.widget(find.byType(MenuAnchor)); - expect(anchor.style?.backgroundColor?.resolve(const {}), colors.popover); + expect(find.byType(MenuAnchor), findsNothing); + expect(find.byType(MenuItemButton), findsNothing); expect( - anchor.style?.elevation?.resolve(const {}), - theme.menuTheme.style?.elevation?.resolve(const {}), - ); - expect( - anchor.style?.shape?.resolve(const {}), - theme.menuTheme.style?.shape?.resolve(const {}), + find.byWidgetPredicate((widget) => widget is PopupMenuItem), + findsNWidgets(2), ); expect( tester @@ -66,23 +83,12 @@ void main() { .where((material) => material.color == colors.popover), isNotEmpty, ); - for (final item in tester.widgetList( - find.byType(MenuItemButton), - )) { - expect( - item.style?.minimumSize?.resolve(const {}), - theme.menuButtonTheme.style?.minimumSize?.resolve(const {}), - ); - expect( - item.style?.maximumSize?.resolve(const {}), - theme.menuButtonTheme.style?.maximumSize?.resolve(const {}), - ); - } - await tester.tap(find.byTooltip('Options')); + controller.close(); await tester.pumpAndSettle(); expect(find.text('Refresh calendar'), findsNothing); + expect(selected, isNull); await tester.tap(find.byTooltip('Options')); await tester.pumpAndSettle(); @@ -96,4 +102,219 @@ void main() { expect(selected, 'open'); expect(find.text('Open in provider'), findsNothing); }); + + testWidgets('menu button maps a native selected index to its domain value', ( + tester, + ) async { + final calls = []; + String? selected; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + calls.add(call); + return switch (call.method) { + 'show' => 1, + 'dismiss' => true, + _ => throw MissingPluginException(), + }; + }); + + await tester.pumpWidget( + localizedTestApp( + child: Scaffold( + body: Center( + child: BusyMaxMenuButton( + tooltip: 'Options', + nativeMenuService: const NativeMenuService(channel: channel), + entries: const [ + BusyMaxMenuEntry( + value: 'refresh', + label: 'Refresh calendar', + selected: true, + ), + BusyMaxMenuEntry(value: 'open', label: 'Open in provider'), + ], + onSelected: (value) => selected = value, + ), + ), + ), + ), + ); + + await tester.tap(find.byTooltip('Options')); + await tester.pump(); + + expect(selected, 'open'); + expect(find.text('Refresh calendar'), findsNothing); + expect(find.text('Open in provider'), findsNothing); + expect(calls, hasLength(1)); + expect(calls.single.method, 'show'); + final arguments = calls.single.arguments! as Map; + expect(arguments['anchor'], isA>()); + expect(arguments['entries'], [ + {'label': 'Refresh calendar', 'enabled': true, 'selected': true}, + {'label': 'Open in provider', 'enabled': true, 'selected': false}, + ]); + }); + + testWidgets('controller dismissal carries the owned native session', ( + tester, + ) async { + final calls = []; + final nativeSelection = Completer(); + final controller = BusyMaxMenuController(); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + calls.add(call); + if (call.method == 'show') { + return await nativeSelection.future; + } + if (call.method == 'dismiss') { + return true; + } + throw MissingPluginException(); + }); + + await tester.pumpWidget( + localizedTestApp( + child: Scaffold( + body: BusyMaxMenuButton( + tooltip: 'Options', + controller: controller, + nativeMenuService: const NativeMenuService(channel: channel), + entries: const [ + BusyMaxMenuEntry(value: 'refresh', label: 'Refresh'), + ], + onSelected: (_) {}, + ), + ), + ), + ); + + await tester.tap(find.byTooltip('Options')); + await tester.pump(); + controller.close(); + await tester.pump(); + + final showArguments = + calls.singleWhere((call) => call.method == 'show').arguments! + as Map; + final dismissArguments = + calls.singleWhere((call) => call.method == 'dismiss').arguments! + as Map; + expect(dismissArguments['sessionId'], showArguments['sessionId']); + + nativeSelection.complete(); + await tester.pumpAndSettle(); + expect(controller.isOpen, isFalse); + }); + + testWidgets('an open menu keeps its entry and callback snapshot', ( + tester, + ) async { + final nativeSelection = Completer(); + final originalSelections = []; + final replacementSelections = []; + late StateSetter rebuild; + var replacement = false; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + if (call.method == 'show') { + return await nativeSelection.future; + } + if (call.method == 'dismiss') { + return true; + } + throw MissingPluginException(); + }); + + await tester.pumpWidget( + localizedTestApp( + child: Scaffold( + body: StatefulBuilder( + builder: (context, setState) { + rebuild = setState; + return BusyMaxMenuButton( + tooltip: 'Options', + nativeMenuService: const NativeMenuService(channel: channel), + entries: replacement + ? const [ + BusyMaxMenuEntry(value: 'second', label: 'Second'), + BusyMaxMenuEntry(value: 'first', label: 'First'), + ] + : const [ + BusyMaxMenuEntry(value: 'first', label: 'First'), + BusyMaxMenuEntry(value: 'second', label: 'Second'), + ], + onSelected: replacement + ? replacementSelections.add + : originalSelections.add, + ); + }, + ), + ), + ), + ); + + await tester.tap(find.byTooltip('Options')); + await tester.pump(); + rebuild(() => replacement = true); + await tester.pump(); + nativeSelection.complete(1); + await tester.pumpAndSettle(); + + expect(originalSelections, ['second']); + expect(replacementSelections, isEmpty); + }); + + testWidgets('fallback dismissal removes its menu, not a newer route', ( + tester, + ) async { + late BuildContext hostContext; + final controller = BusyMaxMenuController(); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler( + channel, + (_) async => throw MissingPluginException(), + ); + + await tester.pumpWidget( + localizedTestApp( + child: Scaffold( + body: Builder( + builder: (context) { + hostContext = context; + return BusyMaxMenuButton( + tooltip: 'Options', + controller: controller, + nativeMenuService: const NativeMenuService(channel: channel), + entries: const [ + BusyMaxMenuEntry(value: 'refresh', label: 'Refresh'), + ], + onSelected: (_) {}, + ); + }, + ), + ), + ), + ); + + await tester.tap(find.byTooltip('Options')); + await tester.pumpAndSettle(); + unawaited( + showDialog( + context: hostContext, + builder: (_) => const AlertDialog(title: Text('Unrelated dialog')), + ), + ); + await tester.pumpAndSettle(); + + controller.close(); + await tester.pumpAndSettle(); + + expect(find.text('Unrelated dialog'), findsOneWidget); + expect(find.text('Refresh'), findsNothing); + + Navigator.of(hostContext).pop(); + await tester.pumpAndSettle(); + }); } diff --git a/test/app/native_ui_audit_test.dart b/test/app/native_ui_audit_test.dart index 220736e..d9ca2bb 100644 --- a/test/app/native_ui_audit_test.dart +++ b/test/app/native_ui_audit_test.dart @@ -683,7 +683,6 @@ void main() { 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('gtk_popover_popup'))); expect(source, isNot(contains('"busymax-popover-open"'))); expect(source, isNot(contains('header_popover_is_open'))); expect(source, isNot(contains('set_header_popover_open'))); @@ -802,6 +801,8 @@ void main() { 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('modal_sidebar_border_css_color')); + expect(source, 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'))); @@ -974,6 +975,38 @@ void main() { expect(source, isNot(contains('"openMenu"'))); }); + test('Linux content menus are native GTK model popovers', () { + 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); + expect(runner, contains('"busymax/native_menus"')); + expect(nativeMenu, contains('gtk_popover_new_from_model(')); + expect(nativeMenu, contains('gtk_popover_set_pointing_to(')); + expect(nativeMenu, contains('gtk_popover_set_modal(')); + expect(nativeMenu, contains('g_simple_action_set_enabled(')); + expect(nativeMenu, contains('g_simple_action_new_stateful(')); + expect(nativeMenu, contains('g_object_ref(G_OBJECT(method_call))')); + expect(nativeMenu, contains('gtk_popover_popup(')); + 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'"), + ); + expect(service, contains("invokeMethod('show'")); + expect(service, contains("invokeMethod('dismiss'")); + expect(service, contains('on MissingPluginException')); + expect(service, contains('on PlatformException')); + }); + test('Linux confirmations are native GTK dialogs with a Yaru fallback', () { final runner = File('linux/runner/my_application.cc').readAsStringSync(); final dialogs = File( @@ -1046,6 +1079,7 @@ void main() { contains('".busymax-titlebar .busymax-header-brand {"'), ); expect(headerCss, contains('"border-right: 1px solid %s;"')); + expect(headerCss, contains('"border-right-color: %s;"')); expect( headerCss, contains( @@ -1293,41 +1327,47 @@ void main() { expect(source, isNot(contains('return TextTheme('))); }); - test('shared menu button preserves Yaru menu geometry and states', () { + test('shared menus prefer native GTK with one Yaru-themed fallback', () { final source = File('lib/src/app/busymax_design.dart').readAsStringSync(); - final menuStart = source.indexOf('MenuStyle busyMaxDropdownMenuStyle'); - final itemStart = source.indexOf( - 'ButtonStyle busyMaxDropdownMenuItemStyle', + final menuStart = source.indexOf( + 'Future?> showBusyMaxMenu', ); - final itemEnd = source.indexOf( - "/// BusyMax's cross-platform fallback for a native desktop search entry.", - itemStart, + final triggerStart = source.indexOf( + 'typedef BusyMaxMenuTriggerBuilder', + menuStart, ); - final menuBody = source.substring(menuStart, itemStart); - final itemBody = source.substring(itemStart, itemEnd); + final menuBody = source.substring(menuStart, triggerStart); + final fallbackStart = menuBody.indexOf( + 'Future _showBusyMaxFlutterMenu', + ); + final fallbackEnd = menuBody.indexOf( + 'Widget _busyMaxFallbackMenuEntry', + fallbackStart, + ); + final fallbackBody = menuBody.substring(fallbackStart, fallbackEnd); expect(source, contains('class BusyMaxMenuButton')); expect(source, contains('class BusyMaxMenuEntry')); - expect(source, contains('builder: (context, controller, child)')); - expect(menuBody, contains('Theme.of(context).menuTheme.style')); - expect(menuBody, contains('base.copyWith(')); - expect(menuBody, contains('minimumSize:')); - expect(menuBody, isNot(contains('BusyMaxElevation'))); - expect(menuBody, isNot(contains('RoundedRectangleBorder'))); - expect(menuBody, isNot(contains('visualDensity:'))); - expect(itemBody, contains('Theme.of(context).menuButtonTheme.style')); - expect(itemBody, isNot(contains('WidgetStateProperty.resolveWith'))); - expect(itemBody, isNot(contains('backgroundColor:'))); - expect(source, isNot(contains('_BusyMaxPopupMenuTrigger'))); - expect(source, isNot(contains('MouseRegion('))); - expect(source, isNot(contains('AnimatedContainer('))); - expect( - source, - isNot(contains('context.findRenderObject() as RenderBox')), - ); + expect(menuBody, contains('nativeMenuService.show(')); + expect(menuBody, contains('if (nativeResult.available)')); + expect(menuBody, contains('_showBusyMaxFlutterMenu(')); + expect(fallbackBody, contains('final selection = showMenu(')); + expect(fallbackBody, contains('return await selection;')); + expect(fallbackBody, contains('session._releaseFallbackRoute();')); + expect(fallbackBody, contains('PopupMenuItem(')); + expect(fallbackBody, contains('YaruRadio(')); + expect(fallbackBody, contains('inMutuallyExclusiveGroup: true')); + expect(fallbackBody, isNot(contains('YaruCheckedPopupMenuItem'))); + expect(fallbackBody, isNot(contains('MenuAnchor('))); + expect(fallbackBody, isNot(contains('MenuItemButton('))); + expect(fallbackBody, isNot(contains('DropdownMenu('))); + expect(fallbackBody, isNot(contains('shape:'))); + expect(fallbackBody, isNot(contains('color:'))); + expect(fallbackBody, isNot(contains('elevation:'))); + expect(fallbackBody, isNot(contains('constraints:'))); }); - test('form combo delegates selection geometry to Yaru dropdown theme', () { + test('form combo delegates its menu to the shared native adapter', () { final source = File('lib/src/app/busymax_design.dart').readAsStringSync(); final comboStart = source.indexOf('class BusyMaxComboBox'); final rowStart = source.indexOf('class BusyMaxComboRow'); @@ -1339,19 +1379,15 @@ void main() { final comboBody = source.substring(comboStart, rowStart); final rowBody = source.substring(rowStart, rowEnd); - expect(comboBody, contains('DropdownMenu<_BusyMaxComboOption>(')); - expect(comboBody, contains('selectOnly: true')); - expect(comboBody, contains('enableSearch: false')); - expect(comboBody, contains('DropdownMenuEntry<_BusyMaxComboOption>(')); - expect(comboBody, contains('selected: option.index == selectedIndex')); - expect( - comboBody, - contains('trailingIcon: const Icon(YaruIcons.pan_down)'), - ); - expect( - comboBody, - contains('selectedTrailingIcon: const Icon(YaruIcons.pan_up)'), - ); + expect(comboBody, contains('BusyMaxPushButton.standard(')); + expect(comboBody, contains('showBusyMaxMenu(')); + expect(comboBody, contains('BusyMaxMenuEntry(')); + expect(comboBody, contains('selected: value == selected')); + expect(comboBody, contains('NativeMenuService')); + expect(comboBody, contains('YaruIcons.pan_down')); + expect(comboBody, contains('YaruIcons.pan_up')); + expect(comboBody, isNot(contains('DropdownMenu'))); + expect(comboBody, isNot(contains('DropdownMenuEntry'))); expect(comboBody, isNot(contains('YaruPopupMenuButton'))); expect(comboBody, isNot(contains('PopupMenuItem'))); expect(comboBody, isNot(contains('inputDecorationTheme:'))); @@ -1366,24 +1402,30 @@ void main() { expect(rowBody, isNot(contains('opacity: 0.6'))); }); - test('time mode delegates linked-button visuals to the Yaru theme', () { + test('time mode delegates the complete mode control to Yaru', () { final source = File('lib/src/app/busymax_design.dart').readAsStringSync(); - final start = source.indexOf('class BusyMaxTimeModeRow'); + final start = source.indexOf('class BusyMaxModeSwitcher'); final end = source.indexOf('class BusyMaxModalEditorScaffold'); expect(start, isNonNegative); expect(end, greaterThan(start)); final body = source.substring(start, end); - expect(body, contains('ToggleButtonsTheme.of(context)')); - expect(body, contains('ToggleButtons(')); - expect(body, contains('constraints.maxWidth')); + expect(body, contains('return YaruTabBar(')); + expect(body, contains('YaruTab(label: widget.labelFor(value))')); + expect(body, contains('BusyMaxModeSwitcher(')); expect(body, isNot(contains('YaruListTile'))); expect(body, isNot(contains('timeModeDescription'))); expect(body, isNot(contains('MediaQuery'))); + expect(body, isNot(contains('ToggleButtons'))); expect(body, isNot(contains('SegmentedButton'))); + expect(body, isNot(contains('LayoutBuilder'))); + expect(body, isNot(contains('BoxConstraints'))); + expect(body, isNot(contains('constraints.maxWidth'))); expect(body, isNot(contains('Padding('))); + expect(body, isNot(contains('height:'))); expect(body, isNot(contains('borderRadius:'))); expect(body, isNot(contains('fillColor:'))); + expect(body, isNot(contains('labelColor:'))); }); test('feature code avoids raw Material controls with Yaru replacements', () { @@ -1410,9 +1452,21 @@ void main() { isNot(contains('DropdownButtonFormField')), reason: location, ); + expect( + _hasRawDropdownMenu(line), + isFalse, + reason: '$location should use BusyMaxComboBox.', + ); + expect( + _hasRawMenuItemButton(line), + isFalse, + reason: '$location should use BusyMaxMenuEntry.', + ); + expect(line, isNot(contains('ToggleButtons(')), reason: location); + expect(line, isNot(contains('SegmentedButton(')), reason: location); expect(_hasRawMenuAnchor(file, line), isFalse, reason: location); expect( - _hasRawPopupMenuButton(file, line), + _hasRawPopupMenuButton(line), isFalse, reason: '$location should use BusyMaxMenuButton.', ); @@ -1457,12 +1511,19 @@ Iterable _dartFilesIn(String path) sync* { } } -bool _hasRawPopupMenuButton(File file, String line) { +bool _hasRawPopupMenuButton(String line) { return line.contains('PopupMenuButton') && - !line.contains('YaruPopupMenuButton') && !line.contains('BusyMaxMenuButton'); } +bool _hasRawDropdownMenu(String line) { + return RegExp(r'\bDropdownMenu(?:<[^>]+>)?\s*\(').hasMatch(line); +} + +bool _hasRawMenuItemButton(String line) { + return RegExp(r'\bMenuItemButton(?:<[^>]+>)?\s*\(').hasMatch(line); +} + bool _hasRawPopupMenuEntry(File file, String line) { if (file.path.endsWith('lib/src/app/busymax_design.dart')) { return false; @@ -1487,7 +1548,9 @@ bool _hasRawSwitch(String line) { } bool _hasRawIconButton(String line) { - return line.contains('IconButton(') && !line.contains('YaruIconButton('); + return line.contains('IconButton(') && + !line.contains('YaruIconButton(') && + !line.contains('BusyMaxPopoverIconButton('); } bool _isAllowedFontSizeException(File file, String line) { diff --git a/test/app/theme_localization_test.dart b/test/app/theme_localization_test.dart index 13b9d9e..a3f1c62 100644 --- a/test/app/theme_localization_test.dart +++ b/test/app/theme_localization_test.dart @@ -70,44 +70,29 @@ void main() { lightSurfaceColors.control, ); expect( - light.elevatedButtonTheme.style?.backgroundColor?.resolve({}), - _testAccentColor, - ); - expect(light.toggleButtonsTheme.color, lightSurfaceColors.foreground); - expect( - light.toggleButtonsTheme.selectedColor, + light.filledButtonTheme.style?.iconColor?.resolve({}), lightSurfaceColors.foreground, ); expect( - light.toggleButtonsTheme.fillColor, - lightSurfaceColors.controlActive, - ); - expect(light.toggleButtonsTheme.fillColor, isNot(_testAccentColor)); - expect(light.toggleButtonsTheme.borderColor, lightSurfaceColors.border); - expect( - light.toggleButtonsTheme.selectedBorderColor, - lightSurfaceColors.border, - ); - expect( - light.toggleButtonsTheme.borderRadius, - BorderRadius.circular(BusyMaxRadius.sm), - ); - expect( - light.toggleButtonsTheme.highlightColor, - yaruBase.toggleButtonsTheme.highlightColor, - ); - expect( - light.toggleButtonsTheme.splashColor, - yaruBase.toggleButtonsTheme.splashColor, - ); - expect( - light.toggleButtonsTheme.focusColor, - yaruBase.toggleButtonsTheme.focusColor, + light.elevatedButtonTheme.style?.backgroundColor?.resolve({}), + _testAccentColor, ); expect( - light.toggleButtonsTheme.hoverColor, - yaruBase.toggleButtonsTheme.hoverColor, + light.elevatedButtonTheme.style?.iconColor?.resolve({}), + light.colorScheme.onPrimary, ); + for (final style in [ + light.filledButtonTheme.style, + light.elevatedButtonTheme.style, + light.outlinedButtonTheme.style, + light.textButtonTheme.style, + ]) { + expect( + style?.iconColor?.resolve({WidgetState.disabled}), + lightSurfaceColors.disabledForeground, + ); + } + expect(light.toggleButtonsTheme, yaruBase.toggleButtonsTheme); expect(light.floatingActionButtonTheme.backgroundColor, _testAccentColor); expect(light.progressIndicatorTheme.color, _testAccentColor); expect(light.textSelectionTheme.cursorColor, _testAccentColor); @@ -256,17 +241,20 @@ void main() { expect(lightColors.groupedSurface, const Color(0xFFFFFFFF)); expect(lightColors.dialog, const Color(0xFFFAFAFB)); expect(lightColors.popover, const Color(0xFFFFFFFF)); - expect(darkColors.window, const Color(0xFF2C2C2C)); - expect(darkColors.view, const Color(0xFF1D1D20)); - expect(darkColors.sidebar, const Color(0xFF393939)); - expect(darkColors.secondarySidebar, const Color(0xFF323232)); - expect(darkColors.headerbar, const Color(0xFF393939)); - expect(darkColors.card, const Color(0xFF3D3D3D)); - expect(darkColors.groupedSurface, const Color(0xFF3D3D3D)); - expect(darkColors.dialog, const Color(0xFF3E3E3E)); - expect(darkColors.popover, const Color(0xFF3E3E3E)); + expect(lightColors.control, const Color.fromRGBO(0, 0, 0, 0.10)); + expect(lightColors.controlHover, const Color.fromRGBO(0, 0, 0, 0.14)); + expect(lightColors.controlActive, const Color.fromRGBO(0, 0, 0, 0.18)); + expect(darkColors.window, const Color(0xFF222226)); + expect(darkColors.view, const Color(0xFF222226)); + expect(darkColors.sidebar, const Color(0xFF2E2E32)); + expect(darkColors.secondarySidebar, const Color(0xFF28282C)); + expect(darkColors.headerbar, const Color(0xFF2E2E32)); + expect(darkColors.card, const Color(0xFF36363A)); + expect(darkColors.groupedSurface, const Color(0xFF36363A)); + expect(darkColors.dialog, const Color(0xFF36363A)); + expect(darkColors.popover, const Color(0xFF36363A)); expect(darkColors.sidebarBorder, const Color.fromRGBO(255, 255, 255, 0.10)); - expect(darkColors.view, isNot(const Color(0xFF3E3E3E))); + expect(darkColors.view, isNot(const Color(0xFF36363A))); expect(light.scaffoldBackgroundColor, lightColors.window); expect(dark.scaffoldBackgroundColor, darkColors.window); expect(light.cardColor, lightColors.card); @@ -726,6 +714,40 @@ void main() { expect(theme.popupMenuTheme.shadowColor, isNot(colors.shade)); }); + test('BusyMax rejects a flat light GTK3 sidebar sample', () { + const gtkColors = GtkThemeColors( + brightness: Brightness.light, + window: Color(0xFFFAFAFA), + view: Color(0xFFFAFAFA), + sidebar: Color(0xFFFAFAFA), + ); + final colors = _buildBusyMaxTheme( + brightness: Brightness.light, + gtkThemeColors: gtkColors, + ).extension()!; + + expect( + colors.sidebar, + busyMaxFallbackSurfaceColors(Brightness.light).sidebar, + ); + expect(colors.sidebar, isNot(gtkColors.sidebar)); + }); + + test('BusyMax preserves a distinct light GTK sidebar sample', () { + const gtkColors = GtkThemeColors( + brightness: Brightness.light, + window: Color(0xFFFAFAFA), + view: Color(0xFFFFFFFF), + sidebar: Color(0xFFE8E8EA), + ); + final colors = _buildBusyMaxTheme( + brightness: Brightness.light, + gtkThemeColors: gtkColors, + ).extension()!; + + expect(colors.sidebar, gtkColors.sidebar); + }); + test('BusyMax theme preserves dark GTK popover samples', () { const gtkColors = GtkThemeColors( brightness: Brightness.dark, @@ -981,6 +1003,45 @@ void main() { expect(theme.colorScheme.surfaceContainerHighest, gtkColors.controlHover); }); + test('BusyMax theme rejects an imperceptible GTK control ladder', () { + const gtkColors = GtkThemeColors( + brightness: Brightness.light, + control: Color.fromRGBO(0, 0, 0, 0.02), + controlHover: Color.fromRGBO(0, 0, 0, 0.04), + controlActive: Color.fromRGBO(0, 0, 0, 0.06), + ); + final colors = _buildBusyMaxTheme( + brightness: Brightness.light, + gtkThemeColors: gtkColors, + ).extension()!; + final fallback = busyMaxFallbackSurfaceColors(Brightness.light); + + expect(colors.control, fallback.control); + expect(colors.controlHover, fallback.controlHover); + expect(colors.controlActive, fallback.controlActive); + }); + + test( + 'BusyMax theme rejects GTK controls indistinguishable from surfaces', + () { + const gtkColors = GtkThemeColors( + brightness: Brightness.light, + control: Color.fromRGBO(255, 255, 255, 0.12), + controlHover: Color.fromRGBO(255, 255, 255, 0.18), + controlActive: Color.fromRGBO(255, 255, 255, 0.24), + ); + final colors = _buildBusyMaxTheme( + brightness: Brightness.light, + gtkThemeColors: gtkColors, + ).extension()!; + final fallback = busyMaxFallbackSurfaceColors(Brightness.light); + + expect(colors.control, fallback.control); + expect(colors.controlHover, fallback.controlHover); + expect(colors.controlActive, fallback.controlActive); + }, + ); + test('BusyMax theme rejects opaque GTK widget samples as overlay roles', () { const gtkColors = GtkThemeColors( brightness: Brightness.dark, diff --git a/test/features/calendar/presentation/event_editor_test.dart b/test/features/calendar/presentation/event_editor_test.dart index 63b689a..f151f09 100644 --- a/test/features/calendar/presentation/event_editor_test.dart +++ b/test/features/calendar/presentation/event_editor_test.dart @@ -9,26 +9,34 @@ import 'package:busymax/src/app/busymax_design.dart'; import 'package:busymax/src/app/busymax_yaru_theme.dart'; import 'package:busymax/src/microsoft_calendar/microsoft_calendar_mapper.dart'; import 'package:busymax/src/platform/native_dialog_service.dart'; +import 'package:busymax/src/platform/native_menu_service.dart'; import 'package:busymax/src/task_providers/task_provider.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:ubuntu_widgets/ubuntu_widgets.dart'; import 'package:yaru/yaru.dart'; import '../../../test_localized_app.dart'; const _nativeDialogChannel = MethodChannel(nativeDialogChannelName); +const _nativeMenuChannel = MethodChannel(nativeMenuChannelName); void main() { setUp(() { TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger .setMockMethodCallHandler(_nativeDialogChannel, (_) async => null); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler( + _nativeMenuChannel, + (_) async => throw MissingPluginException(), + ); }); tearDown(() { TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger .setMockMethodCallHandler(_nativeDialogChannel, null); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(_nativeMenuChannel, null); }); testWidgets('editor actions use natural-width themed controls', ( @@ -57,14 +65,6 @@ void main() { ), ); - expect( - tester.getSize(_headerButtonFinder('Cancel')).width, - lessThan(kPushButtonSize.width), - ); - expect( - tester.getSize(_headerButtonFinder('Save')).width, - lessThan(kPushButtonSize.width), - ); expect( tester.getSize(_headerButtonFinder('Cancel')).height, kYaruButtonHeight, @@ -87,19 +87,22 @@ void main() { ), findsOneWidget, ); - expect( - find.descendant( - of: find.byType(BusyMaxEditorHeader), - matching: find.byWidgetPredicate((widget) => widget is PushButton), - ), - findsNothing, - ); final cancelButton = tester.widget( find.ancestor( of: find.text('Cancel'), matching: find.byType(FilledButton), ), ); + final saveButton = tester.widget( + find.ancestor( + of: find.text('Save'), + matching: find.byType(ElevatedButton), + ), + ); + expect(cancelButton.style?.fixedSize, isNull); + expect(cancelButton.style?.minimumSize, isNull); + expect(saveButton.style?.fixedSize, isNull); + expect(saveButton.style?.minimumSize, isNull); final cancelContext = tester.element(find.text('Cancel')); expect( cancelButton.style?.textStyle?.resolve(const {})?.fontWeight, @@ -244,12 +247,12 @@ void main() { expect(entry.controller?.timeOfDay, isNull); expect( tester - .widget( + .widget( find .ancestor( of: find.text('OK'), matching: find.byWidgetPredicate( - (widget) => widget is PushButton, + (widget) => widget is ButtonStyleButton, ), ) .first, @@ -955,10 +958,9 @@ void main() { expect(find.text('Add Reminder'), findsNothing); expect( - find.byWidgetPredicate( - (widget) => - widget is EditableText && - widget.controller.text == '5 minutes before', + find.descendant( + of: find.byType(BusyMaxComboBox), + matching: find.text('5 minutes before'), ), findsOneWidget, ); @@ -1191,16 +1193,17 @@ void main() { expect(design, contains('BusyMaxEditorHeader(')); expect(design, contains('SingleChildScrollView')); final headerStart = design.indexOf('class BusyMaxEditorHeader'); - final headerEnd = design.indexOf('class BusyMaxTimeModeRow'); + final headerEnd = design.indexOf('class BusyMaxModeSwitcher'); final header = design.substring(headerStart, headerEnd); expect(header, contains('child: Row(')); expect(header, contains('AlignmentDirectional.centerStart')); - expect(header, contains('child: FilledButton(')); + expect(header, contains('child: BusyMaxPushButton.standard(')); expect(header, contains('AlignmentDirectional.centerEnd')); - expect(header, contains('child: ElevatedButton(')); + expect(header, contains('child: BusyMaxPushButton.suggested(')); expect(header, contains('heightFactor: 1')); expect(header, contains('textTheme.titleSmall')); - expect(header, isNot(contains('BusyMaxPushButton'))); + expect(header, isNot(contains('child: FilledButton('))); + expect(header, isNot(contains('child: ElevatedButton('))); expect(header, isNot(contains('NavigationToolbar('))); expect(header, isNot(contains('ConstrainedBox('))); expect(header, isNot(contains('kPushButtonSize'))); @@ -1389,14 +1392,18 @@ void main() { expect(editor, contains('l10n.deleteEvent')); }); - testWidgets('combo selector inherits Yaru dropdown geometry', (tester) async { + testWidgets('combo selector uses the shared Yaru button trigger', ( + tester, + ) async { + final theme = BusyMaxYaruTheme.build( + brightness: Brightness.light, + accentColor: const Color(0xFF3584E4), + ); + final colors = theme.extension()!; await tester.pumpWidget( localizedTestApp( child: Theme( - data: BusyMaxYaruTheme.build( - brightness: Brightness.light, - accentColor: const Color(0xFF3584E4), - ), + data: theme, child: SizedBox( width: 480, child: BusyMaxComboRow( @@ -1412,22 +1419,22 @@ void main() { ); expect(find.byType(BusyMaxComboBox), findsOneWidget); - final trigger = tester.widget( - find.descendant( - of: find.byType(BusyMaxComboRow), - matching: find.byWidgetPredicate((widget) => widget is DropdownMenu), + final triggerFinder = find.descendant( + of: find.byType(BusyMaxComboBox), + matching: find.byWidgetPredicate( + (widget) => widget is ButtonStyleButton && widget is! IconButton, ), ); - expect(trigger.selectOnly, isTrue); - expect(trigger.enableSearch, isFalse); - expect(trigger.inputDecorationTheme, isNull); - expect(trigger.menuStyle, isNull); - expect( - tester - .getSize(find.byWidgetPredicate((widget) => widget is DropdownMenu)) - .height, - kYaruButtonHeight, + final trigger = tester.widget(triggerFinder); + expect(trigger.style, isNull); + expect(trigger.onPressed, isNotNull); + expect(tester.getSize(triggerFinder).height, kYaruButtonHeight); + final restingSurface = tester.widget( + find.descendant(of: triggerFinder, matching: find.byType(Material)).first, ); + expect(restingSurface.type, MaterialType.button); + expect(restingSurface.color, colors.control); + expect(restingSurface.color, isNot(Colors.transparent)); }); } diff --git a/test/features/feedback/presentation/feedback_dialog_test.dart b/test/features/feedback/presentation/feedback_dialog_test.dart index 23b5728..1f4eab9 100644 --- a/test/features/feedback/presentation/feedback_dialog_test.dart +++ b/test/features/feedback/presentation/feedback_dialog_test.dart @@ -4,6 +4,7 @@ import 'package:busymax/src/features/feedback/data/feedback_api_client.dart'; import 'package:busymax/src/features/feedback/data/feedback_submission.dart'; import 'package:busymax/src/features/feedback/presentation/feedback_dialog.dart'; import 'package:busymax/src/platform/native_dialog_service.dart'; +import 'package:busymax/src/platform/native_menu_service.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -12,16 +13,24 @@ import 'package:yaru/yaru.dart'; import '../../../test_localized_app.dart'; const _nativeDialogChannel = MethodChannel(nativeDialogChannelName); +const _nativeMenuChannel = MethodChannel(nativeMenuChannelName); void main() { setUp(() { TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger .setMockMethodCallHandler(_nativeDialogChannel, (_) async => null); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler( + _nativeMenuChannel, + (_) async => throw MissingPluginException(), + ); }); tearDown(() { TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger .setMockMethodCallHandler(_nativeDialogChannel, null); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(_nativeMenuChannel, null); }); testWidgets('shows required-field validation without sending', ( @@ -54,18 +63,15 @@ void main() { return const FeedbackReceipt(id: 'unexpected'); }); await _pumpDialog(tester, service); - final selector = find.descendant( - of: find.byKey(const Key('feedback-category')), - matching: find.byWidgetPredicate((widget) => widget is DropdownMenu), - ); + final selector = _feedbackCategoryTrigger(); await tester.tap(selector); await tester.pumpAndSettle(); - await tester.tap(find.text('Problem or bug').last); + await tester.tap(_feedbackCategoryMenuItem('Problem or bug')); await tester.pumpAndSettle(); await tester.tap(selector); await tester.pumpAndSettle(); - await tester.tap(find.text('Select a category').last); + await tester.tap(_feedbackCategoryMenuItem('Select a category')); await tester.pumpAndSettle(); await tester.tap(find.text('Submit')); await tester.pump(); @@ -414,14 +420,9 @@ Future _pumpDialog( } Future _enterValidRequiredFields(WidgetTester tester) async { - await tester.tap( - find.descendant( - of: find.byKey(const Key('feedback-category')), - matching: find.byWidgetPredicate((widget) => widget is DropdownMenu), - ), - ); + await tester.tap(_feedbackCategoryTrigger()); await tester.pumpAndSettle(); - await tester.tap(find.text('Problem or bug').last); + await tester.tap(_feedbackCategoryMenuItem('Problem or bug')); await tester.pumpAndSettle(); await tester.enterText( find.byKey(const Key('feedback-subject')), @@ -433,6 +434,20 @@ Future _enterValidRequiredFields(WidgetTester tester) async { ); } +Finder _feedbackCategoryTrigger() { + return find.descendant( + of: find.byKey(const Key('feedback-category')), + matching: find.byType(FilledButton), + ); +} + +Finder _feedbackCategoryMenuItem(String label) { + return find.ancestor( + of: find.text(label).last, + matching: find.byType(PopupMenuItem), + ); +} + String _fieldText(WidgetTester tester, String key) { return tester.widget(find.byKey(Key(key))).controller!.text; } diff --git a/test/features/schedule/presentation/compact_agenda_panel_test.dart b/test/features/schedule/presentation/compact_agenda_panel_test.dart index 4c31cfd..c42ba01 100644 --- a/test/features/schedule/presentation/compact_agenda_panel_test.dart +++ b/test/features/schedule/presentation/compact_agenda_panel_test.dart @@ -318,7 +318,7 @@ void main() { await tester.tap(find.text('Team sync')); await tester.pumpAndSettle(); - expect(find.byIcon(Icons.download_outlined), findsOneWidget); + expect(find.byIcon(YaruIcons.share), findsOneWidget); expect(find.byIcon(Icons.edit_outlined), findsOneWidget); expect(find.text('Work'), findsWidgets); }); diff --git a/test/features/schedule/presentation/schedule_create_menu_test.dart b/test/features/schedule/presentation/schedule_create_menu_test.dart index 3c54b5d..bccdc5e 100644 --- a/test/features/schedule/presentation/schedule_create_menu_test.dart +++ b/test/features/schedule/presentation/schedule_create_menu_test.dart @@ -1,15 +1,89 @@ -import 'package:busymax/src/app/busymax_design.dart'; import 'package:busymax/src/features/schedule/presentation/schedule_create_menu.dart'; +import 'package:busymax/src/platform/native_menu_service.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import '../../../test_localized_app.dart'; +const _nativeMenuChannel = MethodChannel(nativeMenuChannelName); + void main() { TestWidgetsFlutterBinding.ensureInitialized(); - testWidgets('create chooser opens as an anchored menu popover', ( + setUp(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler( + _nativeMenuChannel, + (_) async => throw MissingPluginException(), + ); + }); + + tearDown(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(_nativeMenuChannel, null); + }); + + testWidgets('create chooser delegates selection to the native menu host', ( + tester, + ) async { + MethodCall? nativeCall; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(_nativeMenuChannel, (call) async { + nativeCall = call; + return 1; + }); + late BuildContext hostContext; + late BuildContext anchorContext; + await tester.pumpWidget( + localizedTestApp( + child: Scaffold( + body: Builder( + builder: (context) { + hostContext = context; + return Align( + alignment: Alignment.topLeft, + child: Padding( + padding: const EdgeInsets.all(80), + child: Builder( + builder: (context) { + anchorContext = context; + return const SizedBox.square(dimension: 32); + }, + ), + ), + ); + }, + ), + ), + ), + ); + + final result = await showScheduleCreateMenu( + context: hostContext, + anchorContext: anchorContext, + anchorPoint: const Offset(96, 96), + ); + + expect(result, ScheduleCreateChoice.task); + expect(nativeCall?.method, 'show'); + final arguments = nativeCall?.arguments as Map; + expect(arguments['sessionId'], isA()); + expect(arguments['anchor'], { + 'x': 96.0, + 'y': 96.0, + 'width': 0.0, + 'height': 0.0, + }); + expect(arguments['entries'], [ + {'label': 'Event', 'enabled': true, 'selected': false}, + {'label': 'Task', 'enabled': true, 'selected': false}, + ]); + expect(arguments['focusFirst'], isFalse); + expect(find.byType(PopupMenuItem), findsNothing); + }); + + testWidgets('unavailable native host uses an anchored popup-menu fallback', ( tester, ) async { late BuildContext hostContext; @@ -46,21 +120,9 @@ void main() { await tester.pumpAndSettle(); expect(find.byType(Dialog), findsNothing); - expect(find.byType(BusyMaxPopoverSurface), findsOneWidget); - expect(find.byType(MenuItemButton), findsNWidgets(2)); + expect(find.byType(PopupMenuItem), findsNWidgets(2)); expect(find.text('Event'), findsOneWidget); expect(find.text('Task'), findsOneWidget); - expect( - tester.getRect(find.byType(BusyMaxPopoverSurface)).top, - greaterThan(96), - ); - final eventButton = tester.widget( - find.ancestor( - of: find.text('Event'), - matching: find.byType(MenuItemButton), - ), - ); - expect(eventButton.autofocus, isFalse); await tester.tap(find.text('Task')); await tester.pumpAndSettle(); @@ -68,7 +130,7 @@ void main() { expect(await result, ScheduleCreateChoice.task); }); - testWidgets('create chooser disables unavailable creation kinds', ( + testWidgets('popup-menu fallback disables unavailable creation kinds', ( tester, ) async { late BuildContext hostContext; @@ -101,33 +163,32 @@ void main() { ); await tester.pumpAndSettle(); - final eventButton = tester.widget( + final eventItem = tester.widget>( find.ancestor( of: find.text('Event'), - matching: find.byType(MenuItemButton), + matching: find.byType(PopupMenuItem), ), ); - final taskButton = tester.widget( + final taskItem = tester.widget>( find.ancestor( of: find.text('Task'), - matching: find.byType(MenuItemButton), + matching: find.byType(PopupMenuItem), ), ); - expect(eventButton.onPressed, isNull); - expect(eventButton.autofocus, isFalse); - expect(taskButton.onPressed, isNotNull); - expect(taskButton.autofocus, isTrue); + expect(eventItem.enabled, isFalse); + expect(taskItem.enabled, isTrue); + expect(Focus.of(tester.element(find.text('Task'))).hasFocus, isTrue); await tester.tap(find.text('Event')); await tester.pump(); - expect(find.byType(BusyMaxPopoverSurface), findsOneWidget); + expect(find.byType(PopupMenuItem), findsNWidgets(2)); await tester.tap(find.text('Task')); await tester.pumpAndSettle(); expect(await result, ScheduleCreateChoice.task); }); - testWidgets('keyboard chooser supports Escape and restores anchor focus', ( + testWidgets('Escape dismisses the fallback and restores anchor focus', ( tester, ) async { final focusNode = FocusNode(); @@ -164,26 +225,51 @@ void main() { ); await tester.pumpAndSettle(); - final eventButton = tester.widget( - find.ancestor( - of: find.text('Event'), - matching: find.byType(MenuItemButton), - ), - ); - expect(eventButton.autofocus, isTrue); + expect(find.byType(PopupMenuItem), findsNWidgets(2)); + expect(Focus.of(tester.element(find.text('Event'))).hasFocus, isTrue); expect(focusNode.hasFocus, isFalse); await tester.sendKeyEvent(LogicalKeyboardKey.escape); await tester.pumpAndSettle(); expect(await result, isNull); - expect(find.byType(BusyMaxPopoverSurface), findsNothing); + expect(find.byType(PopupMenuItem), findsNothing); expect(focusNode.hasFocus, isTrue); }); + testWidgets('native dismissal does not open the Flutter fallback', ( + tester, + ) async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(_nativeMenuChannel, (_) async => null); + late BuildContext hostContext; + await tester.pumpWidget( + localizedTestApp( + child: Builder( + builder: (context) { + hostContext = context; + return const SizedBox(); + }, + ), + ), + ); + + final result = await showScheduleCreateMenu(context: hostContext); + await tester.pump(); + + expect(result, isNull); + expect(find.byType(PopupMenuItem), findsNothing); + }); + testWidgets('create chooser does not open without an available choice', ( tester, ) async { + var nativeCalls = 0; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(_nativeMenuChannel, (_) async { + nativeCalls += 1; + return null; + }); late BuildContext hostContext; await tester.pumpWidget( localizedTestApp( @@ -204,7 +290,8 @@ void main() { await tester.pump(); expect(result, isNull); - expect(find.byType(BusyMaxPopoverSurface), findsNothing); + expect(nativeCalls, 0); + expect(find.byType(PopupMenuItem), findsNothing); }); test('single available creation kind is resolved for direct creation', () { diff --git a/test/features/schedule/presentation/schedule_toolbar_test.dart b/test/features/schedule/presentation/schedule_toolbar_test.dart index 826bb32..15ff836 100644 --- a/test/features/schedule/presentation/schedule_toolbar_test.dart +++ b/test/features/schedule/presentation/schedule_toolbar_test.dart @@ -1,5 +1,8 @@ +import 'dart:async'; + import 'package:busymax/src/app/busymax_design.dart'; import 'package:busymax/src/features/schedule/presentation/schedule_toolbar.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'; import 'package:flutter/material.dart'; @@ -9,7 +12,73 @@ import 'package:yaru/yaru.dart'; import '../../../test_localized_app.dart'; +const _nativeMenuChannel = MethodChannel(nativeMenuChannelName); + void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + setUp(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler( + _nativeMenuChannel, + (_) async => throw MissingPluginException(), + ); + }); + + tearDown(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(_nativeMenuChannel, null); + }); + + testWidgets('toolbar delegates create selection to the native menu host', ( + tester, + ) async { + MethodCall? nativeCall; + var events = 0; + var tasks = 0; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(_nativeMenuChannel, (call) async { + nativeCall = call; + return 1; + }); + + await tester.pumpWidget( + localizedTestApp( + child: Scaffold( + body: SizedBox( + width: 1000, + child: ScheduleToolbar( + mode: ScheduleViewMode.week, + range: ScheduleRange.week(DateTime(2026, 7, 22)), + selectedDate: DateTime(2026, 7, 22), + onToday: () {}, + onPrevious: () {}, + onNext: () {}, + onModeChanged: (_) {}, + canCreateEvent: true, + canCreateTask: true, + onCreateEvent: () => events++, + onCreateTask: () => tasks++, + onRefresh: () {}, + ), + ), + ), + ), + ); + + await tester.tap(find.byTooltip('Create')); + await tester.pumpAndSettle(); + + expect(events, 0); + expect(tasks, 1); + expect(nativeCall?.method, 'show'); + expect((nativeCall?.arguments as Map)['entries'], [ + {'label': 'Event', 'enabled': true, 'selected': false}, + {'label': 'Task', 'enabled': true, 'selected': false}, + ]); + expect(find.byType(PopupMenuItem), findsNothing); + }); + testWidgets('fallback toolbar exposes the complete shell command set', ( tester, ) async { @@ -56,6 +125,8 @@ void main() { await tester.tap(find.byTooltip('Create')); await tester.pumpAndSettle(); + expect(find.byType(PopupMenuItem), findsNWidgets(2)); + expect(find.byType(YaruRadio), findsNothing); expect(find.text('Event'), findsOneWidget); expect(find.text('Task'), findsOneWidget); await tester.tap(find.text('Event')); @@ -65,12 +136,27 @@ void main() { await tester.tap(find.byTooltip('Week')); await tester.pumpAndSettle(); - await tester.tap(find.text('Month')); + expect( + find.byType(PopupMenuItem), + findsNWidgets(ScheduleViewMode.values.length), + ); + expect( + find.byType(YaruRadio), + findsNWidgets(ScheduleViewMode.values.length), + ); + await tester.tap( + find.ancestor( + of: find.text('Month'), + matching: find.byType(PopupMenuItem), + ), + ); await tester.pumpAndSettle(); expect(selectedMode, ScheduleViewMode.month); await tester.tap(find.byTooltip('Main Menu')); await tester.pumpAndSettle(); + expect(find.byType(PopupMenuItem), findsNWidgets(3)); + expect(find.byType(YaruRadio), findsNothing); await tester.tap(find.text('Settings')); await tester.pumpAndSettle(); expect(selectedMenuAction, ScheduleToolbarMenuAction.settings); @@ -115,6 +201,7 @@ void main() { expect(find.byTooltip('Refresh all'), findsNothing); await tester.tap(find.byTooltip('Main Menu')); await tester.pumpAndSettle(); + expect(find.byType(PopupMenuItem), findsNWidgets(4)); await tester.tap(find.text('Refresh all')); await tester.pumpAndSettle(); @@ -122,7 +209,7 @@ void main() { expect(refreshes, 1); }); - testWidgets('create menu keeps equal choices neutral and capability-aware', ( + testWidgets('fallback create menu keeps actions capability-aware', ( tester, ) async { var events = 0; @@ -155,32 +242,22 @@ void main() { await tester.tap(find.byTooltip('Create')); await tester.pumpAndSettle(); - final eventButton = tester.widget( + expect(find.byType(PopupMenuItem), findsNWidgets(2)); + expect(find.byType(YaruRadio), findsNothing); + final eventItem = tester.widget>( find.ancestor( of: find.text('Event'), - matching: find.byType(MenuItemButton), + matching: find.byType(PopupMenuItem), ), ); - final taskButton = tester.widget( + final taskItem = tester.widget>( find.ancestor( of: find.text('Task'), - matching: find.byType(MenuItemButton), + matching: find.byType(PopupMenuItem), ), ); - expect(eventButton.onPressed, isNull); - expect(taskButton.onPressed, isNotNull); - expect( - eventButton.style?.backgroundColor?.resolve({}), - taskButton.style?.backgroundColor?.resolve({}), - ); - final inheritedBackground = Theme.of( - tester.element(find.text('Task')), - ).menuButtonTheme.style?.backgroundColor?.resolve({}); - expect( - eventButton.style?.backgroundColor?.resolve({}), - inheritedBackground, - ); - expect(taskButton.style?.backgroundColor?.resolve({}), inheritedBackground); + expect(eventItem.enabled, isFalse); + expect(taskItem.enabled, isTrue); await tester.tap(find.text('Task')); await tester.pumpAndSettle(); @@ -219,8 +296,10 @@ void main() { ); expect(controller.openForKeyboard(), isTrue); + expect(controller.isOpen, isTrue); await tester.pumpAndSettle(); + expect(find.byType(PopupMenuItem), findsNWidgets(2)); expect(find.text('Event'), findsOneWidget); expect(find.text('Task'), findsOneWidget); @@ -230,26 +309,81 @@ void main() { matching: find.byType(YaruIconButton), ), ); - final anchor = tester.widget( - find.ancestor( - of: find.byTooltip('Create'), - matching: find.byType(MenuAnchor), - ), - ); expect(trigger.focusNode, isNotNull); - expect(anchor.childFocusNode, same(trigger.focusNode)); - final menuItems = tester - .widgetList(find.byType(MenuItemButton)) - .toList(); - expect(menuItems.first.focusNode?.hasFocus, isTrue); + expect(Focus.of(tester.element(find.text('Event'))).hasFocus, isTrue); await tester.sendKeyEvent(LogicalKeyboardKey.escape); await tester.pumpAndSettle(); + expect(controller.isOpen, isFalse); + expect(find.byType(PopupMenuItem), findsNothing); expect(find.text('Event'), findsNothing); expect(find.text('Task'), findsNothing); }, ); + testWidgets('controller close dismisses a pending native menu', ( + tester, + ) async { + final controller = BusyMaxMenuController(); + final nativeSelection = Completer(); + var showCalls = 0; + var dismissCalls = 0; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(_nativeMenuChannel, (call) async { + switch (call.method) { + case 'show': + showCalls += 1; + return nativeSelection.future; + case 'dismiss': + dismissCalls += 1; + if (!nativeSelection.isCompleted) { + nativeSelection.complete(); + } + return true; + } + throw MissingPluginException(); + }); + + await tester.pumpWidget( + localizedTestApp( + child: Scaffold( + body: SizedBox( + width: 1000, + child: ScheduleToolbar( + mode: ScheduleViewMode.week, + range: ScheduleRange.week(DateTime(2026, 7, 22)), + selectedDate: DateTime(2026, 7, 22), + onToday: () {}, + onPrevious: () {}, + onNext: () {}, + onModeChanged: (_) {}, + canCreateEvent: true, + canCreateTask: true, + onCreateEvent: () {}, + onCreateTask: () {}, + onRefresh: () {}, + createMenuController: controller, + ), + ), + ), + ), + ); + + expect(controller.openForKeyboard(), isTrue); + await tester.pump(); + + expect(controller.isOpen, isTrue); + expect(showCalls, 1); + expect(find.byType(PopupMenuItem), findsNothing); + + controller.close(); + await tester.pumpAndSettle(); + + expect(dismissCalls, 1); + expect(controller.isOpen, isFalse); + expect(find.byType(PopupMenuItem), findsNothing); + }); + testWidgets('keyboard controller follows a responsive toolbar replacement', ( tester, ) async { @@ -303,15 +437,15 @@ void main() { expect(controller.openForKeyboard(), isTrue); await tester.pumpAndSettle(); + expect(find.byType(PopupMenuItem), findsNWidgets(2)); expect(find.text('Event'), findsOneWidget); expect(find.text('Task'), findsOneWidget); - final menuItems = tester - .widgetList(find.byType(MenuItemButton)) - .toList(); - expect(menuItems.first.focusNode?.hasFocus, isTrue); + expect(Focus.of(tester.element(find.text('Event'))).hasFocus, isTrue); controller.close(); await tester.pumpAndSettle(); + expect(controller.isOpen, isFalse); + expect(find.byType(PopupMenuItem), findsNothing); await tester.pumpWidget(const SizedBox.shrink()); expect(controller.isAttached, isFalse); diff --git a/test/features/schedule/presentation/schedule_views_test.dart b/test/features/schedule/presentation/schedule_views_test.dart index f6280bb..9081ceb 100644 --- a/test/features/schedule/presentation/schedule_views_test.dart +++ b/test/features/schedule/presentation/schedule_views_test.dart @@ -2,7 +2,7 @@ import 'dart:io'; import 'dart:ui' as ui; import 'package:busymax/src/app/busymax_design.dart'; -import 'package:busymax/src/app/busymax_surface_colors.dart'; +import 'package:busymax/src/app/busymax_yaru_theme.dart'; import 'package:busymax/src/features/schedule/presentation/schedule_agenda_view.dart'; import 'package:busymax/src/features/schedule/presentation/schedule_anchored_popover.dart'; import 'package:busymax/src/features/schedule/presentation/schedule_day_week_view.dart'; @@ -18,9 +18,11 @@ import 'package:busymax/src/schedule/schedule_range.dart'; import 'package:busymax/src/task_providers/task_provider.dart'; import 'package:flutter/gestures.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:infinite_calendar_view/infinite_calendar_view.dart' as icv; +import 'package:yaru/yaru.dart'; import '../../../test_localized_app.dart'; @@ -622,13 +624,55 @@ void main() { await tester.pumpAndSettle(); expect(find.text('Design review'), findsOneWidget); - expect(find.byIcon(Icons.download_outlined), findsOneWidget); + expect(find.byIcon(YaruIcons.share), findsOneWidget); expect(find.byIcon(Icons.edit_outlined), findsOneWidget); - expect(find.byIcon(Icons.delete_outline), findsOneWidget); - expect(find.byIcon(Icons.close), findsOneWidget); + expect(find.byIcon(YaruIcons.trash), findsOneWidget); + expect(find.byIcon(YaruIcons.window_close), findsOneWidget); expect(find.text('Export'), findsNothing); expect(find.text('Edit event'), findsNothing); expect(find.text('Delete'), findsNothing); + expect(find.byType(BusyMaxPopoverIconButton), findsNWidgets(4)); + + final actionButtons = tester + .widgetList( + find.descendant( + of: find.byType(BusyMaxPopoverIconButton), + matching: find.byType(YaruIconButton), + ), + ) + .toList(); + final actionSurfaces = tester + .widgetList( + find.descendant( + of: find.byType(BusyMaxPopoverIconButton), + matching: find.byWidgetPredicate( + (widget) => widget is Material && widget.shape is CircleBorder, + ), + ), + ) + .toList(); + final actionContext = tester.element( + find.byType(BusyMaxPopoverIconButton).first, + ); + final actionColors = BusyMaxSurfaceColors.of(actionContext); + expect(actionSurfaces, hasLength(4)); + for (final button in actionButtons) { + expect(button.iconSize, kYaruTitleBarItemHeight); + expect(button.style, isNull); + } + for (final surface in actionSurfaces) { + expect(surface.color, actionColors.control); + expect(surface.shape, const CircleBorder()); + expect(surface.clipBehavior, Clip.antiAlias); + } + expect( + tester.widget(find.byIcon(YaruIcons.trash)).color, + Theme.of(actionContext).colorScheme.error, + ); + expect( + tester.widget(find.byIcon(YaruIcons.share)).color, + actionColors.foreground, + ); final popoverSurfaceFinder = find.byWidgetPredicate( (widget) => @@ -657,17 +701,80 @@ void main() { ); final editCenter = tester.getCenter(find.byIcon(Icons.edit_outlined)); - final deleteCenter = tester.getCenter(find.byIcon(Icons.delete_outline)); - final closeCenter = tester.getCenter(find.byIcon(Icons.close)); + final deleteCenter = tester.getCenter(find.byIcon(YaruIcons.trash)); + final closeCenter = tester.getCenter(find.byIcon(YaruIcons.window_close)); expect(editCenter.dx, lessThan(deleteCenter.dx)); expect(deleteCenter.dx, lessThan(closeCenter.dx)); - await tester.tap(find.byIcon(Icons.download_outlined)); + await tester.tap(find.byIcon(YaruIcons.share)); await tester.pumpAndSettle(); expect(await action, ScheduleItemDetailsAction.export); }); + for (final brightness in Brightness.values) { + testWidgets( + 'popover action paints a contained circle and strengthens it on hover ' + 'in $brightness mode', + (tester) async { + final theme = BusyMaxYaruTheme.build( + brightness: brightness, + accentColor: const Color(0xFF3584E4), + ); + final colors = theme.extension()!; + final boundaryKey = GlobalKey(); + + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: Center( + child: RepaintBoundary( + key: boundaryKey, + child: ColoredBox( + color: colors.popover, + child: SizedBox.square( + dimension: 50, + child: Center( + child: BusyMaxPopoverIconButton( + icon: YaruIcons.share, + tooltip: 'Export', + onPressed: () {}, + ), + ), + ), + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + final restingPixels = await _capturePixels(tester, boundaryKey); + final background = _pixelAt(restingPixels, x: 2, y: 2); + final restingFace = _pixelAt(restingPixels, x: 25, y: 12); + expect(restingFace, isNot(background)); + + final mouse = await tester.createGesture(kind: PointerDeviceKind.mouse); + addTearDown(mouse.removePointer); + await mouse.addPointer(location: Offset.zero); + await mouse.moveTo( + tester.getCenter(find.byType(BusyMaxPopoverIconButton)), + ); + await tester.pumpAndSettle(); + + final hoveredPixels = await _capturePixels(tester, boundaryKey); + final hoveredBackground = _pixelAt(hoveredPixels, x: 2, y: 2); + final hoveredFace = _pixelAt(hoveredPixels, x: 25, y: 12); + expect(hoveredBackground, background); + expect(hoveredFace, isNot(restingFace)); + expect( + _luminanceDistance(hoveredFace, background), + greaterThan(_luminanceDistance(restingFace, background)), + ); + }, + ); + } + testWidgets('direct details popover registers for native-header dismissal', ( tester, ) async { @@ -708,7 +815,7 @@ void main() { await dismissal; expect(controller.isOpen, isFalse); - expect(find.byIcon(Icons.close), findsNothing); + expect(find.byIcon(YaruIcons.window_close), findsNothing); }); testWidgets('schedule item details popover delete button returns delete', ( @@ -743,7 +850,7 @@ void main() { await tester.tap(find.text('Open details')); await tester.pumpAndSettle(); - await tester.tap(find.byIcon(Icons.delete_outline)); + await tester.tap(find.byIcon(YaruIcons.trash)); await tester.pumpAndSettle(); expect(await action, ScheduleItemDetailsAction.delete); @@ -783,10 +890,10 @@ void main() { await tester.tap(find.text('Open details')); await tester.pumpAndSettle(); - expect(find.byIcon(Icons.download_outlined), findsOneWidget); - expect(find.byIcon(Icons.close), findsOneWidget); + expect(find.byIcon(YaruIcons.share), findsOneWidget); + expect(find.byIcon(YaruIcons.window_close), findsOneWidget); expect(find.byIcon(Icons.edit_outlined), findsNothing); - expect(find.byIcon(Icons.delete_outline), findsNothing); + expect(find.byIcon(YaruIcons.trash), findsNothing); }); testWidgets('details popover constrains long content without animation', ( @@ -1630,14 +1737,20 @@ void main() { expect(more, isNot(contains('Dialog('))); }); - test('schedule item details actions use Yaru icon buttons', () { + test('schedule item details actions use the shared contained Yaru role', () { + final design = File('lib/src/app/busymax_design.dart').readAsStringSync(); final popover = File( 'lib/src/features/schedule/presentation/schedule_item_details_popover.dart', ).readAsStringSync(); - expect(popover, contains('YaruIconButton(')); - expect(popover, isNot(contains('BusyMaxCircularAction('))); - expect(popover, contains('color: Theme.of(context).colorScheme.error')); + expect(design, contains('class BusyMaxPopoverIconButton')); + expect(design, contains('return Material(')); + expect(design, contains('shape: const CircleBorder()')); + expect(design, contains('child: YaruIconButton(')); + expect(design, contains('iconSize: kYaruTitleBarItemHeight')); + expect(design, contains('color: enabled ? colors.control')); + expect(popover, contains('BusyMaxPopoverIconButton(')); + expect(popover, contains('destructive: true')); expect(popover, isNot(contains('backgroundColor:'))); expect(popover, isNot(contains('foregroundColor:'))); expect(popover, isNot(contains('hoverColor:'))); @@ -2589,6 +2702,41 @@ void main() { }); } +Future<({Uint8List bytes, int width})> _capturePixels( + WidgetTester tester, + GlobalKey key, +) async { + final boundary = + key.currentContext!.findRenderObject()! as RenderRepaintBoundary; + final image = (await tester.binding.runAsync(boundary.toImage))!; + try { + final byteData = (await tester.binding.runAsync( + () => image.toByteData(format: ui.ImageByteFormat.rawStraightRgba), + ))!; + return (bytes: byteData.buffer.asUint8List(), width: image.width); + } finally { + image.dispose(); + } +} + +Color _pixelAt( + ({Uint8List bytes, int width}) 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], + ); +} + +double _luminanceDistance(Color first, Color second) { + return (first.computeLuminance() - second.computeLuminance()).abs(); +} + List _itemsFor(DateTime day) { return [ CalendarScheduleItem( 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 a844314..1c0f564 100644 --- a/test/features/schedule/presentation/schedule_workspace_task_mutations_test.dart +++ b/test/features/schedule/presentation/schedule_workspace_task_mutations_test.dart @@ -7,6 +7,7 @@ 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/native_dialog_service.dart'; +import 'package:busymax/src/platform/native_menu_service.dart'; import 'package:busymax/src/schedule/schedule_scope.dart'; import 'package:busymax/src/task_providers/task_provider.dart'; import 'package:drift/drift.dart'; @@ -19,16 +20,24 @@ import 'package:yaru/yaru.dart'; import '../../../test_localized_app.dart'; const _nativeDialogChannel = MethodChannel(nativeDialogChannelName); +const _nativeMenuChannel = MethodChannel(nativeMenuChannelName); void main() { setUp(() { TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger .setMockMethodCallHandler(_nativeDialogChannel, (_) async => null); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler( + _nativeMenuChannel, + (_) async => throw MissingPluginException(), + ); }); tearDown(() { TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger .setMockMethodCallHandler(_nativeDialogChannel, null); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(_nativeMenuChannel, null); }); testWidgets('creating a task from Schedule refreshes the visible items', ( diff --git a/test/features/settings/presentation/settings_screen_test.dart b/test/features/settings/presentation/settings_screen_test.dart index 4d9f9c4..b78f0e6 100644 --- a/test/features/settings/presentation/settings_screen_test.dart +++ b/test/features/settings/presentation/settings_screen_test.dart @@ -16,6 +16,7 @@ import 'package:busymax/src/features/auth/data/auth_repository.dart'; import 'package:busymax/src/features/settings/presentation/settings_screen.dart'; import 'package:busymax/src/features/sync/sync_auth_error.dart'; import 'package:busymax/src/platform/gtk_font_service.dart'; +import 'package:busymax/src/platform/native_menu_service.dart'; import 'package:busymax/src/features/task_lists/data/task_lists_repository.dart'; import 'package:busymax/src/features/tasks/presentation/desktop_date_time_fields.dart'; import 'package:busymax/src/task_providers/task_provider.dart'; @@ -24,7 +25,22 @@ import 'package:ubuntu_localizations/ubuntu_localizations.dart'; import '../../../test_localized_app.dart'; +const _nativeMenuChannel = MethodChannel(nativeMenuChannelName); + void main() { + setUp(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler( + _nativeMenuChannel, + (_) async => throw MissingPluginException(), + ); + }); + + tearDown(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(_nativeMenuChannel, null); + }); + testWidgets('Settings removes the selected Microsoft account', ( tester, ) async { @@ -429,7 +445,7 @@ void main() { await tester.tap(find.byKey(const ValueKey('settings-page-selector'))); await tester.pumpAndSettle(); - await tester.tap(find.text('Schedule')); + await tester.tap(_settingsMenuItemWithLabel('Schedule')); await tester.pumpAndSettle(); expect(find.text('Day starts at'), findsOneWidget); @@ -604,7 +620,7 @@ void main() { expect(find.byType(SettingsScreen), findsOneWidget); await tester.tap(find.byKey(const ValueKey('settings-page-selector'))); await tester.pumpAndSettle(); - await tester.tap(find.text('Notifications')); + await tester.tap(_settingsMenuItemWithLabel('Notifications')); await tester.pumpAndSettle(); expect(router.state.uri.queryParameters['page'], 'notifications'); @@ -641,6 +657,13 @@ void main() { }); } +Finder _settingsMenuItemWithLabel(String label) { + return find.ancestor( + of: find.text(label).last, + matching: find.byType(PopupMenuItem), + ); +} + Future _openAccountRemovalDialog(WidgetTester tester) async { await tester.tap(find.text('Remove account…').first); await tester.pumpAndSettle(); diff --git a/test/features/tasks/presentation/task_details_pane_test.dart b/test/features/tasks/presentation/task_details_pane_test.dart index dca6e0d..5d2151f 100644 --- a/test/features/tasks/presentation/task_details_pane_test.dart +++ b/test/features/tasks/presentation/task_details_pane_test.dart @@ -16,19 +16,25 @@ import 'package:busymax/src/features/tasks/presentation/desktop_date_time_fields import 'package:busymax/src/features/tasks/presentation/task_details_editor.dart'; import 'package:busymax/src/features/tasks/presentation/task_details_pane.dart'; import 'package:busymax/src/platform/native_dialog_service.dart'; +import 'package:busymax/src/platform/native_menu_service.dart'; import 'package:busymax/src/task_providers/task_provider.dart'; -import 'package:ubuntu_widgets/ubuntu_widgets.dart'; import 'package:yaru/yaru.dart'; import '../../../test_localized_app.dart'; const _nativePickerChannel = MethodChannel(nativeDateTimePickerChannelName); const _nativeDialogChannel = MethodChannel(nativeDialogChannelName); +const _nativeMenuChannel = MethodChannel(nativeMenuChannelName); void main() { setUp(() { TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger .setMockMethodCallHandler(_nativeDialogChannel, (_) async => null); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler( + _nativeMenuChannel, + (_) async => throw MissingPluginException(), + ); }); tearDown(() { @@ -38,6 +44,8 @@ void main() { }); TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger .setMockMethodCallHandler(_nativeDialogChannel, null); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(_nativeMenuChannel, null); }); testWidgets('Task Details header shows Cancel and Save', (tester) async { @@ -68,16 +76,25 @@ void main() { ), findsOneWidget, ); - expect( - find.descendant( - of: find.byType(BusyMaxEditorHeader), - matching: find.byWidgetPredicate((widget) => widget is PushButton), + final cancelButton = tester.widget( + find.ancestor( + of: find.text('Cancel'), + matching: find.byType(FilledButton), + ), + ); + final saveButton = tester.widget( + find.ancestor( + of: find.text('Save'), + matching: find.byType(ElevatedButton), ), - findsNothing, ); + expect(cancelButton.style?.fixedSize, isNull); + expect(cancelButton.style?.minimumSize, isNull); + expect(saveButton.style?.fixedSize, isNull); + expect(saveButton.style?.minimumSize, isNull); }); - testWidgets('task selectors use the shared Yaru form selector', ( + testWidgets('task selectors use the shared native-menu trigger', ( tester, ) async { await _pumpDetails(tester, microsoftTaskProviderCapabilities); @@ -88,7 +105,7 @@ void main() { expect( find.descendant( of: comboRows, - matching: find.byWidgetPredicate((widget) => widget is DropdownMenu), + matching: find.byType(BusyMaxComboBox), ), findsNWidgets(comboCount), ); @@ -110,6 +127,17 @@ void main() { ), findsNothing, ); + final triggers = find.descendant( + of: comboRows, + matching: find.byWidgetPredicate( + (widget) => widget is ButtonStyleButton && widget is! IconButton, + ), + ); + expect(triggers, findsNWidgets(comboCount)); + for (final trigger in tester.widgetList(triggers)) { + expect(trigger.style, isNull); + expect(trigger.onPressed, isNotNull); + } }); test('task selector content mirrors with text direction', () { @@ -135,14 +163,6 @@ void main() { ), ); - expect( - tester.getSize(_headerButtonFinder(tester, 'Cancel')).width, - lessThan(kPushButtonSize.width), - ); - expect( - tester.getSize(_headerButtonFinder(tester, 'Save')).width, - lessThan(kPushButtonSize.width), - ); expect( tester.getSize(_headerButtonFinder(tester, 'Cancel')).height, kYaruButtonHeight, diff --git a/test/platform/native_menu_service_test.dart b/test/platform/native_menu_service_test.dart new file mode 100644 index 0000000..f4478bb --- /dev/null +++ b/test/platform/native_menu_service_test.dart @@ -0,0 +1,179 @@ +import 'package:busymax/src/platform/native_menu_service.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + const channel = MethodChannel('busymax_test/native_menus'); + + tearDown(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, null); + }); + + test('sends the anchor and semantic entries to the native host', () async { + MethodCall? receivedCall; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + receivedCall = call; + return 1; + }); + const service = NativeMenuService(channel: channel); + final session = NativeMenuSession(); + + final result = await service.show( + session: session, + anchor: const Rect.fromLTWH(24, 36, 140, 34), + entries: const [ + NativeMenuEntry(label: 'Personal'), + NativeMenuEntry(label: 'Work'), + NativeMenuEntry(label: 'Archived', enabled: false), + ], + ); + + expect(result.available, isTrue); + expect(result.selectedIndex, 1); + expect(receivedCall?.method, 'show'); + expect(receivedCall?.arguments, { + 'sessionId': session.id, + 'anchor': {'x': 24.0, 'y': 36.0, 'width': 140.0, 'height': 34.0}, + 'entries': [ + {'label': 'Personal', 'enabled': true, 'selected': false}, + {'label': 'Work', 'enabled': true, 'selected': false}, + {'label': 'Archived', 'enabled': false, 'selected': false}, + ], + 'focusFirst': false, + }); + }); + + test('can request keyboard focus for the first native menu entry', () async { + MethodCall? receivedCall; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + receivedCall = call; + return null; + }); + const service = NativeMenuService(channel: channel); + + await service.show( + session: NativeMenuSession(), + anchor: const Rect.fromLTWH(0, 0, 100, 34), + entries: const [NativeMenuEntry(label: 'Event')], + focusFirst: true, + ); + + expect(receivedCall?.arguments, containsPair('focusFirst', true)); + }); + + test('distinguishes menu dismissal from an unavailable host', () async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (_) async => null); + const service = NativeMenuService(channel: channel); + + final result = await service.show( + session: NativeMenuSession(), + anchor: const Rect.fromLTWH(0, 0, 100, 34), + entries: const [NativeMenuEntry(label: 'Event')], + ); + + expect(result.available, isTrue); + expect(result.selectedIndex, isNull); + }); + + test('reports unavailable when the native channel is missing', () async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler( + channel, + (_) async => throw MissingPluginException(), + ); + const service = NativeMenuService(channel: channel); + + final result = await service.show( + session: NativeMenuSession(), + anchor: const Rect.fromLTWH(0, 0, 100, 34), + entries: const [NativeMenuEntry(label: 'Event')], + ); + + expect(result.available, isFalse); + expect(result.selectedIndex, isNull); + }); + + test('reports unavailable when the native host is unavailable', () async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler( + channel, + (_) async => throw PlatformException(code: 'unavailable'), + ); + const service = NativeMenuService(channel: channel); + + final result = await service.show( + session: NativeMenuSession(), + anchor: const Rect.fromLTWH(0, 0, 100, 34), + entries: const [NativeMenuEntry(label: 'Event')], + ); + + expect(result.available, isFalse); + expect(result.selectedIndex, isNull); + }); + + test('surfaces native menu protocol failures', () async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler( + channel, + (_) async => throw PlatformException(code: 'invalid-arguments'), + ); + const service = NativeMenuService(channel: channel); + + expect( + () => service.show( + session: NativeMenuSession(), + anchor: const Rect.fromLTWH(0, 0, 100, 34), + entries: const [NativeMenuEntry(label: 'Event')], + ), + throwsA( + isA().having( + (error) => error.code, + 'code', + 'invalid-arguments', + ), + ), + ); + }); + + test('asks the native host to dismiss only its own menu', () async { + MethodCall? receivedCall; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + receivedCall = call; + return true; + }); + const service = NativeMenuService(channel: channel); + final session = NativeMenuSession(); + + expect(await service.dismiss(session), isTrue); + expect(receivedCall?.method, 'dismiss'); + expect(receivedCall?.arguments, {'sessionId': session.id}); + }); + + test('dismiss is safe when native menus are unavailable', () async { + const service = NativeMenuService(channel: channel); + final messenger = + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger; + + messenger.setMockMethodCallHandler( + channel, + (_) async => throw MissingPluginException(), + ); + expect(await service.dismiss(NativeMenuSession()), isFalse); + + messenger.setMockMethodCallHandler( + channel, + (_) async => throw PlatformException(code: 'unavailable'), + ); + expect(await service.dismiss(NativeMenuSession()), isFalse); + + messenger.setMockMethodCallHandler(channel, (_) async => null); + expect(await service.dismiss(NativeMenuSession()), isFalse); + }); +} From c8e04fef6e22f720ad3a70a3a03cac083e9179dd Mon Sep 17 00:00:00 2001 From: albert Date: Sat, 25 Jul 2026 12:46:19 -0700 Subject: [PATCH 13/73] Refactor UI components for consistency and accessibility. Replace YaruIconButton with BusyMaxHeaderIconButton across multiple files. Update color handling in BusyMaxSurfaceColors and improve theme integration with high contrast settings. Enhance semantic roles for better visual clarity and user experience. --- lib/src/app/busymax_app.dart | 3 + lib/src/app/busymax_design.dart | 731 +++++++++--------- lib/src/app/busymax_surface_colors.dart | 110 ++- lib/src/app/busymax_yaru_theme.dart | 327 ++++---- lib/src/app/system_accent.dart | 140 +++- .../event_description_editor.dart | 19 +- .../calendar/presentation/event_editor.dart | 10 +- .../presentation/compact_agenda_panel.dart | 8 +- .../schedule/presentation/mini_calendar.dart | 12 +- .../presentation/schedule_day_week_view.dart | 10 +- .../presentation/schedule_month_view.dart | 4 +- .../presentation/schedule_sidebar.dart | 20 +- .../presentation/settings_screen.dart | 37 +- lib/src/platform/gtk_font_service.dart | 5 + .../platform/linux_header_bar_service.dart | 15 + linux/runner/my_application.cc | 265 ++++--- test/app/busymax_grouped_surface_test.dart | 599 ++++++++++++-- test/app/busymax_menu_button_test.dart | 35 +- test/app/high_contrast_theme_test.dart | 38 + test/app/native_ui_audit_test.dart | 208 +++-- test/app/system_accent_test.dart | 112 +++ test/app/theme_localization_test.dart | 577 +++++++++----- test/demo/demo_profile_test.dart | 4 +- .../presentation/event_editor_test.dart | 93 ++- .../presentation/feedback_dialog_test.dart | 3 +- .../presentation/schedule_views_test.dart | 129 +++- .../presentation/task_details_pane_test.dart | 100 ++- test/platform/gtk_font_service_test.dart | 2 + ...r_bar_configuration_synchronizer_test.dart | 3 + .../linux_header_bar_service_test.dart | 49 ++ 30 files changed, 2588 insertions(+), 1080 deletions(-) diff --git a/lib/src/app/busymax_app.dart b/lib/src/app/busymax_app.dart index cf32378..0f1f9dc 100644 --- a/lib/src/app/busymax_app.dart +++ b/lib/src/app/busymax_app.dart @@ -231,6 +231,7 @@ class _BusyMaxAppState extends ConsumerState { sidebarWidth: BusyMaxSizes.sidebarWidth, theme: BusyMaxHeaderBarTheme( preferDark: preferDark, + highContrast: MediaQuery.highContrastOf(context), windowBackgroundColor: colors.window, // This header is deliberately borderless and visually continuous // with the main pane, so it uses the flat header role. @@ -238,6 +239,8 @@ class _BusyMaxAppState extends ConsumerState { sidebarBackgroundColor: colors.sidebar, foregroundColor: colors.foreground, sidebarBorderColor: colors.sidebarBorder, + popoverBackgroundColor: colors.popover, + floatingBorderColor: colors.floatingBorder, modalBarrierColor: modalBarrierColor, ), ), diff --git a/lib/src/app/busymax_design.dart b/lib/src/app/busymax_design.dart index bc14725..ce9398e 100644 --- a/lib/src/app/busymax_design.dart +++ b/lib/src/app/busymax_design.dart @@ -36,7 +36,6 @@ abstract final class BusyMaxSizes { static const double detailsWidth = 700; static const double compactDetailsWidth = 700; static const double comboWidth = 220; - static const double comboMinWidth = 120; static const double toolbarHeight = kYaruTitleBarHeight; static const double sidebarRowHeight = 36; static const double taskRowMinHeight = 48; @@ -53,9 +52,7 @@ abstract final class BusyMaxSizes { } abstract final class BusyMaxFormLayout { - static const double comboStackBreakpoint = 560; static const double comboInlineMaxFraction = 0.46; - static const double comboLargeTextScale = 1.2; } abstract final class BusyMaxElevation { @@ -68,6 +65,8 @@ abstract final class BusyMaxStroke { } abstract final class BusyMaxAlpha { + static const double calendarGridLight = 0.10; + static const double calendarGridDark = 0.06; static const double modalBarrier = 0.32; } @@ -198,27 +197,29 @@ class BusyMaxPopoverSurface extends StatelessWidget { side: arrowSide, alignment: alignment, ); + final paddedChild = Padding( + padding: EdgeInsets.only( + top: arrowSide == BusyMaxPopoverArrowSide.top ? arrowHeight : 0, + bottom: arrowSide == BusyMaxPopoverArrowSide.bottom ? arrowHeight : 0, + ), + child: Padding(padding: padding, child: child), + ); + final surfaceChild = MediaQuery.highContrastOf(context) + ? CustomPaint( + foregroundPainter: _BusyMaxPopoverOutlinePainter( + clipper: clipper, + color: BusyMaxSurfaceColors.of(context).floatingBorder, + ), + child: paddedChild, + ) + : paddedChild; return PhysicalShape( clipper: clipper, color: color, elevation: BusyMaxElevation.tooltip, shadowColor: BusyMaxShadow.physicalColor(context), clipBehavior: Clip.antiAlias, - child: CustomPaint( - foregroundPainter: _BusyMaxPopoverOutlinePainter( - clipper: clipper, - color: BusyMaxSurfaceColors.of(context).floatingBorder, - ), - child: Padding( - padding: EdgeInsets.only( - top: arrowSide == BusyMaxPopoverArrowSide.top ? arrowHeight : 0, - bottom: arrowSide == BusyMaxPopoverArrowSide.bottom - ? arrowHeight - : 0, - ), - child: Padding(padding: padding, child: child), - ), - ), + child: surfaceChild, ); } } @@ -308,11 +309,66 @@ RoundedRectangleBorder busyMaxHeaderButtonShape() { ); } -ButtonStyle busyMaxHeaderIconButtonStyle({ +/// An icon button whose BusyMax semantic state style remains authoritative. +/// +/// `YaruIconButton` supplies excellent defaults, but in Yaru 10.2 its +/// internally complete [ButtonStyle] is merged ahead of the caller's style. +/// That makes caller-provided foreground, background, and overlay state +/// properties unreachable. This adapter keeps Flutter's native button +/// interaction model and Yaru's optional keyboard-focus border while allowing +/// the shared BusyMax header style to resolve those semantic states. +class BusyMaxHeaderIconButton extends StatelessWidget { + const BusyMaxHeaderIconButton({ + super.key, + required this.icon, + required this.onPressed, + this.tooltip, + this.iconSize = BusyMaxSizes.headerIcon, + this.foregroundColor, + this.backgroundColor, + this.overlayColor, + }); + + final Widget icon; + final VoidCallback? onPressed; + final String? tooltip; + final double iconSize; + final Color? foregroundColor; + final WidgetStateProperty? backgroundColor; + final WidgetStateProperty? overlayColor; + + @override + Widget build(BuildContext context) { + final button = IconButton( + tooltip: tooltip, + icon: icon, + iconSize: iconSize, + onPressed: onPressed, + style: busyMaxHeaderIconButtonStyle( + context, + foregroundColor: foregroundColor, + backgroundColor: backgroundColor, + overlayColor: overlayColor, + ), + ); + return YaruTheme.maybeOf(context)?.focusBorders == true + ? YaruFocusBorder.primary( + borderRadius: BorderRadius.circular(100), + child: button, + ) + : button; + } +} + +ButtonStyle busyMaxHeaderIconButtonStyle( + BuildContext context, { Color? foregroundColor, WidgetStateProperty? backgroundColor, WidgetStateProperty? overlayColor, }) { + final disabledForeground = BusyMaxSurfaceColors.of( + context, + ).disabledForeground; return ButtonStyle( fixedSize: const WidgetStatePropertyAll( Size.square(BusyMaxSizes.headerIconButton), @@ -325,9 +381,11 @@ ButtonStyle busyMaxHeaderIconButtonStyle({ ), padding: const WidgetStatePropertyAll(EdgeInsets.zero), tapTargetSize: MaterialTapTargetSize.shrinkWrap, - foregroundColor: foregroundColor == null - ? null - : WidgetStatePropertyAll(foregroundColor), + foregroundColor: WidgetStateProperty.resolveWith((states) { + return states.contains(WidgetState.disabled) + ? disabledForeground + : foregroundColor; + }), backgroundColor: backgroundColor, overlayColor: overlayColor, side: const WidgetStatePropertyAll(BorderSide.none), @@ -335,11 +393,15 @@ ButtonStyle busyMaxHeaderIconButtonStyle({ ); } -ButtonStyle busyMaxHeaderTextButtonStyle({ +ButtonStyle busyMaxHeaderTextButtonStyle( + BuildContext context, { Color? foregroundColor, WidgetStateProperty? backgroundColor, WidgetStateProperty? overlayColor, }) { + final disabledForeground = BusyMaxSurfaceColors.of( + context, + ).disabledForeground; return ButtonStyle( minimumSize: const WidgetStatePropertyAll( Size(BusyMaxSizes.headerIconButton, BusyMaxSizes.headerIconButton), @@ -348,9 +410,11 @@ ButtonStyle busyMaxHeaderTextButtonStyle({ EdgeInsets.symmetric(horizontal: BusyMaxSpacing.md), ), tapTargetSize: MaterialTapTargetSize.shrinkWrap, - foregroundColor: foregroundColor == null - ? null - : WidgetStatePropertyAll(foregroundColor), + foregroundColor: WidgetStateProperty.resolveWith((states) { + return states.contains(WidgetState.disabled) + ? disabledForeground + : foregroundColor; + }), backgroundColor: backgroundColor, overlayColor: overlayColor, side: const WidgetStatePropertyAll(BorderSide.none), @@ -384,7 +448,8 @@ WidgetStateProperty busyMaxHeaderButtonBackground( if (states.contains(WidgetState.disabled)) { return surfaceColors.disabledControl; } - if (states.contains(WidgetState.pressed)) { + if (states.contains(WidgetState.pressed) || + states.contains(WidgetState.selected)) { return surfaceColors.controlActive; } if (states.contains(WidgetState.hovered) || @@ -674,6 +739,24 @@ Color busyMaxPanelBorder(BuildContext context) { return Theme.of(context).colorScheme.outlineVariant; } +/// A low-emphasis separator for repeated calendar cells and time slots. +/// +/// GTK's generic separator can be recessed (darker than its surface), which +/// is appropriate for native list separators but makes a dense dark calendar +/// grid look black. Calendar grids instead use a subtle foreground tint, while +/// high-contrast themes retain their full-strength semantic outline. +Color busyMaxCalendarGridColor(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + if (colorScheme.isHighContrast) { + return colorScheme.outlineVariant; + } + return colorScheme.onSurface.withValues( + alpha: colorScheme.brightness == Brightness.dark + ? BusyMaxAlpha.calendarGridDark + : BusyMaxAlpha.calendarGridLight, + ); +} + TextStyle? busyMaxSectionHeaderStyle(BuildContext context) { final theme = Theme.of(context); return theme.textTheme.titleSmall?.copyWith( @@ -795,26 +878,41 @@ class BusyMaxSurface extends StatelessWidget { required this.child, this.filled = true, this.color, - this.side = BorderSide.none, + this.side, this.clipBehavior = Clip.antiAlias, }); final Widget child; final bool filled; final Color? color; - final BorderSide side; + final BorderSide? side; final Clip clipBehavior; @override Widget build(BuildContext context) { - final borderRadius = BorderRadius.circular(BusyMaxRadius.md); + final cardTheme = CardTheme.of(context); final surfaceColors = BusyMaxSurfaceColors.of(context); + final fallbackShape = RoundedRectangleBorder( + borderRadius: BorderRadius.circular(BusyMaxRadius.md), + ); + final themedShape = cardTheme.shape; + final ShapeBorder shape; + if (themedShape is OutlinedBorder) { + shape = side == null ? themedShape : themedShape.copyWith(side: side); + } else if (themedShape != null && side == null) { + shape = themedShape; + } else { + shape = fallbackShape.copyWith(side: side ?? BorderSide.none); + } return Material( - color: filled ? color ?? surfaceColors.card : Colors.transparent, - elevation: filled ? BusyMaxElevation.card : 0, - shadowColor: BusyMaxShadow.physicalColor(context), - surfaceTintColor: Colors.transparent, - shape: RoundedRectangleBorder(borderRadius: borderRadius, side: side), + color: filled + ? color ?? cardTheme.color ?? surfaceColors.card + : Colors.transparent, + elevation: filled ? cardTheme.elevation ?? BusyMaxElevation.card : 0, + shadowColor: + cardTheme.shadowColor ?? BusyMaxShadow.physicalColor(context), + surfaceTintColor: cardTheme.surfaceTintColor ?? Colors.transparent, + shape: shape, clipBehavior: clipBehavior, child: child, ); @@ -833,13 +931,11 @@ class BusyMaxGroupedSurface extends StatelessWidget { @override Widget build(BuildContext context) { - final surfaceColors = BusyMaxSurfaceColors.of(context); final highContrast = MediaQuery.highContrastOf(context); return BusyMaxSurface( - color: surfaceColors.groupedSurface, side: highContrast ? BorderSide(color: Theme.of(context).colorScheme.outline) - : BorderSide.none, + : null, clipBehavior: clipBehavior, child: child, ); @@ -969,7 +1065,7 @@ class _BusyMaxGroupedListSurface extends StatelessWidget { for (var index = 0; index < children.length; index++) ...[ children[index], if (index < children.length - 1) - Divider(height: 1, thickness: 1, color: surfaceColors.divider), + Divider(height: 1, thickness: 1, color: surfaceColors.cardShade), ], ], ); @@ -1034,8 +1130,13 @@ class _BusyMaxActionRowState extends State { @override Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; + final colors = BusyMaxSurfaceColors.of(context); final titleStyle = widget.destructive - ? TextStyle(color: colorScheme.error) + ? TextStyle( + color: widget.enabled + ? colorScheme.error + : colors.disabledForeground, + ) : null; final subtitle = widget.subtitleWidget ?? @@ -1511,35 +1612,40 @@ class BusyMaxCalendarNotesCard extends StatelessWidget { } } -/// A controlled single-selection trigger backed by the host toolkit menu. +/// A single-selection row following the native AdwComboRow interaction model. /// -/// Linux presents a real GTK menu. If the native bridge is unavailable, the -/// centralized Yaru-themed fallback is used without changing domain behavior. -class BusyMaxComboBox extends StatefulWidget { - BusyMaxComboBox({ +/// The whole row owns hover, focus, and activation. Its current value remains +/// plain trailing content, while menu presentation is delegated to the shared +/// host-toolkit adapter. +class BusyMaxComboRow extends StatelessWidget { + BusyMaxComboRow({ super.key, + required this.title, required List values, required this.selected, required this.labelFor, required this.onSelected, - required this.width, + this.subtitle, + this.errorText, + this.leading, this.enabled = true, this.tooltip, - this.leadingBuilder, - this.nativeMenuService = const NativeMenuService(), + this.width = BusyMaxSizes.comboWidth, + this.trailingAction, + this.selectorLeadingBuilder, }) : values = List.unmodifiable(values) { if (this.values.isEmpty) { throw ArgumentError.value( values, 'values', - 'A combo box requires at least one value.', + 'A combo row requires at least one value.', ); } if (this.values.toSet().length != this.values.length) { throw ArgumentError.value( values, 'values', - 'A combo box requires unique values.', + 'A combo row requires unique values.', ); } if (!this.values.contains(selected)) { @@ -1549,192 +1655,15 @@ class BusyMaxComboBox extends StatefulWidget { 'The selected value must be present in values.', ); } - } - - final List values; - final T selected; - final String Function(T value) labelFor; - final ValueChanged onSelected; - final double width; - final bool enabled; - final String? tooltip; - final Widget Function(BuildContext context, T value)? leadingBuilder; - final NativeMenuService nativeMenuService; - - @override - State> createState() => _BusyMaxComboBoxState(); -} - -class _BusyMaxComboBoxState extends State> { - final _triggerKey = GlobalKey(); - late final FocusNode _triggerFocusNode; - BusyMaxMenuSession? _activeMenuSession; - bool _menuOpen = false; - - @override - void initState() { - super.initState(); - _triggerFocusNode = FocusNode( - debugLabel: 'BusyMax combo trigger', - onKeyEvent: _handleTriggerKeyEvent, - ); - } - - @override - void didUpdateWidget(covariant BusyMaxComboBox oldWidget) { - super.didUpdateWidget(oldWidget); - if (oldWidget.enabled && !widget.enabled) { - _dismissMenu(); - } - } - - @override - void dispose() { - final session = _activeMenuSession; - _activeMenuSession = null; - if (session != null) { - unawaited(session.dismiss()); - } - _triggerFocusNode.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - return Builder( - builder: (triggerContext) { - final selector = SizedBox( - key: _triggerKey, - width: widget.width, - child: Semantics( - expanded: _menuOpen, - child: BusyMaxPushButton.standard( - onPressed: widget.enabled - ? () => _openMenu(triggerContext, focusFirst: false) - : null, - focusNode: _triggerFocusNode, - child: Row( - children: [ - if (widget.leadingBuilder?.call(context, widget.selected) - case final leading?) ...[ - leading, - const SizedBox(width: BusyMaxSpacing.sm), - ], - Expanded( - child: Text( - widget.labelFor(widget.selected), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ), - Icon(_menuOpen ? YaruIcons.pan_up : YaruIcons.pan_down), - ], - ), - ), - ), - ); - return widget.tooltip == null - ? selector - : Tooltip( - message: widget.tooltip!, - excludeFromSemantics: true, - child: selector, - ); - }, - ); - } - - KeyEventResult _handleTriggerKeyEvent(FocusNode node, KeyEvent event) { - if (!widget.enabled || event is! KeyDownEvent) { - return KeyEventResult.ignored; - } - final key = event.logicalKey; - if (key != LogicalKeyboardKey.arrowDown && - key != LogicalKeyboardKey.enter && - key != LogicalKeyboardKey.space) { - return KeyEventResult.ignored; - } - final triggerContext = _triggerKey.currentContext; - if (!_menuOpen && triggerContext != null) { - unawaited(_openMenu(triggerContext, focusFirst: true)); - } - return KeyEventResult.handled; - } - - Future _openMenu( - BuildContext triggerContext, { - required bool focusFirst, - }) async { - if (!widget.enabled || _menuOpen) { - return; - } - final values = List.unmodifiable(widget.values); - final selected = widget.selected; - final labelFor = widget.labelFor; - final onSelected = widget.onSelected; - final nativeMenuService = widget.nativeMenuService; - final session = BusyMaxMenuSession(); - _activeMenuSession = session; - setState(() => _menuOpen = true); - BusyMaxMenuSelection? selection; - try { - selection = await showBusyMaxMenu( - context: context, - anchorContext: triggerContext, - entries: [ - for (final value in values) - BusyMaxMenuEntry( - value: value, - label: labelFor(value), - selected: value == selected, - ), - ], - nativeMenuService: nativeMenuService, - session: session, - focusFirst: focusFirst, + if (!width.isFinite || width <= 0) { + throw ArgumentError.value( + width, + 'width', + 'The maximum value width must be finite and positive.', ); - } finally { - if (mounted && identical(_activeMenuSession, session)) { - setState(() { - _activeMenuSession = null; - _menuOpen = false; - }); - } - } - if (mounted && - !session._isDismissed && - selection != null && - selection.value != selected) { - onSelected(selection.value); } } - void _dismissMenu() { - final session = _activeMenuSession; - if (session != null) { - unawaited(session.dismiss()); - } - } -} - -class BusyMaxComboRow extends StatelessWidget { - const BusyMaxComboRow({ - super.key, - required this.title, - required this.values, - required this.selected, - required this.labelFor, - required this.onSelected, - this.subtitle, - this.errorText, - this.leading, - this.enabled = true, - this.tooltip, - this.width = BusyMaxSizes.comboWidth, - this.trailingAction, - this.selectorLeadingBuilder, - }); - final String title; final List values; final T selected; @@ -1755,13 +1684,10 @@ class BusyMaxComboRow extends StatelessWidget { builder: (context, constraints) { final hasError = errorText?.isNotEmpty ?? false; final subtitleWidget = hasError - ? Semantics( - liveRegion: true, - child: Text( - errorText!, - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: Theme.of(context).colorScheme.error, - ), + ? Text( + errorText!, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context).colorScheme.error, ), ) : subtitle == null @@ -1774,122 +1700,136 @@ class BusyMaxComboRow extends StatelessWidget { subtitleWidget, enabled: enabled, ); - final bodyFontSize = Theme.of(context).textTheme.bodyMedium?.fontSize; - final textScale = bodyFontSize == null - ? 1.0 - : MediaQuery.textScalerOf(context).scale(bodyFontSize) / - bodyFontSize; final actionAllowance = trailingAction == null ? 0.0 : BusyMaxSizes.headerIconButton + BusyMaxSpacing.xs; - final stackControl = - !constraints.hasBoundedWidth || - constraints.maxWidth < BusyMaxFormLayout.comboStackBreakpoint || - textScale > BusyMaxFormLayout.comboLargeTextScale; final availableWidth = constraints.hasBoundedWidth ? constraints.maxWidth : width + BusyMaxSpacing.md * 2 + actionAllowance; - final maximumInlineSelectorWidth = - (availableWidth * BusyMaxFormLayout.comboInlineMaxFraction) - .clamp(BusyMaxSizes.comboMinWidth, double.infinity) + final maximumValueWidth = + (availableWidth * BusyMaxFormLayout.comboInlineMaxFraction - + actionAllowance) + .clamp(0.0, width) .toDouble(); - final selectorWidth = stackControl - ? (availableWidth - BusyMaxSpacing.md * 2 - actionAllowance) - .clamp(BusyMaxSizes.comboMinWidth, double.infinity) - .toDouble() - : constraints.hasBoundedWidth - ? width - .clamp(BusyMaxSizes.comboMinWidth, maximumInlineSelectorWidth) - .toDouble() - : width - .clamp(BusyMaxSizes.comboMinWidth, double.infinity) - .toDouble(); - final selector = BusyMaxComboBox( - width: selectorWidth, + final menuButton = BusyMaxMenuButton( tooltip: tooltip ?? title, - values: values, - selected: selected, - labelFor: labelFor, - onSelected: onSelected, - enabled: enabled, - leadingBuilder: selectorLeadingBuilder, - ); - final trailing = Row( - mainAxisSize: MainAxisSize.min, - children: [ - selector, - if (trailingAction != null) ...[ - const SizedBox(width: BusyMaxSpacing.xs), - trailingAction!, - ], + entries: [ + for (final value in values) + BusyMaxMenuEntry( + value: value, + label: labelFor(value), + selected: value == selected, + ), ], - ); - final row = stackControl - ? Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - YaruListTile.square( - leading: leading, - titleText: title, - subtitle: styledSubtitle, - enabled: enabled, - ), - Padding( - padding: const EdgeInsets.fromLTRB( - BusyMaxSpacing.md, - 0, - BusyMaxSpacing.md, - BusyMaxSpacing.md, + onSelected: (value) { + if (value != selected) { + onSelected(value); + } + }, + enabled: enabled, + triggerBuilder: (context, trigger) { + final colors = BusyMaxSurfaceColors.of(context); + final valueForeground = enabled + ? colors.foreground + : colors.disabledForeground; + final value = ExcludeSemantics( + child: ConstrainedBox( + constraints: BoxConstraints(maxWidth: maximumValueWidth), + child: DefaultTextStyle.merge( + style: Theme.of( + context, + ).textTheme.bodyMedium?.copyWith(color: valueForeground), + child: IconTheme.merge( + data: IconThemeData( + color: valueForeground, + size: BusyMaxSizes.iconSm, + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (selectorLeadingBuilder?.call(context, selected) + case final selectedLeading?) ...[ + selectedLeading, + const SizedBox(width: BusyMaxSpacing.sm), + ], + Flexible( + child: Text( + labelFor(selected), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + const SizedBox(width: BusyMaxSpacing.sm), + trigger.anchor(child: const Icon(YaruIcons.pan_down)), + ], ), - child: trailing, ), - ], - ) - : YaruListTile.square( - leading: leading, - titleText: title, - subtitle: styledSubtitle, - trailing: trailing, - enabled: enabled, - ); - // YaruListTile deliberately expands its title inside a Row, so it - // requires a finite horizontal constraint. If dialog content is - // measured unbounded, use the compact stacked form and only reserve - // the selector's requested width plus the tile padding. - final boundedRow = constraints.hasBoundedWidth - ? row - : SizedBox(width: availableWidth, child: row); - final validatedRow = hasError - ? Semantics( - container: true, - validationResult: ui.SemanticsValidationResult.invalid, - child: boundedRow, - ) - : boundedRow; - - if (enabled) { - return validatedRow; - } - - final disabledRow = Semantics( - container: true, - button: true, - enabled: false, - label: subtitle == null || subtitle!.isEmpty - ? title - : '$title, $subtitle', - value: labelFor(selected), - child: ExcludeSemantics( - child: ExcludeFocus(child: IgnorePointer(child: validatedRow)), - ), + ), + ), + ); + final row = YaruListTile.square( + leading: leading == null + ? null + : ExcludeSemantics(child: leading!), + title: ExcludeSemantics(child: Text(title)), + subtitle: styledSubtitle == null + ? null + : ExcludeSemantics(child: styledSubtitle), + trailing: value, + onTap: trigger.onPressed, + focusNode: trigger.focusNode, + hoverColor: busyMaxRowHoverColor(context), + enabled: enabled, + ); + final semanticRow = Semantics( + container: true, + button: true, + enabled: enabled, + expanded: trigger.isOpen, + onTap: enabled ? trigger.onPressed : null, + label: subtitle == null || subtitle!.isEmpty + ? title + : '$title, $subtitle', + value: labelFor(selected), + hint: hasError ? errorText : null, + liveRegion: hasError, + validationResult: hasError + ? ui.SemanticsValidationResult.invalid + : ui.SemanticsValidationResult.valid, + child: ExcludeSemantics(child: row), + ); + final interactionRow = trailingAction == null + ? semanticRow + : Row( + children: [ + Expanded(child: semanticRow), + Padding( + padding: const EdgeInsetsDirectional.only( + end: BusyMaxSpacing.md, + ), + child: trailingAction!, + ), + ], + ); + final statefulRow = ColoredBox( + color: trigger.isOpen + ? busyMaxRowHoverColor(context) + : Colors.transparent, + child: interactionRow, + ); + final boundedRow = constraints.hasBoundedWidth + ? statefulRow + : SizedBox(width: availableWidth, child: statefulRow); + return tooltip == null + ? boundedRow + : Tooltip( + message: tooltip!, + excludeFromSemantics: true, + child: boundedRow, + ); + }, ); - return tooltip == null - ? disabledRow - : Tooltip( - message: tooltip!, - excludeFromSemantics: true, - child: disabledRow, - ); + return menuButton; }, ); } @@ -2025,7 +1965,7 @@ final class BusyMaxMenuSession { } } -/// Presents a semantic menu at [anchorContext] or [anchorPoint]. +/// Presents a semantic menu at [anchorRect], [anchorContext], or [anchorPoint]. /// /// Linux delegates the menu surface, rows, focus, keyboard navigation, and /// dismissal to GTK. The Flutter route exists only for hosts where that @@ -2035,10 +1975,15 @@ Future?> showBusyMaxMenu({ required List> entries, BuildContext? anchorContext, Offset? anchorPoint, + Rect? anchorRect, NativeMenuService nativeMenuService = const NativeMenuService(), BusyMaxMenuSession? session, bool focusFirst = false, }) async { + assert( + anchorRect == null || (anchorContext == null && anchorPoint == null), + 'anchorRect cannot be combined with anchorContext or anchorPoint.', + ); if (entries.isEmpty) { return null; } @@ -2049,10 +1994,12 @@ Future?> showBusyMaxMenu({ return null; } presentation._beginPresentation(nativeMenuService); - final anchor = _busyMaxMenuAnchorRect( - anchorContext ?? context, - anchorPoint: anchorPoint, - ); + final anchor = + anchorRect ?? + _busyMaxMenuAnchorRect( + anchorContext ?? context, + anchorPoint: anchorPoint, + ); final nativeResult = await nativeMenuService.show( session: presentation._nativeSession, anchor: anchor, @@ -2110,6 +2057,21 @@ Rect _busyMaxMenuAnchorRect(BuildContext anchorContext, {Offset? anchorPoint}) { return renderObject.localToGlobal(Offset.zero) & renderObject.size; } +Rect _busyMaxMenuControlAnchorRect({ + required BuildContext triggerContext, + required BuildContext? affordanceContext, +}) { + final triggerRect = _busyMaxMenuAnchorRect(triggerContext); + if (affordanceContext == null) { + return triggerRect; + } + final affordanceRect = _busyMaxMenuAnchorRect(affordanceContext); + if (triggerRect.isEmpty || affordanceRect.isEmpty) { + return triggerRect; + } + return affordanceRect; +} + List _nativeMenuEntries(List> entries) { return [ for (final entry in entries) @@ -2274,11 +2236,35 @@ Widget _busyMaxFallbackMenuEntry( } typedef BusyMaxMenuTriggerBuilder = - Widget Function( - BuildContext context, - VoidCallback? onPressed, - FocusNode focusNode, - ); + Widget Function(BuildContext context, BusyMaxMenuTriggerDetails trigger); + +/// Interaction and geometry supplied to a custom [BusyMaxMenuButton] trigger. +/// +/// The trigger may make a larger control clickable while marking the native +/// relative widget with [anchor]. Menus are positioned from that widget's +/// exact geometry, matching `GtkPopover.relative-to`. +@immutable +class BusyMaxMenuTriggerDetails { + const BusyMaxMenuTriggerDetails._({ + required this.onPressed, + required this.focusNode, + required this.isOpen, + required GlobalKey menuAnchorKey, + }) : _menuAnchorKey = menuAnchorKey; + + final VoidCallback? onPressed; + final FocusNode focusNode; + final bool isOpen; + final GlobalKey _menuAnchorKey; + + /// Marks the visual affordance used as the menu's native relative widget. + /// + /// A custom trigger should call this exactly once. When omitted, the shared + /// menu adapter safely falls back to the complete trigger bounds. + Widget anchor({required Widget child}) { + return KeyedSubtree(key: _menuAnchorKey, child: child); + } +} /// Controls keyboard-driven opening of a [BusyMaxMenuButton]. /// @@ -2363,6 +2349,7 @@ class BusyMaxMenuButton extends StatefulWidget { class _BusyMaxMenuButtonState extends State> { final _triggerKey = GlobalKey(); + final _menuAnchorKey = GlobalKey(); late final FocusNode _triggerFocusNode; BusyMaxMenuSession? _activeMenuSession; bool _menuOpen = false; @@ -2404,21 +2391,28 @@ class _BusyMaxMenuButtonState extends State> { @override Widget build(BuildContext context) { final triggerBuilder = widget.triggerBuilder; + final triggerDetails = BusyMaxMenuTriggerDetails._( + onPressed: widget.enabled ? _toggleMenu : null, + focusNode: _triggerFocusNode, + isOpen: _menuOpen, + menuAnchorKey: _menuAnchorKey, + ); final trigger = triggerBuilder != null - ? triggerBuilder( - context, - widget.enabled ? _toggleMenu : null, - _triggerFocusNode, - ) - : YaruIconButton( - tooltip: widget.tooltip, - icon: widget.icon, - focusNode: _triggerFocusNode, - onPressed: widget.enabled ? _toggleMenu : null, + ? triggerBuilder(context, triggerDetails) + : triggerDetails.anchor( + child: YaruIconButton( + tooltip: widget.tooltip, + icon: widget.icon, + focusNode: _triggerFocusNode, + isSelected: _menuOpen, + onPressed: widget.enabled ? _toggleMenu : null, + ), ); return KeyedSubtree( key: _triggerKey, - child: Semantics(expanded: _menuOpen, child: trigger), + child: triggerBuilder == null + ? Semantics(expanded: _menuOpen, child: trigger) + : trigger, ); } @@ -2469,9 +2463,13 @@ class _BusyMaxMenuButtonState extends State> { BusyMaxMenuSelection? selection; try { + final anchor = _busyMaxMenuControlAnchorRect( + triggerContext: triggerContext, + affordanceContext: _menuAnchorKey.currentContext, + ); selection = await showBusyMaxMenu( context: context, - anchorContext: triggerContext, + anchorRect: anchor, entries: entries, nativeMenuService: nativeMenuService, session: session, @@ -2973,6 +2971,7 @@ class BusyMaxModalEditorSurface extends StatelessWidget { @override Widget build(BuildContext context) { + final editorSurface = Theme.of(context).scaffoldBackgroundColor; final effectiveMaxWidth = maxWidth.isFinite ? maxWidth.clamp(0.0, double.infinity).toDouble() : maxWidth; @@ -2987,6 +2986,8 @@ class BusyMaxModalEditorSurface extends StatelessWidget { : maxHeight!.clamp(0.0, double.infinity).toDouble(); return Dialog( + backgroundColor: editorSurface, + surfaceTintColor: editorSurface, insetPadding: insetPadding, insetAnimationDuration: MediaQuery.disableAnimationsOf(context) ? Duration.zero diff --git a/lib/src/app/busymax_surface_colors.dart b/lib/src/app/busymax_surface_colors.dart index 885bb5c..bd5d8c5 100644 --- a/lib/src/app/busymax_surface_colors.dart +++ b/lib/src/app/busymax_surface_colors.dart @@ -1,7 +1,5 @@ import 'package:flutter/material.dart'; -const _dimLabelOpacity = 0.55; - @immutable class BusyMaxSurfaceColors extends ThemeExtension { const BusyMaxSurfaceColors({ @@ -25,6 +23,7 @@ class BusyMaxSurfaceColors extends ThemeExtension { required this.disabledControl, required this.border, required this.divider, + required this.cardShade, required this.floatingBorder, required this.sidebarBorder, required this.shade, @@ -37,6 +36,14 @@ class BusyMaxSurfaceColors extends ThemeExtension { final Color headerbar; final Color headerbarFlat; final Color card; + + /// Source layer for GTK's boxed-list/card role. + /// + /// Modern Yaru publishes this role as a translucent layer in dark mode. + /// It is retained for semantic color resolution, but must not be painted + /// directly by an elevated Flutter [Material]: the physical shadow would + /// show through the translucent fill. Use the opaque [card] paint token for + /// shared card surfaces. final Color groupedSurface; final Color dialog; final Color popover; @@ -50,6 +57,12 @@ class BusyMaxSurfaceColors extends ThemeExtension { final Color disabledControl; final Color border; final Color divider; + + /// Recessed separator used between rows inside a boxed-list/card surface. + /// + /// This is libadwaita's `card_shade_color`, which is intentionally distinct + /// from the generic GTK separator and outline roles. + final Color cardShade; final Color floatingBorder; final Color sidebarBorder; final Color shade; @@ -82,6 +95,7 @@ class BusyMaxSurfaceColors extends ThemeExtension { Color? disabledControl, Color? border, Color? divider, + Color? cardShade, Color? floatingBorder, Color? sidebarBorder, Color? shade, @@ -107,6 +121,7 @@ class BusyMaxSurfaceColors extends ThemeExtension { disabledControl: disabledControl ?? this.disabledControl, border: border ?? this.border, divider: divider ?? this.divider, + cardShade: cardShade ?? this.cardShade, floatingBorder: floatingBorder ?? this.floatingBorder, sidebarBorder: sidebarBorder ?? this.sidebarBorder, shade: shade ?? this.shade, @@ -147,6 +162,7 @@ class BusyMaxSurfaceColors extends ThemeExtension { disabledControl: Color.lerp(disabledControl, other.disabledControl, t)!, border: Color.lerp(border, other.border, t)!, divider: Color.lerp(divider, other.divider, t)!, + cardShade: Color.lerp(cardShade, other.cardShade, t)!, floatingBorder: Color.lerp(floatingBorder, other.floatingBorder, t)!, sidebarBorder: Color.lerp(sidebarBorder, other.sidebarBorder, t)!, shade: Color.lerp(shade, other.shade, t)!, @@ -155,26 +171,37 @@ class BusyMaxSurfaceColors extends ThemeExtension { } BusyMaxSurfaceColors busyMaxFallbackSurfaceColors(Brightness brightness) { + final window = switch (brightness) { + Brightness.light => const Color(0xFFFAFAFA), + Brightness.dark => const Color(0xFF2C2C2C), + }; final foreground = switch (brightness) { - Brightness.light => const Color.fromRGBO(0, 0, 6, 0.80), - Brightness.dark => const Color(0xFFF6F5F4), + Brightness.light => const Color(0xFF3D3D3D), + Brightness.dark => const Color(0xFFF7F7F7), + }; + // Enabled 10–14 px labels consume this role. Keep it opaque so its contrast + // remains stable on every semantic surface instead of stacking alpha on an + // arbitrary view, dialog, or popover background. + final mutedForeground = switch (brightness) { + Brightness.light => const Color(0xFF666666), + Brightness.dark => const Color(0xFFB5B5B5), }; - final mutedForeground = foreground.withValues( - alpha: foreground.a * _dimLabelOpacity, - ); return switch (brightness) { Brightness.light => BusyMaxSurfaceColors( - window: Color(0xFFFAFAFB), + // Modern Yaru/libadwaita semantic surface roles. GTK 3 does not publish + // every modern role, so named theme values replace these fallbacks only + // when the bridge can identify the role and the resolver can read it. + window: window, view: Color(0xFFFFFFFF), - sidebar: Color(0xFFEBEBED), - secondarySidebar: Color(0xFFF3F3F5), - headerbar: Color(0xFFFFFFFF), + sidebar: Color(0xFFEBEBEB), + secondarySidebar: Color(0xFFF0F0F0), + headerbar: Color(0xFFFAFAFA), headerbarFlat: Color(0xFFFFFFFF), card: Color(0xFFFFFFFF), groupedSurface: Color(0xFFFFFFFF), - dialog: Color(0xFFFAFAFB), - popover: Color(0xFFFFFFFF), + dialog: window, + popover: Color(0xFFFAFAFA), // Match Yaru's contained-button ladder. A weaker resting layer makes // standard controls look flat until their hover overlay appears. control: Color.fromRGBO(0, 0, 0, 0.10), @@ -183,41 +210,52 @@ BusyMaxSurfaceColors busyMaxFallbackSurfaceColors(Brightness brightness) { activeToggle: Color(0xFFFFFFFF), foreground: foreground, mutedForeground: mutedForeground, - disabledForeground: Color.fromRGBO(0, 0, 6, 0.38), + // Yaru derives disabled content from the semantic foreground rather + // than from absolute black. Keep the shared fallback on that same role + // so native and Flutter controls resolve to one disabled color. + disabledForeground: foreground.withValues(alpha: 0.38), disabledControl: Color.fromRGBO(0, 0, 0, 0.04), - border: Color.fromRGBO(0, 0, 6, 0.18), - divider: Color.fromRGBO(0, 0, 6, 0.10), - floatingBorder: Color.fromRGBO(0, 0, 6, 0.10), - sidebarBorder: Color.fromRGBO(0, 0, 6, 0.07), - shade: Color.fromRGBO(0, 0, 6, 0.07), + border: Color.fromRGBO(0, 0, 0, 0.18), + divider: Color.fromRGBO(0, 0, 0, 0.10), + cardShade: Color.fromRGBO(24, 24, 24, 0.08), + floatingBorder: Color.fromRGBO(0, 0, 0, 0.10), + sidebarBorder: Color.fromRGBO(24, 24, 24, 0.08), + shade: Color.fromRGBO(0, 0, 0, 0.07), ), Brightness.dark => BusyMaxSurfaceColors( - // Current Yaru/libadwaita semantic surface ladder. GTK 3 cannot expose - // every libadwaita role reliably, so incompatible legacy samples fall - // back to the matching modern surface rather than a hand-tuned shade. - window: Color(0xFF222226), - view: Color(0xFF222226), - sidebar: Color(0xFF2E2E32), - secondarySidebar: Color(0xFF28282C), - headerbar: Color(0xFF2E2E32), - headerbarFlat: Color(0xFF222226), - card: Color(0xFF36363A), - groupedSurface: Color(0xFF36363A), - dialog: Color(0xFF36363A), - popover: Color(0xFF36363A), + // Modern Yaru/libadwaita semantic surface roles. In particular, native + // floating surfaces are raised neutral grays rather than the near-black + // widget-class colors reported by legacy GTK 3 sampling. + window: window, + view: Color(0xFF272727), + sidebar: Color(0xFF393939), + secondarySidebar: Color(0xFF323232), + headerbar: Color(0xFF393939), + headerbarFlat: Color(0xFF272727), + card: Color(0xFF3D3D3D), + // libadwaita/Yaru's card role is a contextual layer, not a fixed dark + // gray. It resolves against the semantic surface that contains it. + groupedSurface: Color.fromRGBO(255, 255, 255, 0.08), + dialog: Color(0xFF3E3E3E), + popover: Color(0xFF3E3E3E), control: Color.fromRGBO(255, 255, 255, 0.10), controlHover: Color.fromRGBO(255, 255, 255, 0.14), controlActive: Color.fromRGBO(255, 255, 255, 0.18), activeToggle: Color.fromRGBO(255, 255, 255, 0.20), foreground: foreground, mutedForeground: mutedForeground, - disabledForeground: Color.fromRGBO(255, 255, 255, 0.38), + disabledForeground: foreground.withValues(alpha: 0.38), disabledControl: Color.fromRGBO(255, 255, 255, 0.06), - border: Color.fromRGBO(0, 0, 6, 0.75), + border: Color.fromRGBO(0, 0, 0, 0.75), divider: Color.fromRGBO(255, 255, 255, 0.10), + // Modern Yaru uses a 36% near-black recessed edge for dark cards. + // Keep the neutral fallback free of the theme's slight blue component. + cardShade: Color.fromRGBO(0, 0, 0, 0.36), floatingBorder: Color.fromRGBO(255, 255, 255, 0.10), - sidebarBorder: Color.fromRGBO(255, 255, 255, 0.10), - shade: Color.fromRGBO(0, 0, 6, 0.25), + // A sidebar boundary is recessed in Yaru, not highlighted. This exact + // fallback mirrors its named semantic role when GTK 3 omits that role. + sidebarBorder: Color.fromRGBO(16, 16, 16, 0.35), + shade: Color.fromRGBO(0, 0, 0, 0.25), ), }; } diff --git a/lib/src/app/busymax_yaru_theme.dart b/lib/src/app/busymax_yaru_theme.dart index ab0a18e..7eacfea 100644 --- a/lib/src/app/busymax_yaru_theme.dart +++ b/lib/src/app/busymax_yaru_theme.dart @@ -8,7 +8,6 @@ import 'busymax_surface_colors.dart'; export 'busymax_surface_colors.dart'; -const _minimumRaisedSurfaceContrast = 1.08; const _minimumControlSurfaceContrast = 1.02; abstract final class BusyMaxLinuxPalette { @@ -44,6 +43,7 @@ class BusyMaxYaruTheme { final colors = highContrast ? _highContrastSurfaceColors(brightness) : resolvedColors; + final surfaceContainers = _surfaceContainerLadder(colors, brightness); final sampledAccentForeground = gtkThemeColors?.brightness == brightness && gtkThemeColors?.accent == accentColor @@ -75,11 +75,13 @@ class BusyMaxYaruTheme { surface: colors.view, onSurface: colors.foreground, onSurfaceVariant: colors.mutedForeground, - surfaceContainerLowest: colors.window, - surfaceContainerLow: colors.view, - surfaceContainer: colors.card, - surfaceContainerHigh: colors.control, - surfaceContainerHighest: colors.controlHover, + // Generic Material surfaces need opaque elevation roles. The translucent + // control ladder belongs exclusively to interactive widget states. + surfaceContainerLowest: surfaceContainers.lowest, + surfaceContainerLow: surfaceContainers.low, + surfaceContainer: surfaceContainers.container, + surfaceContainerHigh: surfaceContainers.high, + surfaceContainerHighest: surfaceContainers.highest, outline: colors.border, outlineVariant: colors.divider, scrim: BusyMaxLinuxPalette.dark5, @@ -125,6 +127,7 @@ class BusyMaxYaruTheme { _yaruDesktopButtonStyle(base.filledButtonTheme.style), foreground: colors.foreground, background: colors.control, + selectedBackground: colors.controlActive, disabledForeground: colors.disabledForeground, disabledBackground: colors.disabledControl, textStyle: _normalizeTextStyleProperty( @@ -157,15 +160,36 @@ class BusyMaxYaruTheme { fallback: textTheme.labelLarge, ), ); + final floatingSurfaceSide = highContrast + ? BorderSide(color: colors.border) + : BorderSide.none; final menuStyle = _semanticMenuSurfaceStyle( base.menuTheme.style, color: colors.popover, shadowColor: colorScheme.shadow, + side: floatingSurfaceSide, ); final dropdownMenuStyle = _semanticMenuSurfaceStyle( base.dropdownMenuTheme.menuStyle, color: colors.popover, shadowColor: colorScheme.shadow, + side: floatingSurfaceSide, + ); + final cardTheme = base.cardTheme.copyWith( + // Elevated Flutter surfaces must be opaque. A translucent card layer + // lets PhysicalShape's shadow show through its own fill on Linux, + // darkening the card well below the native Yaru result. [colors.card] + // is the same semantic GTK layer precomposited over the window/editor + // surface by the resolver. + color: colors.card, + surfaceTintColor: Colors.transparent, + shadowColor: colorScheme.shadow, + elevation: BusyMaxElevation.card, + shape: + base.cardTheme.shape ?? + RoundedRectangleBorder( + borderRadius: BorderRadius.circular(BusyMaxRadius.md), + ), ); return base.copyWith( @@ -176,6 +200,7 @@ class BusyMaxYaruTheme { scaffoldBackgroundColor: colors.window, canvasColor: colors.window, cardColor: colors.card, + cardTheme: cardTheme, extensions: [ for (final extension in base.extensions.values) if (extension is! BusyMaxSurfaceColors) extension, @@ -200,12 +225,7 @@ class BusyMaxYaruTheme { dialogTheme: base.dialogTheme.copyWith( backgroundColor: colors.dialog, surfaceTintColor: colors.dialog, - shape: highContrast - ? _withOutlineSide( - base.dialogTheme.shape, - BorderSide(color: colors.border), - ) - : base.dialogTheme.shape, + shape: _withOutlineSide(base.dialogTheme.shape, floatingSurfaceSide), titleTextStyle: normalizer.apply( base.dialogTheme.titleTextStyle, fallback: textTheme.titleLarge, @@ -321,12 +341,7 @@ class BusyMaxYaruTheme { : colors.foreground, ); }), - shape: highContrast - ? _withOutlineSide( - base.popupMenuTheme.shape, - BorderSide(color: colors.border), - ) - : base.popupMenuTheme.shape, + shape: _withOutlineSide(base.popupMenuTheme.shape, floatingSurfaceSide), ), menuTheme: MenuThemeData( style: menuStyle, @@ -464,6 +479,72 @@ class BusyMaxYaruTheme { } } +({Color lowest, Color low, Color container, Color high, Color highest}) +_surfaceContainerLadder(BusyMaxSurfaceColors colors, Brightness brightness) { + var ladder = [ + colors.view, + colors.window, + colors.secondarySidebar, + colors.secondarySidebar, + colors.sidebar, + ]; + if (!_isOpaqueMonotonicLadder(ladder, brightness)) { + var candidates = [ + colors.view, + colors.window, + colors.secondarySidebar, + colors.sidebar, + ]; + if (candidates.any((color) => color.a < 1)) { + final fallback = busyMaxFallbackSurfaceColors(brightness); + candidates = [ + fallback.view, + fallback.window, + fallback.secondarySidebar, + fallback.sidebar, + ]; + } + candidates.sort((first, second) { + final luminanceOrder = first.computeLuminance().compareTo( + second.computeLuminance(), + ); + if (luminanceOrder != 0) { + return brightness == Brightness.dark ? luminanceOrder : -luminanceOrder; + } + return first.toARGB32().compareTo(second.toARGB32()); + }); + ladder = [ + candidates[0], + candidates[1], + candidates[2], + candidates[2], + candidates[3], + ]; + } + + return ( + lowest: ladder[0], + low: ladder[1], + container: ladder[2], + high: ladder[3], + highest: ladder[4], + ); +} + +bool _isOpaqueMonotonicLadder(List ladder, Brightness brightness) { + if (ladder.any((color) => color.a < 1)) { + return false; + } + for (var index = 0; index < ladder.length - 1; index++) { + final current = ladder[index].computeLuminance(); + final next = ladder[index + 1].computeLuminance(); + if (brightness == Brightness.light ? current < next : current > next) { + return false; + } + } + return true; +} + BusyMaxSurfaceColors _highContrastSurfaceColors(Brightness brightness) { final background = brightness == Brightness.dark ? Colors.black @@ -496,6 +577,7 @@ BusyMaxSurfaceColors _highContrastSurfaceColors(Brightness brightness) { disabledControl: layer(0.06), border: foreground, divider: foreground, + cardShade: foreground, floatingBorder: foreground, sidebarBorder: foreground, shade: Colors.black, @@ -635,6 +717,7 @@ class _BusyMaxResolvedSurfaceColors { _runtimeReadableColor( runtime.foreground, backgrounds: sampledBackgrounds, + requireOpaque: true, ) ?? fallback.foreground; @@ -644,71 +727,46 @@ class _BusyMaxResolvedSurfaceColors { : fallbackSurface; } - // BusyMax currently has one generic foreground role. Preserve compatible - // GTK roles when that foreground remains readable; raised roles receive - // the additional hierarchy validation below. + // The Linux bridge only publishes named GTK semantic roles here; legacy + // widget-class samples are omitted instead of being mislabeled as modern + // surface roles. Preserve every supplied role when the shared foreground + // remains readable. Inferring validity from luminance ordering would + // reject legitimate custom GTK palettes. final window = readableSurface(sampledWindow, fallback.window); final view = readableSurface(sampledView, fallback.view); - final sidebar = _resolvedSidebarSurface( - runtimeSidebar, - brightness: brightness, - parent: window, - adjacent: view, - foreground: foreground, - fallback: fallback.sidebar, - ); - final secondarySidebar = _resolvedRaisedSurface( - runtimeSecondarySidebar, - brightness: brightness, - parent: window, - foreground: foreground, - fallback: fallback.secondarySidebar, - ); - final headerbar = _resolvedRaisedSurface( - runtimeHeaderbar, - brightness: brightness, - parent: window, - foreground: foreground, - fallback: fallback.headerbar, + final sidebar = readableSurface(sampledSidebar, fallback.sidebar); + final secondarySidebar = readableSurface( + sampledSecondarySidebar, + fallback.secondarySidebar, ); + final headerbar = readableSurface(sampledHeaderbar, fallback.headerbar); final headerbarFlat = readableSurface( sampledHeaderbarFlat, fallback.headerbarFlat, ); - final card = _resolvedRaisedSurface( - runtimeCard, - brightness: brightness, - parent: window, - foreground: foreground, - fallback: fallback.card, - ); - final dialog = _resolvedRaisedSurface( - runtimeDialog, - brightness: brightness, - parent: window, + final card = readableSurface(sampledCard, fallback.card); + final dialog = readableSurface(sampledDialog, fallback.dialog); + final popover = readableSurface(sampledPopover, fallback.popover); + // Preserve GTK's semantic card layer as source data. Modern Yaru makes + // this translucent, while [card] above is its opaque window/editor + // composition. Elevated Flutter cards must paint that opaque role so + // their physical shadow cannot bleed through the fill. + final groupedSurface = _resolvedGroupedSurfaceLayer( + runtime.card, + fallback: fallback.groupedSurface, foreground: foreground, - fallback: fallback.dialog, + backgrounds: [window, view, dialog, popover], ); - final popover = _resolvedRaisedSurface( - runtimePopover, - brightness: brightness, - parent: window, - foreground: foreground, - fallback: fallback.popover, - ); - // Boxed/grouped content is one semantic card role. Keeping a single - // resolved token prevents Settings, Agenda, and Year view from drifting. - final groupedSurface = card; - final sidebarBorder = _resolvedSidebarBorder( - runtime.sidebarBorder, - brightness: brightness, - sidebar: sidebar, - fallback: fallback.sidebarBorder, - ); - // Inset separators and floating-surface outlines are distinct native - // roles. Resolve each GTK sample directly rather than evaluating a shared - // color against an unrelated card surface. + final effectiveGroupedSurfaces = [ + for (final background in [window, view, dialog, popover]) + _surfaceColorOver(groupedSurface, over: background), + ]; + // Sidebar boundaries, inset separators, and floating-surface outlines are + // distinct named native roles. Preserve their GTK values directly instead + // of rejecting a legitimate recessed edge by its luminance polarity. + final runtimeSidebarBorder = _runtimeColor(runtime.sidebarBorder); final runtimeDivider = _runtimeColor(runtime.divider); + final runtimeCardShade = _runtimeColor(runtime.cardShade); final runtimeFloatingBorder = _runtimeColor(runtime.floatingBorder); final readableBackgrounds = [ window, @@ -726,7 +784,8 @@ class _BusyMaxResolvedSurfaceColors { fallback: fallback.mutedForeground, guaranteed: foreground, backgrounds: readableBackgrounds, - minContrast: 3, + minContrast: 4.5, + requireOpaque: true, ); final disabledForeground = _resolvedReadableColor( runtime.disabledForeground, @@ -740,7 +799,13 @@ class _BusyMaxResolvedSurfaceColors { runtimeHover: runtime.controlHover, runtimeActive: runtime.controlActive, fallback: fallback, - backgrounds: [view, sidebar, groupedSurface, dialog, popover], + backgrounds: [ + view, + sidebar, + dialog, + popover, + ...effectiveGroupedSurfaces, + ], ); return fallback.copyWith( @@ -764,99 +829,31 @@ class _BusyMaxResolvedSurfaceColors { disabledControl: _runtimeOverlayColor(runtime.disabledControl), border: _runtimeColor(runtime.border), divider: runtimeDivider, + cardShade: runtimeCardShade, floatingBorder: runtimeFloatingBorder, - sidebarBorder: sidebarBorder, + sidebarBorder: runtimeSidebarBorder, shade: _runtimeShadeColor(runtime.shade, over: popover), ); } } -Color _resolvedSidebarSurface( - Color? runtimeSurface, { - required Brightness brightness, - required Color parent, - required Color adjacent, - required Color foreground, +Color _resolvedGroupedSurfaceLayer( + Color? runtimeCard, { required Color fallback, -}) { - bool isReadable(Color color) => _contrastRatio(foreground, color) >= 4.5; - - bool hasExpectedHierarchy(Color color) { - final surfaceLuminance = color.computeLuminance(); - final isOnRaisedSideOfBoth = [parent, adjacent].every((background) { - final backgroundLuminance = background.computeLuminance(); - return brightness == Brightness.dark - ? surfaceLuminance > backgroundLuminance - : surfaceLuminance < backgroundLuminance; - }); - return isOnRaisedSideOfBoth; - } - - if (runtimeSurface != null && - isReadable(runtimeSurface) && - hasExpectedHierarchy(runtimeSurface)) { - return runtimeSurface; - } - if (isReadable(fallback) && hasExpectedHierarchy(fallback)) { - return fallback; - } - - // A custom palette can invert the fixed fallback hierarchy. Matching the - // adjacent content is safer than drawing the sidebar on the wrong side of - // either parent surface. - return adjacent; -} - -Color _resolvedRaisedSurface( - Color? runtimeSurface, { - required Brightness brightness, - required Color parent, required Color foreground, - required Color fallback, + required Iterable backgrounds, }) { - bool isReadable(Color color) => _contrastRatio(foreground, color) >= 4.5; - - bool hasExpectedHierarchy(Color color) { - if (brightness != Brightness.dark) { - return true; - } - return color.computeLuminance() > parent.computeLuminance() && - _contrastRatio(color, parent) >= _minimumRaisedSurfaceContrast; - } - - if (runtimeSurface != null && - isReadable(runtimeSurface) && - hasExpectedHierarchy(runtimeSurface)) { - return runtimeSurface; - } - if (isReadable(fallback) && hasExpectedHierarchy(fallback)) { + final candidate = _runtimeColor(runtimeCard); + if (candidate == null) { return fallback; } - - // A fixed fallback may itself be recessed against a brighter custom theme. - // Flat is safer than inverting the intended raised hierarchy. - return parent; -} - -Color _resolvedSidebarBorder( - Color? runtimeBorder, { - required Brightness brightness, - required Color sidebar, - required Color fallback, -}) { - final candidate = _runtimeColor(runtimeBorder); - if (candidate == null || brightness != Brightness.dark) { - return candidate ?? fallback; + for (final background in backgrounds) { + final effective = _surfaceColorOver(candidate, over: background); + if (_contrastRatio(foreground, effective) < 4.5) { + return fallback; + } } - final effective = candidate.a < 1 - ? Color.alphaBlend(candidate, sidebar) - : candidate; - // A dark separator sampled from GTK's generic `borders` token becomes a - // heavy inset edge on a dark sidebar. Retain native light separators and - // use the semantic fallback when the sample is visually recessed. - return effective.computeLuminance() < sidebar.computeLuminance() - ? fallback - : candidate; + return candidate; } Color? _runtimeColor(Color? color) { @@ -947,9 +944,10 @@ Color? _runtimeReadableColor( Color? color, { required Iterable backgrounds, double minContrast = 4.5, + bool requireOpaque = false, }) { final runtime = _runtimeColor(color); - if (runtime == null) { + if (runtime == null || (requireOpaque && runtime.a < 1)) { return null; } for (final background in backgrounds) { @@ -966,16 +964,19 @@ Color _resolvedReadableColor( required Color guaranteed, required Iterable backgrounds, required double minContrast, + bool requireOpaque = false, }) { return _runtimeReadableColor( runtime, backgrounds: backgrounds, minContrast: minContrast, + requireOpaque: requireOpaque, ) ?? _runtimeReadableColor( fallback, backgrounds: backgrounds, minContrast: minContrast, + requireOpaque: requireOpaque, ) ?? guaranteed; } @@ -1000,7 +1001,11 @@ Color? _runtimeSurfaceColor(Color? color, {required Color over}) { if (runtime == null) { return null; } - return runtime.a < 1 ? Color.alphaBlend(runtime, over) : runtime; + return _surfaceColorOver(runtime, over: over); +} + +Color _surfaceColorOver(Color color, {required Color over}) { + return color.a < 1 ? Color.alphaBlend(color, over) : color; } String? _validFontFamily(String? family) { @@ -1101,11 +1106,13 @@ MenuStyle _semanticMenuSurfaceStyle( MenuStyle? base, { required Color color, required Color shadowColor, + required BorderSide side, }) { return (base ?? const MenuStyle()).copyWith( backgroundColor: WidgetStatePropertyAll(color), surfaceTintColor: WidgetStatePropertyAll(color), shadowColor: WidgetStatePropertyAll(shadowColor), + side: WidgetStatePropertyAll(side), ); } @@ -1153,6 +1160,7 @@ ButtonStyle _semanticButtonStyle( ButtonStyle? base, { required Color foreground, required Color background, + Color? selectedBackground, required Color disabledForeground, required Color disabledBackground, WidgetStateProperty? textStyle, @@ -1175,6 +1183,9 @@ ButtonStyle _semanticButtonStyle( if (states.contains(WidgetState.disabled)) { return disabledBackground; } + if (selectedBackground != null && states.contains(WidgetState.selected)) { + return selectedBackground; + } return background; }), ); diff --git a/lib/src/app/system_accent.dart b/lib/src/app/system_accent.dart index 3c42aa5..61bfff8 100644 --- a/lib/src/app/system_accent.dart +++ b/lib/src/app/system_accent.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:io'; import 'package:dbus/dbus.dart'; @@ -15,11 +16,14 @@ final ubuntuSystemAccentColorProvider = StreamProvider((ref) async* { } final appearance = ref.watch(linuxPortalAppearanceProvider); - final initial = await appearance.readAccentColor(); - if (initial != null) { - yield initial; + Color? current; + await for (final color in appearance.accentColorChanges()) { + if (color == current) { + continue; + } + current = color; + yield color; } - yield* appearance.accentColorChanges().distinct(); }); class LinuxPortalAppearance { @@ -36,8 +40,10 @@ class LinuxPortalAppearance { final client = DBusClient.session(); try { final object = _portalObject(client); - return await _readFreedesktopAccent(object) ?? - await _readGnomeAccentName(object); + return await readPreferredLinuxAccentColor( + readFreedesktop: () => _readFreedesktopAccent(object), + readGnome: () => _readGnomeAccentName(object), + ); } on Object { return null; } finally { @@ -55,22 +61,19 @@ class LinuxPortalAppearance { name: 'SettingChanged', signature: DBusSignature('ssv'), ); - await for (final signal in signals) { - final namespace = signal.values[0].asString(); - final key = signal.values[1].asString(); - if (key != _accentColor) { - continue; - } - final value = signal.values[2].asVariant(); - final color = namespace == _freedesktopAppearance - ? colorFromPortalAccentValue(value) - : namespace == _gnomeInterface - ? colorFromUbuntuAccentNameValue(value) - : null; - if (color != null) { - yield color; - } - } + final changes = signals + .where((signal) => signal.values[1].asString() == _accentColor) + .map( + (signal) => ( + namespace: signal.values[0].asString(), + value: signal.values[2].asVariant(), + ), + ); + yield* watchPreferredLinuxAccentColors( + readFreedesktop: () => _readFreedesktopAccent(object), + readGnome: () => _readGnomeAccentName(object), + changes: changes, + ); } on Object { return; } finally { @@ -113,6 +116,99 @@ class LinuxPortalAppearance { } } +/// Reads the exact freedesktop RGB value first, while keeping the Ubuntu +/// named-accent setting as an independent fallback for older portals. +@visibleForTesting +Future readPreferredLinuxAccentColor({ + required Future Function() readFreedesktop, + required Future Function() readGnome, +}) async { + final freedesktopAccent = await _readAccentSafely(readFreedesktop); + if (freedesktopAccent != null) { + return freedesktopAccent; + } + return _readAccentSafely(readGnome); +} + +Future _readAccentSafely(Future Function() read) async { + try { + return await read(); + } on Object { + return null; + } +} + +typedef LinuxAccentSettingChange = ({String namespace, DBusValue value}); + +/// Watches the modern exact accent and the legacy named fallback as one +/// ordered source. +/// +/// The signal subscription is established before the initial snapshot is +/// read. This prevents a portal update from being lost in the read/subscribe +/// gap and ensures the same resolver carries exact-RGB authority from the +/// initial value into all later signals. +@visibleForTesting +Stream watchPreferredLinuxAccentColors({ + required Future Function() readFreedesktop, + required Future Function() readGnome, + required Stream changes, +}) async* { + final iterator = StreamIterator(changes); + var nextChange = iterator.moveNext(); + try { + final freedesktopAccent = await _readAccentSafely(readFreedesktop); + final resolver = LinuxAccentChangeResolver( + freedesktopAuthoritative: freedesktopAccent != null, + ); + if (freedesktopAccent != null) { + yield freedesktopAccent; + } else { + final gnomeAccent = await _readAccentSafely(readGnome); + if (gnomeAccent != null) { + yield gnomeAccent; + } + } + + while (await nextChange) { + final change = iterator.current; + // Resume the subscription before yielding so subsequent portal updates + // are buffered even while the consumer processes this color. + nextChange = iterator.moveNext(); + final color = resolver.resolve(change.namespace, change.value); + if (color != null) { + yield color; + } + } + } finally { + await iterator.cancel(); + } +} + +/// Resolves portal change signals without allowing an approximate named color +/// to replace an exact RGB value once the modern freedesktop key is available. +@visibleForTesting +class LinuxAccentChangeResolver { + LinuxAccentChangeResolver({bool freedesktopAuthoritative = false}) + : _freedesktopAuthoritative = freedesktopAuthoritative; + + bool _freedesktopAuthoritative; + + Color? resolve(String namespace, DBusValue value) { + if (namespace == LinuxPortalAppearance._freedesktopAppearance) { + final color = colorFromPortalAccentValue(value); + if (color != null) { + _freedesktopAuthoritative = true; + } + return color; + } + if (namespace == LinuxPortalAppearance._gnomeInterface && + !_freedesktopAuthoritative) { + return colorFromUbuntuAccentNameValue(value); + } + return null; + } +} + Color? colorFromPortalAccentValue(DBusValue value) { final resolved = value.signature == DBusSignature('v') ? value.asVariant() diff --git a/lib/src/features/calendar/presentation/event_description_editor.dart b/lib/src/features/calendar/presentation/event_description_editor.dart index 467e681..92f09d5 100644 --- a/lib/src/features/calendar/presentation/event_description_editor.dart +++ b/lib/src/features/calendar/presentation/event_description_editor.dart @@ -1,5 +1,4 @@ import 'package:flutter/material.dart'; -import 'package:yaru/yaru.dart'; import '../../../app/busymax_design.dart'; import '../../../app/busymax_yaru_theme.dart'; @@ -182,18 +181,16 @@ class _FormatButton extends StatelessWidget { message: tooltip, child: SizedBox.square( dimension: BusyMaxSizes.headerIconButton, - child: YaruIconButton( + child: BusyMaxHeaderIconButton( tooltip: tooltip, onPressed: onPressed, - style: busyMaxHeaderIconButtonStyle( - foregroundColor: Theme.of(context).colorScheme.onSurface, - backgroundColor: WidgetStateProperty.resolveWith((states) { - if (active || states.contains(WidgetState.hovered)) { - return surfaceColors.controlHover; - } - return Colors.transparent; - }), - ), + foregroundColor: Theme.of(context).colorScheme.onSurface, + backgroundColor: WidgetStateProperty.resolveWith((states) { + if (active || states.contains(WidgetState.hovered)) { + return surfaceColors.controlHover; + } + return Colors.transparent; + }), icon: Text( label, style: Theme.of(context).textTheme.labelLarge?.copyWith( diff --git a/lib/src/features/calendar/presentation/event_editor.dart b/lib/src/features/calendar/presentation/event_editor.dart index c05fb77..87a3f00 100644 --- a/lib/src/features/calendar/presentation/event_editor.dart +++ b/lib/src/features/calendar/presentation/event_editor.dart @@ -491,7 +491,7 @@ class _EventEditorState extends State { ...minutes.skip(index + 1), ]); }, - trailingAction: YaruIconButton( + trailingAction: BusyMaxHeaderIconButton( tooltip: l10n.removeReminder, iconSize: BusyMaxSizes.headerIcon, icon: const Icon(YaruIcons.window_close), @@ -501,11 +501,9 @@ class _EventEditorState extends State { ...minutes.skip(index + 1), ]); }, - style: busyMaxHeaderIconButtonStyle( - foregroundColor: colorScheme.onSurfaceVariant, - backgroundColor: busyMaxSubtleButtonBackground(context), - overlayColor: const WidgetStatePropertyAll(Colors.transparent), - ), + foregroundColor: colorScheme.onSurfaceVariant, + backgroundColor: busyMaxSubtleButtonBackground(context), + overlayColor: const WidgetStatePropertyAll(Colors.transparent), ), ), if (canAddReminder) diff --git a/lib/src/features/schedule/presentation/compact_agenda_panel.dart b/lib/src/features/schedule/presentation/compact_agenda_panel.dart index 5f47b47..dc77e5f 100644 --- a/lib/src/features/schedule/presentation/compact_agenda_panel.dart +++ b/lib/src/features/schedule/presentation/compact_agenda_panel.dart @@ -881,15 +881,13 @@ class _CompactHeaderButton extends StatelessWidget { @override Widget build(BuildContext context) { - return YaruIconButton( + return BusyMaxHeaderIconButton( tooltip: tooltip, icon: Icon(icon), iconSize: BusyMaxSizes.iconMd, onPressed: onPressed, - style: busyMaxHeaderIconButtonStyle( - foregroundColor: BusyMaxSurfaceColors.of(context).foreground, - backgroundColor: busyMaxSubtleButtonBackground(context), - ), + foregroundColor: BusyMaxSurfaceColors.of(context).foreground, + backgroundColor: busyMaxSubtleButtonBackground(context), ); } } diff --git a/lib/src/features/schedule/presentation/mini_calendar.dart b/lib/src/features/schedule/presentation/mini_calendar.dart index 5e1ed11..f8978f1 100644 --- a/lib/src/features/schedule/presentation/mini_calendar.dart +++ b/lib/src/features/schedule/presentation/mini_calendar.dart @@ -216,6 +216,7 @@ class _MiniCalendarWeekNumberButton extends StatelessWidget { onPressed: () => onSelected(weekStart), style: busyMaxHeaderIconButtonStyle( + context, foregroundColor: colorScheme.onSurfaceVariant, backgroundColor: busyMaxHeaderButtonBackground(context), overlayColor: const WidgetStatePropertyAll(Colors.transparent), @@ -469,16 +470,14 @@ class _MiniCalendarStepper extends StatelessWidget { required IconData icon, required VoidCallback onPressed, }) { - return YaruIconButton( + return BusyMaxHeaderIconButton( tooltip: tooltip, iconSize: BusyMaxSizes.headerIcon, icon: Icon(icon), onPressed: onPressed, - style: busyMaxHeaderIconButtonStyle( - foregroundColor: colorScheme.onSurfaceVariant, - backgroundColor: busyMaxHeaderButtonBackground(context), - overlayColor: const WidgetStatePropertyAll(Colors.transparent), - ), + foregroundColor: colorScheme.onSurfaceVariant, + backgroundColor: busyMaxHeaderButtonBackground(context), + overlayColor: const WidgetStatePropertyAll(Colors.transparent), ); } @@ -500,6 +499,7 @@ class _MiniCalendarStepper extends StatelessWidget { child: TextButton( onPressed: action, style: busyMaxHeaderTextButtonStyle( + context, foregroundColor: colorScheme.onSurfaceVariant, backgroundColor: busyMaxHeaderButtonBackground(context), overlayColor: const WidgetStatePropertyAll(Colors.transparent), 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 a64c998..66f316e 100644 --- a/lib/src/features/schedule/presentation/schedule_day_week_view.dart +++ b/lib/src/features/schedule/presentation/schedule_day_week_view.dart @@ -96,7 +96,7 @@ class _ScheduleDayWeekViewState extends State { Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; final surfaceColors = BusyMaxSurfaceColors.of(context); - final borderColor = busyMaxPanelBorder(context); + final gridColor = busyMaxCalendarGridColor(context); final todayOverlayAlpha = widget.daysShowed == 1 ? 0.0 : 0.035; final todayColor = Color.alphaBlend( surfaceColors.controlActive.withValues(alpha: todayOverlayAlpha), @@ -145,7 +145,7 @@ class _ScheduleDayWeekViewState extends State { ), fullDayEventsBarDecoration: BoxDecoration( color: colorScheme.surface, - border: Border(bottom: BorderSide(color: borderColor)), + border: Border(bottom: BorderSide(color: gridColor)), ), fullDayBackgroundColor: colorScheme.surface, fullDayEventsBuilder: (events, width) { @@ -203,7 +203,7 @@ class _ScheduleDayWeekViewState extends State { dayCustomPainter: (heightPerMinute, isToday) => icv.LinesPainter( heightPerMinute: heightPerMinute, isToday: isToday, - lineColor: borderColor, + lineColor: gridColor, hourStrokeWidth: 0.7, halfStrokeWidth: 0.35, quarterStrokeWidth: 0, @@ -488,7 +488,9 @@ class _PlannerDayHeader extends StatelessWidget { alignment: Alignment.center, decoration: BoxDecoration( color: colorScheme.surface, - border: Border(bottom: BorderSide(color: busyMaxPanelBorder(context))), + border: Border( + bottom: BorderSide(color: busyMaxCalendarGridColor(context)), + ), ), child: Column( mainAxisAlignment: MainAxisAlignment.center, diff --git a/lib/src/features/schedule/presentation/schedule_month_view.dart b/lib/src/features/schedule/presentation/schedule_month_view.dart index 0900338..48efcb2 100644 --- a/lib/src/features/schedule/presentation/schedule_month_view.dart +++ b/lib/src/features/schedule/presentation/schedule_month_view.dart @@ -46,9 +46,7 @@ class ScheduleMonthView extends StatelessWidget { final month = DateTime(selectedDate.year, selectedDate.month); final grouped = ScheduleProjection.groupByDay(items); final theme = Theme.of(context); - final border = theme.colorScheme.onSurface.withValues( - alpha: theme.brightness == Brightness.dark ? 0.06 : 0.10, - ); + final border = busyMaxCalendarGridColor(context); return Column( children: [ diff --git a/lib/src/features/schedule/presentation/schedule_sidebar.dart b/lib/src/features/schedule/presentation/schedule_sidebar.dart index 6fd5e26..6ae0d8a 100644 --- a/lib/src/features/schedule/presentation/schedule_sidebar.dart +++ b/lib/src/features/schedule/presentation/schedule_sidebar.dart @@ -237,16 +237,14 @@ class _SourceVisibilityButton extends StatelessWidget { checked: value, button: true, onTap: () => onChanged(!value), - child: YaruIconButton( + child: BusyMaxHeaderIconButton( tooltip: tooltip, iconSize: BusyMaxSizes.sidebarActionIcon, icon: Icon(YaruIcons.checkmark, color: iconColor), onPressed: () => onChanged(!value), - style: busyMaxHeaderIconButtonStyle( - foregroundColor: iconColor, - backgroundColor: busyMaxSubtleButtonBackground(context), - overlayColor: const WidgetStatePropertyAll(Colors.transparent), - ), + foregroundColor: iconColor, + backgroundColor: busyMaxSubtleButtonBackground(context), + overlayColor: const WidgetStatePropertyAll(Colors.transparent), ), ); } @@ -360,7 +358,7 @@ class _AccountHeaderRow extends StatelessWidget { ), ), const SizedBox(width: BusyMaxSpacing.xs), - YaruIconButton( + BusyMaxHeaderIconButton( tooltip: expanded ? MaterialLocalizations.of(context).expandedIconTapHint : MaterialLocalizations.of(context).collapsedIconTapHint, @@ -371,11 +369,9 @@ class _AccountHeaderRow extends StatelessWidget { child: const Icon(YaruIcons.pan_end, size: 16), ), onPressed: onToggleExpanded, - style: busyMaxHeaderIconButtonStyle( - foregroundColor: colorScheme.onSurfaceVariant, - backgroundColor: busyMaxSubtleButtonBackground(context), - overlayColor: const WidgetStatePropertyAll(Colors.transparent), - ), + foregroundColor: colorScheme.onSurfaceVariant, + backgroundColor: busyMaxSubtleButtonBackground(context), + overlayColor: const WidgetStatePropertyAll(Colors.transparent), ), ], ), diff --git a/lib/src/features/settings/presentation/settings_screen.dart b/lib/src/features/settings/presentation/settings_screen.dart index 1605092..d6b5f36 100644 --- a/lib/src/features/settings/presentation/settings_screen.dart +++ b/lib/src/features/settings/presentation/settings_screen.dart @@ -648,23 +648,28 @@ class _SettingsPageSelector extends StatelessWidget { ), ], onSelected: onSelected, - triggerBuilder: (context, onPressed, focusNode) { - return BusyMaxPushButton.standard( - onPressed: onPressed, - focusNode: focusNode, - child: Row( - children: [ - Icon(_settingsPageIcon(selected)), - const SizedBox(width: BusyMaxSpacing.sm), - Expanded( - child: Text( - _settingsPageLabel(context, selected), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), + triggerBuilder: (context, trigger) { + return trigger.anchor( + child: Semantics( + expanded: trigger.isOpen, + child: BusyMaxPushButton.standard( + onPressed: trigger.onPressed, + focusNode: trigger.focusNode, + child: Row( + children: [ + Icon(_settingsPageIcon(selected)), + const SizedBox(width: BusyMaxSpacing.sm), + Expanded( + child: Text( + _settingsPageLabel(context, selected), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + const Icon(YaruIcons.pan_down), + ], ), - const Icon(YaruIcons.pan_down), - ], + ), ), ); }, diff --git a/lib/src/platform/gtk_font_service.dart b/lib/src/platform/gtk_font_service.dart index 0ba2b84..79c3533 100644 --- a/lib/src/platform/gtk_font_service.dart +++ b/lib/src/platform/gtk_font_service.dart @@ -162,6 +162,7 @@ class GtkThemeColors { this.disabledControl, this.border, this.divider, + this.cardShade, this.floatingBorder, this.sidebarBorder, this.shade, @@ -189,6 +190,7 @@ class GtkThemeColors { final Color? disabledControl; final Color? border; final Color? divider; + final Color? cardShade; final Color? floatingBorder; final Color? sidebarBorder; final Color? shade; @@ -219,6 +221,7 @@ class GtkThemeColors { other.disabledControl == disabledControl && other.border == border && other.divider == divider && + other.cardShade == cardShade && other.floatingBorder == floatingBorder && other.sidebarBorder == sidebarBorder && other.shade == shade; @@ -248,6 +251,7 @@ class GtkThemeColors { disabledControl, border, divider, + cardShade, floatingBorder, sidebarBorder, shade, @@ -336,6 +340,7 @@ GtkThemeColors? _parseThemeColors(Object? value) { disabledControl: _parseColor(value['disabledControl']), border: _parseColor(value['border']), divider: _parseColor(value['divider']), + cardShade: _parseColor(value['cardShade']), floatingBorder: _parseColor(value['floatingBorder']), sidebarBorder: _parseColor(value['sidebarBorder']), shade: _parseColor(value['shade']), diff --git a/lib/src/platform/linux_header_bar_service.dart b/lib/src/platform/linux_header_bar_service.dart index 08c63c6..0a179a3 100644 --- a/lib/src/platform/linux_header_bar_service.dart +++ b/lib/src/platform/linux_header_bar_service.dart @@ -179,30 +179,39 @@ class BusyMaxHeaderBarLabels { 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.popoverBackgroundColor, + required this.floatingBorderColor, required this.modalBarrierColor, }); final bool preferDark; + final bool highContrast; final Color windowBackgroundColor; final Color backgroundColor; final Color sidebarBackgroundColor; final Color foregroundColor; final Color sidebarBorderColor; + final Color popoverBackgroundColor; + final Color floatingBorderColor; final Color modalBarrierColor; Map toJson() { return { 'preferDark': preferDark, + 'highContrast': highContrast, 'windowBackgroundColor': busyMaxCssColor(windowBackgroundColor), 'backgroundColor': busyMaxCssColor(backgroundColor), 'sidebarBackgroundColor': busyMaxCssColor(sidebarBackgroundColor), 'foregroundColor': busyMaxCssColor(foregroundColor), 'sidebarBorderColor': busyMaxCssColor(sidebarBorderColor), + 'popoverBackgroundColor': busyMaxCssColor(popoverBackgroundColor), + 'floatingBorderColor': busyMaxCssColor(floatingBorderColor), 'modalBarrierColor': busyMaxCssColor(modalBarrierColor), }; } @@ -212,22 +221,28 @@ class BusyMaxHeaderBarTheme { 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.popoverBackgroundColor == popoverBackgroundColor && + other.floatingBorderColor == floatingBorderColor && other.modalBarrierColor == modalBarrierColor; } @override int get hashCode => Object.hash( preferDark, + highContrast, windowBackgroundColor, backgroundColor, sidebarBackgroundColor, foregroundColor, sidebarBorderColor, + popoverBackgroundColor, + floatingBorderColor, modalBarrierColor, ); } diff --git a/linux/runner/my_application.cc b/linux/runner/my_application.cc index e9243bb..265b064 100644 --- a/linux/runner/my_application.cc +++ b/linux/runner/my_application.cc @@ -32,8 +32,6 @@ constexpr char kCompactAgendaWindowChannel[] = constexpr gint64 kHeaderBarStateSchemaVersion = 3; constexpr gint kHeaderButtonHeight = 34; constexpr gint kHeaderButtonSpacing = 6; -constexpr gint kHeaderWindowControlsBalanceWidth = - kHeaderButtonHeight * 3 + kHeaderButtonSpacing * 2; constexpr gint kHeaderCenterMaximumWidthChars = 48; constexpr gint kHeaderOnboardingContentWidth = 480; constexpr gint kHeaderOnboardingSideWidth = 120; @@ -57,8 +55,12 @@ constexpr gint kCompactAgendaWindowMaxWidth = constexpr gint kCompactAgendaWindowMaxHeight = 840 + kCompactAgendaWindowShadowMargin * 2; constexpr char kDefaultWindowBackgroundColor[] = "#2C2C2C"; -constexpr char kDefaultHeaderBarBackgroundColor[] = "#1D1D20"; +constexpr char kDefaultHeaderBarBackgroundColor[] = "#272727"; constexpr char kDefaultHeaderBarSidebarBackgroundColor[] = "#393939"; +constexpr char kDefaultHeaderBarSidebarBorderColor[] = + "rgba(16,16,16,0.35)"; +constexpr char kHeaderControlStyleClass[] = "busymax-header-control"; +constexpr char kNativePopoverStyleClass[] = "busymax-native-popover"; struct _MyApplication { GtkApplication parent_instance; @@ -82,7 +84,10 @@ struct _MyApplication { gchar* header_bar_sidebar_background_color; gchar* header_bar_sidebar_border_color; gchar* header_bar_foreground_color; + gchar* header_bar_popover_background_color; + gchar* header_bar_floating_border_color; gchar* header_bar_modal_barrier_color; + gboolean header_bar_high_contrast; gint header_bar_sidebar_width; gboolean header_bar_can_show_sidebar; gboolean header_bar_sidebar_visible; @@ -93,7 +98,6 @@ struct _MyApplication { GtkWidget* titlebar_box; GtkHeaderBar* header_bar; GtkWidget* header_start_box; - GtkWidget* header_title_balance_spacer; GtkWidget* header_title_box; GtkWidget* header_title_stack; GtkWidget* onboarding_back_slot; @@ -146,6 +150,14 @@ struct _MyApplication { G_DEFINE_TYPE(MyApplication, my_application, GTK_TYPE_APPLICATION) +static void style_native_popover(GtkWidget* popover) { + if (popover == nullptr || !GTK_IS_POPOVER(popover)) { + return; + } + gtk_style_context_add_class(gtk_widget_get_style_context(popover), + kNativePopoverStyleClass); +} + static GdkPixbuf* load_application_icon_at_size(gint size) { g_autofree gchar* executable_path = g_file_read_link("/proc/self/exe", nullptr); @@ -846,6 +858,7 @@ static void show_native_menu(NativeMenuHandlerData* data, session->popover = gtk_popover_new_from_model( data->view, G_MENU_MODEL(session->model)); g_object_ref_sink(session->popover); + style_native_popover(session->popover); gtk_popover_set_pointing_to(GTK_POPOVER(session->popover), &anchor); gtk_popover_set_position(GTK_POPOVER(session->popover), GTK_POS_BOTTOM); gtk_popover_set_constrain_to(GTK_POPOVER(session->popover), @@ -1091,9 +1104,29 @@ static void refresh_header_bar_css(MyApplication* self) { ? self->header_bar_sidebar_background_color : background_color; const gchar* sidebar_border_color = css_color_or( - self->header_bar_sidebar_border_color, "rgba(255,255,255,0.10)"); + 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* floating_border_color = css_color_or( + self->header_bar_floating_border_color, foreground_color); + g_autofree gchar* native_popover_border_css = + self->header_bar_high_contrast + ? g_strdup_printf("border-color: %s;", floating_border_color) + : g_strdup("border: none;"); + g_autofree gchar* native_popover_css = + is_css_color_token(self->header_bar_popover_background_color) + ? g_strdup_printf( + "popover.background.%s," + "popover.background.%s:backdrop {" + "background-color: %s;" + "background-image: none;" + "%s" + "}", + kNativePopoverStyleClass, kNativePopoverStyleClass, + self->header_bar_popover_background_color, + native_popover_border_css) + : g_strdup(""); const gchar* modal_barrier_color = css_color_or( self->header_bar_modal_barrier_color, "rgba(0,0,0,0.32)"); g_autofree gchar* modal_sidebar_border_color = @@ -1134,6 +1167,46 @@ static void refresh_header_bar_css(MyApplication* self) { ".busymax-titlebar .busymax-header-title {" "color: %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;" + "}" + "%s" ".busymax-titlebar.busymax-modal-barrier .busymax-header-brand," ".busymax-titlebar.busymax-modal-barrier " ".busymax-header-brand:backdrop {" @@ -1150,7 +1223,7 @@ static void refresh_header_bar_css(MyApplication* self) { "}", window_background_color, background_color, foreground_color, sidebar_background_color, foreground_color, sidebar_border_color, - foreground_color, foreground_color, + foreground_color, foreground_color, native_popover_css, sidebar_background_color, modal_barrier_color, modal_barrier_color, modal_sidebar_border_color, background_color, modal_barrier_color, modal_barrier_color); @@ -1192,6 +1265,8 @@ static void set_header_bar_theme(MyApplication* self, FlValue* args) { if (fl_lookup_optional_bool_arg(args, "preferDark", &prefer_dark)) { set_gtk_theme_preference(prefer_dark); } + 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, @@ -1202,6 +1277,10 @@ static void set_header_bar_theme(MyApplication* self, FlValue* args) { 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_popover_background_color, + fl_lookup_string_arg(args, "popoverBackgroundColor")); + set_css_color_field(&self->header_bar_floating_border_color, + fl_lookup_string_arg(args, "floatingBorderColor")); set_css_color_field(&self->header_bar_modal_barrier_color, fl_lookup_string_arg(args, "modalBarrierColor")); set_main_flutter_view_background(self); @@ -1272,6 +1351,37 @@ static void invoke_header_bar_bool_action(MyApplication* self, 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; @@ -1283,26 +1393,36 @@ static void cache_header_search_query(MyApplication* self, } static void set_header_search_query(MyApplication* self, - const gchar* query) { + const gchar* query, + gboolean incoming_state_is_active) { const gchar* normalized_query = query == nullptr ? "" : query; - const gboolean echoes_last_native_query = - g_strcmp0(self->header_search_query, normalized_query) == 0; - cache_header_search_query(self, normalized_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)); - if (g_strcmp0(current_query, normalized_query) == 0) { - return; - } - if (echoes_last_native_query && self->header_search_active && - gtk_widget_has_focus(self->search_entry)) { - // Dart mirrors native query events into its route state. Do not let that - // asynchronous echo overwrite newer text that the user has already typed. - return; + 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); @@ -1468,6 +1588,7 @@ static void set_header_menu_button_model(GtkWidget* button, return; } track_widget_pointer(tracked_popover, GTK_WIDGET(popover)); + style_native_popover(GTK_WIDGET(popover)); gtk_popover_set_position(popover, GTK_POS_BOTTOM); } @@ -1604,16 +1725,21 @@ static void make_header_icon_button_square(GtkWidget* button) { 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 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_button_set_relief(GTK_BUTTON(button), GTK_RELIEF_NONE); gtk_widget_set_tooltip_text(button, tooltip); - gtk_widget_set_valign(button, GTK_ALIGN_CENTER); - gtk_style_context_add_class(gtk_widget_get_style_context(button), - GTK_STYLE_CLASS_FLAT); + style_header_control(button); make_header_icon_button_square(button); return button; } @@ -1623,10 +1749,8 @@ static GtkWidget* create_header_toggle_icon_button(const gchar* icon_name, 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_button_set_relief(GTK_BUTTON(button), GTK_RELIEF_NONE); gtk_widget_set_tooltip_text(button, tooltip); - gtk_style_context_add_class(gtk_widget_get_style_context(button), - GTK_STYLE_CLASS_FLAT); + style_header_control(button); make_header_icon_button_square(button); return button; } @@ -1634,11 +1758,8 @@ static GtkWidget* create_header_toggle_icon_button(const gchar* icon_name, static GtkWidget* create_header_text_button(const gchar* label, const gchar* tooltip) { GtkWidget* button = gtk_button_new_with_label(label); - gtk_button_set_relief(GTK_BUTTON(button), GTK_RELIEF_NONE); gtk_widget_set_tooltip_text(button, tooltip); - gtk_widget_set_valign(button, GTK_ALIGN_CENTER); - gtk_style_context_add_class(gtk_widget_get_style_context(button), - GTK_STYLE_CLASS_FLAT); + style_header_control(button); return button; } @@ -1777,7 +1898,10 @@ static void set_header_search_state(MyApplication* self, self->header_search_active != effective_active; const gboolean previous_suppression = self->suppress_header_bar_actions; self->suppress_header_bar_actions = TRUE; - set_header_search_query(self, query); + // 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 && @@ -1825,18 +1949,6 @@ static void set_header_view_mode(MyApplication* self, const gchar* mode) { update_header_view_mode_label(self); } -static void update_header_title_balance_spacer(MyApplication* self) { - if (self->header_title_balance_spacer == nullptr || - !GTK_IS_WIDGET(self->header_title_balance_spacer)) { - return; - } - const gboolean visible = - !self->header_schedule_controls_visible && !self->header_back_visible; - gtk_widget_set_size_request(self->header_title_balance_spacer, - kHeaderWindowControlsBalanceWidth, -1); - set_widget_visible(self->header_title_balance_spacer, visible); -} - static void update_header_title_box_geometry(MyApplication* self) { if (self->header_title_box == nullptr || !GTK_IS_WIDGET(self->header_title_box)) { @@ -1874,7 +1986,6 @@ static void update_header_control_visibility(MyApplication* self) { set_widget_visible(self->refresh_button, schedule_controls_visible); set_widget_visible(self->settings_menu_button, schedule_controls_visible || self->header_back_visible); - update_header_title_balance_spacer(self); } static void set_header_schedule_controls_visible(MyApplication* self, @@ -1917,7 +2028,6 @@ static void set_header_onboarding_controls(MyApplication* self, FlValue* args) { set_button_label_and_tooltip(self->onboarding_continue_button, continue_label, continue_label); update_header_title_box_geometry(self); - update_header_title_balance_spacer(self); } static void set_header_sidebar_visible(MyApplication* self, gboolean visible) { @@ -2093,14 +2203,10 @@ static GtkWidget* create_busymax_titlebar(MyApplication* self) { FALSE, FALSE, 0); track_widget_pointer(&self->settings_menu_button, gtk_menu_button_new()); - gtk_button_set_relief(GTK_BUTTON(self->settings_menu_button), - GTK_RELIEF_NONE); gtk_button_set_image(GTK_BUTTON(self->settings_menu_button), gtk_image_new_from_icon_name("open-menu-symbolic", GTK_ICON_SIZE_MENU)); - gtk_style_context_add_class( - gtk_widget_get_style_context(self->settings_menu_button), - GTK_STYLE_CLASS_FLAT); + 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); @@ -2111,13 +2217,6 @@ static GtkWidget* create_busymax_titlebar(MyApplication* self) { gtk_box_pack_start(GTK_BOX(self->titlebar_box), self->header_sidebar_brand_box, FALSE, FALSE, 0); - track_widget_pointer(&self->header_title_balance_spacer, - gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 0)); - gtk_widget_set_size_request(self->header_title_balance_spacer, - kHeaderWindowControlsBalanceWidth, -1); - gtk_widget_set_visible(self->header_title_balance_spacer, FALSE); - gtk_header_bar_pack_start(header_bar, self->header_title_balance_spacer); - track_widget_pointer(&self->header_start_box, gtk_box_new(GTK_ORIENTATION_HORIZONTAL, kHeaderButtonSpacing)); @@ -2248,9 +2347,7 @@ static GtkWidget* create_busymax_titlebar(MyApplication* self) { gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 0)); track_widget_pointer(&self->view_mode_button, gtk_menu_button_new()); - gtk_button_set_relief(GTK_BUTTON(self->view_mode_button), GTK_RELIEF_NONE); - gtk_style_context_add_class(gtk_widget_get_style_context(self->view_mode_button), - GTK_STYLE_CLASS_FLAT); + style_header_control(self->view_mode_button); GtkWidget* view_mode_button_box = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, kHeaderButtonSpacing); @@ -2273,12 +2370,10 @@ static GtkWidget* create_busymax_titlebar(MyApplication* self) { gtk_box_pack_start(GTK_BOX(end_box), self->search_button, FALSE, FALSE, 0); track_widget_pointer(&self->create_button, gtk_menu_button_new()); - gtk_button_set_relief(GTK_BUTTON(self->create_button), GTK_RELIEF_NONE); gtk_button_set_image( GTK_BUTTON(self->create_button), gtk_image_new_from_icon_name("list-add-symbolic", GTK_ICON_SIZE_MENU)); - gtk_style_context_add_class(gtk_widget_get_style_context(self->create_button), - GTK_STYLE_CLASS_FLAT); + 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); @@ -2598,10 +2693,6 @@ static const gchar* brightness_for_color(const GdkRGBA* color) { static FlValue* get_gtk_theme_colors() { GtkWidget* window = gtk_window_new(GTK_WINDOW_TOPLEVEL); GtkWidget* view = gtk_text_view_new(); - GtkWidget* sidebar = gtk_box_new(GTK_ORIENTATION_VERTICAL, 0); - GtkWidget* header = gtk_header_bar_new(); - GtkWidget* card = gtk_frame_new(nullptr); - GtkWidget* dialog = gtk_dialog_new(); GtkWidget* popover = gtk_popover_new(nullptr); GtkWidget* control = gtk_button_new(); GtkWidget* separator = gtk_separator_new(GTK_ORIENTATION_HORIZONTAL); @@ -2624,6 +2715,7 @@ static FlValue* get_gtk_theme_colors() { GdkRGBA muted_foreground_color = {0, 0, 0, 0}; GdkRGBA border_color = {0, 0, 0, 0}; GdkRGBA divider_color = {0, 0, 0, 0}; + GdkRGBA card_shade_color = {0, 0, 0, 0}; GdkRGBA floating_border_color = {0, 0, 0, 0}; GdkRGBA sidebar_border_color = {0, 0, 0, 0}; GdkRGBA shade_color = {0, 0, 0, 0}; @@ -2659,30 +2751,23 @@ static FlValue* get_gtk_theme_colors() { lookup_context_color(window_context, "theme_selected_fg_color", &accent_foreground_color); - // Prefer public semantic roles. Classic GTK 3 themes often expose only - // widget-class styling, so retain those samples as compatibility input; - // Dart validates their hierarchy before accepting them as raised surfaces. - lookup_context_color(window_context, "sidebar_bg_color", &sidebar_color) || - sample_widget_background(sidebar, GTK_STYLE_CLASS_SIDEBAR, - GTK_STATE_FLAG_NORMAL, &sidebar_color); + // Optional modern surface roles must remain semantic. A classic GTK 3 + // widget-class sample is valid for that widget, but it is not equivalent to + // Yaru/libadwaita's modern sidebar, card, dialog, or popover role. Omit a + // role when the theme does not publish its named color so Dart can use the + // matching modern fallback instead of mislabelling a legacy sample. + lookup_context_color(window_context, "sidebar_bg_color", &sidebar_color); lookup_context_color(window_context, "secondary_sidebar_bg_color", &secondary_sidebar_color); if (!color_is_visible(&secondary_sidebar_color) && color_is_visible(&sidebar_color)) { secondary_sidebar_color = sidebar_color; } - lookup_context_color(window_context, "headerbar_bg_color", &header_color) || - sample_widget_background(header, GTK_STYLE_CLASS_TITLEBAR, - GTK_STATE_FLAG_NORMAL, &header_color); - lookup_context_color(window_context, "card_bg_color", &card_color) || - sample_widget_background(card, "card", GTK_STATE_FLAG_NORMAL, - &card_color); - lookup_context_color(window_context, "dialog_bg_color", &dialog_color) || - sample_widget_background(dialog, GTK_STYLE_CLASS_BACKGROUND, - GTK_STATE_FLAG_NORMAL, &dialog_color); - lookup_context_color(window_context, "popover_bg_color", &popover_color) || - sample_widget_background(popover, GTK_STYLE_CLASS_BACKGROUND, - GTK_STATE_FLAG_NORMAL, &popover_color); + lookup_context_color(window_context, "headerbar_bg_color", &header_color); + lookup_context_color(window_context, "card_bg_color", &card_color); + lookup_context_color(window_context, "card_shade_color", &card_shade_color); + lookup_context_color(window_context, "dialog_bg_color", &dialog_color); + lookup_context_color(window_context, "popover_bg_color", &popover_color); sample_widget_border_color(popover, GTK_STYLE_CLASS_BACKGROUND, GTK_STATE_FLAG_NORMAL, &floating_border_color); sample_widget_background(separator, GTK_STYLE_CLASS_SEPARATOR, @@ -2717,6 +2802,7 @@ static FlValue* get_gtk_theme_colors() { set_theme_color(result, "mutedForeground", &muted_foreground_color); set_theme_color(result, "border", &border_color); set_theme_color(result, "divider", ÷r_color); + set_theme_color(result, "cardShade", &card_shade_color); set_theme_color(result, "floatingBorder", &floating_border_color); set_theme_color(result, "sidebarBorder", &sidebar_border_color); set_theme_color(result, "shade", &shade_color); @@ -2725,10 +2811,6 @@ static FlValue* get_gtk_theme_colors() { gtk_widget_destroy(separator); gtk_widget_destroy(control); gtk_widget_destroy(popover); - gtk_widget_destroy(dialog); - gtk_widget_destroy(card); - gtk_widget_destroy(header); - gtk_widget_destroy(sidebar); gtk_widget_destroy(view); gtk_widget_destroy(window); return result; @@ -3566,7 +3648,6 @@ static void my_application_dispose(GObject* object) { clear_widget_pointer(&self->titlebar_box); clear_header_bar_pointer(self); clear_widget_pointer(&self->header_start_box); - clear_widget_pointer(&self->header_title_balance_spacer); clear_widget_pointer(&self->header_title_box); clear_widget_pointer(&self->header_title_stack); clear_widget_pointer(&self->onboarding_back_slot); @@ -3597,6 +3678,8 @@ static void my_application_dispose(GObject* object) { 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_popover_background_color, g_free); + g_clear_pointer(&self->header_bar_floating_border_color, g_free); g_clear_pointer(&self->header_bar_modal_barrier_color, g_free); g_clear_pointer(&self->header_view_mode, g_free); g_clear_pointer(&self->header_day_label, g_free); @@ -3651,7 +3734,10 @@ static void my_application_init(MyApplication* self) { g_strdup(kDefaultHeaderBarSidebarBackgroundColor); self->header_bar_sidebar_border_color = nullptr; self->header_bar_foreground_color = nullptr; + self->header_bar_popover_background_color = nullptr; + self->header_bar_floating_border_color = nullptr; self->header_bar_modal_barrier_color = nullptr; + 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; @@ -3662,7 +3748,6 @@ static void my_application_init(MyApplication* self) { self->titlebar_box = nullptr; self->header_bar = nullptr; self->header_start_box = nullptr; - self->header_title_balance_spacer = nullptr; self->header_title_box = nullptr; self->header_title_stack = nullptr; self->onboarding_back_slot = nullptr; diff --git a/test/app/busymax_grouped_surface_test.dart b/test/app/busymax_grouped_surface_test.dart index e7bcc6a..e192d0e 100644 --- a/test/app/busymax_grouped_surface_test.dart +++ b/test/app/busymax_grouped_surface_test.dart @@ -69,12 +69,13 @@ void main() { find.descendant( of: groupedSurface, matching: find.byWidgetPredicate( - (widget) => - widget is Material && widget.color == colors.groupedSurface, + (widget) => widget is Material && widget.color == colors.card, ), ), ); expect(BusyMaxElevation.card, 2); + expect(materialSurface.color, theme.cardTheme.color); + expect(materialSurface.color?.a, 1); expect(materialSurface.elevation, BusyMaxElevation.card); expect(materialSurface.shadowColor, theme.colorScheme.shadow); final shape = materialSurface.shape! as RoundedRectangleBorder; @@ -86,6 +87,20 @@ void main() { ), findsNWidgets(2), ); + final divider = tester.widget( + find.descendant(of: groupedSurface, matching: find.byType(Divider)), + ); + expect(divider.height, 1); + expect(divider.thickness, 1); + expect(divider.color, colors.cardShade); + expect(colors.cardShade, isNot(colors.divider)); + expect( + Color.alphaBlend(colors.cardShade, colors.card).toARGB32(), + switch (brightness) { + Brightness.light => const Color(0xFFEDEDED).toARGB32(), + Brightness.dark => const Color(0xFF272727).toARGB32(), + }, + ); final materialLayers = tester.widgetList( find.descendant(of: groupedSurface, matching: find.byType(Material)), @@ -140,6 +155,97 @@ void main() { ); }); + testWidgets( + 'disabled shared header and destructive actions use semantic roles', + (tester) async { + final theme = BusyMaxYaruTheme.build( + brightness: Brightness.dark, + accentColor: const Color(0xFF3584E4), + ); + final colors = theme.extension()!; + + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: Scaffold( + body: Builder( + builder: (context) => Column( + children: [ + BusyMaxHeaderIconButton( + key: const ValueKey('disabled-header-icon'), + tooltip: 'Disabled icon action', + icon: const Icon(Icons.edit_outlined), + onPressed: null, + foregroundColor: colors.foreground, + backgroundColor: busyMaxHeaderButtonBackground(context), + ), + TextButton( + key: const ValueKey('disabled-header-text'), + onPressed: null, + style: busyMaxHeaderTextButtonStyle( + context, + foregroundColor: colors.foreground, + backgroundColor: busyMaxHeaderButtonBackground(context), + ), + child: const Text('Disabled text action'), + ), + const BusyMaxActionRow( + title: 'Disabled destructive action', + destructive: true, + enabled: false, + ), + ], + ), + ), + ), + ), + ); + + final icon = find.descendant( + of: find.byKey(const ValueKey('disabled-header-icon')), + matching: find.byIcon(Icons.edit_outlined), + ); + expect( + IconTheme.of(tester.element(icon)).color, + colors.disabledForeground, + ); + expect( + DefaultTextStyle.of( + tester.element(find.text('Disabled text action')), + ).style.color, + colors.disabledForeground, + ); + expect( + tester + .widget(find.text('Disabled destructive action')) + .style + ?.color, + colors.disabledForeground, + ); + expect( + busyMaxHeaderButtonBackground( + tester.element(find.byKey(const ValueKey('disabled-header-icon'))), + ).resolve({WidgetState.disabled}), + colors.disabledControl, + ); + final iconButton = tester.widget( + find.descendant( + of: find.byKey(const ValueKey('disabled-header-icon')), + matching: find.byType(IconButton), + ), + ); + expect(iconButton.style?.backgroundColor?.resolve({}), colors.control); + expect( + iconButton.style?.backgroundColor?.resolve({WidgetState.hovered}), + colors.controlHover, + ); + expect( + iconButton.style?.backgroundColor?.resolve({WidgetState.pressed}), + colors.controlActive, + ); + }, + ); + testWidgets('grouped cards add a semantic outline in high contrast', ( tester, ) async { @@ -170,6 +276,104 @@ void main() { expect(shape.side.width, BusyMaxStroke.outline); }); + testWidgets('grouped cards preserve inherited card geometry and outline', ( + tester, + ) async { + const inheritedSide = BorderSide(color: Color(0xFF4A4A4A), width: 2); + const inheritedRadius = BorderRadius.all(Radius.circular(9)); + final baseTheme = BusyMaxYaruTheme.build( + brightness: Brightness.light, + accentColor: const Color(0xFF3584E4), + ); + final theme = baseTheme.copyWith( + cardTheme: baseTheme.cardTheme.copyWith( + shape: const RoundedRectangleBorder( + borderRadius: inheritedRadius, + side: inheritedSide, + ), + ), + ); + + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: const BusyMaxGroupedSurface( + child: SizedBox(width: 120, height: 48), + ), + ), + ); + + final materialSurface = tester.widget( + find.descendant( + of: find.byType(BusyMaxGroupedSurface), + matching: find.byType(Material), + ), + ); + final shape = materialSurface.shape! as RoundedRectangleBorder; + expect(shape.borderRadius, inheritedRadius); + expect(shape.side, inheritedSide); + }); + + testWidgets( + 'grouped card paints its opaque semantic role in a dark editor sheet', + (tester) async { + final theme = BusyMaxYaruTheme.build( + brightness: Brightness.dark, + accentColor: const Color(0xFF3584E4), + ); + final colors = theme.extension()!; + + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: Scaffold( + body: BusyMaxModalEditorSurface( + child: BusyMaxGroupedList( + filled: true, + children: [BusyMaxActionRow(title: 'Calendar', onTap: () {})], + ), + ), + ), + ), + ); + + final groupedMaterial = tester.widget( + find.descendant( + of: find.byType(BusyMaxGroupedSurface), + matching: find.byWidgetPredicate( + (widget) => widget is Material && widget.color == colors.card, + ), + ), + ); + final editorDialog = tester.widget( + find.descendant( + of: find.byType(BusyMaxModalEditorSurface), + matching: find.byType(Dialog), + ), + ); + final cardOnEditor = Color.alphaBlend( + colors.groupedSurface, + colors.window, + ); + expect(colors.groupedSurface.a, lessThan(1)); + expect(cardOnEditor.toARGB32(), colors.card.toARGB32()); + expect(colors.card.a, 1); + expect(editorDialog.backgroundColor, colors.window); + expect(editorDialog.surfaceTintColor, colors.window); + expect(theme.dialogTheme.backgroundColor, colors.dialog); + expect(colors.dialog, isNot(colors.window)); + expect(cardOnEditor, isNot(colors.window)); + expect( + cardOnEditor.computeLuminance(), + greaterThan(colors.window.computeLuminance()), + ); + expect(groupedMaterial.color, colors.card); + expect(groupedMaterial.color?.a, 1); + expect(groupedMaterial.elevation, BusyMaxElevation.card); + expect(groupedMaterial.shadowColor, theme.colorScheme.shadow); + }, + ); + for (final brightness in Brightness.values) { testWidgets('rows use the subtle Yaru $brightness hover role', ( tester, @@ -424,6 +628,43 @@ void main() { expect(activations, [isNull]); }); + testWidgets('combo row keeps a trailing action independently accessible', ( + tester, + ) async { + final selections = []; + var trailingActivations = 0; + final semanticsHandle = tester.ensureSemantics(); + + await tester.pumpWidget( + _testApp( + BusyMaxComboRow( + title: 'Reminder', + values: const ['5 minutes', '10 minutes'], + selected: '5 minutes', + labelFor: (value) => value, + onSelected: selections.add, + trailingAction: BusyMaxHeaderIconButton( + icon: const Icon(YaruIcons.window_close), + tooltip: 'Remove reminder', + onPressed: () => trailingActivations += 1, + ), + ), + ), + ); + + expect(find.bySemanticsLabel('Reminder'), findsOneWidget); + final trailingButton = find.byType(IconButton); + expect(tester.getSemantics(trailingButton).tooltip, 'Remove reminder'); + + await tester.tap(trailingButton); + await tester.pump(); + + expect(trailingActivations, 1); + expect(selections, isEmpty); + expect(find.byType(PopupMenuItem).hitTestable(), findsNothing); + semanticsHandle.dispose(); + }); + testWidgets('disabled combo row cannot open or receive keyboard focus', ( tester, ) async { @@ -465,6 +706,7 @@ void main() { ); expect(disabledSemantics.properties.button, isTrue); expect(disabledSemantics.properties.enabled, isFalse); + expect(disabledSemantics.properties.onTap, isNull); expect(disabledSemantics.properties.value, 'Personal'); }); @@ -491,13 +733,21 @@ void main() { of: find.byType(BusyMaxComboRow), matching: _comboTriggerFinder(), ); - final trigger = tester.widget(triggerFinder); - final comboBox = tester.widget>( - find.byType(BusyMaxComboBox), + final trigger = tester.widget(triggerFinder); + expect(trigger.onTap, isNotNull); + expect( + find.descendant( + of: find.byType(BusyMaxComboRow), + matching: find.byWidgetPredicate( + (widget) => widget is ButtonStyleButton && widget is! IconButton, + ), + ), + findsNothing, + ); + final restingSurface = tester.widget( + find.descendant(of: triggerFinder, matching: find.byType(Material)).first, ); - expect(trigger.onPressed, isNotNull); - expect(trigger.style, isNull); - expect(tester.getSize(triggerFinder).width, comboBox.width); + expect(restingSurface.color, Colors.transparent); final selectedRect = tester.getRect(find.text('Calendar 1').first); final arrowRect = tester.getRect( @@ -513,13 +763,26 @@ void main() { await tester.tap(triggerFinder); await tester.pumpAndSettle(); + final expandedSemantics = tester.widget( + find.descendant( + of: find.byType(BusyMaxComboRow), + matching: find.byWidgetPredicate( + (widget) => + widget is Semantics && + widget.properties.label == 'Calendar' && + widget.properties.expanded == true, + ), + ), + ); + expect(expandedSemantics.properties.onTap, isNotNull); + expect(expandedSemantics.properties.value, 'Calendar 1'); final firstChoice = _menuItemWithLabel('Calendar 1'); final secondChoice = _menuItemWithLabel('Calendar 2'); expect(firstChoice, findsOneWidget); expect(secondChoice, findsOneWidget); expect( tester.getRect(firstChoice).top, - greaterThanOrEqualTo(tester.getRect(triggerFinder).bottom), + greaterThanOrEqualTo(arrowRect.bottom), ); final visibleMenuItems = find.byType(PopupMenuItem).hitTestable(); expect(visibleMenuItems, findsNWidgets(2)); @@ -535,6 +798,66 @@ void main() { expect(selections, [2]); }); + testWidgets('combo row anchors its native menu to the trailing affordance', ( + tester, + ) async { + MethodCall? showCall; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(_nativeMenuChannel, (call) async { + if (call.method == 'show') { + showCall = call; + return null; + } + return true; + }); + + await tester.pumpWidget( + _testApp( + SizedBox( + width: 560, + child: BusyMaxComboRow( + title: 'Repeat', + values: const ['Never', 'Daily'], + selected: 'Never', + labelFor: (value) => value, + onSelected: (_) {}, + ), + ), + ), + ); + + final combo = find.byType(BusyMaxComboRow); + final triggerFinder = find.descendant( + of: combo, + matching: _comboTriggerFinder(), + ); + final triggerRect = tester.getRect(triggerFinder); + final arrowRect = tester.getRect( + find.descendant( + of: triggerFinder, + matching: find.byIcon(YaruIcons.pan_down), + ), + ); + + await tester.tap(triggerFinder); + await tester.pumpAndSettle(); + + final arguments = showCall!.arguments! as Map; + final rawAnchor = arguments['anchor']! as Map; + final anchor = Rect.fromLTWH( + (rawAnchor['x']! as num).toDouble(), + (rawAnchor['y']! as num).toDouble(), + (rawAnchor['width']! as num).toDouble(), + (rawAnchor['height']! as num).toDouble(), + ); + expect(anchor.left, closeTo(arrowRect.left, 0.01)); + expect(anchor.right, closeTo(arrowRect.right, 0.01)); + expect(anchor.top, closeTo(arrowRect.top, 0.01)); + expect(anchor.bottom, closeTo(arrowRect.bottom, 0.01)); + expect(anchor.left, greaterThan(triggerRect.left)); + expect(anchor.width, lessThan(triggerRect.width)); + }); + testWidgets('disposing a combo dismisses only its native menu session', ( tester, ) async { @@ -629,10 +952,7 @@ void main() { ); final trigger = _comboTriggerFinder(); - final comboBox = tester.widget>( - find.byType(BusyMaxComboBox), - ); - expect(tester.getSize(trigger).width, comboBox.width); + expect(tester.getSize(trigger).width, greaterThan(selectorWidth)); await tester.tap(trigger); await tester.pumpAndSettle(); @@ -698,7 +1018,7 @@ void main() { expect(_comboTriggerFinder(), findsOneWidget); expect( tester.getSize(_comboTriggerFinder()).width, - BusyMaxSizes.comboWidth, + BusyMaxSizes.comboWidth + BusyMaxSpacing.md * 2, ); expect(tester.takeException(), isNull); }); @@ -772,81 +1092,146 @@ void main() { }); for (final brightness in Brightness.values) { - testWidgets('dialogs and popovers use native $brightness surface roles', ( - tester, - ) async { - final theme = BusyMaxYaruTheme.build( - brightness: brightness, - accentColor: const Color(0xFF3584E4), - ); - final colors = theme.extension()!; + testWidgets( + 'editor sheets, alerts, and popovers use native $brightness roles', + (tester) async { + final theme = BusyMaxYaruTheme.build( + brightness: brightness, + accentColor: const Color(0xFF3584E4), + ); + final colors = theme.extension()!; - await tester.pumpWidget( - MaterialApp( - theme: theme, - home: Scaffold( - body: Stack( - children: [ - BusyMaxModalEditorSurface( - child: const SizedBox(width: 240, height: 120), - ), - Align( - alignment: Alignment.bottomCenter, - child: BusyMaxPopoverSurface( - color: colors.popover, - child: const SizedBox(width: 180, height: 80), + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: Scaffold( + body: Stack( + children: [ + BusyMaxModalEditorSurface( + child: const SizedBox(width: 240, height: 120), ), - ), - ], + Align( + alignment: Alignment.bottomCenter, + child: BusyMaxPopoverSurface( + color: colors.popover, + child: const SizedBox(width: 180, height: 80), + ), + ), + ], + ), ), ), - ), - ); + ); - final modalMaterial = tester.widget( - find.descendant( - of: find.byType(BusyMaxModalEditorSurface), - matching: find.byWidgetPredicate( - (widget) => widget is Material && widget.color == colors.dialog, + final modalMaterial = tester.widget( + find.descendant( + of: find.byType(BusyMaxModalEditorSurface), + matching: find.byWidgetPredicate( + (widget) => widget is Material && widget.color == colors.window, + ), ), - ), - ); - final modalDialog = tester.widget( - find.descendant( - of: find.byType(BusyMaxModalEditorSurface), - matching: find.byType(Dialog), - ), - ); - expect(modalDialog.backgroundColor, isNull); - expect(modalDialog.surfaceTintColor, isNull); - expect(modalDialog.elevation, isNull); - expect(modalDialog.shadowColor, isNull); - expect(modalDialog.shape, isNull); - expect(modalMaterial.shape, theme.dialogTheme.shape); - - final physicalShape = tester.widget( - find.descendant( - of: find.byType(BusyMaxPopoverSurface), - matching: find.byType(PhysicalShape), - ), - ); - expect(physicalShape.elevation, BusyMaxElevation.tooltip); - expect(physicalShape.shadowColor, theme.colorScheme.shadow); - final outlinePaint = tester.widget( - find.descendant( + ); + final modalDialog = tester.widget( + find.descendant( + of: find.byType(BusyMaxModalEditorSurface), + matching: find.byType(Dialog), + ), + ); + expect(modalDialog.backgroundColor, colors.window); + expect(modalDialog.surfaceTintColor, colors.window); + expect(modalDialog.elevation, isNull); + expect(modalDialog.shadowColor, isNull); + expect(modalDialog.shape, isNull); + expect(modalMaterial.shape, theme.dialogTheme.shape); + + final physicalShape = tester.widget( + find.descendant( + of: find.byType(BusyMaxPopoverSurface), + matching: find.byType(PhysicalShape), + ), + ); + expect(physicalShape.elevation, BusyMaxElevation.tooltip); + expect(physicalShape.shadowColor, theme.colorScheme.shadow); + final outlinePaint = find.descendant( of: find.byType(BusyMaxPopoverSurface), matching: find.byWidgetPredicate( (widget) => widget is CustomPaint && widget.foregroundPainter != null, ), - ), - ); - expect(outlinePaint.foregroundPainter, isNotNull); - expect(tester.takeException(), isNull); - }); + ); + expect(outlinePaint, findsNothing); + expect(tester.takeException(), isNull); + + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: const Scaffold( + body: AlertDialog( + title: Text('Discard changes?'), + content: Text('Unsaved changes will be lost.'), + ), + ), + ), + ); + + final alertDialog = tester.widget( + find.byType(AlertDialog), + ); + final alertMaterial = tester.widget( + find.descendant( + of: find.byType(AlertDialog), + matching: find.byWidgetPredicate( + (widget) => widget is Material && widget.color == colors.dialog, + ), + ), + ); + expect(alertDialog.backgroundColor, isNull); + expect(alertDialog.surfaceTintColor, isNull); + expect(theme.dialogTheme.backgroundColor, colors.dialog); + expect(alertMaterial.color, colors.dialog); + expect(alertMaterial.shape, theme.dialogTheme.shape); + expect(tester.takeException(), isNull); + }, + ); } - testWidgets('combo row stacks its selector for large text', (tester) async { + testWidgets('popover perimeter is outlined only in high contrast', ( + tester, + ) async { + final theme = BusyMaxYaruTheme.build( + brightness: Brightness.dark, + accentColor: const Color(0xFF3584E4), + highContrast: true, + ); + final colors = theme.extension()!; + + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: MediaQuery( + data: const MediaQueryData(highContrast: true), + child: BusyMaxPopoverSurface( + color: colors.popover, + child: const SizedBox(width: 180, height: 80), + ), + ), + ), + ); + + expect( + find.descendant( + of: find.byType(BusyMaxPopoverSurface), + matching: find.byWidgetPredicate( + (widget) => widget is CustomPaint && widget.foregroundPainter != null, + ), + ), + findsOneWidget, + ); + }); + + testWidgets('combo row keeps its value inline for large text', ( + tester, + ) async { await tester.pumpWidget( MaterialApp( theme: BusyMaxYaruTheme.build( @@ -874,8 +1259,9 @@ void main() { final titleRect = tester.getRect( find.text('Calendar account with a long label'), ); - final triggerRect = tester.getRect(_comboTriggerFinder()); - expect(triggerRect.top, greaterThanOrEqualTo(titleRect.bottom)); + final valueRect = tester.getRect(find.text('Personal calendar')); + expect(valueRect.top, lessThan(titleRect.bottom)); + expect(tester.getSize(_comboTriggerFinder()).width, 760); expect(tester.takeException(), isNull); }); @@ -1222,13 +1608,68 @@ void main() { expect(dialogSemantics.properties.namesRoute, isTrue); expect(dialogSemantics.explicitChildNodes, isTrue); }); + + testWidgets('dialog actions wrap at narrow localized text widths', ( + tester, + ) async { + final theme = BusyMaxYaruTheme.build( + brightness: Brightness.light, + accentColor: const Color(0xFFE95420), + ); + + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: Scaffold( + body: Center( + child: MediaQuery( + data: const MediaQueryData(textScaler: TextScaler.linear(1.4)), + child: SizedBox( + width: 320, + height: 520, + child: Builder( + builder: (context) => BusyMaxDialogShell( + title: 'Unsaved changes', + actions: [ + BusyMaxPushButton.standard( + onPressed: () {}, + child: const Text('Keep editing'), + ), + BusyMaxPushButton.destructive( + context: context, + onPressed: () {}, + child: const Text('Discard changes'), + ), + BusyMaxPushButton.suggested( + onPressed: () {}, + child: const Text('Save changes'), + ), + ], + children: const [Text('Choose how to continue.')], + ), + ), + ), + ), + ), + ), + ), + ); + + expect(tester.takeException(), isNull); + expect(find.byType(OverflowBar), findsOneWidget); + final actionRows = { + for (final label in ['Keep editing', 'Discard changes', 'Save changes']) + tester.getCenter(find.text(label)).dy, + }; + expect(actionRows.length, greaterThan(1)); + }); } void _ignoreBool(bool value) {} Finder _comboTriggerFinder() { return find.byWidgetPredicate( - (widget) => widget is ButtonStyleButton && widget is! IconButton, + (widget) => widget is YaruListTile && widget.focusNode != null, ); } diff --git a/test/app/busymax_menu_button_test.dart b/test/app/busymax_menu_button_test.dart index 53477ad..d1dffff 100644 --- a/test/app/busymax_menu_button_test.dart +++ b/test/app/busymax_menu_button_test.dart @@ -65,12 +65,33 @@ void main() { ), ); + final triggerFinder = find.ancestor( + of: find.byTooltip('Options'), + matching: find.byType(YaruIconButton), + ); + var trigger = tester.widget(triggerFinder); + final colors = theme.extension()!; + expect(trigger.isSelected, isFalse); + expect(trigger.style, isNull); + final yaruStyle = trigger.defaultStyleOf(tester.element(triggerFinder)); + expect(yaruStyle.backgroundColor?.resolve({}), isNull); + expect(yaruStyle.overlayColor?.resolve({WidgetState.hovered}), isNotNull); + expect(yaruStyle.overlayColor?.resolve({WidgetState.pressed}), isNotNull); + await tester.tap(find.byTooltip('Options')); await tester.pumpAndSettle(); expect(find.text('Refresh calendar'), findsOneWidget); expect(find.text('Open in provider'), findsOneWidget); - final colors = theme.extension()!; + trigger = tester.widget(triggerFinder); + expect(trigger.isSelected, isTrue); + expect( + trigger + .defaultStyleOf(tester.element(triggerFinder)) + .backgroundColor + ?.resolve({WidgetState.selected}), + isNotNull, + ); expect(find.byType(MenuAnchor), findsNothing); expect(find.byType(MenuItemButton), findsNothing); expect( @@ -87,6 +108,8 @@ void main() { controller.close(); await tester.pumpAndSettle(); + trigger = tester.widget(triggerFinder); + expect(trigger.isSelected, isFalse); expect(find.text('Refresh calendar'), findsNothing); expect(selected, isNull); @@ -140,6 +163,7 @@ void main() { ), ); + final triggerRect = tester.getRect(find.byType(YaruIconButton)); await tester.tap(find.byTooltip('Options')); await tester.pump(); @@ -149,7 +173,14 @@ void main() { expect(calls, hasLength(1)); expect(calls.single.method, 'show'); final arguments = calls.single.arguments! as Map; - expect(arguments['anchor'], isA>()); + final rawAnchor = arguments['anchor']! as Map; + final anchor = Rect.fromLTWH( + (rawAnchor['x']! as num).toDouble(), + (rawAnchor['y']! as num).toDouble(), + (rawAnchor['width']! as num).toDouble(), + (rawAnchor['height']! as num).toDouble(), + ); + expect(anchor, triggerRect); expect(arguments['entries'], [ {'label': 'Refresh calendar', 'enabled': true, 'selected': true}, {'label': 'Open in provider', 'enabled': true, 'selected': false}, diff --git a/test/app/high_contrast_theme_test.dart b/test/app/high_contrast_theme_test.dart index 626cc3d..f8f5b42 100644 --- a/test/app/high_contrast_theme_test.dart +++ b/test/app/high_contrast_theme_test.dart @@ -1,4 +1,5 @@ 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/gtk_font_service.dart'; import 'package:flutter/material.dart'; @@ -52,6 +53,7 @@ void main() { expect(surfaces.disabledForeground, isNot(surfaces.foreground)); expect(surfaces.border, surfaces.foreground); expect(surfaces.divider, surfaces.foreground); + expect(surfaces.cardShade, surfaces.foreground); expect(surfaces.floatingBorder, surfaces.foreground); expect(surfaces.sidebarBorder, surfaces.foreground); expect(theme.colorScheme.outline, surfaces.foreground); @@ -73,6 +75,13 @@ void main() { final popupShape = theme.popupMenuTheme.shape! as OutlineInputBorder; expect(popupShape.borderSide.color, surfaces.border); + final dialogShape = theme.dialogTheme.shape! as RoundedRectangleBorder; + expect(dialogShape.side.color, surfaces.border); + expect(theme.menuTheme.style?.side?.resolve({})?.color, surfaces.border); + expect( + theme.dropdownMenuTheme.menuStyle?.side?.resolve({})?.color, + surfaces.border, + ); final tooltipDecoration = theme.tooltipTheme.decoration! as BoxDecoration; expect(tooltipDecoration.border, isNotNull); @@ -102,6 +111,35 @@ void main() { expect(surfaces.foreground, Colors.black); }); + testWidgets('calendar grids retain the high-contrast outline', ( + tester, + ) async { + for (final brightness in Brightness.values) { + final theme = buildBusyMaxTheme( + brightness: brightness, + accentColor: _testAccent, + highContrast: true, + ); + Color? gridColor; + + await tester.pumpWidget( + MaterialApp( + home: Theme( + data: theme, + child: Builder( + builder: (context) { + gridColor = busyMaxCalendarGridColor(context); + return const SizedBox(); + }, + ), + ), + ), + ); + + expect(gridColor, theme.colorScheme.outlineVariant); + } + }); + test('standard themes retain the requested system accent', () { const accent = Color(0xFF3584E4); final theme = buildBusyMaxTheme( diff --git a/test/app/native_ui_audit_test.dart b/test/app/native_ui_audit_test.dart index d9ca2bb..7c80281 100644 --- a/test/app/native_ui_audit_test.dart +++ b/test/app/native_ui_audit_test.dart @@ -134,7 +134,7 @@ void main() { expect(design, contains('final bool filled;')); expect(design, contains('BusyMaxSurfaceColors.of(context)')); expect(design, contains('surfaceColors.card')); - expect(design, contains('surfaceColors.groupedSurface')); + expect(design, contains('CardTheme.of(context)')); expect(design, contains('surfaceColors.control')); expect(design, contains('YaruListTile.square(')); expect(design, isNot(contains('class _BusyMaxRowTile'))); @@ -402,7 +402,7 @@ void main() { expect(main, isNot(contains('await windowManager.show();'))); }); - test('native headerbar keeps sidebar top branded and aligned', () { + 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', @@ -424,9 +424,9 @@ void main() { ); expect(source, isNot(contains('create_header_logo()'))); expect(source, contains('header_sidebar_brand_box')); - expect(source, contains('header_title_balance_spacer')); + expect(source, isNot(contains('header_title_balance_spacer'))); expect(source, contains('header_title_box')); - expect(source, contains('kHeaderWindowControlsBalanceWidth')); + expect(source, isNot(contains('kHeaderWindowControlsBalanceWidth'))); expect(source, contains('kHeaderOnboardingContentWidth')); expect(source, contains('kHeaderOnboardingSideWidth')); expect(source, contains('onboarding_back_slot')); @@ -446,13 +446,8 @@ void main() { expect(schedule, contains('if (_headerBarSession.isAvailable)')); expect(schedule, contains('_headerBarSession.setOnboardingControls(')); expect(schedule, contains('force: true')); - expect( - source, - contains( - 'gtk_header_bar_pack_start(header_bar, self->header_title_balance_spacer)', - ), - ); - expect(source, contains('update_header_title_balance_spacer')); + 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( @@ -633,7 +628,8 @@ void main() { expect(source, isNot(contains('kHeaderTooltipVerticalPadding'))); expect(source, isNot(contains('kHeaderTooltipHorizontalPadding'))); expect(source, isNot(contains('"padding: %dpx %dpx;"'))); - expect(source, isNot(contains('"border-color: transparent;"'))); + 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'))); @@ -692,8 +688,11 @@ void main() { 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('header_bar_popover_background_color'))); - expect(source, isNot(contains('"popoverBackgroundColor"'))); + expect(source, contains('"busymax-native-popover"')); + expect(source, contains('header_bar_popover_background_color')); + expect(source, contains('header_bar_floating_border_color')); + expect(source, contains('"popoverBackgroundColor"')); + expect(source, contains('"floatingBorderColor"')); expect(source, isNot(contains('"busymax-header-popover-row"'))); expect(source, isNot(contains('kHeaderPopoverRowSpacing'))); expect(source, isNot(contains('busymax-keyboard-focus'))); @@ -797,10 +796,13 @@ void main() { 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, contains('header_bar_popover_background_color')); + expect(source, 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, contains('modal_sidebar_border_css_color')); expect(source, contains('composite_rgba')); expect(source, isNot(contains('header_bar_shade_color'))); @@ -891,17 +893,24 @@ void main() { expect(source, contains('"card_bg_color"')); expect(source, contains('"dialog_bg_color"')); expect(source, contains('"popover_bg_color"')); - expect(source, contains('GtkWidget* sidebar =')); expect(source, contains('GtkWidget* popover =')); - expect(source, contains('sample_widget_background(sidebar')); - expect(source, contains('sample_widget_background(popover')); + expect(source, isNot(contains('GtkWidget* sidebar = gtk_box_new('))); expect( - source.indexOf('"sidebar_bg_color"'), - lessThan(source.indexOf('sample_widget_background(sidebar')), + source, + isNot(contains('GtkWidget* header = gtk_header_bar_new()')), ); expect( - source.indexOf('"popover_bg_color"'), - lessThan(source.indexOf('sample_widget_background(popover')), + 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"')); @@ -990,6 +999,7 @@ void main() { expect(nativeMenu, contains('gtk_popover_new_from_model(')); expect(nativeMenu, contains('gtk_popover_set_pointing_to(')); expect(nativeMenu, contains('gtk_popover_set_modal(')); + expect(nativeMenu, contains('style_native_popover(session->popover)')); expect(nativeMenu, contains('g_simple_action_set_enabled(')); expect(nativeMenu, contains('g_simple_action_new_stateful(')); expect(nativeMenu, contains('g_object_ref(G_OBJECT(method_call))')); @@ -1044,7 +1054,7 @@ void main() { expect(confirmBody, isNot(contains('return BusyMaxDialogShell('))); }); - test('modal editors delegate their surface styling to DialogTheme', () { + test('modal editors use the semantic window role with themed geometry', () { final design = File('lib/src/app/busymax_design.dart').readAsStringSync(); final start = design.indexOf('class BusyMaxModalEditorSurface'); final end = design.indexOf('class BusyMaxInlineBadge', start); @@ -1053,12 +1063,16 @@ void main() { final surface = design.substring(start, end); expect(surface, contains('return Dialog(')); + expect(surface, contains('Theme.of(context).scaffoldBackgroundColor')); + expect(surface, contains('backgroundColor: editorSurface')); + expect(surface, contains('surfaceTintColor: editorSurface')); expect(surface, isNot(contains('floatingBorder'))); expect(surface, isNot(contains('BorderSide('))); expect(surface, isNot(contains('BusyMaxElevation'))); + expect(surface, isNot(contains('Color(0x'))); }); - test('native headerbar CSS is limited to semantic surfaces', () { + test('native headerbar CSS uses scoped semantic surfaces and states', () { final source = File('linux/runner/my_application.cc').readAsStringSync(); final headerCssStart = source.indexOf( 'g_autofree gchar* css = g_strdup_printf(', @@ -1070,6 +1084,19 @@ void main() { expect(headerCssStart, isNonNegative); expect(headerCssEnd, isNonNegative); final headerCss = source.substring(headerCssStart, headerCssEnd); + final nativePopoverCssStart = source.indexOf( + 'g_autofree gchar* native_popover_border_css =', + ); + final nativePopoverCssEnd = source.indexOf( + 'const gchar* modal_barrier_color', + nativePopoverCssStart, + ); + expect(nativePopoverCssStart, isNonNegative); + expect(nativePopoverCssEnd, isNonNegative); + final nativePopoverCss = source.substring( + nativePopoverCssStart, + nativePopoverCssEnd, + ); expect(source, contains('"busymax-header-title"')); expect(source, contains('".busymax-titlebar .busymax-header-title {"')); @@ -1098,7 +1125,7 @@ void main() { expect(source, contains('kDefaultWindowBackgroundColor[] = "#2C2C2C"')); expect( source, - contains('kDefaultHeaderBarBackgroundColor[] = "#1D1D20"'), + contains('kDefaultHeaderBarBackgroundColor[] = "#272727"'), ); expect( source, @@ -1119,6 +1146,9 @@ void main() { expect(source, contains('"sidebarBackgroundColor"')); expect(source, contains('"sidebarBorderColor"')); expect(source, contains('"foregroundColor"')); + expect(source, contains('"popoverBackgroundColor"')); + expect(source, contains('"floatingBorderColor"')); + expect(source, contains('"highContrast"')); expect(source, isNot(contains('"shadeColor"'))); expect(source, contains('"modalBarrierColor"')); expect( @@ -1135,16 +1165,64 @@ void main() { ); expect( source, - isNot(contains('fl_lookup_string_arg(args, "popoverBackgroundColor")')), + contains('fl_lookup_string_arg(args, "popoverBackgroundColor")'), + ); + expect( + source, + contains('fl_lookup_string_arg(args, "floatingBorderColor")'), ); + expect( + source, + contains('fl_lookup_optional_bool_arg(args, "highContrast"'), + ); + expect(source, contains('"popover.background.%s,"')); + expect(source, contains('"background-color: %s;"')); + expect(nativePopoverCss, contains('kNativePopoverStyleClass')); + expect(nativePopoverCss, contains('"background-color: %s;"')); + expect(nativePopoverCss, contains('g_strdup_printf("border-color: %s;"')); + expect(nativePopoverCss, contains('g_strdup("border: none;")')); + expect(nativePopoverCss, isNot(contains('box-shadow'))); + expect(nativePopoverCss, isNot(contains('border-radius'))); + expect(nativePopoverCss, isNot(contains('padding'))); + expect(nativePopoverCss, isNot(contains('outline'))); + expect(nativePopoverCss, isNot(contains('modelbutton'))); + expect(nativePopoverCss, isNot(contains('#'))); + expect(source, contains('style_native_popover(session->popover)')); + expect(source, contains('style_native_popover(GTK_WIDGET(popover))')); expect(headerCss, isNot(contains('.busymax-titlebar button'))); + 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, isNot(contains('tooltip.background'))); - expect(headerCss, isNot(contains(':hover'))); - expect(headerCss, isNot(contains(':active'))); - expect(headerCss, isNot(contains(':checked'))); + expect(headerCss, contains(':hover')); + expect(headerCss, contains(':active')); + expect(headerCss, contains(':checked')); expect(headerCss, isNot(contains(':focus'))); - expect(headerCss, isNot(contains(':disabled'))); + expect(headerCss, contains(':disabled')); expect(headerCss, isNot(contains('box-shadow: inset'))); expect(headerCss, isNot(contains('transition: none'))); expect(headerCss, isNot(contains('text-shadow: none'))); @@ -1251,7 +1329,12 @@ void main() { 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('gtk_separator_new(GTK_ORIENTATION_HORIZONTAL)')); expect(source, contains('sample_widget_background(separator')); expect(source, isNot(contains('divider_color.alpha *='))); @@ -1367,41 +1450,48 @@ void main() { expect(fallbackBody, isNot(contains('constraints:'))); }); - test('form combo delegates its menu to the shared native adapter', () { + test('form combo is a native-style row using the shared menu adapter', () { final source = File('lib/src/app/busymax_design.dart').readAsStringSync(); - final comboStart = source.indexOf('class BusyMaxComboBox'); final rowStart = source.indexOf('class BusyMaxComboRow'); final rowEnd = source.indexOf('class BusyMaxSwitchRow'); - expect(comboStart, isNonNegative); - expect(rowStart, greaterThan(comboStart)); + expect(rowStart, isNonNegative); expect(rowEnd, greaterThan(rowStart)); - final comboBody = source.substring(comboStart, rowStart); final rowBody = source.substring(rowStart, rowEnd); - expect(comboBody, contains('BusyMaxPushButton.standard(')); - expect(comboBody, contains('showBusyMaxMenu(')); - expect(comboBody, contains('BusyMaxMenuEntry(')); - expect(comboBody, contains('selected: value == selected')); - expect(comboBody, contains('NativeMenuService')); - expect(comboBody, contains('YaruIcons.pan_down')); - expect(comboBody, contains('YaruIcons.pan_up')); - expect(comboBody, isNot(contains('DropdownMenu'))); - expect(comboBody, isNot(contains('DropdownMenuEntry'))); - expect(comboBody, isNot(contains('YaruPopupMenuButton'))); - expect(comboBody, isNot(contains('PopupMenuItem'))); - expect(comboBody, isNot(contains('inputDecorationTheme:'))); - expect(comboBody, isNot(contains('menuStyle:'))); - expect(comboBody, isNot(contains('MenuAnchor('))); - expect(comboBody, isNot(contains('OutlinedButton('))); - expect(comboBody, isNot(contains('RoundedRectangleBorder'))); - expect(comboBody, isNot(contains('BusyMaxElevation'))); - expect(rowBody, contains('BusyMaxComboBox(')); - expect(rowBody, isNot(contains('BusyMaxMenuButton('))); + expect(rowBody, contains('BusyMaxMenuButton(')); + expect(rowBody, contains('BusyMaxMenuEntry(')); + expect(rowBody, contains('selected: value == selected')); + expect(rowBody, contains('triggerBuilder:')); + expect(rowBody, contains('trigger.anchor(')); + expect(rowBody, contains('YaruListTile.square(')); + expect(rowBody, contains('onTap: trigger.onPressed')); + expect(rowBody, contains('focusNode: trigger.focusNode')); + expect(rowBody, contains('YaruIcons.pan_down')); + expect(rowBody, isNot(contains('YaruIcons.pan_up'))); + expect(rowBody, isNot(contains('BusyMaxPushButton.standard('))); + expect(rowBody, isNot(contains('ButtonStyleButton'))); + expect(rowBody, isNot(contains('DropdownMenu'))); + expect(rowBody, isNot(contains('DropdownMenuEntry'))); + expect(rowBody, isNot(contains('YaruPopupMenuButton'))); + expect(rowBody, isNot(contains('PopupMenuItem'))); expect(rowBody, isNot(contains('OutlinedButton('))); + expect(rowBody, isNot(contains('BusyMaxElevation'))); expect(rowBody, isNot(contains('opacity: 0.6'))); }); + test('boxed-list rows use the dedicated native card-shade role', () { + final source = File('lib/src/app/busymax_design.dart').readAsStringSync(); + final start = source.indexOf('class _BusyMaxGroupedListSurface'); + final end = source.indexOf('typedef BusyMaxRowActivationCallback', start); + + expect(start, isNonNegative); + expect(end, greaterThan(start)); + final body = source.substring(start, end); + expect(body, contains('color: surfaceColors.cardShade')); + expect(body, isNot(contains('color: surfaceColors.divider'))); + }); + test('time mode delegates the complete mode control to Yaru', () { final source = File('lib/src/app/busymax_design.dart').readAsStringSync(); final start = source.indexOf('class BusyMaxModeSwitcher'); @@ -1455,7 +1545,7 @@ void main() { expect( _hasRawDropdownMenu(line), isFalse, - reason: '$location should use BusyMaxComboBox.', + reason: '$location should use BusyMaxComboRow.', ); expect( _hasRawMenuItemButton(line), @@ -1489,7 +1579,7 @@ void main() { expect(line, isNot(contains('AppBar(')), reason: location); expect(line, isNot(contains('TextButton.icon')), reason: location); expect( - _hasRawIconButton(line), + _hasRawIconButton(file, line), isFalse, reason: '$location should use YaruIconButton.', ); @@ -1547,9 +1637,13 @@ bool _hasRawSwitch(String line) { return line.contains('Switch(') && !line.contains('YaruSwitch('); } -bool _hasRawIconButton(String line) { +bool _hasRawIconButton(File file, String line) { + if (file.path.endsWith('lib/src/app/busymax_design.dart')) { + return false; + } return line.contains('IconButton(') && !line.contains('YaruIconButton(') && + !line.contains('BusyMaxHeaderIconButton(') && !line.contains('BusyMaxPopoverIconButton('); } diff --git a/test/app/system_accent_test.dart b/test/app/system_accent_test.dart index dfb3c1a..99945ed 100644 --- a/test/app/system_accent_test.dart +++ b/test/app/system_accent_test.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:busymax/src/app/system_accent.dart'; import 'package:dbus/dbus.dart'; import 'package:flutter/material.dart'; @@ -28,4 +30,114 @@ void main() { expect(ubuntuAccentNameColor('orange'), YaruColors.orange); expect(colorFromUbuntuAccentNameValue(const DBusString('unknown')), isNull); }); + + test('falls back to the Ubuntu setting when the RGB read fails', () async { + var readGnome = false; + + final color = await readPreferredLinuxAccentColor( + readFreedesktop: () => Future.error( + StateError('freedesktop accent key is unavailable'), + ), + readGnome: () async { + readGnome = true; + return YaruVariant.orange.color; + }, + ); + + expect(color, YaruVariant.orange.color); + expect(readGnome, isTrue); + }); + + test('does not read the named fallback after an exact RGB result', () async { + var readGnome = false; + const exactRgb = Color(0xFF336699); + + final color = await readPreferredLinuxAccentColor( + readFreedesktop: () async => exactRgb, + readGnome: () async { + readGnome = true; + return YaruVariant.blue.color; + }, + ); + + expect(color, exactRgb); + expect(readGnome, isFalse); + }); + + test('an exact RGB signal remains authoritative over named signals', () { + final resolver = LinuxAccentChangeResolver(); + final exactRgb = DBusStruct([ + const DBusDouble(0.2), + const DBusDouble(0.4), + const DBusDouble(0.6), + ]); + + expect( + resolver.resolve( + 'org.gnome.desktop.interface', + const DBusString('orange'), + ), + YaruVariant.orange.color, + ); + expect( + resolver.resolve('org.freedesktop.appearance', exactRgb), + const Color(0xFF336699), + ); + expect( + resolver.resolve('org.gnome.desktop.interface', const DBusString('blue')), + isNull, + ); + }); + + test('subscribes before reading the initial accent snapshot', () async { + var subscribed = false; + final snapshot = Completer(); + final changes = StreamController( + onListen: () => subscribed = true, + ); + final colors = watchPreferredLinuxAccentColors( + readFreedesktop: () => snapshot.future, + readGnome: () async => YaruVariant.orange.color, + changes: changes.stream, + ).toList(); + + await Future.delayed(Duration.zero); + expect(subscribed, isTrue); + changes.add(( + namespace: 'org.freedesktop.appearance', + value: DBusStruct([ + const DBusDouble(0.2), + const DBusDouble(0.4), + const DBusDouble(0.6), + ]), + )); + snapshot.complete(null); + await changes.close(); + + expect(await colors, [YaruVariant.orange.color, const Color(0xFF336699)]); + }); + + test( + 'an initial exact accent cannot be downgraded by a named signal', + () async { + const exactRgb = Color(0xFF336699); + final snapshot = Completer(); + final changes = StreamController(); + final colors = watchPreferredLinuxAccentColors( + readFreedesktop: () => snapshot.future, + readGnome: () async => YaruVariant.orange.color, + changes: changes.stream, + ).toList(); + + await Future.delayed(Duration.zero); + changes.add(( + namespace: 'org.gnome.desktop.interface', + value: const DBusString('blue'), + )); + snapshot.complete(exactRgb); + await changes.close(); + + expect(await colors, [exactRgb]); + }, + ); } diff --git a/test/app/theme_localization_test.dart b/test/app/theme_localization_test.dart index a3f1c62..4dbd35f 100644 --- a/test/app/theme_localization_test.dart +++ b/test/app/theme_localization_test.dart @@ -8,6 +8,7 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:system_theme/system_theme.dart'; import 'package:yaru/constants.dart'; import 'package:yaru/theme.dart'; +import 'package:yaru/widgets.dart' show YaruInfoType; import 'package:busymax/l10n/generated/app_localizations.dart'; import 'package:busymax/src/app/app_bootstrap.dart'; import 'package:busymax/src/app/busymax_yaru_theme.dart'; @@ -69,6 +70,10 @@ void main() { light.filledButtonTheme.style?.backgroundColor?.resolve({}), lightSurfaceColors.control, ); + expect( + light.filledButtonTheme.style?.backgroundColor?.resolve(selected), + lightSurfaceColors.controlActive, + ); expect( light.filledButtonTheme.style?.iconColor?.resolve({}), lightSurfaceColors.foreground, @@ -155,6 +160,16 @@ void main() { ); expect(inputShape.borderSide.color, colors.border); + expect(theme.cardTheme.color, colors.card); + expect(theme.cardTheme.color?.a, 1); + expect(theme.cardTheme.surfaceTintColor, Colors.transparent); + expect(theme.cardTheme.shadowColor, theme.colorScheme.shadow); + expect(theme.cardTheme.elevation, BusyMaxElevation.card); + expect(theme.cardTheme.margin, base.cardTheme.margin); + expect(theme.cardTheme.clipBehavior, base.cardTheme.clipBehavior); + final cardShape = theme.cardTheme.shape! as RoundedRectangleBorder; + expect(cardShape.borderRadius, BorderRadius.circular(BusyMaxRadius.md)); + expect( theme.dropdownMenuTheme.inputDecorationTheme?.constraints, base.dropdownMenuTheme.inputDecorationTheme?.constraints, @@ -164,7 +179,7 @@ void main() { final baseDialogShape = base.dialogTheme.shape! as RoundedRectangleBorder; expect(dialogShape.borderRadius, baseDialogShape.borderRadius); expect(dialogShape.borderRadius, BorderRadius.circular(kYaruWindowRadius)); - expect(dialogShape.side, baseDialogShape.side); + expect(dialogShape.side, BorderSide.none); expect(BusyMaxRadius.window, kYaruWindowRadius); for (final pair in [ @@ -194,10 +209,15 @@ void main() { final popupShape = theme.popupMenuTheme.shape! as OutlineInputBorder; final basePopupShape = base.popupMenuTheme.shape! as OutlineInputBorder; expect(popupShape.borderRadius, basePopupShape.borderRadius); - expect(popupShape.borderSide, basePopupShape.borderSide); + expect(popupShape.borderSide, BorderSide.none); expect(theme.popupMenuTheme.elevation, base.popupMenuTheme.elevation); expect(theme.popupMenuTheme.menuPadding, base.popupMenuTheme.menuPadding); expect(theme.popupMenuTheme.position, base.popupMenuTheme.position); + expect(theme.menuTheme.style?.side?.resolve({}), BorderSide.none); + expect( + theme.dropdownMenuTheme.menuStyle?.side?.resolve({}), + BorderSide.none, + ); for (final style in [ theme.textTheme.titleSmall, @@ -211,6 +231,51 @@ void main() { } }); + testWidgets('semantic Yaru status colors do not follow the app accent', ( + tester, + ) async { + for (final brightness in Brightness.values) { + Map? colorsForFirstAccent; + for (final accent in const [Color(0xFFE95420), Color(0xFF7764D8)]) { + final theme = _buildBusyMaxTheme( + brightness: brightness, + accentColor: accent, + ); + await tester.pumpWidget( + MaterialApp( + theme: theme, + darkTheme: theme, + themeMode: brightness == Brightness.dark + ? ThemeMode.dark + : ThemeMode.light, + home: const Scaffold( + body: SizedBox(key: ValueKey('status-color-probe')), + ), + ), + ); + + final context = tester.element( + find.byKey(const ValueKey('status-color-probe')), + ); + final semanticColors = YaruColors.of(context); + final actual = { + for (final type in YaruInfoType.values) type: type.getColor(context), + }; + + expect(actual, { + YaruInfoType.information: semanticColors.link, + YaruInfoType.success: semanticColors.success, + YaruInfoType.important: YaruColors.purple, + YaruInfoType.warning: semanticColors.warning, + YaruInfoType.danger: semanticColors.error, + }); + colorsForFirstAccent ??= actual; + expect(actual, colorsForFirstAccent); + expect(actual[YaruInfoType.information], isNot(accent)); + } + } + }); + test('shared push buttons expose semantic Yaru roles', () { final standard = BusyMaxPushButton.standard( onPressed: () {}, @@ -234,31 +299,109 @@ void main() { final lightColors = light.extension()!; final darkColors = dark.extension()!; - expect(lightColors.window, const Color(0xFFFAFAFB)); + expect(lightColors.window, const Color(0xFFFAFAFA)); expect(lightColors.view, const Color(0xFFFFFFFF)); - expect(lightColors.sidebar, const Color(0xFFEBEBED)); + expect(lightColors.sidebar, const Color(0xFFEBEBEB)); + expect(lightColors.secondarySidebar, const Color(0xFFF0F0F0)); + expect(lightColors.headerbar, const Color(0xFFFAFAFA)); expect(lightColors.card, const Color(0xFFFFFFFF)); expect(lightColors.groupedSurface, const Color(0xFFFFFFFF)); - expect(lightColors.dialog, const Color(0xFFFAFAFB)); - expect(lightColors.popover, const Color(0xFFFFFFFF)); + expect(lightColors.dialog, const Color(0xFFFAFAFA)); + expect(lightColors.popover, const Color(0xFFFAFAFA)); expect(lightColors.control, const Color.fromRGBO(0, 0, 0, 0.10)); expect(lightColors.controlHover, const Color.fromRGBO(0, 0, 0, 0.14)); expect(lightColors.controlActive, const Color.fromRGBO(0, 0, 0, 0.18)); - expect(darkColors.window, const Color(0xFF222226)); - expect(darkColors.view, const Color(0xFF222226)); - expect(darkColors.sidebar, const Color(0xFF2E2E32)); - expect(darkColors.secondarySidebar, const Color(0xFF28282C)); - expect(darkColors.headerbar, const Color(0xFF2E2E32)); - expect(darkColors.card, const Color(0xFF36363A)); - expect(darkColors.groupedSurface, const Color(0xFF36363A)); - expect(darkColors.dialog, const Color(0xFF36363A)); - expect(darkColors.popover, const Color(0xFF36363A)); - expect(darkColors.sidebarBorder, const Color.fromRGBO(255, 255, 255, 0.10)); - expect(darkColors.view, isNot(const Color(0xFF36363A))); + expect(lightColors.mutedForeground, const Color(0xFF666666)); + expect(lightColors.sidebarBorder, const Color.fromRGBO(24, 24, 24, 0.08)); + expect(darkColors.window, const Color(0xFF2C2C2C)); + expect(darkColors.view, const Color(0xFF272727)); + expect(darkColors.sidebar, const Color(0xFF393939)); + expect(darkColors.secondarySidebar, const Color(0xFF323232)); + expect(darkColors.headerbar, const Color(0xFF393939)); + expect(darkColors.card, const Color(0xFF3D3D3D)); + expect( + darkColors.groupedSurface, + const Color.fromRGBO(255, 255, 255, 0.08), + ); + expect(darkColors.dialog, const Color(0xFF3E3E3E)); + expect(darkColors.popover, const Color(0xFF3E3E3E)); + expect(darkColors.mutedForeground, const Color(0xFFB5B5B5)); + expect(darkColors.border, const Color.fromRGBO(0, 0, 0, 0.75)); + expect(darkColors.sidebarBorder, const Color.fromRGBO(16, 16, 16, 0.35)); + expect( + Color.alphaBlend(darkColors.sidebarBorder, darkColors.sidebar).toARGB32(), + const Color(0xFF2B2B2B).toARGB32(), + ); + expect(darkColors.shade, const Color.fromRGBO(0, 0, 0, 0.25)); + expect(darkColors.groupedSurface.a, lessThan(1)); + expect( + Color.alphaBlend(darkColors.groupedSurface, darkColors.window).toARGB32(), + darkColors.card.toARGB32(), + ); + expect( + Color.alphaBlend( + darkColors.groupedSurface, + darkColors.dialog, + ).computeLuminance(), + greaterThan(darkColors.dialog.computeLuminance()), + ); + for (final (theme, colors) in [(light, lightColors), (dark, darkColors)]) { + _expectOpaqueMonotonicSurfaceContainers(theme); + expect(theme.colorScheme.surfaceContainerLowest, colors.view); + expect(theme.colorScheme.surfaceContainerLow, colors.window); + expect(theme.colorScheme.surfaceContainer, colors.secondarySidebar); + expect(theme.colorScheme.surfaceContainerHigh, colors.secondarySidebar); + expect(theme.colorScheme.surfaceContainerHighest, colors.sidebar); + expect(colors.mutedForeground.a, 1); + final effectiveGroupedSurfaces = [ + for (final parent in [ + colors.window, + colors.view, + colors.dialog, + colors.popover, + ]) + Color.alphaBlend(colors.groupedSurface, parent), + ]; + for (final surface in [ + colors.window, + colors.view, + colors.sidebar, + colors.secondarySidebar, + colors.headerbar, + colors.headerbarFlat, + colors.card, + colors.dialog, + colors.popover, + ]) { + _expectNeutralSurface(surface); + expect( + _contrastRatio(colors.mutedForeground, surface), + greaterThanOrEqualTo(4.5), + reason: 'Muted text must remain legible on $surface', + ); + } + for (final surface in effectiveGroupedSurfaces) { + _expectNeutralSurface(surface); + expect( + _contrastRatio(colors.mutedForeground, surface), + greaterThanOrEqualTo(4), + reason: + 'Native contextual card layers must retain clear secondary text', + ); + } + } expect(light.scaffoldBackgroundColor, lightColors.window); expect(dark.scaffoldBackgroundColor, darkColors.window); expect(light.cardColor, lightColors.card); expect(dark.cardColor, darkColors.card); + expect(light.cardTheme.color, lightColors.card); + expect(dark.cardTheme.color, darkColors.card); + expect(light.cardTheme.color?.a, 1); + expect(dark.cardTheme.color?.a, 1); + expect(light.cardTheme.elevation, BusyMaxElevation.card); + expect(dark.cardTheme.elevation, BusyMaxElevation.card); + expect(light.cardTheme.shadowColor, light.colorScheme.shadow); + expect(dark.cardTheme.shadowColor, dark.colorScheme.shadow); expect(light.dialogTheme.backgroundColor, lightColors.dialog); expect(dark.dialogTheme.backgroundColor, darkColors.dialog); expect(light.popupMenuTheme.color, lightColors.popover); @@ -294,10 +437,7 @@ void main() { pair.$1?.shape?.resolve(const {}), pair.$2?.shape?.resolve(const {}), ); - expect( - pair.$1?.side?.resolve(const {}), - pair.$2?.side?.resolve(const {}), - ); + expect(pair.$1?.side?.resolve(const {}), BorderSide.none); expect( pair.$1?.padding?.resolve(const {}), pair.$2?.padding?.resolve(const {}), @@ -633,7 +773,7 @@ void main() { sidebar: Color(0xFF303030), headerbar: Color(0xFF282828), headerbarFlat: Color(0xFF242424), - card: Color(0xFF2C2C2C), + card: Color(0xFF444444), dialog: Color(0xFF343434), popover: Color(0xFF383838), control: Color(0x1AFFFFFF), @@ -646,6 +786,7 @@ void main() { disabledControl: Color(0x0FFFFFFF), border: Color(0x66000000), divider: Color(0x1AFFFFFF), + cardShade: Color(0x5A101010), floatingBorder: Color(0x24000000), sidebarBorder: Color(0x33000000), shade: Color(0x55000000), @@ -657,9 +798,12 @@ void main() { expect(theme.scaffoldBackgroundColor, gtkColors.window); expect(theme.colorScheme.surface, gtkColors.view); - expect(theme.colorScheme.surfaceContainer, gtkColors.card); - expect(theme.colorScheme.surfaceContainerHigh, gtkColors.control); - expect(theme.colorScheme.surfaceContainerHighest, gtkColors.controlHover); + _expectOpaqueMonotonicSurfaceContainers(theme); + expect(theme.colorScheme.surfaceContainerHigh, isNot(gtkColors.control)); + expect( + theme.colorScheme.surfaceContainerHighest, + isNot(gtkColors.controlHover), + ); expect(theme.colorScheme.onSurface, gtkColors.foreground); expect(theme.colorScheme.onSurfaceVariant, gtkColors.mutedForeground); expect(theme.dialogTheme.backgroundColor, gtkColors.dialog); @@ -667,6 +811,7 @@ void main() { final colors = theme.extension()!; expect(colors.sidebar, gtkColors.sidebar); expect(colors.groupedSurface, gtkColors.card); + expect(colors.cardShade, gtkColors.cardShade); }); test('BusyMax theme uses GTK accent foreground when it is readable', () { @@ -714,7 +859,7 @@ void main() { expect(theme.popupMenuTheme.shadowColor, isNot(colors.shade)); }); - test('BusyMax rejects a flat light GTK3 sidebar sample', () { + test('BusyMax preserves a flat light GTK3 sidebar role', () { const gtkColors = GtkThemeColors( brightness: Brightness.light, window: Color(0xFFFAFAFA), @@ -726,11 +871,7 @@ void main() { gtkThemeColors: gtkColors, ).extension()!; - expect( - colors.sidebar, - busyMaxFallbackSurfaceColors(Brightness.light).sidebar, - ); - expect(colors.sidebar, isNot(gtkColors.sidebar)); + expect(colors.sidebar, gtkColors.sidebar); }); test('BusyMax preserves a distinct light GTK sidebar sample', () { @@ -771,7 +912,7 @@ void main() { ); }); - test('BusyMax rejects recessed legacy GTK3 sidebar and popover samples', () { + test('BusyMax preserves explicitly supplied readable GTK surface roles', () { const gtkColors = GtkThemeColors( brightness: Brightness.dark, window: Color(0xFF2C2C2C), @@ -785,20 +926,8 @@ void main() { gtkThemeColors: gtkColors, ); final colors = theme.extension()!; - final fallback = busyMaxFallbackSurfaceColors(Brightness.dark); - - expect(colors.sidebar, fallback.sidebar); - expect(colors.popover, fallback.popover); - expect(colors.sidebar, isNot(gtkColors.sidebar)); - expect(colors.popover, isNot(gtkColors.popover)); - expect( - colors.sidebar.computeLuminance(), - greaterThan(theme.colorScheme.surface.computeLuminance()), - ); - expect( - colors.popover.computeLuminance(), - greaterThan(theme.colorScheme.surface.computeLuminance()), - ); + expect(colors.sidebar, gtkColors.sidebar); + expect(colors.popover, gtkColors.popover); expect(theme.popupMenuTheme.color, colors.popover); expect( theme.menuTheme.style?.backgroundColor?.resolve(const {}), @@ -810,61 +939,100 @@ void main() { ); }); - test('BusyMax composites translucent card roles over the window surface', () { + test('BusyMax does not tint readable neutral GTK semantic roles', () { const gtkColors = GtkThemeColors( brightness: Brightness.dark, window: Color(0xFF2C2C2C), - view: Color(0xFF1D1D20), + view: Color(0xFF272727), + sidebar: Color(0xFF2C2C2C), + headerbar: Color(0xFF131313), + dialog: Color(0xFF2C2C2C), + popover: Color(0xFF1D1D1D), + foreground: Color(0xFFF7F7F7), + ); + final colors = _buildBusyMaxTheme( + brightness: Brightness.dark, + gtkThemeColors: gtkColors, + ).extension()!; + + expect(colors.window, gtkColors.window); + expect(colors.view, gtkColors.view); + expect(colors.sidebar, gtkColors.sidebar); + expect(colors.headerbar, gtkColors.headerbar); + expect(colors.dialog, gtkColors.dialog); + expect(colors.popover, gtkColors.popover); + for (final surface in [ + colors.window, + colors.view, + colors.sidebar, + colors.secondarySidebar, + colors.headerbar, + colors.headerbarFlat, + colors.card, + colors.groupedSurface, + colors.dialog, + colors.popover, + ]) { + _expectNeutralSurface(surface); + } + }); + + test('BusyMax preserves a GTK card layer for its actual parent', () { + const gtkColors = GtkThemeColors( + brightness: Brightness.dark, + window: Color(0xFF2C2C2C), + view: Color(0xFF272727), card: Color.fromRGBO(255, 255, 255, 0.08), ); final colors = _buildBusyMaxTheme( brightness: Brightness.dark, gtkThemeColors: gtkColors, ).extension()!; - final expected = Color.alphaBlend(gtkColors.card!, gtkColors.window!); + final expectedCard = Color.alphaBlend(gtkColors.card!, gtkColors.window!); final wrongParent = Color.alphaBlend(gtkColors.card!, gtkColors.view!); + final expectedDialogCard = Color.alphaBlend(gtkColors.card!, colors.dialog); - expect(colors.card, expected); - expect(colors.groupedSurface, expected); + expect(colors.card, expectedCard); + expect(colors.groupedSurface, gtkColors.card); + expect(colors.groupedSurface.a, lessThan(1)); + expect( + Color.alphaBlend(colors.groupedSurface, colors.window), + expectedCard, + ); + expect( + Color.alphaBlend(colors.groupedSurface, colors.dialog), + expectedDialogCard, + ); + expect(expectedDialogCard, isNot(expectedCard)); expect(colors.card, isNot(wrongParent)); }); - test( - 'BusyMax never makes raised roles recessed on a bright custom theme', - () { - const parent = Color(0xFF3E3E3E); - const gtkColors = GtkThemeColors( - brightness: Brightness.dark, - window: parent, - view: parent, - sidebar: Color(0xFF2A2A2A), - secondarySidebar: Color(0xFF303030), - headerbar: Color(0xFF303030), - card: Color(0xFF303030), - dialog: Color(0xFF303030), - popover: Color(0xFF303030), - ); - final colors = _buildBusyMaxTheme( - brightness: Brightness.dark, - gtkThemeColors: gtkColors, - ).extension()!; + test('BusyMax preserves readable darker roles from a custom GTK theme', () { + const parent = Color(0xFF3E3E3E); + const gtkColors = GtkThemeColors( + brightness: Brightness.dark, + window: parent, + view: parent, + sidebar: Color(0xFF2A2A2A), + secondarySidebar: Color(0xFF303030), + headerbar: Color(0xFF303030), + card: Color(0xFF303030), + dialog: Color(0xFF303030), + popover: Color(0xFF303030), + ); + final colors = _buildBusyMaxTheme( + brightness: Brightness.dark, + gtkThemeColors: gtkColors, + ).extension()!; - for (final raised in [ - colors.sidebar, - colors.secondarySidebar, - colors.headerbar, - colors.card, - colors.groupedSurface, - colors.dialog, - colors.popover, - ]) { - expect( - raised.computeLuminance(), - greaterThanOrEqualTo(parent.computeLuminance()), - ); - } - }, - ); + expect(colors.sidebar, gtkColors.sidebar); + expect(colors.secondarySidebar, gtkColors.secondarySidebar); + expect(colors.headerbar, gtkColors.headerbar); + expect(colors.card, gtkColors.card); + expect(colors.groupedSurface, gtkColors.card); + expect(colors.dialog, gtkColors.dialog); + expect(colors.popover, gtkColors.popover); + }); test('BusyMax grouped surfaces ignore unreadable GTK card samples', () { const gtkColors = GtkThemeColors( @@ -887,62 +1055,103 @@ void main() { expect(colors.groupedSurface, isNot(gtkColors.popover)); }); - test( - 'BusyMax dark grouped surfaces reject flat or recessed card samples', - () { - for (final card in const [Color(0xFF242424), Color(0xFF101010)]) { - final colors = _buildBusyMaxTheme( + test('BusyMax preserves readable flat or darker GTK card samples', () { + for (final card in const [Color(0xFF242424), Color(0xFF101010)]) { + final colors = _buildBusyMaxTheme( + brightness: Brightness.dark, + gtkThemeColors: GtkThemeColors( brightness: Brightness.dark, - gtkThemeColors: GtkThemeColors( - brightness: Brightness.dark, - window: const Color(0xFF202020), - view: const Color(0xFF242424), - card: card, - ), - ).extension()!; + window: const Color(0xFF202020), + view: const Color(0xFF242424), + card: card, + ), + ).extension()!; - expect( - colors.groupedSurface, - busyMaxFallbackSurfaceColors(Brightness.dark).groupedSurface, - ); - } - }, - ); + expect(colors.card, card); + expect(colors.groupedSurface, card); + expect(colors.groupedSurface.a, 1); + } + }); + + test('BusyMax keeps an opaque GTK card independent of floating surfaces', () { + const gtkColors = GtkThemeColors( + brightness: Brightness.dark, + window: Color(0xFF202020), + view: Color(0xFF242424), + card: Color(0xFF303030), + dialog: Color(0xFF3E3E3E), + popover: Color(0xFF3E3E3E), + ); + final colors = _buildBusyMaxTheme( + brightness: Brightness.dark, + gtkThemeColors: gtkColors, + ).extension()!; + expect(colors.card, gtkColors.card); + expect(colors.groupedSurface, gtkColors.card); + expect(colors.groupedSurface.a, 1); + }); - test('BusyMax dark sidebar rejects recessed boundary samples', () { + test('BusyMax dark sidebar preserves its named recessed boundary role', () { const gtkColors = GtkThemeColors( brightness: Brightness.dark, window: Color(0xFF202020), view: Color(0xFF242424), sidebar: Color(0xFF303030), - sidebarBorder: Color.fromRGBO(0, 0, 0, 0.36), + sidebarBorder: Color.fromRGBO(16, 16, 16, 0.35), ); final colors = _buildBusyMaxTheme( brightness: Brightness.dark, gtkThemeColors: gtkColors, ).extension()!; + expect(colors.sidebarBorder, gtkColors.sidebarBorder); expect( - colors.sidebarBorder, - busyMaxFallbackSurfaceColors(Brightness.dark).sidebarBorder, + Color.alphaBlend(colors.sidebarBorder, colors.sidebar).computeLuminance(), + lessThan(colors.sidebar.computeLuminance()), ); }); - test('BusyMax keeps native divider and floating outline roles separate', () { + test( + 'BusyMax keeps native divider, card shade, and outline roles separate', + () { + const gtkColors = GtkThemeColors( + brightness: Brightness.dark, + window: Color(0xFF2C2C2C), + card: Color(0xFF3D3D3D), + divider: Color.fromRGBO(0, 0, 6, 0.56), + cardShade: Color.fromRGBO(16, 16, 16, 0.35), + floatingBorder: Color.fromRGBO(255, 255, 255, 0.14), + ); + final colors = _buildBusyMaxTheme( + brightness: Brightness.dark, + gtkThemeColors: gtkColors, + ).extension()!; + + expect(colors.divider, gtkColors.divider); + expect(colors.cardShade, gtkColors.cardShade); + expect(colors.floatingBorder, gtkColors.floatingBorder); + expect(colors.cardShade, isNot(colors.divider)); + expect(colors.cardShade, isNot(colors.floatingBorder)); + }, + ); + + test('BusyMax uses the semantic card shade when GTK omits the role', () { const gtkColors = GtkThemeColors( brightness: Brightness.dark, window: Color(0xFF2C2C2C), card: Color(0xFF3D3D3D), - divider: Color.fromRGBO(0, 0, 6, 0.56), - floatingBorder: Color.fromRGBO(255, 255, 255, 0.14), + divider: Color.fromRGBO(255, 255, 255, 0.10), ); final colors = _buildBusyMaxTheme( brightness: Brightness.dark, gtkThemeColors: gtkColors, ).extension()!; - expect(colors.divider, gtkColors.divider); - expect(colors.floatingBorder, gtkColors.floatingBorder); + expect( + colors.cardShade, + busyMaxFallbackSurfaceColors(Brightness.dark).cardShade, + ); + expect(colors.cardShade, isNot(colors.divider)); }); test('BusyMax theme preserves chromatic GTK dark surface samples', () { @@ -950,11 +1159,11 @@ void main() { brightness: Brightness.dark, window: Color(0xFF241F31), view: Color(0xFF241F31), - sidebar: Color(0xFF3D3846), - headerbar: Color(0xFF241F31), - card: Color(0xFF3D3846), - dialog: Color(0xFF241F31), - popover: Color(0xFF3D3846), + sidebar: Color(0xFF4A4458), + headerbar: Color(0xFF342F40), + card: Color(0xFF4A4458), + dialog: Color(0xFF342F40), + popover: Color(0xFF342F40), ); final theme = _buildBusyMaxTheme( brightness: Brightness.dark, @@ -964,19 +1173,8 @@ void main() { expect(theme.scaffoldBackgroundColor, gtkColors.window); expect(theme.colorScheme.surface, gtkColors.view); - expect(theme.colorScheme.surfaceContainer, gtkColors.card); - expect( - theme.colorScheme.surfaceContainerHigh, - const Color.fromRGBO(255, 255, 255, 0.10), - ); - expect( - theme.colorScheme.surfaceContainerHighest, - const Color.fromRGBO(255, 255, 255, 0.14), - ); - expect( - theme.dialogTheme.backgroundColor, - busyMaxFallbackSurfaceColors(Brightness.dark).dialog, - ); + _expectOpaqueMonotonicSurfaceContainers(theme); + expect(theme.dialogTheme.backgroundColor, gtkColors.dialog); expect(colors.sidebar, gtkColors.sidebar); expect(colors.control, const Color.fromRGBO(255, 255, 255, 0.10)); expect(colors.controlHover, const Color.fromRGBO(255, 255, 255, 0.14)); @@ -987,9 +1185,9 @@ void main() { test('BusyMax theme preserves chromatic GTK control samples', () { const gtkColors = GtkThemeColors( brightness: Brightness.dark, - control: Color(0x22004A99), - controlHover: Color(0x33005BBB), - controlActive: Color(0x44006DDD), + control: Color.fromRGBO(120, 180, 255, 0.12), + controlHover: Color.fromRGBO(120, 180, 255, 0.18), + controlActive: Color.fromRGBO(120, 180, 255, 0.24), ); final theme = _buildBusyMaxTheme( brightness: Brightness.dark, @@ -999,8 +1197,12 @@ void main() { expect(colors.control, gtkColors.control); expect(colors.controlHover, gtkColors.controlHover); expect(colors.controlActive, gtkColors.controlActive); - expect(theme.colorScheme.surfaceContainerHigh, gtkColors.control); - expect(theme.colorScheme.surfaceContainerHighest, gtkColors.controlHover); + _expectOpaqueMonotonicSurfaceContainers(theme); + expect(theme.colorScheme.surfaceContainerHigh, isNot(gtkColors.control)); + expect( + theme.colorScheme.surfaceContainerHighest, + isNot(gtkColors.controlHover), + ); }); test('BusyMax theme rejects an imperceptible GTK control ladder', () { @@ -1064,7 +1266,7 @@ void main() { expect(colors.disabledControl, fallback.disabledControl); }); - test('BusyMax theme avoids a recessed fallback for a flat custom role', () { + test('BusyMax theme preserves a flat custom sidebar role', () { const gtkColors = GtkThemeColors( brightness: Brightness.dark, window: Color(0xFF3E3E3E), @@ -1096,14 +1298,11 @@ void main() { expect(theme.colorScheme.surface, gtkColors.view); final colors = theme.extension()!; - expect( - colors.sidebar, - busyMaxFallbackSurfaceColors(Brightness.dark).sidebar, - ); + expect(colors.sidebar, gtkColors.sidebar); expect(colors.headerbar, gtkColors.headerbar); }); - test('BusyMax theme rejects a recessed black sidebar sample', () { + test('BusyMax theme preserves a readable black sidebar sample', () { const gtkColors = GtkThemeColors( brightness: Brightness.dark, window: Color(0xFF000000), @@ -1119,17 +1318,11 @@ void main() { expect(theme.scaffoldBackgroundColor, gtkColors.window); expect(theme.colorScheme.surface, gtkColors.view); - expect( - colors.sidebar, - busyMaxFallbackSurfaceColors(Brightness.dark).sidebar, - ); - expect( - colors.headerbar, - busyMaxFallbackSurfaceColors(Brightness.dark).headerbar, - ); + expect(colors.sidebar, gtkColors.sidebar); + expect(colors.headerbar, gtkColors.headerbar); }); - test('BusyMax theme rejects a flat near-black sidebar sample', () { + test('BusyMax theme preserves a flat near-black sidebar sample', () { const gtkColors = GtkThemeColors( brightness: Brightness.dark, window: Color(0xFF101010), @@ -1145,14 +1338,8 @@ void main() { expect(theme.scaffoldBackgroundColor, gtkColors.window); expect(theme.colorScheme.surface, gtkColors.view); - expect( - colors.sidebar, - busyMaxFallbackSurfaceColors(Brightness.dark).sidebar, - ); - expect( - colors.headerbar, - busyMaxFallbackSurfaceColors(Brightness.dark).headerbar, - ); + expect(colors.sidebar, gtkColors.sidebar); + expect(colors.headerbar, gtkColors.headerbar); }); test('BusyMax theme composites translucent GTK surface layers', () { @@ -1176,14 +1363,8 @@ void main() { expect(theme.scaffoldBackgroundColor, window); expect(theme.colorScheme.surface, view); - expect( - colors.sidebar, - busyMaxFallbackSurfaceColors(Brightness.dark).sidebar, - ); - expect( - colors.headerbar, - busyMaxFallbackSurfaceColors(Brightness.dark).headerbar, - ); + expect(colors.sidebar, Color.alphaBlend(gtkColors.sidebar!, window)); + expect(colors.headerbar, Color.alphaBlend(gtkColors.headerbar!, window)); }); test('BusyMax theme rejects unreadable GTK foreground samples', () { @@ -1204,10 +1385,7 @@ void main() { expect(colors.foreground, fallback.foreground); expect(colors.mutedForeground, fallback.mutedForeground); - expect( - colors.mutedForeground.a, - closeTo(colors.foreground.a * 0.55, 0.001), - ); + expect(colors.mutedForeground.a, 1); expect(theme.colorScheme.onSurface, colors.foreground); expect(theme.colorScheme.onSurfaceVariant, colors.mutedForeground); }); @@ -1254,7 +1432,7 @@ void main() { gtkThemeColors: gtkColors, ); - expect(theme.scaffoldBackgroundColor, const Color(0xFFFAFAFB)); + expect(theme.scaffoldBackgroundColor, const Color(0xFFFAFAFA)); }); test( @@ -1376,9 +1554,10 @@ void main() { expect(source, contains('sidebarBackgroundColor: colors.sidebar')); expect(source, contains('foregroundColor: colors.foreground')); expect(source, contains('sidebarBorderColor: colors.sidebarBorder')); + expect(source, contains('popoverBackgroundColor: colors.popover')); + expect(source, contains('floatingBorderColor: colors.floatingBorder')); expect(source, contains('modalBarrierColor: modalBarrierColor')); expect(source, isNot(contains('controlHoverColor: colors.controlHover'))); - expect(source, isNot(contains('popoverBackgroundColor: colors.popover'))); expect(source, isNot(contains('accentColor: colorScheme.primary'))); expect(source, contains('menu: l10n.mainMenu')); expect(source, contains('settings: l10n.settings')); @@ -1554,6 +1733,56 @@ void main() { const _testAccentColor = Color(0xFF2E7D32); const _alternateTestAccentColor = Color(0xFF8A1D61); +void _expectNeutralSurface(Color color) { + final channels = color.toARGB32(); + final red = (channels >> 16) & 0xff; + final green = (channels >> 8) & 0xff; + final blue = channels & 0xff; + expect(green, red, reason: '$color has a red/green surface tint'); + expect(blue, red, reason: '$color has a red/blue surface tint'); +} + +void _expectOpaqueMonotonicSurfaceContainers(ThemeData theme) { + final containers = [ + theme.colorScheme.surfaceContainerLowest, + theme.colorScheme.surfaceContainerLow, + theme.colorScheme.surfaceContainer, + theme.colorScheme.surfaceContainerHigh, + theme.colorScheme.surfaceContainerHighest, + ]; + for (final color in containers) { + expect( + color.a, + 1, + reason: '${theme.brightness} surface containers must remain opaque', + ); + } + for (var index = 0; index < containers.length - 1; index++) { + final current = containers[index].computeLuminance(); + final next = containers[index + 1].computeLuminance(); + expect( + theme.brightness == Brightness.light ? current >= next : current <= next, + isTrue, + reason: '${theme.brightness} surface containers must follow elevation', + ); + } +} + +double _contrastRatio(Color foreground, Color background) { + final effectiveForeground = foreground.a < 1 + ? Color.alphaBlend(foreground, background) + : foreground; + final foregroundLuminance = effectiveForeground.computeLuminance(); + final backgroundLuminance = background.computeLuminance(); + final lighter = foregroundLuminance > backgroundLuminance + ? foregroundLuminance + : backgroundLuminance; + final darker = foregroundLuminance > backgroundLuminance + ? backgroundLuminance + : foregroundLuminance; + return (lighter + 0.05) / (darker + 0.05); +} + ThemeData _buildBusyMaxTheme({ required Brightness brightness, Color accentColor = _testAccentColor, diff --git a/test/demo/demo_profile_test.dart b/test/demo/demo_profile_test.dart index 45e7322..1926b51 100644 --- a/test/demo/demo_profile_test.dart +++ b/test/demo/demo_profile_test.dart @@ -39,9 +39,7 @@ void main() { }); test('demo provider graph remains local and owns its database', () async { - final profile = await BusyMaxDemoProfile.create( - now: DateTime(2026, 7, 23, 10), - ); + final profile = await BusyMaxDemoProfile.create(); final settings = busyMaxDemoSettings(BusyMaxDemoTheme.system); final settingsStore = InMemoryLocalSettingsStore(settings.toJson()); final container = ProviderContainer( diff --git a/test/features/calendar/presentation/event_editor_test.dart b/test/features/calendar/presentation/event_editor_test.dart index f151f09..0cd1545 100644 --- a/test/features/calendar/presentation/event_editor_test.dart +++ b/test/features/calendar/presentation/event_editor_test.dart @@ -110,6 +110,64 @@ void main() { ); }); + testWidgets('event editor groups use the contextual semantic card layer', ( + tester, + ) async { + final theme = BusyMaxYaruTheme.build( + brightness: Brightness.dark, + accentColor: const Color(0xFF3584E4), + ); + final colors = theme.extension()!; + tester.view.physicalSize = const Size(1000, 900); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + await tester.pumpWidget( + localizedTestApp( + theme: theme, + child: Scaffold( + body: BusyMaxModalEditorSurface( + maxWidth: 640, + maxHeight: 720, + child: 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: (_) {}, + ), + ), + ), + ), + ); + + final groupedMaterials = tester.widgetList( + find.descendant( + of: find.byType(BusyMaxGroupedSurface), + matching: find.byWidgetPredicate( + (widget) => widget is Material && widget.color == colors.card, + ), + ), + ); + expect(groupedMaterials, isNotEmpty); + expect(colors.groupedSurface.a, lessThan(1)); + expect(colors.card.a, 1); + expect( + groupedMaterials.every((material) => material.color?.a == 1), + isTrue, + ); + expect( + Color.alphaBlend(colors.groupedSurface, colors.window).toARGB32(), + colors.card.toARGB32(), + ); + }); + testWidgets('all-day event hides time rows and conference placeholder', ( tester, ) async { @@ -959,7 +1017,7 @@ void main() { expect(find.text('Add Reminder'), findsNothing); expect( find.descendant( - of: find.byType(BusyMaxComboBox), + of: find.byType(BusyMaxComboRow), matching: find.text('5 minutes before'), ), findsOneWidget, @@ -1392,7 +1450,7 @@ void main() { expect(editor, contains('l10n.deleteEvent')); }); - testWidgets('combo selector uses the shared Yaru button trigger', ( + testWidgets('combo selector uses a flat native-style row trigger', ( tester, ) async { final theme = BusyMaxYaruTheme.build( @@ -1418,23 +1476,34 @@ void main() { ), ); - expect(find.byType(BusyMaxComboBox), findsOneWidget); + expect(find.byType(BusyMaxMenuButton), findsOneWidget); final triggerFinder = find.descendant( - of: find.byType(BusyMaxComboBox), + of: find.byType(BusyMaxComboRow), matching: find.byWidgetPredicate( - (widget) => widget is ButtonStyleButton && widget is! IconButton, + (widget) => widget is YaruListTile && widget.focusNode != null, + ), + ); + final trigger = tester.widget(triggerFinder); + expect(trigger.onTap, isNotNull); + expect( + tester.getSize(triggerFinder).height, + greaterThan(kYaruButtonHeight), + ); + expect( + find.descendant( + of: find.byType(BusyMaxComboRow), + matching: find.byWidgetPredicate( + (widget) => widget is ButtonStyleButton && widget is! IconButton, + ), ), + findsNothing, ); - final trigger = tester.widget(triggerFinder); - expect(trigger.style, isNull); - expect(trigger.onPressed, isNotNull); - expect(tester.getSize(triggerFinder).height, kYaruButtonHeight); final restingSurface = tester.widget( find.descendant(of: triggerFinder, matching: find.byType(Material)).first, ); - expect(restingSurface.type, MaterialType.button); - expect(restingSurface.color, colors.control); - expect(restingSurface.color, isNot(Colors.transparent)); + expect(restingSurface.type, MaterialType.canvas); + expect(restingSurface.color, Colors.transparent); + expect(restingSurface.color, isNot(colors.control)); }); } diff --git a/test/features/feedback/presentation/feedback_dialog_test.dart b/test/features/feedback/presentation/feedback_dialog_test.dart index 1f4eab9..78112dd 100644 --- a/test/features/feedback/presentation/feedback_dialog_test.dart +++ b/test/features/feedback/presentation/feedback_dialog_test.dart @@ -1,5 +1,6 @@ import 'dart:async'; +import 'package:busymax/src/app/busymax_design.dart'; import 'package:busymax/src/features/feedback/data/feedback_api_client.dart'; import 'package:busymax/src/features/feedback/data/feedback_submission.dart'; import 'package:busymax/src/features/feedback/presentation/feedback_dialog.dart'; @@ -437,7 +438,7 @@ Future _enterValidRequiredFields(WidgetTester tester) async { Finder _feedbackCategoryTrigger() { return find.descendant( of: find.byKey(const Key('feedback-category')), - matching: find.byType(FilledButton), + matching: find.byType(BusyMaxMenuButton), ); } diff --git a/test/features/schedule/presentation/schedule_views_test.dart b/test/features/schedule/presentation/schedule_views_test.dart index 9081ceb..ddb80e5 100644 --- a/test/features/schedule/presentation/schedule_views_test.dart +++ b/test/features/schedule/presentation/schedule_views_test.dart @@ -13,6 +13,7 @@ import 'package:busymax/src/features/schedule/presentation/schedule_item_exporte import 'package:busymax/src/features/schedule/presentation/mini_calendar.dart'; import 'package:busymax/src/features/schedule/presentation/schedule_month_view.dart'; import 'package:busymax/src/features/schedule/presentation/schedule_year_view.dart'; +import 'package:busymax/src/platform/gtk_font_service.dart'; import 'package:busymax/src/schedule/schedule_item.dart'; import 'package:busymax/src/schedule/schedule_range.dart'; import 'package:busymax/src/task_providers/task_provider.dart'; @@ -61,6 +62,114 @@ void main() { expect(find.byType(icv.EventsList), findsNothing); }); + for (final configuration in const [ + (label: 'day', daysShowed: 1), + (label: 'week', daysShowed: 7), + ]) { + testWidgets( + 'dark ${configuration.label} grid stays visible with a recessed GTK divider', + (tester) async { + final selectedDate = DateTime(2026, 1, 15); + final theme = _darkCalendarGridTestTheme(); + final expectedGridColor = theme.colorScheme.onSurface.withValues( + alpha: BusyMaxAlpha.calendarGridDark, + ); + + await tester.pumpWidget( + localizedTestApp( + theme: theme, + child: Scaffold( + body: SizedBox( + width: 1000, + height: 720, + child: ScheduleDayWeekView( + range: configuration.daysShowed == 1 + ? ScheduleRange.day(selectedDate) + : ScheduleRange.week(selectedDate), + selectedDate: selectedDate, + daysShowed: configuration.daysShowed, + items: _itemsFor(selectedDate), + onDaySelected: (_) {}, + onEmptySlot: (_) {}, + onItemSelected: (_, _, [_]) {}, + onTaskCompletionChanged: (_, _) {}, + ), + ), + ), + ), + ); + await tester.pump(const Duration(milliseconds: 100)); + + final viewContext = tester.element(find.byType(ScheduleDayWeekView)); + final planner = tester.widget( + find.byType(icv.EventsPlanner), + ); + final painter = + planner.dayParam.dayCustomPainter!(1, false) as icv.LinesPainter; + final effectiveGridColor = Color.alphaBlend( + painter.lineColor, + theme.colorScheme.surface, + ); + + // GTK's generic separator remains a distinct, recessed native role. + expect(theme.colorScheme.outlineVariant, _recessedGtkDivider); + expect(planner.daysShowed, configuration.daysShowed); + expect(busyMaxCalendarGridColor(viewContext), expectedGridColor); + expect(painter.lineColor, expectedGridColor); + expect(painter.lineColor, isNot(_recessedGtkDivider)); + expect( + effectiveGridColor.computeLuminance(), + greaterThan(theme.colorScheme.surface.computeLuminance()), + ); + }, + ); + } + + testWidgets('dark month grid uses the shared neutral grid color', ( + tester, + ) async { + final selectedDate = DateTime(2026, 1, 15); + final theme = _darkCalendarGridTestTheme(); + final expectedGridColor = theme.colorScheme.onSurface.withValues( + alpha: BusyMaxAlpha.calendarGridDark, + ); + + await tester.pumpWidget( + localizedTestApp( + theme: theme, + child: Scaffold( + body: SizedBox( + width: 1000, + height: 720, + child: ScheduleMonthView( + range: ScheduleRange.month(selectedDate), + selectedDate: selectedDate, + firstWeekday: DateTime.monday, + items: const [], + onDaySelected: (_) {}, + onCreateAtDay: (_) {}, + onItemSelected: (_, _, [_]) {}, + onTaskCompletionChanged: (_, _) {}, + ), + ), + ), + ), + ); + + final monthCell = tester.widget( + find + .descendant( + of: find.byType(GridView), + matching: find.byType(DecoratedBox), + ) + .first, + ); + final cellBorder = + (monthCell.decoration as BoxDecoration).border! as Border; + expect(cellBorder.top.color, expectedGridColor); + expect(cellBorder.left.color, expectedGridColor); + }); + testWidgets('day view applies configured display hours to planner scroll', ( tester, ) async { @@ -1746,7 +1855,7 @@ void main() { expect(design, contains('class BusyMaxPopoverIconButton')); expect(design, contains('return Material(')); expect(design, contains('shape: const CircleBorder()')); - expect(design, contains('child: YaruIconButton(')); + expect(design, contains('class BusyMaxHeaderIconButton')); expect(design, contains('iconSize: kYaruTitleBarItemHeight')); expect(design, contains('color: enabled ? colors.control')); expect(popover, contains('BusyMaxPopoverIconButton(')); @@ -1812,7 +1921,7 @@ void main() { ); expect(design, isNot(contains('final Color? surfaceColor;'))); expect(design, isNot(contains('color: color ?? surfaceColors.control'))); - expect(design, contains('color: surfaceColors.groupedSurface')); + expect(design, contains('CardTheme.of(context)')); expect(design, contains('BusyMaxShadow.physicalColor(context)')); expect(design, isNot(contains('lightSurfaceShadowMinimum'))); expect(design, isNot(contains('class _BusyMaxRowTile'))); @@ -2694,8 +2803,7 @@ void main() { 'lib/src/features/schedule/presentation/schedule_month_view.dart', ).readAsStringSync(); - expect(source, contains('colorScheme.onSurface.withValues')); - expect(source, contains('Brightness.dark ? 0.06 : 0.10')); + expect(source, contains('busyMaxCalendarGridColor(context)')); expect(source, contains('position: DecorationPosition.foreground')); expect(source, contains('left: BorderSide(color: border)')); expect(source, contains('bottom: row == rows - 1')); @@ -2737,6 +2845,19 @@ double _luminanceDistance(Color first, Color second) { return (first.computeLuminance() - second.computeLuminance()).abs(); } +const _recessedGtkDivider = Color.fromRGBO(0, 0, 6, 0.56); + +ThemeData _darkCalendarGridTestTheme() { + return BusyMaxYaruTheme.build( + brightness: Brightness.dark, + accentColor: const Color(0xFF3584E4), + gtkThemeColors: const GtkThemeColors( + brightness: Brightness.dark, + divider: _recessedGtkDivider, + ), + ); +} + List _itemsFor(DateTime day) { return [ CalendarScheduleItem( diff --git a/test/features/tasks/presentation/task_details_pane_test.dart b/test/features/tasks/presentation/task_details_pane_test.dart index 5d2151f..c59de8e 100644 --- a/test/features/tasks/presentation/task_details_pane_test.dart +++ b/test/features/tasks/presentation/task_details_pane_test.dart @@ -94,10 +94,55 @@ void main() { expect(saveButton.style?.minimumSize, isNull); }); - testWidgets('task selectors use the shared native-menu trigger', ( + testWidgets('task editor groups use the contextual semantic card layer', ( tester, ) async { - await _pumpDetails(tester, microsoftTaskProviderCapabilities); + final theme = BusyMaxYaruTheme.build( + brightness: Brightness.dark, + accentColor: const Color(0xFF3584E4), + ); + final colors = theme.extension()!; + + await _pumpDetails( + tester, + microsoftTaskProviderCapabilities, + theme: theme, + modalEditorSurface: true, + ); + + final groupedMaterials = tester.widgetList( + find.descendant( + of: find.byType(BusyMaxGroupedSurface), + matching: find.byWidgetPredicate( + (widget) => widget is Material && widget.color == colors.card, + ), + ), + ); + expect(groupedMaterials, isNotEmpty); + expect(colors.groupedSurface.a, lessThan(1)); + expect(colors.card.a, 1); + expect( + groupedMaterials.every((material) => material.color?.a == 1), + isTrue, + ); + expect( + Color.alphaBlend(colors.groupedSurface, colors.window).toARGB32(), + colors.card.toARGB32(), + ); + }); + + testWidgets('task selectors use native combo-row triggers', (tester) async { + final theme = BusyMaxYaruTheme.build( + brightness: Brightness.dark, + accentColor: const Color(0xFF3584E4), + ); + final colors = theme.extension()!; + await _pumpDetails( + tester, + microsoftTaskProviderCapabilities, + theme: theme, + modalEditorSurface: true, + ); final comboRows = find.byType(BusyMaxComboRow); final comboCount = comboRows.evaluate().length; @@ -105,14 +150,16 @@ void main() { expect( find.descendant( of: comboRows, - matching: find.byType(BusyMaxComboBox), + matching: find.byType(BusyMaxMenuButton), ), findsNWidgets(comboCount), ); expect( find.descendant( of: comboRows, - matching: find.byType(BusyMaxMenuButton), + matching: find.byWidgetPredicate( + (widget) => widget is ButtonStyleButton && widget is! IconButton, + ), ), findsNothing, ); @@ -130,13 +177,26 @@ void main() { final triggers = find.descendant( of: comboRows, matching: find.byWidgetPredicate( - (widget) => widget is ButtonStyleButton && widget is! IconButton, + (widget) => widget is YaruListTile && widget.focusNode != null, ), ); expect(triggers, findsNWidgets(comboCount)); - for (final trigger in tester.widgetList(triggers)) { - expect(trigger.style, isNull); - expect(trigger.onPressed, isNotNull); + for (var index = 0; index < comboCount; index += 1) { + final triggerFinder = triggers.at(index); + final trigger = tester.widget(triggerFinder); + expect(trigger.onTap, isNotNull); + expect( + trigger.hoverColor, + busyMaxRowHoverColor(tester.element(triggerFinder)), + ); + final restingSurface = tester.widget( + find + .descendant(of: triggerFinder, matching: find.byType(Material)) + .first, + ); + expect(restingSurface.type, MaterialType.canvas); + expect(restingSurface.color, Colors.transparent); + expect(restingSurface.color, isNot(colors.control)); } }); @@ -1336,6 +1396,7 @@ Future _pumpDetails( String? email, Stream>? accountsStream, ThemeData? theme, + bool modalEditorSurface = false, }) async { final accountId = accountIdOverride ?? @@ -1402,12 +1463,23 @@ Future _pumpDetails( alwaysUse24HourFormat: alwaysUse24HourFormat, theme: theme, child: Scaffold( - body: TaskDetailsPane( - accountId: accountId, - taskListId: 'list-1', - taskId: 'task-1', - onClose: onClose, - ), + body: modalEditorSurface + ? BusyMaxModalEditorSurface( + maxWidth: 640, + maxHeight: 1000, + child: TaskDetailsPane( + accountId: accountId, + taskListId: 'list-1', + taskId: 'task-1', + onClose: onClose, + ), + ) + : TaskDetailsPane( + accountId: accountId, + taskListId: 'list-1', + taskId: 'task-1', + onClose: onClose, + ), ), ), ), diff --git a/test/platform/gtk_font_service_test.dart b/test/platform/gtk_font_service_test.dart index de5aefd..f9f96f7 100644 --- a/test/platform/gtk_font_service_test.dart +++ b/test/platform/gtk_font_service_test.dart @@ -284,6 +284,7 @@ void main() { 'disabledControl': '#0FFFFFFF', 'border': '#99000000', 'divider': '#1AFFFFFF', + 'cardShade': '#5A101010', 'floatingBorder': '#24000000', 'sidebarBorder': '#33000000', 'shade': '#55000000', @@ -308,6 +309,7 @@ void main() { expect(colors?.accent, const Color(0xFFC061CB)); expect(colors?.accentForeground, const Color(0xFFFFFFFF)); expect(colors?.divider, const Color(0x1AFFFFFF)); + expect(colors?.cardShade, const Color(0x5A101010)); expect(colors?.floatingBorder, const Color(0x24000000)); }); diff --git a/test/platform/linux_header_bar_configuration_synchronizer_test.dart b/test/platform/linux_header_bar_configuration_synchronizer_test.dart index 122220c..d6b95fb 100644 --- a/test/platform/linux_header_bar_configuration_synchronizer_test.dart +++ b/test/platform/linux_header_bar_configuration_synchronizer_test.dart @@ -124,11 +124,14 @@ BusyMaxHeaderBarConfiguration _configuration({required bool dark}) { sidebarWidth: 300, 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, + popoverBackgroundColor: dark ? Colors.black : Colors.white, + floatingBorderColor: Colors.grey, modalBarrierColor: Colors.black54, ), ); diff --git a/test/platform/linux_header_bar_service_test.dart b/test/platform/linux_header_bar_service_test.dart index 811f319..0cd2d20 100644 --- a/test/platform/linux_header_bar_service_test.dart +++ b/test/platform/linux_header_bar_service_test.dart @@ -96,11 +96,14 @@ void main() { 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), + popoverBackgroundColor: Color(0xFF36363A), + floatingBorderColor: Color.fromRGBO(255, 255, 255, 0.10), modalBarrierColor: Color.fromRGBO(0, 0, 0, 0.32), ), ); @@ -139,11 +142,14 @@ void main() { calls.last.arguments, equals({ 'preferDark': true, + 'highContrast': false, 'windowBackgroundColor': '#18181B', 'backgroundColor': '#1D1D20', 'sidebarBackgroundColor': '#2E2E32', 'foregroundColor': '#FFFFFF', 'sidebarBorderColor': 'rgba(0,0,6,0.75)', + 'popoverBackgroundColor': '#36363A', + 'floatingBorderColor': 'rgba(255,255,255,0.10)', 'modalBarrierColor': 'rgba(0,0,0,0.32)', }), ); @@ -573,6 +579,47 @@ void main() { expect(source, isNot(contains('busymax-search-entry'))); }); + 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(); @@ -590,6 +637,8 @@ void main() { expect(source, isNot(contains('outline-style: none'))); expect(source, isNot(contains('transition: none'))); expect(source, isNot(contains('popover.busymax-header-popover'))); + expect(source, contains('kNativePopoverStyleClass')); + expect(source, contains('style_native_popover(GTK_WIDGET(popover))')); expect(source, isNot(contains('tooltip.background'))); expect(source, isNot(contains('button.busymax-header-popover-row'))); expect(source, isNot(contains('busymax-keyboard-focus'))); From 31e754af25b34c95d618ef2bc4283655d6ca3233 Mon Sep 17 00:00:00 2001 From: albert Date: Sat, 25 Jul 2026 13:28:01 -0700 Subject: [PATCH 14/73] Implement GTK theme compatibility for Yaru window decorations. Add functions to check for legacy Yaru shadow usage and update header bar CSS accordingly. Connect and disconnect GTK theme color signals during application startup and shutdown for improved theme handling. --- linux/runner/my_application.cc | 85 ++++++++++++++++++--- test/app/native_ui_audit_test.dart | 117 ++++++++++++++++++++++++++++- 2 files changed, 189 insertions(+), 13 deletions(-) diff --git a/linux/runner/my_application.cc b/linux/runner/my_application.cc index 265b064..e60b826 100644 --- a/linux/runner/my_application.cc +++ b/linux/runner/my_application.cc @@ -1090,6 +1090,26 @@ static gint header_sidebar_effective_width(MyApplication* self) { 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; + } + + 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; +} + static void refresh_header_bar_css(MyApplication* self) { if (!has_header_bar(self) || !is_css_color_token(self->header_bar_background_color)) { @@ -1133,6 +1153,41 @@ static void refresh_header_bar_css(MyApplication* self) { modal_sidebar_border_css_color(sidebar_border_color, sidebar_background_color, modal_barrier_color); + const gboolean use_yaru_window_decoration_compatibility = + !self->header_bar_high_contrast && + current_gtk_theme_uses_legacy_yaru_shadow(); + const gchar* yaru_window_decoration_css = + use_yaru_window_decoration_compatibility + ? "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;" + "}" + : ""; 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"); @@ -1143,6 +1198,7 @@ static void refresh_header_bar_css(MyApplication* self) { "background-color: %s;" "background-image: none;" "}" + "%s" "headerbar.busymax-flat-headerbar," "headerbar.busymax-flat-headerbar:backdrop {" "background-color: %s;" @@ -1221,7 +1277,8 @@ static void refresh_header_bar_css(MyApplication* self) { "background-color: %s;" "background-image: linear-gradient(%s, %s);" "}", - window_background_color, background_color, foreground_color, + window_background_color, yaru_window_decoration_css, background_color, + foreground_color, sidebar_background_color, foreground_color, sidebar_border_color, foreground_color, foreground_color, native_popover_css, sidebar_background_color, modal_barrier_color, modal_barrier_color, @@ -2921,20 +2978,15 @@ static void send_gtk_theme_colors_event(MyApplication* self) { } } -static void gtk_theme_colors_notify_cb(GObject* object, - GParamSpec* pspec, +static void gtk_theme_colors_notify_cb(GObject*, + GParamSpec*, gpointer user_data) { MyApplication* self = MY_APPLICATION(user_data); + refresh_header_bar_css(self); send_gtk_theme_colors_event(self); } -static FlMethodErrorResponse* gtk_theme_colors_listen_cb( - FlEventChannel* channel, - FlValue* args, - gpointer user_data) { - MyApplication* self = MY_APPLICATION(user_data); - self->gtk_theme_colors_listening = TRUE; - +static void connect_gtk_theme_colors_signals(MyApplication* self) { GtkSettings* settings = gtk_settings_get_default(); if (settings != nullptr && self->gtk_theme_name_signal_id == 0) { self->gtk_theme_name_signal_id = @@ -2946,7 +2998,16 @@ static FlMethodErrorResponse* gtk_theme_colors_listen_cb( settings, "notify::gtk-application-prefer-dark-theme", G_CALLBACK(gtk_theme_colors_notify_cb), self); } +} +static FlMethodErrorResponse* gtk_theme_colors_listen_cb( + FlEventChannel* channel, + FlValue* args, + gpointer user_data) { + MyApplication* self = MY_APPLICATION(user_data); + self->gtk_theme_colors_listening = TRUE; + + connect_gtk_theme_colors_signals(self); send_gtk_theme_colors_event(self); return nullptr; } @@ -2957,7 +3018,6 @@ static FlMethodErrorResponse* gtk_theme_colors_cancel_cb( gpointer user_data) { MyApplication* self = MY_APPLICATION(user_data); self->gtk_theme_colors_listening = FALSE; - disconnect_gtk_theme_colors_signals(self); return nullptr; } @@ -3605,6 +3665,7 @@ static gboolean my_application_local_command_line(GApplication* application, static void my_application_startup(GApplication* application) { G_APPLICATION_CLASS(my_application_parent_class)->startup(application); hdy_init(); + connect_gtk_theme_colors_signals(MY_APPLICATION(application)); } // Implements GApplication::shutdown. @@ -3619,6 +3680,7 @@ static void my_application_shutdown(GApplication* application) { // Implements GObject::dispose. 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) { gtk_style_context_remove_provider_for_screen( gdk_screen_get_default(), GTK_STYLE_PROVIDER(self->header_bar_css_provider)); @@ -3632,7 +3694,6 @@ static void my_application_dispose(GObject* object) { g_clear_object(&self->gtk_settings_channel); disconnect_gtk_font_settings_signal(self); g_clear_object(&self->gtk_font_settings_event_channel); - disconnect_gtk_theme_colors_signals(self); 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", diff --git a/test/app/native_ui_audit_test.dart b/test/app/native_ui_audit_test.dart index 7c80281..ddff892 100644 --- a/test/app/native_ui_audit_test.dart +++ b/test/app/native_ui_audit_test.dart @@ -90,6 +90,85 @@ void main() { }, ); + test( + 'Yaru GTK3 window compatibility removes only the legacy frame ring', + () { + final source = File( + 'linux/runner/my_application.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)', + ); + final refreshEnd = source.indexOf( + 'static void set_css_color_field(', + refreshStart, + ); + + expect(themeGateStart, isNonNegative); + expect(refreshStart, greaterThan(themeGateStart)); + expect(refreshEnd, greaterThan(refreshStart)); + + final themeGate = source.substring(themeGateStart, refreshStart); + final refresh = source.substring(refreshStart, refreshEnd); + final compatibilityStart = refresh.indexOf( + 'const gchar* yaru_window_decoration_css =', + ); + final compatibilityEnd = refresh.indexOf( + 'GtkWidget* header_bar', + 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-")'), + ); + expect(themeGate, contains('strstr(normalized_theme, "highcontrast")')); + expect(compatibilityStart, isNonNegative); + expect(compatibilityEnd, greaterThan(compatibilityStart)); + + final compatibility = refresh.substring( + compatibilityStart, + compatibilityEnd, + ); + + expect(refresh, contains('!self->header_bar_high_contrast')); + expect( + refresh, + contains('current_gtk_theme_uses_legacy_yaru_shadow()'), + ); + expect( + compatibility, + contains('box-shadow: 0 3px 9px 1px rgba(0,0,0,0.5);'), + ); + 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)')); + expect(compatibility, contains('.tiled-top')); + expect(compatibility, contains('.tiled-right')); + expect(compatibility, contains('.tiled-bottom')); + expect(compatibility, contains('.tiled-left')); + expect(compatibility, isNot(contains('0 0 0 1px'))); + expect(compatibility, isNot(contains('border-radius'))); + expect( + compatibility, + isNot(contains('gdk_window_shape_combine_region')), + ); + }, + ); + test( 'Task Details, Settings, and Agenda use BusyMax Yaru row patterns', () { @@ -597,7 +676,11 @@ void main() { 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'))); + 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'))); @@ -1297,7 +1380,39 @@ void main() { 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)'), From f4ebda002b429964f51b76b1a03c06b4985c267d Mon Sep 17 00:00:00 2001 From: albert Date: Sat, 25 Jul 2026 17:37:56 -0700 Subject: [PATCH 15/73] Refactor dialog components to enhance accessibility and consistency. Introduce BusyMaxSurfaceScope for dialog and popover roles, update color handling for dialog outlines, and improve overall UI structure for better user experience. --- lib/src/app/busymax_about_dialog.dart | 173 +++++---- lib/src/app/busymax_app.dart | 2 +- lib/src/app/busymax_design.dart | 163 ++++++-- .../busymax_keyboard_shortcuts_dialog.dart | 364 +++++++++--------- lib/src/app/busymax_surface_colors.dart | 46 ++- lib/src/app/busymax_yaru_theme.dart | 21 +- .../schedule_item_details_popover.dart | 5 +- .../platform/linux_header_bar_service.dart | 10 +- linux/runner/my_application.cc | 161 ++++---- test/app/about_dialog_test.dart | 64 +++ test/app/busymax_grouped_surface_test.dart | 22 +- test/app/high_contrast_theme_test.dart | 1 + test/app/keyboard_shortcuts_dialog_test.dart | 62 +++ test/app/native_ui_audit_test.dart | 256 ++++++++---- test/app/surface_palette_render_test.dart | 294 ++++++++++++++ test/app/theme_localization_test.dart | 43 ++- .../presentation/schedule_views_test.dart | 227 ++++++++++- ...r_bar_configuration_synchronizer_test.dart | 2 +- .../linux_header_bar_service_test.dart | 4 +- 19 files changed, 1406 insertions(+), 514 deletions(-) create mode 100644 test/app/surface_palette_render_test.dart diff --git a/lib/src/app/busymax_about_dialog.dart b/lib/src/app/busymax_about_dialog.dart index 6ee2c49..9aaa2d1 100644 --- a/lib/src/app/busymax_about_dialog.dart +++ b/lib/src/app/busymax_about_dialog.dart @@ -46,99 +46,102 @@ class BusyMaxAboutDialog extends StatelessWidget { Widget build(BuildContext context) { final l10n = context.l10n; final textTheme = Theme.of(context).textTheme; - return Dialog( - child: ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 420), - child: Stack( - children: [ - Padding( - padding: const EdgeInsets.all(BusyMaxSpacing.lg), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Align( - alignment: Alignment.center, - child: const _BusyMaxLogo(size: 72), - ), - const SizedBox(height: BusyMaxSpacing.md), - Text( - l10n.appTitle, - textAlign: TextAlign.center, - style: textTheme.headlineSmall, - ), - const SizedBox(height: BusyMaxSpacing.xs), - Text( - l10n.aboutBusyMaxDescription, - textAlign: TextAlign.center, - style: textTheme.bodyMedium?.copyWith( - color: Theme.of(context).colorScheme.onSurfaceVariant, + return BusyMaxSurfaceScope( + role: BusyMaxSurfaceRole.dialog, + child: Dialog( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 420), + child: Stack( + children: [ + Padding( + padding: const EdgeInsets.all(BusyMaxSpacing.lg), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Align( + alignment: Alignment.center, + child: const _BusyMaxLogo(size: 72), ), - ), - const SizedBox(height: BusyMaxSpacing.sm), - Align( - alignment: Alignment.center, - child: FutureBuilder( - future: PackageInfo.fromPlatform(), - builder: (context, snapshot) { - final info = snapshot.data; - final version = info == null - ? '' - : 'v${info.version}+${info.buildNumber}'; - return _VersionTag(version: version); - }, + const SizedBox(height: BusyMaxSpacing.md), + Text( + l10n.appTitle, + textAlign: TextAlign.center, + style: textTheme.headlineSmall, ), - ), - const SizedBox(height: BusyMaxSpacing.lg), - BusyMaxGroupedList( - filled: true, - children: [ - BusyMaxActionRow( - title: l10n.website, - leading: const Icon(Icons.language), - trailing: const Icon( - Icons.open_in_new, - size: BusyMaxSizes.iconSm, - ), - onTap: () => unawaited( - _openExternalUri(Uri.parse(_busyMaxWebsiteUri)), - ), + const SizedBox(height: BusyMaxSpacing.xs), + Text( + l10n.aboutBusyMaxDescription, + textAlign: TextAlign.center, + style: textTheme.bodyMedium?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, ), - BusyMaxActionRow( - title: l10n.sendFeedback, - leading: const Icon(Icons.feedback_outlined), - trailing: const Icon( - Icons.chevron_right, - size: BusyMaxSizes.iconSm, - ), - onTap: onSendFeedback, + ), + const SizedBox(height: BusyMaxSpacing.sm), + Align( + alignment: Alignment.center, + child: FutureBuilder( + future: PackageInfo.fromPlatform(), + builder: (context, snapshot) { + final info = snapshot.data; + final version = info == null + ? '' + : 'v${info.version}+${info.buildNumber}'; + return _VersionTag(version: version); + }, ), - BusyMaxActionRow( - title: l10n.reportAnIssue, - leading: const Icon(YaruIcons.warning), - trailing: const Icon( - Icons.open_in_new, - size: BusyMaxSizes.iconSm, + ), + const SizedBox(height: BusyMaxSpacing.lg), + BusyMaxGroupedList( + filled: true, + children: [ + BusyMaxActionRow( + title: l10n.website, + leading: const Icon(Icons.language), + trailing: const Icon( + Icons.open_in_new, + size: BusyMaxSizes.iconSm, + ), + onTap: () => unawaited( + _openExternalUri(Uri.parse(_busyMaxWebsiteUri)), + ), ), - onTap: () => unawaited( - _openExternalUri(Uri.parse(_busyMaxIssuesUri)), + BusyMaxActionRow( + title: l10n.sendFeedback, + leading: const Icon(Icons.feedback_outlined), + trailing: const Icon( + Icons.chevron_right, + size: BusyMaxSizes.iconSm, + ), + onTap: onSendFeedback, ), - ), - ], - ), - ], + BusyMaxActionRow( + title: l10n.reportAnIssue, + leading: const Icon(YaruIcons.warning), + trailing: const Icon( + Icons.open_in_new, + size: BusyMaxSizes.iconSm, + ), + onTap: () => unawaited( + _openExternalUri(Uri.parse(_busyMaxIssuesUri)), + ), + ), + ], + ), + ], + ), ), - ), - PositionedDirectional( - top: BusyMaxSpacing.sm, - end: BusyMaxSpacing.sm, - child: YaruIconButton( - icon: const Icon(Icons.close, size: BusyMaxSizes.iconSm), - tooltip: l10n.close, - onPressed: () => Navigator.of(context).pop(), + PositionedDirectional( + top: BusyMaxSpacing.sm, + end: BusyMaxSpacing.sm, + child: YaruIconButton( + icon: const Icon(Icons.close, size: BusyMaxSizes.iconSm), + tooltip: l10n.close, + onPressed: () => Navigator.of(context).pop(), + ), ), - ), - ], + ], + ), ), ), ); diff --git a/lib/src/app/busymax_app.dart b/lib/src/app/busymax_app.dart index 0f1f9dc..43a4820 100644 --- a/lib/src/app/busymax_app.dart +++ b/lib/src/app/busymax_app.dart @@ -240,7 +240,7 @@ class _BusyMaxAppState extends ConsumerState { foregroundColor: colors.foreground, sidebarBorderColor: colors.sidebarBorder, popoverBackgroundColor: colors.popover, - floatingBorderColor: colors.floatingBorder, + dialogOutlineColor: colors.dialogOutline, modalBarrierColor: modalBarrierColor, ), ), diff --git a/lib/src/app/busymax_design.dart b/lib/src/app/busymax_design.dart index ce9398e..a93e1a9 100644 --- a/lib/src/app/busymax_design.dart +++ b/lib/src/app/busymax_design.dart @@ -47,6 +47,8 @@ abstract final class BusyMaxSizes { static const double sidebarActionButton = headerIconButton; static const double sidebarActionIcon = headerIcon; static const double miniCalendarWeekButton = headerIconButton; + static const double popoverActionButton = kYaruTitleBarItemHeight; + static const double popoverActionIcon = iconSm; static const double popoverArrowWidth = 18; static const double popoverArrowHeight = 10; } @@ -178,6 +180,7 @@ class BusyMaxPopoverSurface extends StatelessWidget { super.key, required this.child, required this.color, + this.outlineColor, this.arrowSide = BusyMaxPopoverArrowSide.top, this.arrowAlignment = 0.5, this.padding = EdgeInsets.zero, @@ -185,6 +188,7 @@ class BusyMaxPopoverSurface extends StatelessWidget { final Widget child; final Color color; + final Color? outlineColor; final BusyMaxPopoverArrowSide arrowSide; final double arrowAlignment; final EdgeInsetsGeometry padding; @@ -204,15 +208,13 @@ class BusyMaxPopoverSurface extends StatelessWidget { ), child: Padding(padding: padding, child: child), ); - final surfaceChild = MediaQuery.highContrastOf(context) - ? CustomPaint( - foregroundPainter: _BusyMaxPopoverOutlinePainter( - clipper: clipper, - color: BusyMaxSurfaceColors.of(context).floatingBorder, - ), - child: paddedChild, - ) - : paddedChild; + final surfaceChild = CustomPaint( + foregroundPainter: _BusyMaxPopoverOutlinePainter( + clipper: clipper, + color: outlineColor ?? BusyMaxSurfaceColors.of(context).floatingBorder, + ), + child: paddedChild, + ); return PhysicalShape( clipper: clipper, color: color, @@ -224,6 +226,39 @@ class BusyMaxPopoverSurface extends StatelessWidget { } } +/// A rich anchored content surface, distinct from compact GTK-style menus. +/// +/// Details cards use the shared raised-card fill and the standard floating +/// perimeter. This keeps their rich content surface distinct from compact +/// menus while all popovers retain one native edge, geometry, and shadow. +class BusyMaxContentPopoverSurface extends StatelessWidget { + const BusyMaxContentPopoverSurface({ + super.key, + required this.child, + this.arrowSide = BusyMaxPopoverArrowSide.top, + this.arrowAlignment = 0.5, + this.padding = EdgeInsets.zero, + }); + + final Widget child; + final BusyMaxPopoverArrowSide arrowSide; + final double arrowAlignment; + final EdgeInsetsGeometry padding; + + @override + Widget build(BuildContext context) { + final colors = BusyMaxSurfaceColors.of(context); + return BusyMaxPopoverSurface( + color: colors.card, + outlineColor: colors.floatingBorder, + arrowSide: arrowSide, + arrowAlignment: arrowAlignment, + padding: padding, + child: child, + ); + } +} + class _BusyMaxPopoverClipper extends CustomClipper { const _BusyMaxPopoverClipper({required this.side, required this.alignment}); @@ -698,10 +733,23 @@ class BusyMaxPopoverIconButton extends StatelessWidget { child: YaruIconButton( icon: Icon( icon, - size: kYaruIconSize, + size: BusyMaxSizes.popoverActionIcon, color: enabled ? foreground : colors.disabledForeground, ), - iconSize: kYaruTitleBarItemHeight, + iconSize: BusyMaxSizes.popoverActionButton, + style: const ButtonStyle( + fixedSize: WidgetStatePropertyAll( + Size.square(BusyMaxSizes.popoverActionButton), + ), + minimumSize: WidgetStatePropertyAll( + Size.square(BusyMaxSizes.popoverActionButton), + ), + maximumSize: WidgetStatePropertyAll( + Size.square(BusyMaxSizes.popoverActionButton), + ), + padding: WidgetStatePropertyAll(EdgeInsets.zero), + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), tooltip: tooltip, onPressed: onPressed, ), @@ -817,6 +865,55 @@ class BusyMaxClamp extends StatelessWidget { } } +/// Semantic parent surfaces that can contain a grouped card. +/// +/// GTK's card role is a contextual layer in dark themes. Keeping the parent +/// role explicit lets the shared grouped-card adapter resolve one opaque paint +/// color for Flutter's elevated [Material] without tying every card to the +/// main window background. +enum BusyMaxSurfaceRole { window, view, sidebar, dialog, popover } + +class BusyMaxSurfaceScope extends InheritedWidget { + const BusyMaxSurfaceScope({ + super.key, + required this.role, + required super.child, + }); + + final BusyMaxSurfaceRole role; + + static BusyMaxSurfaceRole roleOf(BuildContext context) { + return context + .dependOnInheritedWidgetOfExactType() + ?.role ?? + BusyMaxSurfaceRole.window; + } + + @override + bool updateShouldNotify(BusyMaxSurfaceScope oldWidget) { + return role != oldWidget.role; + } +} + +Color busyMaxGroupedSurfaceColor(BuildContext context) { + final colors = BusyMaxSurfaceColors.of(context); + final role = BusyMaxSurfaceScope.roleOf(context); + if (role == BusyMaxSurfaceRole.window) { + // The native bridge already resolves the opaque card role against the + // window. Reuse that authoritative value exactly instead of recomputing + // an equivalent color with different floating-point channel values. + return colors.card; + } + final parent = switch (role) { + BusyMaxSurfaceRole.window => colors.window, + BusyMaxSurfaceRole.view => colors.view, + BusyMaxSurfaceRole.sidebar => colors.sidebar, + BusyMaxSurfaceRole.dialog => colors.dialog, + BusyMaxSurfaceRole.popover => colors.popover, + }; + return Color.alphaBlend(colors.groupedSurface, parent); +} + class BusyMaxGroupedList extends StatelessWidget { const BusyMaxGroupedList({ super.key, @@ -933,6 +1030,7 @@ class BusyMaxGroupedSurface extends StatelessWidget { Widget build(BuildContext context) { final highContrast = MediaQuery.highContrastOf(context); return BusyMaxSurface( + color: busyMaxGroupedSurfaceColor(context), side: highContrast ? BorderSide(color: Theme.of(context).colorScheme.outline) : null, @@ -2985,22 +3083,25 @@ class BusyMaxModalEditorSurface extends StatelessWidget { ? double.infinity : maxHeight!.clamp(0.0, double.infinity).toDouble(); - return Dialog( - backgroundColor: editorSurface, - surfaceTintColor: editorSurface, - insetPadding: insetPadding, - insetAnimationDuration: MediaQuery.disableAnimationsOf(context) - ? Duration.zero - : BusyMaxMotion.dialogInsets, - insetAnimationCurve: BusyMaxMotion.dialogInsetsCurve, - clipBehavior: Clip.antiAlias, - child: ConstrainedBox( - constraints: BoxConstraints( - minWidth: effectiveMinWidth, - maxWidth: effectiveMaxWidth, - maxHeight: effectiveMaxHeight, + return BusyMaxSurfaceScope( + role: BusyMaxSurfaceRole.window, + child: Dialog( + backgroundColor: editorSurface, + surfaceTintColor: editorSurface, + insetPadding: insetPadding, + insetAnimationDuration: MediaQuery.disableAnimationsOf(context) + ? Duration.zero + : BusyMaxMotion.dialogInsets, + insetAnimationCurve: BusyMaxMotion.dialogInsetsCurve, + clipBehavior: Clip.antiAlias, + child: ConstrainedBox( + constraints: BoxConstraints( + minWidth: effectiveMinWidth, + maxWidth: effectiveMaxWidth, + maxHeight: effectiveMaxHeight, + ), + child: child, ), - child: child, ), ); } @@ -3053,12 +3154,10 @@ class BusyMaxDialogShell extends StatelessWidget { namesRoute: true, explicitChildNodes: true, label: title, - child: Dialog( - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(BusyMaxRadius.lg), - ), - child: ClipRRect( - borderRadius: BorderRadius.circular(BusyMaxRadius.lg), + child: BusyMaxSurfaceScope( + role: BusyMaxSurfaceRole.dialog, + child: Dialog( + clipBehavior: Clip.antiAlias, child: ConstrainedBox( constraints: BoxConstraints(maxWidth: maxWidth), child: Column( diff --git a/lib/src/app/busymax_keyboard_shortcuts_dialog.dart b/lib/src/app/busymax_keyboard_shortcuts_dialog.dart index 8ef50d0..97f5165 100644 --- a/lib/src/app/busymax_keyboard_shortcuts_dialog.dart +++ b/lib/src/app/busymax_keyboard_shortcuts_dialog.dart @@ -26,194 +26,198 @@ class BusyMaxKeyboardShortcutsDialog extends StatelessWidget { final l10n = context.l10n; final textTheme = Theme.of(context).textTheme; final colorScheme = Theme.of(context).colorScheme; - return Dialog( - child: ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 460, maxHeight: 560), - child: Stack( - children: [ - SingleChildScrollView( - padding: const EdgeInsets.all(BusyMaxSpacing.lg), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Align( - alignment: Alignment.center, - child: Icon( - Icons.keyboard_alt_outlined, - size: 64, - color: colorScheme.primary, + return BusyMaxSurfaceScope( + role: BusyMaxSurfaceRole.dialog, + child: Dialog( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 460, maxHeight: 560), + child: Stack( + children: [ + SingleChildScrollView( + padding: const EdgeInsets.all(BusyMaxSpacing.lg), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Align( + alignment: Alignment.center, + child: Icon( + Icons.keyboard_alt_outlined, + size: 64, + color: colorScheme.primary, + ), + ), + const SizedBox(height: BusyMaxSpacing.md), + Text( + l10n.keyboardShortcuts, + textAlign: TextAlign.center, + style: textTheme.headlineSmall, ), - ), - const SizedBox(height: BusyMaxSpacing.md), - Text( - l10n.keyboardShortcuts, - textAlign: TextAlign.center, - style: textTheme.headlineSmall, - ), - const SizedBox(height: BusyMaxSpacing.lg), - BusyMaxGroupedList( - title: l10n.shortcutGroupGeneral, - filled: true, - children: [ - BusyMaxActionRow( - title: l10n.keyboardShortcuts, - subtitle: l10n.shortcutKeyboardShortcutsDescription, - leading: const Icon(Icons.keyboard_alt_outlined), - trailing: const _KeyboardShortcutBadge( - BusyMaxShortcutLabels.keyboardShortcuts, + const SizedBox(height: BusyMaxSpacing.lg), + BusyMaxGroupedList( + title: l10n.shortcutGroupGeneral, + filled: true, + children: [ + BusyMaxActionRow( + title: l10n.keyboardShortcuts, + subtitle: l10n.shortcutKeyboardShortcutsDescription, + leading: const Icon(Icons.keyboard_alt_outlined), + trailing: const _KeyboardShortcutBadge( + BusyMaxShortcutLabels.keyboardShortcuts, + ), ), - ), - BusyMaxActionRow( - title: l10n.settings, - leading: const Icon(Icons.settings_outlined), - trailing: const _KeyboardShortcutBadge( - BusyMaxShortcutLabels.settings, + BusyMaxActionRow( + title: l10n.settings, + leading: const Icon(Icons.settings_outlined), + trailing: const _KeyboardShortcutBadge( + BusyMaxShortcutLabels.settings, + ), ), - ), - BusyMaxActionRow( - title: MaterialLocalizations.of( - context, - ).searchFieldLabel, - leading: const Icon(Icons.search), - trailing: const _KeyboardShortcutBadge( - BusyMaxShortcutLabels.search, + BusyMaxActionRow( + title: MaterialLocalizations.of( + context, + ).searchFieldLabel, + leading: const Icon(Icons.search), + trailing: const _KeyboardShortcutBadge( + BusyMaxShortcutLabels.search, + ), ), - ), - ], - ), - BusyMaxGroupedList( - title: l10n.shortcutGroupNavigation, - filled: true, - children: [ - BusyMaxActionRow( - title: l10n.shortcutNextPeriod, - subtitle: l10n.shortcutNextPeriodDescription, - leading: const Icon(Icons.arrow_forward), - trailing: const _KeyboardShortcutBadge('Shift+Right'), - ), - BusyMaxActionRow( - title: l10n.shortcutPreviousPeriod, - subtitle: l10n.shortcutPreviousPeriodDescription, - leading: const Icon(Icons.arrow_back), - trailing: const _KeyboardShortcutBadge('Shift+Left'), - ), - BusyMaxActionRow( - title: l10n.shortcutJumpToToday, - leading: const Icon(Icons.today_outlined), - trailing: const _KeyboardShortcutBadge('Shift+T'), - ), - ], - ), - BusyMaxGroupedList( - title: l10n.shortcutGroupCreateAndEdit, - filled: true, - children: [ - BusyMaxActionRow( - title: l10n.create, - leading: const Icon(Icons.add), - trailing: const _KeyboardShortcutBadge( - BusyMaxShortcutLabels.create, + ], + ), + BusyMaxGroupedList( + title: l10n.shortcutGroupNavigation, + filled: true, + children: [ + BusyMaxActionRow( + title: l10n.shortcutNextPeriod, + subtitle: l10n.shortcutNextPeriodDescription, + leading: const Icon(Icons.arrow_forward), + trailing: const _KeyboardShortcutBadge('Shift+Right'), ), - ), - BusyMaxActionRow( - title: l10n.newEvent, - leading: const Icon(Icons.event_outlined), - trailing: const _KeyboardShortcutBadge('E'), - ), - BusyMaxActionRow( - title: l10n.newTask, - leading: const Icon(Icons.task_alt_outlined), - trailing: const _KeyboardShortcutBadge('T'), - ), - BusyMaxActionRow( - title: l10n.shortcutSaveItem, - leading: const Icon(Icons.save_outlined), - trailing: const _KeyboardShortcutBadge('Ctrl+S'), - ), - BusyMaxActionRow( - title: l10n.shortcutDeleteItem, - leading: const Icon(Icons.delete_outline), - trailing: const _KeyboardShortcutBadge( - 'Backspace / Delete', + BusyMaxActionRow( + title: l10n.shortcutPreviousPeriod, + subtitle: l10n.shortcutPreviousPeriodDescription, + leading: const Icon(Icons.arrow_back), + trailing: const _KeyboardShortcutBadge('Shift+Left'), ), - ), - ], - ), - BusyMaxGroupedList( - title: l10n.shortcutGroupTaskEditing, - filled: true, - children: [ - BusyMaxActionRow( - title: l10n.shortcutCancelEditing, - subtitle: l10n.shortcutCancelEditingDescription, - leading: const Icon(Icons.close), - trailing: const _KeyboardShortcutBadge('Esc'), - ), - ], - ), - BusyMaxGroupedList( - title: l10n.shortcutGroupView, - filled: true, - children: [ - BusyMaxActionRow( - title: l10n.shortcutDayView, - leading: const Icon(Icons.calendar_view_day_outlined), - trailing: const _KeyboardShortcutBadge('1 / D'), - ), - BusyMaxActionRow( - title: l10n.shortcutWeekView, - leading: const Icon(Icons.view_week_outlined), - trailing: const _KeyboardShortcutBadge('2 / W'), - ), - BusyMaxActionRow( - title: l10n.shortcutMonthView, - leading: const Icon(Icons.calendar_view_month), - trailing: const _KeyboardShortcutBadge('3 / M'), - ), - BusyMaxActionRow( - title: l10n.shortcutYearView, - leading: const Icon(Icons.calendar_today_outlined), - trailing: const _KeyboardShortcutBadge('4 / Y'), - ), - BusyMaxActionRow( - title: l10n.shortcutAgendaView, - leading: const Icon(Icons.view_agenda_outlined), - trailing: const _KeyboardShortcutBadge('0 / A'), - ), - ], - ), - BusyMaxGroupedList( - title: l10n.shortcutGroupCompactAgenda, - filled: true, - children: [ - BusyMaxActionRow( - title: l10n.compactAgendaRefresh, - subtitle: l10n.shortcutRefreshCompactAgendaDescription, - leading: const Icon(Icons.refresh), - trailing: const _KeyboardShortcutBadge('Ctrl+R'), - ), - BusyMaxActionRow( - title: l10n.compactAgendaHide, - subtitle: l10n.shortcutHideCompactAgendaDescription, - leading: const Icon(Icons.visibility_off_outlined), - trailing: const _KeyboardShortcutBadge('Esc'), - ), - ], - ), - ], + BusyMaxActionRow( + title: l10n.shortcutJumpToToday, + leading: const Icon(Icons.today_outlined), + trailing: const _KeyboardShortcutBadge('Shift+T'), + ), + ], + ), + BusyMaxGroupedList( + title: l10n.shortcutGroupCreateAndEdit, + filled: true, + children: [ + BusyMaxActionRow( + title: l10n.create, + leading: const Icon(Icons.add), + trailing: const _KeyboardShortcutBadge( + BusyMaxShortcutLabels.create, + ), + ), + BusyMaxActionRow( + title: l10n.newEvent, + leading: const Icon(Icons.event_outlined), + trailing: const _KeyboardShortcutBadge('E'), + ), + BusyMaxActionRow( + title: l10n.newTask, + leading: const Icon(Icons.task_alt_outlined), + trailing: const _KeyboardShortcutBadge('T'), + ), + BusyMaxActionRow( + title: l10n.shortcutSaveItem, + leading: const Icon(Icons.save_outlined), + trailing: const _KeyboardShortcutBadge('Ctrl+S'), + ), + BusyMaxActionRow( + title: l10n.shortcutDeleteItem, + leading: const Icon(Icons.delete_outline), + trailing: const _KeyboardShortcutBadge( + 'Backspace / Delete', + ), + ), + ], + ), + BusyMaxGroupedList( + title: l10n.shortcutGroupTaskEditing, + filled: true, + children: [ + BusyMaxActionRow( + title: l10n.shortcutCancelEditing, + subtitle: l10n.shortcutCancelEditingDescription, + leading: const Icon(Icons.close), + trailing: const _KeyboardShortcutBadge('Esc'), + ), + ], + ), + BusyMaxGroupedList( + title: l10n.shortcutGroupView, + filled: true, + children: [ + BusyMaxActionRow( + title: l10n.shortcutDayView, + leading: const Icon(Icons.calendar_view_day_outlined), + trailing: const _KeyboardShortcutBadge('1 / D'), + ), + BusyMaxActionRow( + title: l10n.shortcutWeekView, + leading: const Icon(Icons.view_week_outlined), + trailing: const _KeyboardShortcutBadge('2 / W'), + ), + BusyMaxActionRow( + title: l10n.shortcutMonthView, + leading: const Icon(Icons.calendar_view_month), + trailing: const _KeyboardShortcutBadge('3 / M'), + ), + BusyMaxActionRow( + title: l10n.shortcutYearView, + leading: const Icon(Icons.calendar_today_outlined), + trailing: const _KeyboardShortcutBadge('4 / Y'), + ), + BusyMaxActionRow( + title: l10n.shortcutAgendaView, + leading: const Icon(Icons.view_agenda_outlined), + trailing: const _KeyboardShortcutBadge('0 / A'), + ), + ], + ), + BusyMaxGroupedList( + title: l10n.shortcutGroupCompactAgenda, + filled: true, + children: [ + BusyMaxActionRow( + title: l10n.compactAgendaRefresh, + subtitle: + l10n.shortcutRefreshCompactAgendaDescription, + leading: const Icon(Icons.refresh), + trailing: const _KeyboardShortcutBadge('Ctrl+R'), + ), + BusyMaxActionRow( + title: l10n.compactAgendaHide, + subtitle: l10n.shortcutHideCompactAgendaDescription, + leading: const Icon(Icons.visibility_off_outlined), + trailing: const _KeyboardShortcutBadge('Esc'), + ), + ], + ), + ], + ), ), - ), - PositionedDirectional( - top: BusyMaxSpacing.sm, - end: BusyMaxSpacing.sm, - child: YaruIconButton( - icon: const Icon(Icons.close, size: BusyMaxSizes.iconSm), - tooltip: l10n.close, - onPressed: () => Navigator.of(context).pop(), + PositionedDirectional( + top: BusyMaxSpacing.sm, + end: BusyMaxSpacing.sm, + child: YaruIconButton( + icon: const Icon(Icons.close, size: BusyMaxSizes.iconSm), + tooltip: l10n.close, + onPressed: () => Navigator.of(context).pop(), + ), ), - ), - ], + ], + ), ), ), ); diff --git a/lib/src/app/busymax_surface_colors.dart b/lib/src/app/busymax_surface_colors.dart index bd5d8c5..b65f300 100644 --- a/lib/src/app/busymax_surface_colors.dart +++ b/lib/src/app/busymax_surface_colors.dart @@ -24,6 +24,7 @@ class BusyMaxSurfaceColors extends ThemeExtension { required this.border, required this.divider, required this.cardShade, + required this.dialogOutline, required this.floatingBorder, required this.sidebarBorder, required this.shade, @@ -42,8 +43,8 @@ class BusyMaxSurfaceColors extends ThemeExtension { /// Modern Yaru publishes this role as a translucent layer in dark mode. /// It is retained for semantic color resolution, but must not be painted /// directly by an elevated Flutter [Material]: the physical shadow would - /// show through the translucent fill. Use the opaque [card] paint token for - /// shared card surfaces. + /// show through the translucent fill. Shared grouped surfaces composite it + /// over their declared semantic parent before painting an opaque material. final Color groupedSurface; final Color dialog; final Color popover; @@ -63,6 +64,18 @@ class BusyMaxSurfaceColors extends ThemeExtension { /// This is libadwaita's `card_shade_color`, which is intentionally distinct /// from the generic GTK separator and outline roles. final Color cardShade; + + /// Restrained inside outline for modal dialog surfaces. + /// + /// Modern libadwaita uses a low-opacity light outline for this role. It is + /// intentionally separate from both generic control borders and the darker + /// perimeter used by anchored menus and popovers. + final Color dialogOutline; + + /// Subtle perimeter for anchored menus and popovers. + /// + /// This is the current libadwaita/Yaru popover edge. Dialog decoration is a + /// separate native role and must not reuse this token. final Color floatingBorder; final Color sidebarBorder; final Color shade; @@ -96,6 +109,7 @@ class BusyMaxSurfaceColors extends ThemeExtension { Color? border, Color? divider, Color? cardShade, + Color? dialogOutline, Color? floatingBorder, Color? sidebarBorder, Color? shade, @@ -122,6 +136,7 @@ class BusyMaxSurfaceColors extends ThemeExtension { border: border ?? this.border, divider: divider ?? this.divider, cardShade: cardShade ?? this.cardShade, + dialogOutline: dialogOutline ?? this.dialogOutline, floatingBorder: floatingBorder ?? this.floatingBorder, sidebarBorder: sidebarBorder ?? this.sidebarBorder, shade: shade ?? this.shade, @@ -163,6 +178,7 @@ class BusyMaxSurfaceColors extends ThemeExtension { border: Color.lerp(border, other.border, t)!, divider: Color.lerp(divider, other.divider, t)!, cardShade: Color.lerp(cardShade, other.cardShade, t)!, + dialogOutline: Color.lerp(dialogOutline, other.dialogOutline, t)!, floatingBorder: Color.lerp(floatingBorder, other.floatingBorder, t)!, sidebarBorder: Color.lerp(sidebarBorder, other.sidebarBorder, t)!, shade: Color.lerp(shade, other.shade, t)!, @@ -175,6 +191,10 @@ BusyMaxSurfaceColors busyMaxFallbackSurfaceColors(Brightness brightness) { Brightness.light => const Color(0xFFFAFAFA), Brightness.dark => const Color(0xFF2C2C2C), }; + final view = switch (brightness) { + Brightness.light => const Color(0xFFFFFFFF), + Brightness.dark => const Color(0xFF272727), + }; final foreground = switch (brightness) { Brightness.light => const Color(0xFF3D3D3D), Brightness.dark => const Color(0xFFF7F7F7), @@ -193,15 +213,15 @@ BusyMaxSurfaceColors busyMaxFallbackSurfaceColors(Brightness brightness) { // every modern role, so named theme values replace these fallbacks only // when the bridge can identify the role and the resolver can read it. window: window, - view: Color(0xFFFFFFFF), + view: view, sidebar: Color(0xFFEBEBEB), secondarySidebar: Color(0xFFF0F0F0), headerbar: Color(0xFFFAFAFA), headerbarFlat: Color(0xFFFFFFFF), card: Color(0xFFFFFFFF), groupedSurface: Color(0xFFFFFFFF), - dialog: window, - popover: Color(0xFFFAFAFA), + dialog: const Color(0xFFFAFAFA), + popover: const Color(0xFFFAFAFA), // Match Yaru's contained-button ladder. A weaker resting layer makes // standard controls look flat until their hover overlay appears. control: Color.fromRGBO(0, 0, 0, 0.10), @@ -218,16 +238,19 @@ BusyMaxSurfaceColors busyMaxFallbackSurfaceColors(Brightness brightness) { border: Color.fromRGBO(0, 0, 0, 0.18), divider: Color.fromRGBO(0, 0, 0, 0.10), cardShade: Color.fromRGBO(24, 24, 24, 0.08), - floatingBorder: Color.fromRGBO(0, 0, 0, 0.10), + // libadwaita's modal/floating-sheet surface uses this restrained inset + // outline. Keep it independent of the black anchored-popover edge. + dialogOutline: Color.fromRGBO(255, 255, 255, 0.07), + // Current libadwaita/Yaru uses the same restrained edge in both modes. + floatingBorder: Color.fromRGBO(0, 0, 0, 0.14), sidebarBorder: Color.fromRGBO(24, 24, 24, 0.08), shade: Color.fromRGBO(0, 0, 0, 0.07), ), Brightness.dark => BusyMaxSurfaceColors( - // Modern Yaru/libadwaita semantic surface roles. In particular, native - // floating surfaces are raised neutral grays rather than the near-black - // widget-class colors reported by legacy GTK 3 sampling. + // Modern Yaru/libadwaita semantic surface roles. Floating surfaces are + // raised neutral gray rather than the near-black main content role. window: window, - view: Color(0xFF272727), + view: view, sidebar: Color(0xFF393939), secondarySidebar: Color(0xFF323232), headerbar: Color(0xFF393939), @@ -251,7 +274,8 @@ BusyMaxSurfaceColors busyMaxFallbackSurfaceColors(Brightness brightness) { // Modern Yaru uses a 36% near-black recessed edge for dark cards. // Keep the neutral fallback free of the theme's slight blue component. cardShade: Color.fromRGBO(0, 0, 0, 0.36), - floatingBorder: Color.fromRGBO(255, 255, 255, 0.10), + dialogOutline: Color.fromRGBO(255, 255, 255, 0.07), + floatingBorder: Color.fromRGBO(0, 0, 0, 0.14), // A sidebar boundary is recessed in Yaru, not highlighted. This exact // fallback mirrors its named semantic role when GTK 3 omits that role. sidebarBorder: Color.fromRGBO(16, 16, 16, 0.35), diff --git a/lib/src/app/busymax_yaru_theme.dart b/lib/src/app/busymax_yaru_theme.dart index 7eacfea..66f5f19 100644 --- a/lib/src/app/busymax_yaru_theme.dart +++ b/lib/src/app/busymax_yaru_theme.dart @@ -160,20 +160,23 @@ class BusyMaxYaruTheme { fallback: textTheme.labelLarge, ), ); - final floatingSurfaceSide = highContrast - ? BorderSide(color: colors.border) - : BorderSide.none; + final popoverSurfaceSide = BorderSide( + color: highContrast ? colors.border : colors.floatingBorder, + ); + final dialogSurfaceSide = BorderSide( + color: highContrast ? colors.border : colors.dialogOutline, + ); final menuStyle = _semanticMenuSurfaceStyle( base.menuTheme.style, color: colors.popover, shadowColor: colorScheme.shadow, - side: floatingSurfaceSide, + side: popoverSurfaceSide, ); final dropdownMenuStyle = _semanticMenuSurfaceStyle( base.dropdownMenuTheme.menuStyle, color: colors.popover, shadowColor: colorScheme.shadow, - side: floatingSurfaceSide, + side: popoverSurfaceSide, ); final cardTheme = base.cardTheme.copyWith( // Elevated Flutter surfaces must be opaque. A translucent card layer @@ -225,7 +228,10 @@ class BusyMaxYaruTheme { dialogTheme: base.dialogTheme.copyWith( backgroundColor: colors.dialog, surfaceTintColor: colors.dialog, - shape: _withOutlineSide(base.dialogTheme.shape, floatingSurfaceSide), + // Retain Yaru's dialog radius and geometry, but use the modern + // libadwaita dialog outline instead of Yaru Flutter's conspicuous + // dark-mode white outline. Popovers have a separate perimeter role. + shape: _withOutlineSide(base.dialogTheme.shape, dialogSurfaceSide), titleTextStyle: normalizer.apply( base.dialogTheme.titleTextStyle, fallback: textTheme.titleLarge, @@ -341,7 +347,7 @@ class BusyMaxYaruTheme { : colors.foreground, ); }), - shape: _withOutlineSide(base.popupMenuTheme.shape, floatingSurfaceSide), + shape: _withOutlineSide(base.popupMenuTheme.shape, popoverSurfaceSide), ), menuTheme: MenuThemeData( style: menuStyle, @@ -578,6 +584,7 @@ BusyMaxSurfaceColors _highContrastSurfaceColors(Brightness brightness) { border: foreground, divider: foreground, cardShade: foreground, + dialogOutline: foreground, floatingBorder: foreground, sidebarBorder: foreground, shade: Colors.black, diff --git a/lib/src/features/schedule/presentation/schedule_item_details_popover.dart b/lib/src/features/schedule/presentation/schedule_item_details_popover.dart index 09d8cfe..8a76f5d 100644 --- a/lib/src/features/schedule/presentation/schedule_item_details_popover.dart +++ b/lib/src/features/schedule/presentation/schedule_item_details_popover.dart @@ -3,7 +3,6 @@ import 'package:intl/intl.dart' hide TextDirection; import 'package:yaru/yaru.dart'; import '../../../app/busymax_design.dart'; -import '../../../app/busymax_yaru_theme.dart'; import '../../../calendar_providers/calendar_description.dart'; import '../../../l10n/l10n.dart'; import '../../../schedule/schedule_item.dart'; @@ -48,15 +47,13 @@ class _ScheduleItemDetailsPopoverCard extends StatelessWidget { @override Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; - final surfaceColors = BusyMaxSurfaceColors.of(context); final itemColor = ScheduleProjection.colorForItem( item, colorScheme.brightness, ); return Material( color: Colors.transparent, - child: BusyMaxPopoverSurface( - color: surfaceColors.popover, + child: BusyMaxContentPopoverSurface( arrowSide: arrowSide, arrowAlignment: arrowAlignment, child: SingleChildScrollView( diff --git a/lib/src/platform/linux_header_bar_service.dart b/lib/src/platform/linux_header_bar_service.dart index 0a179a3..ec9e8d6 100644 --- a/lib/src/platform/linux_header_bar_service.dart +++ b/lib/src/platform/linux_header_bar_service.dart @@ -186,7 +186,7 @@ class BusyMaxHeaderBarTheme { required this.foregroundColor, required this.sidebarBorderColor, required this.popoverBackgroundColor, - required this.floatingBorderColor, + required this.dialogOutlineColor, required this.modalBarrierColor, }); @@ -198,7 +198,7 @@ class BusyMaxHeaderBarTheme { final Color foregroundColor; final Color sidebarBorderColor; final Color popoverBackgroundColor; - final Color floatingBorderColor; + final Color dialogOutlineColor; final Color modalBarrierColor; Map toJson() { @@ -211,7 +211,7 @@ class BusyMaxHeaderBarTheme { 'foregroundColor': busyMaxCssColor(foregroundColor), 'sidebarBorderColor': busyMaxCssColor(sidebarBorderColor), 'popoverBackgroundColor': busyMaxCssColor(popoverBackgroundColor), - 'floatingBorderColor': busyMaxCssColor(floatingBorderColor), + 'dialogOutlineColor': busyMaxCssColor(dialogOutlineColor), 'modalBarrierColor': busyMaxCssColor(modalBarrierColor), }; } @@ -228,7 +228,7 @@ class BusyMaxHeaderBarTheme { other.foregroundColor == foregroundColor && other.sidebarBorderColor == sidebarBorderColor && other.popoverBackgroundColor == popoverBackgroundColor && - other.floatingBorderColor == floatingBorderColor && + other.dialogOutlineColor == dialogOutlineColor && other.modalBarrierColor == modalBarrierColor; } @@ -242,7 +242,7 @@ class BusyMaxHeaderBarTheme { foregroundColor, sidebarBorderColor, popoverBackgroundColor, - floatingBorderColor, + dialogOutlineColor, modalBarrierColor, ); } diff --git a/linux/runner/my_application.cc b/linux/runner/my_application.cc index e60b826..69ca6c5 100644 --- a/linux/runner/my_application.cc +++ b/linux/runner/my_application.cc @@ -59,7 +59,9 @@ 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 kHeaderControlStyleClass[] = "busymax-header-control"; +constexpr char kNativeDialogStyleClass[] = "busymax-native-dialog"; constexpr char kNativePopoverStyleClass[] = "busymax-native-popover"; struct _MyApplication { @@ -85,7 +87,7 @@ struct _MyApplication { gchar* header_bar_sidebar_border_color; gchar* header_bar_foreground_color; gchar* header_bar_popover_background_color; - gchar* header_bar_floating_border_color; + gchar* header_bar_dialog_outline_color; gchar* header_bar_modal_barrier_color; gboolean header_bar_high_contrast; gint header_bar_sidebar_width; @@ -278,6 +280,11 @@ static void respond_string(FlMethodCall* method_call, const gchar* value) { fl_method_call_respond_success(method_call, result, nullptr); } +static void style_native_dialog(GtkWidget* dialog) { + gtk_style_context_add_class(gtk_widget_get_style_context(dialog), + kNativeDialogStyleClass); +} + static void handle_pick_date(FlMethodCall* method_call, FlValue* args, GtkWindow* parent) { @@ -289,6 +296,7 @@ static void handle_pick_date(FlMethodCall* method_call, title != nullptr ? title : "Date", parent, GTK_DIALOG_MODAL, cancel_label != nullptr ? cancel_label : "_Cancel", GTK_RESPONSE_CANCEL, ok_label != nullptr ? ok_label : "_OK", GTK_RESPONSE_OK, nullptr); + style_native_dialog(dialog); gtk_window_set_resizable(GTK_WINDOW(dialog), FALSE); GtkWidget* content = gtk_dialog_get_content_area(GTK_DIALOG(dialog)); @@ -380,6 +388,7 @@ static void handle_native_confirmation(FlMethodCall* method_call, GTK_DIALOG_DESTROY_WITH_PARENT), destructive ? GTK_MESSAGE_WARNING : GTK_MESSAGE_QUESTION, GTK_BUTTONS_NONE, "%s", title != nullptr ? title : ""); + style_native_dialog(dialog); if (message != nullptr && message[0] != '\0') { gtk_message_dialog_format_secondary_text(GTK_MESSAGE_DIALOG(dialog), "%s", message); @@ -1128,12 +1137,6 @@ static void refresh_header_bar_css(MyApplication* self) { kDefaultHeaderBarSidebarBorderColor); const gchar* foreground_color = css_color_or( self->header_bar_foreground_color, "rgba(255,255,255,0.86)"); - const gchar* floating_border_color = css_color_or( - self->header_bar_floating_border_color, foreground_color); - g_autofree gchar* native_popover_border_css = - self->header_bar_high_contrast - ? g_strdup_printf("border-color: %s;", floating_border_color) - : g_strdup("border: none;"); g_autofree gchar* native_popover_css = is_css_color_token(self->header_bar_popover_background_color) ? g_strdup_printf( @@ -1141,12 +1144,25 @@ static void refresh_header_bar_css(MyApplication* self) { "popover.background.%s:backdrop {" "background-color: %s;" "background-image: none;" - "%s" "}", kNativePopoverStyleClass, kNativePopoverStyleClass, - self->header_bar_popover_background_color, - native_popover_border_css) + self->header_bar_popover_background_color) : g_strdup(""); + g_autofree gchar* native_dialog_css = g_strdup_printf( + ".%s,.%s:backdrop {" + "background-color: %s;" + "background-image: none;" + "}" + ".%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, + window_background_color, kNativeDialogStyleClass, + css_color_or(self->header_bar_dialog_outline_color, + kDefaultDialogOutlineColor)); const gchar* modal_barrier_color = css_color_or( self->header_bar_modal_barrier_color, "rgba(0,0,0,0.32)"); g_autofree gchar* modal_sidebar_border_color = @@ -1156,38 +1172,50 @@ static void refresh_header_bar_css(MyApplication* self) { const gboolean use_yaru_window_decoration_compatibility = !self->header_bar_high_contrast && current_gtk_theme_uses_legacy_yaru_shadow(); - const gchar* yaru_window_decoration_css = + g_autofree gchar* yaru_window_decoration_css = use_yaru_window_decoration_compatibility - ? "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;" - "}" - : ""; + ? 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);" + "}", + kNativeDialogStyleClass) + : 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"); @@ -1199,6 +1227,7 @@ static void refresh_header_bar_css(MyApplication* self) { "background-image: none;" "}" "%s" + "%s" "headerbar.busymax-flat-headerbar," "headerbar.busymax-flat-headerbar:backdrop {" "background-color: %s;" @@ -1277,8 +1306,8 @@ static void refresh_header_bar_css(MyApplication* self) { "background-color: %s;" "background-image: linear-gradient(%s, %s);" "}", - window_background_color, yaru_window_decoration_css, background_color, - foreground_color, + window_background_color, yaru_window_decoration_css, native_dialog_css, + background_color, foreground_color, sidebar_background_color, foreground_color, sidebar_border_color, foreground_color, foreground_color, native_popover_css, sidebar_background_color, modal_barrier_color, modal_barrier_color, @@ -1336,8 +1365,8 @@ static void set_header_bar_theme(MyApplication* self, FlValue* args) { fl_lookup_string_arg(args, "foregroundColor")); set_css_color_field(&self->header_bar_popover_background_color, fl_lookup_string_arg(args, "popoverBackgroundColor")); - set_css_color_field(&self->header_bar_floating_border_color, - fl_lookup_string_arg(args, "floatingBorderColor")); + 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")); set_main_flutter_view_background(self); @@ -2674,32 +2703,6 @@ static gboolean sample_widget_background(GtkWidget* widget, return color_is_visible(color); } -static gboolean sample_widget_border_color(GtkWidget* widget, - const gchar* style_class, - GtkStateFlags state, - GdkRGBA* color) { - if (widget == nullptr || color == nullptr) { - return FALSE; - } - GtkStyleContext* context = gtk_widget_get_style_context(widget); - if (context == nullptr) { - return FALSE; - } - if (style_class != nullptr) { - gtk_style_context_add_class(context, style_class); - } - gtk_style_context_set_state(context, state); - GValue value = G_VALUE_INIT; - gtk_style_context_get_property(context, "border-color", state, &value); - const GdkRGBA* border = - static_cast(g_value_get_boxed(&value)); - if (border != nullptr) { - *color = *border; - } - g_value_unset(&value); - return color_is_visible(color); -} - static gboolean sample_widget_color(GtkWidget* widget, const gchar* style_class, GtkStateFlags state, @@ -2750,7 +2753,6 @@ static const gchar* brightness_for_color(const GdkRGBA* color) { static FlValue* get_gtk_theme_colors() { GtkWidget* window = gtk_window_new(GTK_WINDOW_TOPLEVEL); GtkWidget* view = gtk_text_view_new(); - GtkWidget* popover = gtk_popover_new(nullptr); GtkWidget* control = gtk_button_new(); GtkWidget* separator = gtk_separator_new(GTK_ORIENTATION_HORIZONTAL); GtkWidget* dim_label = gtk_label_new(nullptr); @@ -2825,8 +2827,13 @@ static FlValue* get_gtk_theme_colors() { lookup_context_color(window_context, "card_shade_color", &card_shade_color); lookup_context_color(window_context, "dialog_bg_color", &dialog_color); lookup_context_color(window_context, "popover_bg_color", &popover_color); - sample_widget_border_color(popover, GTK_STYLE_CLASS_BACKGROUND, - GTK_STATE_FLAG_NORMAL, &floating_border_color); + // Only publish a named floating-surface role. Sampling GTK 3's computed + // popover border here imports its legacy light rim into the Flutter GTK 4 + // palette, where modern Yaru uses a recessed edge instead. + lookup_context_color(window_context, "popover_border_color", + &floating_border_color) || + lookup_context_color(window_context, "floating_border_color", + &floating_border_color); sample_widget_background(separator, GTK_STYLE_CLASS_SEPARATOR, GTK_STATE_FLAG_NORMAL, ÷r_color); @@ -2867,7 +2874,6 @@ static FlValue* get_gtk_theme_colors() { gtk_widget_destroy(dim_label); gtk_widget_destroy(separator); gtk_widget_destroy(control); - gtk_widget_destroy(popover); gtk_widget_destroy(view); gtk_widget_destroy(window); return result; @@ -3740,7 +3746,7 @@ static void my_application_dispose(GObject* object) { 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_popover_background_color, g_free); - g_clear_pointer(&self->header_bar_floating_border_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_view_mode, g_free); g_clear_pointer(&self->header_day_label, g_free); @@ -3796,7 +3802,8 @@ static void my_application_init(MyApplication* self) { self->header_bar_sidebar_border_color = nullptr; self->header_bar_foreground_color = nullptr; self->header_bar_popover_background_color = nullptr; - self->header_bar_floating_border_color = nullptr; + self->header_bar_dialog_outline_color = + g_strdup(kDefaultDialogOutlineColor); self->header_bar_modal_barrier_color = nullptr; self->header_bar_high_contrast = FALSE; self->header_bar_sidebar_width = 300; diff --git a/test/app/about_dialog_test.dart b/test/app/about_dialog_test.dart index b8044ce..ec21855 100644 --- a/test/app/about_dialog_test.dart +++ b/test/app/about_dialog_test.dart @@ -1,6 +1,8 @@ import 'dart:io'; import 'package:busymax/src/app/busymax_about_dialog.dart'; +import 'package:busymax/src/app/busymax_design.dart'; +import 'package:busymax/src/app/busymax_yaru_theme.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -21,6 +23,68 @@ void main() { expect(find.text('Close'), findsNothing); }); + for (final (brightness, dialogColor, popoverColor) in const [ + (Brightness.light, Color(0xFFFAFAFA), Color(0xFFFAFAFA)), + (Brightness.dark, Color(0xFF3E3E3E), Color(0xFF3E3E3E)), + ]) { + testWidgets('about dialog keeps grouped content distinct in $brightness', ( + tester, + ) async { + final theme = BusyMaxYaruTheme.build( + brightness: brightness, + accentColor: const Color(0xFF3584E4), + ); + final colors = theme.extension()!; + + await tester.pumpWidget( + localizedTestApp(theme: theme, child: const BusyMaxAboutDialog()), + ); + + final dialogMaterials = tester.widgetList( + find.descendant( + of: find.byType(Dialog), + matching: find.byType(Material), + ), + ); + final groupedMaterial = tester.widget( + find.descendant( + of: find.byType(BusyMaxGroupedSurface), + matching: find.byWidgetPredicate( + (widget) => + widget is Material && widget.elevation == BusyMaxElevation.card, + ), + ), + ); + final expectedGroupedColor = Color.alphaBlend( + colors.groupedSurface, + colors.dialog, + ); + + expect(colors.dialog, dialogColor); + expect(colors.popover, popoverColor); + expect(colors.dialog, isNot(colors.sidebar)); + expect( + dialogMaterials.any((material) => material.color == dialogColor), + isTrue, + ); + expect( + groupedMaterial.color?.toARGB32(), + expectedGroupedColor.toARGB32(), + ); + expect(groupedMaterial.color, isNot(dialogColor)); + if (brightness == Brightness.dark) { + expect( + groupedMaterial.color?.toARGB32(), + isNot(colors.card.toARGB32()), + ); + expect( + groupedMaterial.color!.computeLuminance(), + greaterThan(colors.dialog.computeLuminance()), + ); + } + }); + } + test('about links point to BusyStack repository', () { final source = File( 'lib/src/app/busymax_about_dialog.dart', diff --git a/test/app/busymax_grouped_surface_test.dart b/test/app/busymax_grouped_surface_test.dart index e192d0e..f768799 100644 --- a/test/app/busymax_grouped_surface_test.dart +++ b/test/app/busymax_grouped_surface_test.dart @@ -38,7 +38,6 @@ void main() { accentColor: const Color(0xFF3584E4), ); final colors = theme.extension()!; - await tester.pumpWidget( MaterialApp( theme: theme, @@ -1100,6 +1099,14 @@ void main() { accentColor: const Color(0xFF3584E4), ); final colors = theme.extension()!; + final expectedDialog = switch (brightness) { + Brightness.light => const Color(0xFFFAFAFA), + Brightness.dark => const Color(0xFF3E3E3E), + }; + final expectedPopover = switch (brightness) { + Brightness.light => const Color(0xFFFAFAFA), + Brightness.dark => const Color(0xFF3E3E3E), + }; await tester.pumpWidget( MaterialApp( @@ -1137,6 +1144,8 @@ void main() { matching: find.byType(Dialog), ), ); + expect(colors.dialog, expectedDialog); + expect(colors.popover, expectedPopover); expect(modalDialog.backgroundColor, colors.window); expect(modalDialog.surfaceTintColor, colors.window); expect(modalDialog.elevation, isNull); @@ -1150,6 +1159,7 @@ void main() { matching: find.byType(PhysicalShape), ), ); + expect(physicalShape.color, expectedPopover); expect(physicalShape.elevation, BusyMaxElevation.tooltip); expect(physicalShape.shadowColor, theme.colorScheme.shadow); final outlinePaint = find.descendant( @@ -1159,7 +1169,7 @@ void main() { widget is CustomPaint && widget.foregroundPainter != null, ), ); - expect(outlinePaint, findsNothing); + expect(outlinePaint, findsOneWidget); expect(tester.takeException(), isNull); await tester.pumpWidget( @@ -1181,21 +1191,21 @@ void main() { find.descendant( of: find.byType(AlertDialog), matching: find.byWidgetPredicate( - (widget) => widget is Material && widget.color == colors.dialog, + (widget) => widget is Material && widget.color == expectedDialog, ), ), ); expect(alertDialog.backgroundColor, isNull); expect(alertDialog.surfaceTintColor, isNull); - expect(theme.dialogTheme.backgroundColor, colors.dialog); - expect(alertMaterial.color, colors.dialog); + expect(theme.dialogTheme.backgroundColor, expectedDialog); + expect(alertMaterial.color, expectedDialog); expect(alertMaterial.shape, theme.dialogTheme.shape); expect(tester.takeException(), isNull); }, ); } - testWidgets('popover perimeter is outlined only in high contrast', ( + testWidgets('popover perimeter remains outlined in high contrast', ( tester, ) async { final theme = BusyMaxYaruTheme.build( diff --git a/test/app/high_contrast_theme_test.dart b/test/app/high_contrast_theme_test.dart index f8f5b42..015d587 100644 --- a/test/app/high_contrast_theme_test.dart +++ b/test/app/high_contrast_theme_test.dart @@ -54,6 +54,7 @@ void main() { expect(surfaces.border, surfaces.foreground); expect(surfaces.divider, surfaces.foreground); expect(surfaces.cardShade, surfaces.foreground); + expect(surfaces.dialogOutline, surfaces.foreground); expect(surfaces.floatingBorder, surfaces.foreground); expect(surfaces.sidebarBorder, surfaces.foreground); expect(theme.colorScheme.outline, surfaces.foreground); diff --git a/test/app/keyboard_shortcuts_dialog_test.dart b/test/app/keyboard_shortcuts_dialog_test.dart index ea503cc..42c8b27 100644 --- a/test/app/keyboard_shortcuts_dialog_test.dart +++ b/test/app/keyboard_shortcuts_dialog_test.dart @@ -1,6 +1,8 @@ import 'dart:io'; +import 'package:busymax/src/app/busymax_design.dart'; import 'package:busymax/src/app/busymax_keyboard_shortcuts_dialog.dart'; +import 'package:busymax/src/app/busymax_yaru_theme.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -43,6 +45,66 @@ void main() { expect(find.text('Close'), findsNothing); }); + for (final brightness in Brightness.values) { + testWidgets( + 'keyboard shortcut groups resolve the contextual $brightness dialog card', + (tester) async { + final theme = BusyMaxYaruTheme.build( + brightness: brightness, + accentColor: const Color(0xFFE95420), + ); + final colors = theme.extension()!; + final expectedGroupedColor = Color.alphaBlend( + colors.groupedSurface, + colors.dialog, + ); + final expectedDialogSide = BorderSide(color: colors.dialogOutline); + + await tester.pumpWidget( + localizedTestApp( + theme: theme, + child: const BusyMaxKeyboardShortcutsDialog(), + ), + ); + + final groupedMaterials = tester + .widgetList( + find.descendant( + of: find.byType(BusyMaxGroupedSurface), + matching: find.byType(Material), + ), + ) + .where((material) => material.elevation == BusyMaxElevation.card) + .toList(); + + expect(groupedMaterials, hasLength(6)); + expect( + groupedMaterials.every( + (material) => + material.color?.toARGB32() == expectedGroupedColor.toARGB32(), + ), + isTrue, + ); + final dialog = tester.widget(find.byType(Dialog)); + final dialogShape = + (dialog.shape ?? theme.dialogTheme.shape)! + as RoundedRectangleBorder; + expect(dialogShape.side, expectedDialogSide); + expect(dialogShape.side.color, isNot(colors.floatingBorder)); + if (brightness == Brightness.dark) { + expect( + expectedGroupedColor.toARGB32(), + isNot(colors.card.toARGB32()), + ); + expect( + expectedGroupedColor.computeLuminance(), + greaterThan(colors.dialog.computeLuminance()), + ); + } + }, + ); + } + test('keyboard shortcuts are available from native headerbar menu', () { final app = File('lib/src/app/busymax_app.dart').readAsStringSync(); final service = File( diff --git a/test/app/native_ui_audit_test.dart b/test/app/native_ui_audit_test.dart index ddff892..c6c620b 100644 --- a/test/app/native_ui_audit_test.dart +++ b/test/app/native_ui_audit_test.dart @@ -90,84 +90,72 @@ void main() { }, ); - test( - 'Yaru GTK3 window compatibility removes only the legacy frame ring', - () { - final source = File( - 'linux/runner/my_application.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)', - ); - final refreshEnd = source.indexOf( - 'static void set_css_color_field(', - refreshStart, - ); + test('Yaru GTK3 compatibility replaces legacy decoration rings', () { + final source = File('linux/runner/my_application.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)', + ); + final refreshEnd = source.indexOf( + 'static void set_css_color_field(', + refreshStart, + ); - expect(themeGateStart, isNonNegative); - expect(refreshStart, greaterThan(themeGateStart)); - expect(refreshEnd, greaterThan(refreshStart)); + expect(themeGateStart, isNonNegative); + expect(refreshStart, greaterThan(themeGateStart)); + expect(refreshEnd, greaterThan(refreshStart)); - final themeGate = source.substring(themeGateStart, refreshStart); - final refresh = source.substring(refreshStart, refreshEnd); - final compatibilityStart = refresh.indexOf( - 'const gchar* yaru_window_decoration_css =', - ); - final compatibilityEnd = refresh.indexOf( - 'GtkWidget* header_bar', - compatibilityStart, - ); + final themeGate = source.substring(themeGateStart, refreshStart); + final refresh = source.substring(refreshStart, refreshEnd); + final compatibilityStart = refresh.indexOf( + 'g_autofree gchar* yaru_window_decoration_css =', + ); + final compatibilityEnd = refresh.indexOf( + 'GtkWidget* header_bar', + 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-")'), - ); - expect(themeGate, contains('strstr(normalized_theme, "highcontrast")')); - expect(compatibilityStart, isNonNegative); - expect(compatibilityEnd, greaterThan(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-")'), + ); + expect(themeGate, contains('strstr(normalized_theme, "highcontrast")')); + expect(compatibilityStart, isNonNegative); + expect(compatibilityEnd, greaterThan(compatibilityStart)); - final compatibility = refresh.substring( - compatibilityStart, - compatibilityEnd, - ); + final compatibility = refresh.substring( + compatibilityStart, + compatibilityEnd, + ); - expect(refresh, contains('!self->header_bar_high_contrast')); - expect( - refresh, - contains('current_gtk_theme_uses_legacy_yaru_shadow()'), - ); - expect( - compatibility, - contains('box-shadow: 0 3px 9px 1px rgba(0,0,0,0.5);'), - ); - 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)')); - expect(compatibility, contains('.tiled-top')); - expect(compatibility, contains('.tiled-right')); - expect(compatibility, contains('.tiled-bottom')); - expect(compatibility, contains('.tiled-left')); - expect(compatibility, isNot(contains('0 0 0 1px'))); - expect(compatibility, isNot(contains('border-radius'))); - expect( - compatibility, - isNot(contains('gdk_window_shape_combine_region')), - ); - }, - ); + expect(refresh, contains('!self->header_bar_high_contrast')); + expect(refresh, contains('current_gtk_theme_uses_legacy_yaru_shadow()')); + expect( + compatibility, + contains('box-shadow: 0 3px 9px 1px rgba(0,0,0,0.5);'), + ); + 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)')); + expect(compatibility, contains('.tiled-top')); + expect(compatibility, contains('.tiled-right')); + expect(compatibility, contains('.tiled-bottom')); + expect(compatibility, contains('.tiled-left')); + expect(compatibility, contains('0 0 0 1px rgba(0,0,0,0.05);')); + expect(compatibility, isNot(contains('rgba(0,0,0,0.65)'))); + expect(compatibility, isNot(contains('rgba(0,0,0,0.75)'))); + expect(compatibility, isNot(contains('border-radius'))); + expect(compatibility, isNot(contains('gdk_window_shape_combine_region'))); + }); test( 'Task Details, Settings, and Agenda use BusyMax Yaru row patterns', @@ -773,9 +761,10 @@ void main() { expect(source, isNot(contains('"busymax-header-popover"'))); expect(source, contains('"busymax-native-popover"')); expect(source, contains('header_bar_popover_background_color')); - expect(source, contains('header_bar_floating_border_color')); + expect(source, isNot(contains('header_bar_floating_border_color'))); expect(source, contains('"popoverBackgroundColor"')); - expect(source, contains('"floatingBorderColor"')); + 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'))); @@ -880,7 +869,7 @@ void main() { expect(source, isNot(contains('header_bar_disabled_foreground_color'))); expect(source, isNot(contains('header_bar_control_hover_color'))); expect(source, contains('header_bar_popover_background_color')); - expect(source, contains('header_bar_floating_border_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;')); @@ -976,7 +965,10 @@ void main() { expect(source, contains('"card_bg_color"')); expect(source, contains('"dialog_bg_color"')); expect(source, contains('"popover_bg_color"')); - expect(source, contains('GtkWidget* popover =')); + expect( + source, + isNot(contains('GtkWidget* popover = gtk_popover_new(nullptr);')), + ); expect(source, isNot(contains('GtkWidget* sidebar = gtk_box_new('))); expect( source, @@ -1145,7 +1137,9 @@ void main() { expect(end, greaterThan(start)); final surface = design.substring(start, end); - expect(surface, contains('return Dialog(')); + expect(surface, contains('return BusyMaxSurfaceScope(')); + expect(surface, contains('role: BusyMaxSurfaceRole.window')); + expect(surface, contains('child: Dialog(')); expect(surface, contains('Theme.of(context).scaffoldBackgroundColor')); expect(surface, contains('backgroundColor: editorSurface')); expect(surface, contains('surfaceTintColor: editorSurface')); @@ -1155,6 +1149,21 @@ void main() { expect(surface, isNot(contains('Color(0x'))); }); + test('dialog shells preserve the shared themed shape and perimeter', () { + final design = File('lib/src/app/busymax_design.dart').readAsStringSync(); + final start = design.indexOf('class BusyMaxDialogShell'); + final end = design.indexOf('class BusyMaxConfirmDialog', start); + expect(start, isNonNegative); + expect(end, greaterThan(start)); + final shell = design.substring(start, end); + + expect(shell, contains('child: Dialog(')); + expect(shell, contains('clipBehavior: Clip.antiAlias')); + expect(shell, isNot(contains('shape: RoundedRectangleBorder('))); + expect(shell, isNot(contains('ClipRRect('))); + 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 headerCssStart = source.indexOf( @@ -1168,18 +1177,42 @@ void main() { expect(headerCssEnd, isNonNegative); final headerCss = source.substring(headerCssStart, headerCssEnd); final nativePopoverCssStart = source.indexOf( - 'g_autofree gchar* native_popover_border_css =', + 'g_autofree gchar* native_popover_css =', ); final nativePopoverCssEnd = source.indexOf( - 'const gchar* modal_barrier_color', + 'g_autofree gchar* native_dialog_css =', nativePopoverCssStart, ); + final nativeDialogCssStart = nativePopoverCssEnd; + final nativeDialogCssEnd = source.indexOf( + 'const gchar* modal_barrier_color', + nativeDialogCssStart, + ); expect(nativePopoverCssStart, isNonNegative); expect(nativePopoverCssEnd, isNonNegative); + expect(nativeDialogCssStart, isNonNegative); + expect(nativeDialogCssEnd, isNonNegative); final nativePopoverCss = source.substring( nativePopoverCssStart, nativePopoverCssEnd, ); + final nativeDialogCss = source.substring( + nativeDialogCssStart, + nativeDialogCssEnd, + ); + final yaruDecorationCssStart = source.indexOf( + 'const gboolean use_yaru_window_decoration_compatibility =', + ); + 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 {"')); @@ -1230,7 +1263,7 @@ void main() { expect(source, contains('"sidebarBorderColor"')); expect(source, contains('"foregroundColor"')); expect(source, contains('"popoverBackgroundColor"')); - expect(source, contains('"floatingBorderColor"')); + expect(source, isNot(contains('"floatingBorderColor"'))); expect(source, contains('"highContrast"')); expect(source, isNot(contains('"shadeColor"'))); expect(source, contains('"modalBarrierColor"')); @@ -1252,7 +1285,11 @@ void main() { ); expect( source, - contains('fl_lookup_string_arg(args, "floatingBorderColor")'), + contains('fl_lookup_string_arg(args, "dialogOutlineColor")'), + ); + expect( + source, + isNot(contains('fl_lookup_string_arg(args, "floatingBorderColor")')), ); expect( source, @@ -1262,14 +1299,54 @@ void main() { expect(source, contains('"background-color: %s;"')); expect(nativePopoverCss, contains('kNativePopoverStyleClass')); expect(nativePopoverCss, contains('"background-color: %s;"')); - expect(nativePopoverCss, contains('g_strdup_printf("border-color: %s;"')); - expect(nativePopoverCss, contains('g_strdup("border: none;")')); + expect( + nativePopoverCss, + isNot(contains('g_strdup_printf("border-color: %s;"')), + ); + expect(nativePopoverCss, isNot(contains('g_strdup("border: none;")'))); expect(nativePopoverCss, isNot(contains('box-shadow'))); expect(nativePopoverCss, isNot(contains('border-radius'))); expect(nativePopoverCss, isNot(contains('padding'))); expect(nativePopoverCss, isNot(contains('outline'))); expect(nativePopoverCss, isNot(contains('modelbutton'))); expect(nativePopoverCss, isNot(contains('#'))); + expect(source, contains('"busymax-native-dialog"')); + expect(source, contains('style_native_dialog(GtkWidget* dialog)')); + expect('style_native_dialog(dialog);'.allMatches(source).length, 2); + expect( + nativeDialogCss, + contains('g_autofree gchar* native_dialog_css ='), + ); + 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( + yaruDecorationCss, + contains('current_gtk_theme_uses_legacy_yaru_shadow()'), + ); + expect(yaruDecorationCss, 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' + ' window_background_color', + ), + ); + expect(nativeDialogCss, isNot(contains('"border:'))); + expect(nativeDialogCss, isNot(contains('border-radius'))); expect(source, contains('style_native_popover(session->popover)')); expect(source, contains('style_native_popover(GTK_WIDGET(popover))')); expect(headerCss, isNot(contains('.busymax-titlebar button'))); @@ -1450,6 +1527,17 @@ void main() { 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 *='))); diff --git a/test/app/surface_palette_render_test.dart b/test/app/surface_palette_render_test.dart new file mode 100644 index 0000000..fbc3cb4 --- /dev/null +++ b/test/app/surface_palette_render_test.dart @@ -0,0 +1,294 @@ +import 'dart:typed_data'; +import 'dart:ui' as ui; + +import 'package:busymax/src/app/busymax_design.dart'; +import 'package:busymax/src/app/busymax_yaru_theme.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + for (final baseline in const [ + _SurfaceBaseline( + brightness: Brightness.light, + view: Color(0xFFFFFFFF), + window: Color(0xFFFAFAFA), + sidebar: Color(0xFFEBEBEB), + card: Color(0xFFFFFFFF), + popover: Color(0xFFFAFAFA), + floatingBorder: Color.fromRGBO(0, 0, 0, 0.14), + ), + _SurfaceBaseline( + brightness: Brightness.dark, + view: Color(0xFF272727), + window: Color(0xFF2C2C2C), + sidebar: Color(0xFF393939), + card: Color(0xFF3D3D3D), + popover: Color(0xFF3E3E3E), + floatingBorder: Color.fromRGBO(0, 0, 0, 0.14), + ), + ]) { + testWidgets( + 'renders the reviewed ${baseline.brightness.name} surface palette', + (tester) async { + tester.view + ..physicalSize = const Size(800, 600) + ..devicePixelRatio = 1; + addTearDown(tester.view.reset); + + final boundaryKey = GlobalKey(); + final viewProbe = GlobalKey(); + final sidebarProbe = GlobalKey(); + final dialogProbe = GlobalKey(); + final cardProbe = GlobalKey(); + final popoverProbe = GlobalKey(); + final popoverSurfaceKey = GlobalKey(); + final contentPopoverProbe = GlobalKey(); + final theme = BusyMaxYaruTheme.build( + brightness: baseline.brightness, + accentColor: const Color(0xFFE95464), + ); + + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: Builder( + builder: (context) { + final colors = BusyMaxSurfaceColors.of(context); + return RepaintBoundary( + key: boundaryKey, + child: Scaffold( + backgroundColor: colors.view, + body: Stack( + children: [ + Align( + alignment: Alignment.centerLeft, + child: BusyMaxSidebarSurface( + child: SizedBox( + width: 120, + height: double.infinity, + child: Center( + child: SizedBox.square( + key: sidebarProbe, + dimension: 16, + ), + ), + ), + ), + ), + Align( + alignment: Alignment.topRight, + child: Padding( + padding: const EdgeInsets.all(24), + child: SizedBox.square( + key: viewProbe, + dimension: 16, + ), + ), + ), + BusyMaxModalEditorSurface( + maxWidth: 320, + maxHeight: 260, + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + SizedBox.square( + key: dialogProbe, + dimension: 16, + ), + const SizedBox(height: 24), + BusyMaxGroupedSurface( + child: SizedBox( + key: cardProbe, + width: 180, + height: 72, + ), + ), + ], + ), + ), + ), + Align( + alignment: Alignment.bottomCenter, + child: Padding( + padding: const EdgeInsets.all(24), + child: BusyMaxContentPopoverSurface( + padding: const EdgeInsets.all(20), + child: SizedBox.square( + key: contentPopoverProbe, + dimension: 16, + ), + ), + ), + ), + Align( + alignment: Alignment.bottomRight, + child: Padding( + padding: const EdgeInsets.all(24), + child: BusyMaxPopoverSurface( + key: popoverSurfaceKey, + color: colors.popover, + padding: const EdgeInsets.all(20), + child: SizedBox.square( + key: popoverProbe, + dimension: 16, + ), + ), + ), + ), + ], + ), + ), + ); + }, + ), + ), + ); + await tester.pumpAndSettle(); + + final pixels = await _capturePixels(tester, boundaryKey); + expect(_pixelAtProbe(tester, pixels, sidebarProbe), baseline.sidebar); + expect(_pixelAtProbe(tester, pixels, viewProbe), baseline.view); + expect(_pixelAtProbe(tester, pixels, dialogProbe), baseline.window); + expect(_pixelAtProbe(tester, pixels, cardProbe), baseline.card); + expect(_pixelAtProbe(tester, pixels, popoverProbe), baseline.popover); + final popoverSize = tester.getSize(find.byKey(popoverSurfaceKey)); + final edge = _pixelAtLocal( + tester, + pixels, + popoverSurfaceKey, + Offset(0.5, popoverSize.height / 2), + ); + final expectedEdge = Color.alphaBlend( + baseline.floatingBorder, + baseline.popover, + ); + _expectColorNear(edge, expectedEdge, tolerance: 3); + if (baseline.brightness == Brightness.dark) { + expect( + edge.computeLuminance(), + lessThan(baseline.popover.computeLuminance()), + ); + } + expect( + _pixelAtProbe(tester, pixels, contentPopoverProbe), + baseline.card, + ); + }, + ); + } +} + +class _SurfaceBaseline { + const _SurfaceBaseline({ + required this.brightness, + required this.view, + required this.window, + required this.sidebar, + required this.card, + required this.popover, + required this.floatingBorder, + }); + + final Brightness brightness; + final Color view; + final Color window; + final Color sidebar; + final Color card; + final Color popover; + final Color floatingBorder; +} + +Future<_CapturedPixels> _capturePixels( + WidgetTester tester, + GlobalKey boundaryKey, +) async { + final boundary = + boundaryKey.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: data.buffer.asUint8List(data.offsetInBytes, data.lengthInBytes), + width: image.width, + boundary: boundary, + ); + } finally { + image.dispose(); + } +} + +Color _pixelAtProbe( + WidgetTester tester, + _CapturedPixels pixels, + GlobalKey probeKey, +) { + final globalCenter = tester.getCenter(find.byKey(probeKey)); + final localCenter = pixels.boundary.globalToLocal(globalCenter); + final x = localCenter.dx.round(); + final y = localCenter.dy.round(); + 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 _pixelAtLocal( + WidgetTester tester, + _CapturedPixels pixels, + GlobalKey probeKey, + Offset localOffset, +) { + final box = tester.renderObject(find.byKey(probeKey)); + final globalPoint = box.localToGlobal(localOffset); + final point = pixels.boundary.globalToLocal(globalPoint); + final x = point.dx.floor(); + final y = point.dy.floor(); + 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], + ); +} + +void _expectColorNear(Color actual, Color expected, {required int tolerance}) { + expect( + (actual.r * 255 - expected.r * 255).abs(), + lessThanOrEqualTo(tolerance), + ); + expect( + (actual.g * 255 - expected.g * 255).abs(), + lessThanOrEqualTo(tolerance), + ); + expect( + (actual.b * 255 - expected.b * 255).abs(), + lessThanOrEqualTo(tolerance), + ); + expect( + (actual.a * 255 - expected.a * 255).abs(), + lessThanOrEqualTo(tolerance), + ); +} + +class _CapturedPixels { + const _CapturedPixels({ + required this.bytes, + required this.width, + required this.boundary, + }); + + final Uint8List bytes; + final int width; + final RenderRepaintBoundary boundary; +} diff --git a/test/app/theme_localization_test.dart b/test/app/theme_localization_test.dart index 4dbd35f..9885561 100644 --- a/test/app/theme_localization_test.dart +++ b/test/app/theme_localization_test.dart @@ -179,7 +179,8 @@ void main() { final baseDialogShape = base.dialogTheme.shape! as RoundedRectangleBorder; expect(dialogShape.borderRadius, baseDialogShape.borderRadius); expect(dialogShape.borderRadius, BorderRadius.circular(kYaruWindowRadius)); - expect(dialogShape.side, BorderSide.none); + expect(dialogShape.side, BorderSide(color: colors.dialogOutline)); + expect(dialogShape.side, isNot(baseDialogShape.side)); expect(BusyMaxRadius.window, kYaruWindowRadius); for (final pair in [ @@ -209,14 +210,17 @@ void main() { final popupShape = theme.popupMenuTheme.shape! as OutlineInputBorder; final basePopupShape = base.popupMenuTheme.shape! as OutlineInputBorder; expect(popupShape.borderRadius, basePopupShape.borderRadius); - expect(popupShape.borderSide, BorderSide.none); + expect(popupShape.borderSide, BorderSide(color: colors.floatingBorder)); expect(theme.popupMenuTheme.elevation, base.popupMenuTheme.elevation); expect(theme.popupMenuTheme.menuPadding, base.popupMenuTheme.menuPadding); expect(theme.popupMenuTheme.position, base.popupMenuTheme.position); - expect(theme.menuTheme.style?.side?.resolve({}), BorderSide.none); + expect( + theme.menuTheme.style?.side?.resolve({}), + BorderSide(color: colors.floatingBorder), + ); expect( theme.dropdownMenuTheme.menuStyle?.side?.resolve({}), - BorderSide.none, + BorderSide(color: colors.floatingBorder), ); for (final style in [ @@ -312,6 +316,11 @@ void main() { expect(lightColors.controlHover, const Color.fromRGBO(0, 0, 0, 0.14)); expect(lightColors.controlActive, const Color.fromRGBO(0, 0, 0, 0.18)); expect(lightColors.mutedForeground, const Color(0xFF666666)); + expect( + lightColors.dialogOutline, + const Color.fromRGBO(255, 255, 255, 0.07), + ); + expect(lightColors.floatingBorder, const Color.fromRGBO(0, 0, 0, 0.14)); expect(lightColors.sidebarBorder, const Color.fromRGBO(24, 24, 24, 0.08)); expect(darkColors.window, const Color(0xFF2C2C2C)); expect(darkColors.view, const Color(0xFF272727)); @@ -327,6 +336,8 @@ void main() { expect(darkColors.popover, const Color(0xFF3E3E3E)); expect(darkColors.mutedForeground, const Color(0xFFB5B5B5)); expect(darkColors.border, const Color.fromRGBO(0, 0, 0, 0.75)); + expect(darkColors.dialogOutline, const Color.fromRGBO(255, 255, 255, 0.07)); + expect(darkColors.floatingBorder, const Color.fromRGBO(0, 0, 0, 0.14)); expect(darkColors.sidebarBorder, const Color.fromRGBO(16, 16, 16, 0.35)); expect( Color.alphaBlend(darkColors.sidebarBorder, darkColors.sidebar).toARGB32(), @@ -437,7 +448,10 @@ void main() { pair.$1?.shape?.resolve(const {}), pair.$2?.shape?.resolve(const {}), ); - expect(pair.$1?.side?.resolve(const {}), BorderSide.none); + expect( + pair.$1?.side?.resolve(const {}), + BorderSide(color: darkColors.floatingBorder), + ); expect( pair.$1?.padding?.resolve(const {}), pair.$2?.padding?.resolve(const {}), @@ -485,6 +499,7 @@ void main() { sidebar: const Color(0xFF040506), groupedSurface: const Color(0xFF060708), disabledForeground: const Color(0xFF070809), + dialogOutline: const Color(0xFF090A0B), shade: const Color(0xFF0A0B0C), ); @@ -492,6 +507,7 @@ void main() { expect(updated.sidebar, const Color(0xFF040506)); expect(updated.groupedSurface, const Color(0xFF060708)); expect(updated.disabledForeground, const Color(0xFF070809)); + expect(updated.dialogOutline, const Color(0xFF090A0B)); expect(updated.shade, const Color(0xFF0A0B0C)); expect(updated.view, base.view); expect(updated.popover, base.popover); @@ -514,6 +530,10 @@ void main() { Color.lerp(start.groupedSurface, end.groupedSurface, 0.5), ); expect(midpoint.dialog, Color.lerp(start.dialog, end.dialog, 0.5)); + expect( + midpoint.dialogOutline, + Color.lerp(start.dialogOutline, end.dialogOutline, 0.5), + ); expect( midpoint.disabledForeground, Color.lerp(start.disabledForeground, end.disabledForeground, 0.5), @@ -806,9 +826,11 @@ void main() { ); expect(theme.colorScheme.onSurface, gtkColors.foreground); expect(theme.colorScheme.onSurfaceVariant, gtkColors.mutedForeground); + final colors = theme.extension()!; expect(theme.dialogTheme.backgroundColor, gtkColors.dialog); expect(theme.popupMenuTheme.color, gtkColors.popover); - final colors = theme.extension()!; + expect(colors.dialog, gtkColors.dialog); + expect(colors.popover, gtkColors.popover); expect(colors.sidebar, gtkColors.sidebar); expect(colors.groupedSurface, gtkColors.card); expect(colors.cardShade, gtkColors.cardShade); @@ -889,7 +911,7 @@ void main() { expect(colors.sidebar, gtkColors.sidebar); }); - test('BusyMax theme preserves dark GTK popover samples', () { + test('BusyMax preserves named GTK floating-surface roles', () { const gtkColors = GtkThemeColors( brightness: Brightness.dark, window: Color(0xFF242424), @@ -906,6 +928,7 @@ void main() { expect(theme.popupMenuTheme.color, gtkColors.popover); expect(colors.popover, gtkColors.popover); + expect(colors.dialog, busyMaxFallbackSurfaceColors(Brightness.dark).dialog); expect( colors.groupedSurface, busyMaxFallbackSurfaceColors(Brightness.dark).groupedSurface, @@ -927,6 +950,7 @@ void main() { ); final colors = theme.extension()!; expect(colors.sidebar, gtkColors.sidebar); + expect(colors.dialog, busyMaxFallbackSurfaceColors(Brightness.dark).dialog); expect(colors.popover, gtkColors.popover); expect(theme.popupMenuTheme.color, colors.popover); expect( @@ -1007,7 +1031,7 @@ void main() { expect(colors.card, isNot(wrongParent)); }); - test('BusyMax preserves readable darker roles from a custom GTK theme', () { + test('BusyMax preserves custom GTK surface roles', () { const parent = Color(0xFF3E3E3E); const gtkColors = GtkThemeColors( brightness: Brightness.dark, @@ -1178,6 +1202,7 @@ void main() { expect(colors.sidebar, gtkColors.sidebar); expect(colors.control, const Color.fromRGBO(255, 255, 255, 0.10)); expect(colors.controlHover, const Color.fromRGBO(255, 255, 255, 0.14)); + expect(colors.dialog, gtkColors.dialog); expect(colors.popover, gtkColors.popover); expect(colors.groupedSurface, gtkColors.card); }); @@ -1555,7 +1580,7 @@ void main() { expect(source, contains('foregroundColor: colors.foreground')); expect(source, contains('sidebarBorderColor: colors.sidebarBorder')); expect(source, contains('popoverBackgroundColor: colors.popover')); - expect(source, contains('floatingBorderColor: colors.floatingBorder')); + expect(source, isNot(contains('floatingBorderColor:'))); expect(source, contains('modalBarrierColor: modalBarrierColor')); expect(source, isNot(contains('controlHoverColor: colors.controlHover'))); expect(source, isNot(contains('accentColor: colorScheme.primary'))); diff --git a/test/features/schedule/presentation/schedule_views_test.dart b/test/features/schedule/presentation/schedule_views_test.dart index ddb80e5..3de9d56 100644 --- a/test/features/schedule/presentation/schedule_views_test.dart +++ b/test/features/schedule/presentation/schedule_views_test.dart @@ -1,4 +1,5 @@ import 'dart:io'; +import 'dart:math' as math; import 'dart:ui' as ui; import 'package:busymax/src/app/busymax_design.dart'; @@ -764,16 +765,32 @@ void main() { find.byType(BusyMaxPopoverIconButton).first, ); final actionColors = BusyMaxSurfaceColors.of(actionContext); + expect(BusyMaxSizes.popoverActionButton, kYaruTitleBarItemHeight); + expect(BusyMaxSizes.popoverActionIcon, BusyMaxSizes.iconSm); expect(actionSurfaces, hasLength(4)); for (final button in actionButtons) { - expect(button.iconSize, kYaruTitleBarItemHeight); - expect(button.style, isNull); + expect(button.iconSize, BusyMaxSizes.popoverActionButton); + expect(button.style?.tapTargetSize, MaterialTapTargetSize.shrinkWrap); + } + for (final button in find.byType(BusyMaxPopoverIconButton).evaluate()) { + expect( + tester.getSize(find.byWidget(button.widget)), + const Size.square(BusyMaxSizes.popoverActionButton), + ); } for (final surface in actionSurfaces) { expect(surface.color, actionColors.control); expect(surface.shape, const CircleBorder()); expect(surface.clipBehavior, Clip.antiAlias); } + for (final icon in tester.widgetList( + find.descendant( + of: find.byType(BusyMaxPopoverIconButton), + matching: find.byType(Icon), + ), + )) { + expect(icon.size, BusyMaxSizes.popoverActionIcon); + } expect( tester.widget(find.byIcon(YaruIcons.trash)).color, Theme.of(actionContext).colorScheme.error, @@ -804,10 +821,16 @@ void main() { Theme.of(popoverContext).colorScheme.shadow, ); expect(popoverSurface.shadowColor.a, 1); - expect( - popoverSurface.color, - BusyMaxSurfaceColors.of(popoverContext).popover, + expect(popoverSurface.color, BusyMaxSurfaceColors.of(popoverContext).card); + final contentSurface = tester.widget( + find.descendant( + of: find.byType(BusyMaxContentPopoverSurface), + matching: find.byType(BusyMaxPopoverSurface), + ), ); + final contentColors = BusyMaxSurfaceColors.of(popoverContext); + expect(contentSurface.color, contentColors.card); + expect(contentSurface.outlineColor, contentColors.floatingBorder); final editCenter = tester.getCenter(find.byIcon(Icons.edit_outlined)); final deleteCenter = tester.getCenter(find.byIcon(YaruIcons.trash)); @@ -821,6 +844,166 @@ void main() { expect(await action, ScheduleItemDetailsAction.export); }); + for (final baseline in const [ + ( + brightness: Brightness.light, + interior: Color(0xFFFFFFFF), + outline: Color.fromRGBO(0, 0, 0, 0.14), + wrongOutline: Color.fromRGBO(24, 24, 24, 0.08), + ), + ( + brightness: Brightness.dark, + interior: Color(0xFF3D3D3D), + outline: Color.fromRGBO(0, 0, 0, 0.14), + wrongOutline: Color.fromRGBO(0, 0, 0, 0.36), + ), + ]) { + testWidgets( + 'details popover paints the reviewed ${baseline.brightness.name} ' + 'content surface and native floating edge', + (tester) async { + tester.view + ..physicalSize = const Size(800, 600) + ..devicePixelRatio = 1; + addTearDown(tester.view.reset); + + final boundaryKey = GlobalKey(); + final selectedDate = DateTime(2026, 1, 15); + final event = _itemsFor( + selectedDate, + ).whereType().first; + final theme = BusyMaxYaruTheme.build( + brightness: baseline.brightness, + accentColor: const Color(0xFF3584E4), + ); + + await tester.pumpWidget( + RepaintBoundary( + key: boundaryKey, + child: localizedTestApp( + theme: theme, + child: Builder( + builder: (context) { + final mediaQuery = MediaQuery.of( + context, + ).copyWith(disableAnimations: true); + return MediaQuery( + data: mediaQuery, + child: Scaffold( + body: Align( + alignment: Alignment.topCenter, + child: Padding( + padding: const EdgeInsets.only(top: 40), + child: Builder( + builder: (anchorContext) { + return TextButton( + onPressed: () { + showScheduleItemDetailsPopover( + context: anchorContext, + anchorContext: anchorContext, + item: event, + ); + }, + child: const Text('Open details palette probe'), + ); + }, + ), + ), + ), + ), + ); + }, + ), + ), + ), + ); + + await tester.tap(find.text('Open details palette probe')); + await tester.pumpAndSettle(); + + final shapeFinder = find.descendant( + of: find.byType(BusyMaxContentPopoverSurface), + matching: find.byWidgetPredicate( + (widget) => + widget is PhysicalShape && + widget.elevation == BusyMaxElevation.tooltip, + ), + ); + final popoverFinder = find.descendant( + of: find.byType(BusyMaxContentPopoverSurface), + matching: find.byType(BusyMaxPopoverSurface), + ); + expect(shapeFinder, findsOneWidget); + expect(popoverFinder, findsOneWidget); + + final popover = tester.widget(popoverFinder); + expect(popover.color, baseline.interior); + expect(popover.outlineColor, baseline.outline); + + final shapeBox = tester.renderObject(shapeFinder); + final boundary = + boundaryKey.currentContext!.findRenderObject()! + as RenderRepaintBoundary; + final pixels = await _capturePixels(tester, boundaryKey); + final bodyMidpoint = shapeBox.size.height / 2; + final interior = _pixelAtGlobalPosition( + pixels, + boundary: boundary, + globalPosition: shapeBox.localToGlobal(Offset(8, bodyMidpoint)), + ); + // The route's PhysicalShape anti-aliases its one-pixel perimeter at + // half coverage. Compare the rendered pixel to that visible result, + // while the separate palette test locks the unmodified source token. + final expectedEdge = Color.alphaBlend( + baseline.outline.withValues(alpha: baseline.outline.a * 0.5), + baseline.interior, + ); + final wrongEdge = Color.alphaBlend( + baseline.wrongOutline.withValues( + alpha: baseline.wrongOutline.a * 0.5, + ), + baseline.interior, + ); + final edgeCandidates = [ + for (final x in const [0.5, 1.0, 1.5, 2.0]) + for (final yOffset in const [-2.0, 0.0, 2.0]) + _pixelAtGlobalPosition( + pixels, + boundary: boundary, + globalPosition: shapeBox.localToGlobal( + Offset(x, bodyMidpoint + yOffset), + ), + ), + ]; + final edge = edgeCandidates.reduce( + (closest, candidate) => + _rgbDistance(candidate, expectedEdge) < + _rgbDistance(closest, expectedEdge) + ? candidate + : closest, + ); + + expect(interior, baseline.interior); + expect(edgeCandidates, contains(isNot(interior))); + expect( + _rgbDistance(edge, expectedEdge), + lessThanOrEqualTo(25), + reason: 'edge candidates: $edgeCandidates', + ); + expect( + _rgbDistance(edge, expectedEdge), + lessThan(_rgbDistance(edge, wrongEdge)), + ); + if (baseline.brightness == Brightness.dark) { + expect( + edge.computeLuminance(), + lessThan(interior.computeLuminance()), + ); + } + }, + ); + } + for (final brightness in Brightness.values) { testWidgets( 'popover action paints a contained circle and strengthens it on hover ' @@ -840,7 +1023,7 @@ void main() { child: RepaintBoundary( key: boundaryKey, child: ColoredBox( - color: colors.popover, + color: colors.card, child: SizedBox.square( dimension: 50, child: Center( @@ -860,7 +1043,7 @@ void main() { final restingPixels = await _capturePixels(tester, boundaryKey); final background = _pixelAt(restingPixels, x: 2, y: 2); - final restingFace = _pixelAt(restingPixels, x: 25, y: 12); + final restingFace = _pixelAt(restingPixels, x: 25, y: 10); expect(restingFace, isNot(background)); final mouse = await tester.createGesture(kind: PointerDeviceKind.mouse); @@ -873,7 +1056,7 @@ void main() { final hoveredPixels = await _capturePixels(tester, boundaryKey); final hoveredBackground = _pixelAt(hoveredPixels, x: 2, y: 2); - final hoveredFace = _pixelAt(hoveredPixels, x: 25, y: 12); + final hoveredFace = _pixelAt(hoveredPixels, x: 25, y: 10); expect(hoveredBackground, background); expect(hoveredFace, isNot(restingFace)); expect( @@ -1856,9 +2039,11 @@ void main() { expect(design, contains('return Material(')); expect(design, contains('shape: const CircleBorder()')); expect(design, contains('class BusyMaxHeaderIconButton')); - expect(design, contains('iconSize: kYaruTitleBarItemHeight')); + expect(design, contains('iconSize: BusyMaxSizes.popoverActionButton')); expect(design, contains('color: enabled ? colors.control')); expect(popover, contains('BusyMaxPopoverIconButton(')); + expect(popover, contains('BusyMaxContentPopoverSurface(')); + expect(popover, isNot(contains('surfaceColors.popover'))); expect(popover, contains('destructive: true')); expect(popover, isNot(contains('backgroundColor:'))); expect(popover, isNot(contains('foregroundColor:'))); @@ -2821,7 +3006,13 @@ Future<({Uint8List bytes, int width})> _capturePixels( final byteData = (await tester.binding.runAsync( () => image.toByteData(format: ui.ImageByteFormat.rawStraightRgba), ))!; - return (bytes: byteData.buffer.asUint8List(), width: image.width); + return ( + bytes: byteData.buffer.asUint8List( + byteData.offsetInBytes, + byteData.lengthInBytes, + ), + width: image.width, + ); } finally { image.dispose(); } @@ -2841,6 +3032,22 @@ Color _pixelAt( ); } +Color _pixelAtGlobalPosition( + ({Uint8List bytes, int width}) pixels, { + required RenderRepaintBoundary boundary, + required Offset globalPosition, +}) { + final local = boundary.globalToLocal(globalPosition); + return _pixelAt(pixels, x: local.dx.floor(), y: local.dy.floor()); +} + +double _rgbDistance(Color first, Color second) { + final red = (first.r - second.r) * 255; + final green = (first.g - second.g) * 255; + final blue = (first.b - second.b) * 255; + return math.sqrt(red * red + green * green + blue * blue); +} + double _luminanceDistance(Color first, Color second) { return (first.computeLuminance() - second.computeLuminance()).abs(); } diff --git a/test/platform/linux_header_bar_configuration_synchronizer_test.dart b/test/platform/linux_header_bar_configuration_synchronizer_test.dart index d6b95fb..912de26 100644 --- a/test/platform/linux_header_bar_configuration_synchronizer_test.dart +++ b/test/platform/linux_header_bar_configuration_synchronizer_test.dart @@ -131,7 +131,7 @@ BusyMaxHeaderBarConfiguration _configuration({required bool dark}) { foregroundColor: dark ? Colors.white : Colors.black, sidebarBorderColor: Colors.grey, popoverBackgroundColor: dark ? Colors.black : Colors.white, - floatingBorderColor: Colors.grey, + dialogOutlineColor: dark ? Colors.white : Colors.white10, modalBarrierColor: Colors.black54, ), ); diff --git a/test/platform/linux_header_bar_service_test.dart b/test/platform/linux_header_bar_service_test.dart index 0cd2d20..fa353a8 100644 --- a/test/platform/linux_header_bar_service_test.dart +++ b/test/platform/linux_header_bar_service_test.dart @@ -103,7 +103,7 @@ void main() { foregroundColor: Color(0xFFFFFFFF), sidebarBorderColor: Color.fromRGBO(0, 0, 6, 0.75), popoverBackgroundColor: Color(0xFF36363A), - floatingBorderColor: Color.fromRGBO(255, 255, 255, 0.10), + dialogOutlineColor: Color.fromRGBO(255, 255, 255, 0.07), modalBarrierColor: Color.fromRGBO(0, 0, 0, 0.32), ), ); @@ -149,7 +149,7 @@ void main() { 'foregroundColor': '#FFFFFF', 'sidebarBorderColor': 'rgba(0,0,6,0.75)', 'popoverBackgroundColor': '#36363A', - 'floatingBorderColor': 'rgba(255,255,255,0.10)', + 'dialogOutlineColor': 'rgba(255,255,255,0.07)', 'modalBarrierColor': 'rgba(0,0,0,0.32)', }), ); From 5509c2babe1e790d253ae55d75b28c102121f984 Mon Sep 17 00:00:00 2001 From: albert Date: Sun, 26 Jul 2026 22:37:52 -0700 Subject: [PATCH 16/73] Refactor color handling in schedule and dialog components for consistency. Update background colors to use BusyMaxSurfaceColors.window across multiple files, enhancing UI coherence and accessibility. Enhance header bar styling and modal handling. Introduce new CSS classes for popover and modal elements, improve opacity handling for backdrop states, and ensure consistent color management across header components for better user experience. --- lib/src/app/busymax_about_dialog.dart | 201 +++-- lib/src/app/busymax_app.dart | 14 +- lib/src/app/busymax_design.dart | 672 ++++++++++++----- lib/src/app/busymax_dialog_identity.dart | 102 +++ lib/src/app/busymax_dialogs.dart | 59 +- .../busymax_keyboard_shortcuts_dialog.dart | 381 +++++----- lib/src/app/busymax_surface_colors.dart | 8 +- lib/src/app/busymax_yaru_theme.dart | 31 +- .../auth/presentation/sign_in_screen.dart | 2 +- .../calendar/presentation/event_editor.dart | 87 +-- .../presentation/feedback_dialog.dart | 9 +- .../presentation/schedule_agenda_view.dart | 2 +- .../schedule_anchored_popover.dart | 57 +- .../presentation/schedule_day_week_view.dart | 15 +- .../presentation/schedule_month_view.dart | 9 +- .../presentation/schedule_sidebar.dart | 10 +- .../presentation/schedule_toolbar.dart | 2 +- .../presentation/schedule_workspace.dart | 2 +- .../presentation/schedule_year_view.dart | 2 +- .../presentation/settings_screen.dart | 4 +- .../desktop_date_time_fields.dart | 697 +++++++++--------- .../presentation/task_details_editor.dart | 128 ++-- .../platform/linux_header_bar_service.dart | 15 + linux/runner/my_application.cc | 376 +++++++--- test/app/about_dialog_test.dart | 295 +++++++- test/app/busymax_dialogs_test.dart | 323 +++++++- test/app/busymax_grouped_surface_test.dart | 514 +++++++++++-- test/app/busymax_menu_button_test.dart | 137 +++- test/app/high_contrast_theme_test.dart | 12 +- test/app/keyboard_shortcuts_dialog_test.dart | 101 ++- test/app/modal_barrier_test.dart | 124 ++++ test/app/native_ui_audit_test.dart | 420 ++++++++++- test/app/surface_palette_render_test.dart | 2 +- test/app/theme_localization_test.dart | 121 +-- .../presentation/event_editor_test.dart | 376 ++++++++-- .../presentation/feedback_dialog_test.dart | 40 +- .../schedule_create_menu_test.dart | 43 +- .../presentation/schedule_toolbar_test.dart | 115 ++- .../presentation/schedule_views_test.dart | 601 ++++++++++----- .../schedule_workspace_states_test.dart | 2 +- .../presentation/settings_screen_test.dart | 53 +- .../desktop_date_time_fields_test.dart | 627 +++++++++++++++- .../presentation/task_details_pane_test.dart | 387 +++++++--- test/platform/gtk_font_service_test.dart | 2 + ...r_bar_configuration_synchronizer_test.dart | 3 + .../linux_header_bar_service_test.dart | 49 +- test/test_localized_app.dart | 12 +- 47 files changed, 5529 insertions(+), 1715 deletions(-) create mode 100644 lib/src/app/busymax_dialog_identity.dart create mode 100644 test/app/modal_barrier_test.dart diff --git a/lib/src/app/busymax_about_dialog.dart b/lib/src/app/busymax_about_dialog.dart index 9aaa2d1..ae149bb 100644 --- a/lib/src/app/busymax_about_dialog.dart +++ b/lib/src/app/busymax_about_dialog.dart @@ -10,6 +10,7 @@ import '../features/feedback/presentation/feedback_dialog.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'; const _busyMaxWebsiteUri = 'https://github.com/busystack/busymax'; @@ -45,104 +46,74 @@ class BusyMaxAboutDialog extends StatelessWidget { @override Widget build(BuildContext context) { final l10n = context.l10n; - final textTheme = Theme.of(context).textTheme; - return BusyMaxSurfaceScope( - role: BusyMaxSurfaceRole.dialog, - child: Dialog( - child: ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 420), - child: Stack( + final theme = Theme.of(context); + return BusyMaxInformationalDialog( + closeLabel: l10n.close, + maxWidth: 420, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + BusyMaxDialogIdentity( + visual: const _BusyMaxLogo(), + title: l10n.appTitle, + ), + const SizedBox(height: BusyMaxSpacing.xs), + Text( + l10n.aboutBusyMaxDescription, + textAlign: TextAlign.center, + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: BusyMaxSpacing.sm), + Align( + alignment: Alignment.center, + child: FutureBuilder( + future: PackageInfo.fromPlatform(), + builder: (context, snapshot) { + final info = snapshot.data; + final version = info == null ? '' : _formatVersion(info); + return _VersionTag(version: version); + }, + ), + ), + const SizedBox(height: BusyMaxSpacing.lg), + BusyMaxGroupedList( + filled: true, children: [ - Padding( - padding: const EdgeInsets.all(BusyMaxSpacing.lg), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Align( - alignment: Alignment.center, - child: const _BusyMaxLogo(size: 72), - ), - const SizedBox(height: BusyMaxSpacing.md), - Text( - l10n.appTitle, - textAlign: TextAlign.center, - style: textTheme.headlineSmall, - ), - const SizedBox(height: BusyMaxSpacing.xs), - Text( - l10n.aboutBusyMaxDescription, - textAlign: TextAlign.center, - style: textTheme.bodyMedium?.copyWith( - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - ), - const SizedBox(height: BusyMaxSpacing.sm), - Align( - alignment: Alignment.center, - child: FutureBuilder( - future: PackageInfo.fromPlatform(), - builder: (context, snapshot) { - final info = snapshot.data; - final version = info == null - ? '' - : 'v${info.version}+${info.buildNumber}'; - return _VersionTag(version: version); - }, - ), - ), - const SizedBox(height: BusyMaxSpacing.lg), - BusyMaxGroupedList( - filled: true, - children: [ - BusyMaxActionRow( - title: l10n.website, - leading: const Icon(Icons.language), - trailing: const Icon( - Icons.open_in_new, - size: BusyMaxSizes.iconSm, - ), - onTap: () => unawaited( - _openExternalUri(Uri.parse(_busyMaxWebsiteUri)), - ), - ), - BusyMaxActionRow( - title: l10n.sendFeedback, - leading: const Icon(Icons.feedback_outlined), - trailing: const Icon( - Icons.chevron_right, - size: BusyMaxSizes.iconSm, - ), - onTap: onSendFeedback, - ), - BusyMaxActionRow( - title: l10n.reportAnIssue, - leading: const Icon(YaruIcons.warning), - trailing: const Icon( - Icons.open_in_new, - size: BusyMaxSizes.iconSm, - ), - onTap: () => unawaited( - _openExternalUri(Uri.parse(_busyMaxIssuesUri)), - ), - ), - ], - ), - ], + BusyMaxActionRow( + title: l10n.website, + leading: const Icon(Icons.language), + trailing: const Icon( + Icons.open_in_new, + size: BusyMaxSizes.iconSm, ), + onTap: () => + unawaited(_openExternalUri(Uri.parse(_busyMaxWebsiteUri))), ), - PositionedDirectional( - top: BusyMaxSpacing.sm, - end: BusyMaxSpacing.sm, - child: YaruIconButton( - icon: const Icon(Icons.close, size: BusyMaxSizes.iconSm), - tooltip: l10n.close, - onPressed: () => Navigator.of(context).pop(), + BusyMaxActionRow( + title: l10n.sendFeedback, + leading: const Icon(Icons.feedback_outlined), + trailing: const Icon( + Icons.chevron_right, + size: BusyMaxSizes.iconSm, ), + onTap: onSendFeedback, + ), + BusyMaxActionRow( + title: l10n.reportAnIssue, + leading: const Icon(YaruIcons.warning), + trailing: const Icon( + Icons.open_in_new, + size: BusyMaxSizes.iconSm, + ), + onTap: () => + unawaited(_openExternalUri(Uri.parse(_busyMaxIssuesUri))), ), ], ), - ), + ], ), ); } @@ -151,23 +122,37 @@ class BusyMaxAboutDialog extends StatelessWidget { enum _BusyMaxAboutAction { sendFeedback } class _BusyMaxLogo extends StatelessWidget { - const _BusyMaxLogo({required this.size}); - - final double size; + const _BusyMaxLogo(); @override Widget build(BuildContext context) { return Image.asset( 'assets/branding/busymax-logo.png', - width: size, - height: size, + width: BusyMaxDialogIdentity.visualExtent, + height: BusyMaxDialogIdentity.visualExtent, filterQuality: FilterQuality.high, errorBuilder: (context, error, stackTrace) => - SizedBox.square(dimension: size), + const SizedBox.square(dimension: BusyMaxDialogIdentity.visualExtent), ); } } +String _formatVersion(PackageInfo info) { + final version = info.version.trim(); + final buildNumber = info.buildNumber.trim(); + if (version.isEmpty && buildNumber.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'; +} + class _VersionTag extends StatelessWidget { const _VersionTag({required this.version}); @@ -178,17 +163,27 @@ class _VersionTag extends StatelessWidget { if (version.isEmpty) { return const SizedBox.shrink(); } - return DecoratedBox( - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surfaceContainerHighest, - borderRadius: BorderRadius.circular(BusyMaxRadius.headerButton), - ), + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + return YaruTranslucentContainer( + opacity: 1, + border: const Border(), + borderRadius: const BorderRadius.all(Radius.circular(kYaruButtonRadius)), + color: colorScheme.primary, child: Padding( padding: const EdgeInsets.symmetric( horizontal: BusyMaxSpacing.sm, vertical: BusyMaxSpacing.xs, ), - child: Text(version, style: Theme.of(context).textTheme.labelMedium), + child: Text( + version, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.labelMedium?.copyWith( + color: colorScheme.onPrimary, + fontWeight: FontWeight.w600, + ), + ), ), ); } diff --git a/lib/src/app/busymax_app.dart b/lib/src/app/busymax_app.dart index 43a4820..069721d 100644 --- a/lib/src/app/busymax_app.dart +++ b/lib/src/app/busymax_app.dart @@ -203,7 +203,8 @@ class _BusyMaxAppState extends ConsumerState { final l10n = AppLocalizations.of(context); final materialL10n = MaterialLocalizations.of(context); final modalBarrierColor = busyMaxModalBarrierColor(context); - final preferDark = Theme.of(context).brightness == Brightness.dark; + final theme = Theme.of(context); + final preferDark = theme.brightness == Brightness.dark; final labels = BusyMaxHeaderBarLabels( today: l10n.today, day: l10n.viewDay, @@ -234,12 +235,19 @@ class _BusyMaxAppState extends ConsumerState { highContrast: MediaQuery.highContrastOf(context), windowBackgroundColor: colors.window, // This header is deliberately borderless and visually continuous - // with the main pane, so it uses the flat header role. - backgroundColor: colors.headerbarFlat, + // with the main workspace, so both use the window surface role. + backgroundColor: colors.window, sidebarBackgroundColor: colors.sidebar, foregroundColor: colors.foreground, sidebarBorderColor: colors.sidebarBorder, popoverBackgroundColor: colors.popover, + menuHoverColor: colors.controlHover, + popoverShadowColor: theme.colorScheme.shadow.withValues( + alpha: + theme.colorScheme.shadow.a * + BusyMaxAlpha.nativeHeaderMenuShadowOpacity, + ), + dialogBackgroundColor: colors.dialog, dialogOutlineColor: colors.dialogOutline, modalBarrierColor: modalBarrierColor, ), diff --git a/lib/src/app/busymax_design.dart b/lib/src/app/busymax_design.dart index a93e1a9..c58b6fc 100644 --- a/lib/src/app/busymax_design.dart +++ b/lib/src/app/busymax_design.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:math' as math; import 'dart:ui' as ui; import 'package:flutter/gestures.dart'; @@ -16,8 +17,6 @@ abstract final class BusyMaxSpacing { static const double headerInset = 6; static const double sm = 8; static const double md = 12; - static const double tooltipHorizontal = sm; - static const double tooltipVertical = 5; static const double lg = kYaruPagePadding; static const double xl = kYaruPagePadding * 1.5; static const double xxl = kYaruPagePadding * 2; @@ -57,9 +56,13 @@ abstract final class BusyMaxFormLayout { static const double comboInlineMaxFraction = 0.46; } +/// BusyMax's single deliberate adjustment to Yaru's surface depth. +/// +/// Grouped cards need a little more separation from the application canvas. +/// Floating controls keep the elevations supplied by Yaru; feature widgets +/// must not define their own elevations or shadow geometry. abstract final class BusyMaxElevation { - static const double card = 2; - static const double tooltip = 10; + static const double groupedCard = 2; } abstract final class BusyMaxStroke { @@ -69,7 +72,8 @@ abstract final class BusyMaxStroke { abstract final class BusyMaxAlpha { static const double calendarGridLight = 0.10; static const double calendarGridDark = 0.06; - static const double modalBarrier = 0.32; + static const double groupedRowLightHoverStrength = 0.50; + static const double nativeHeaderMenuShadowOpacity = 0.30; } abstract final class BusyMaxMotion { @@ -77,55 +81,131 @@ abstract final class BusyMaxMotion { static const Curve dialogInsetsCurve = Curves.easeOutCubic; } +enum BusyMaxPopoverShadowRole { standard, details } + abstract final class BusyMaxShadow { static const double floatingBlur = 24; static const Offset floatingOffset = Offset(0, 8); static const double windowMargin = 32; - static Color _scaleAlpha(Color color, double scale) { - return color.withValues( - alpha: (color.a * scale).clamp(0.0, 1.0).toDouble(), - ); - } + /// The native libadwaita/Yaru card depth used by `.card` and + /// `list.boxed-list`. + /// + /// Flutter's physical elevation shadow is deliberately biased toward the + /// bottom edge. Native boxed lists instead combine a quiet perimeter with + /// two compact layers, which keeps the top edge legible without making the + /// card look detached from the page. + static List nativeCardShadows(Color semanticShadow) { + Color layer(double opacity) { + return semanticShadow.withValues(alpha: semanticShadow.a * opacity); + } - static Color floatingColor(BuildContext context) { - return BusyMaxSurfaceColors.of(context).shade; + // Flutter paints later shadows over earlier ones, so keep the native CSS + // perimeter last and the broadest layer first. + return [ + BoxShadow( + color: layer(0.03), + blurRadius: 6, + spreadRadius: 2, + offset: const Offset(0, 2), + ), + BoxShadow( + color: layer(0.07), + blurRadius: 3, + spreadRadius: 1, + offset: const Offset(0, 1), + ), + BoxShadow(color: layer(0.03), spreadRadius: 1), + ]; } - /// Flutter's physical-elevation renderer applies its own ambient and spot - /// opacity. It therefore needs the theme's unattenuated semantic shadow, - /// unlike [BoxShadow], which consumes the GTK shade alpha directly. - static Color physicalColor(BuildContext context) { - return Theme.of(context).colorScheme.shadow; + static List nativeCardShadowsFor(BuildContext context) { + final theme = Theme.of(context); + return nativeCardShadows( + CardTheme.of(context).shadowColor ?? theme.colorScheme.shadow, + ); } - static List floatingShadows(Color color) { - return [ - BoxShadow(color: color, blurRadius: floatingBlur, offset: floatingOffset), - ]; - } + /// Current libadwaita popover depth, expressed as native CSS layers. + /// + /// A physical Material elevation uses a directional spot shadow and is + /// therefore a poor match for an anchored Linux popover. These two restrained + /// layers mirror libadwaita's popover profile: a defined near edge over a + /// broad, low-opacity ambient shadow. + static List nativePopoverShadows( + Color semanticShadow, { + BusyMaxPopoverShadowRole role = BusyMaxPopoverShadowRole.standard, + }) { + Color layer(double opacity) { + return semanticShadow.withValues(alpha: semanticShadow.a * opacity); + } + + final (ambientBlur, nearBlur) = switch (role) { + BusyMaxPopoverShadowRole.standard => (14.0, 5.0), + // Details carry more visual content than a compact menu. Preserve the + // same native depth and semantic color while tightening only the blur + // by half a pixel. + BusyMaxPopoverShadowRole.details => (13.5, 4.5), + }; - static List tooltipShadows(Color color) { + // ShapeDecoration paints in declaration order. Put the ambient layer + // first so the defined near edge remains above it, matching CSS stacking. return [ BoxShadow( - color: _scaleAlpha(color, 1.45), - blurRadius: 30, - offset: const Offset(0, 10), + color: layer(0.05), + blurRadius: ambientBlur, + spreadRadius: 3, + offset: const Offset(0, 2), ), BoxShadow( - color: _scaleAlpha(color, 0.9), - blurRadius: 8, - offset: const Offset(0, 2), + color: layer(0.09), + blurRadius: nearBlur, + spreadRadius: 1, + offset: const Offset(0, 1), ), ]; } - static List floatingShadowsFor(BuildContext context) { - return floatingShadows(floatingColor(context)); + static List nativePopoverShadowsFor( + BuildContext context, { + BusyMaxPopoverShadowRole role = BusyMaxPopoverShadowRole.standard, + }) { + return nativePopoverShadows( + Theme.of(context).colorScheme.shadow, + role: role, + ); } - static List tooltipShadowsFor(BuildContext context) { - return tooltipShadows(floatingColor(context)); + /// Viewport space required for the farthest libadwaita popover shadow. + /// + /// Flutter's blur mask extends to roughly three standard deviations. Derive + /// the reservation from the shared profile so geometry and paint cannot + /// silently drift apart. + static double get nativePopoverPaintMargin { + final shadows = nativePopoverShadows(const Color(0xFF000000)); + final extent = shadows.fold(0, (maximum, shadow) { + final blurExtent = ui.Shadow.convertRadiusToSigma(shadow.blurRadius) * 3; + final offsetExtent = math.max( + shadow.offset.dx.abs(), + shadow.offset.dy.abs(), + ); + return math.max(maximum, blurExtent + shadow.spreadRadius + offsetExtent); + }); + return extent.ceilToDouble(); + } + + static Color floatingColor(BuildContext context) { + return BusyMaxSurfaceColors.of(context).shade; + } + + static List floatingShadows(Color color) { + return [ + BoxShadow(color: color, blurRadius: floatingBlur, offset: floatingOffset), + ]; + } + + static List floatingShadowsFor(BuildContext context) { + return floatingShadows(floatingColor(context)); } static List windowShadows(Color color) { @@ -184,6 +264,7 @@ class BusyMaxPopoverSurface extends StatelessWidget { this.arrowSide = BusyMaxPopoverArrowSide.top, this.arrowAlignment = 0.5, this.padding = EdgeInsets.zero, + this.shadowRole = BusyMaxPopoverShadowRole.standard, }); final Widget child; @@ -192,15 +273,23 @@ class BusyMaxPopoverSurface extends StatelessWidget { final BusyMaxPopoverArrowSide arrowSide; final double arrowAlignment; final EdgeInsetsGeometry padding; + final BusyMaxPopoverShadowRole shadowRole; @override Widget build(BuildContext context) { final arrowHeight = BusyMaxSizes.popoverArrowHeight; final alignment = arrowAlignment.clamp(0.0, 1.0).toDouble(); - final clipper = _BusyMaxPopoverClipper( - side: arrowSide, + final shape = _BusyMaxPopoverBorder( + arrowSide: arrowSide, alignment: alignment, ); + final outlineShape = shape.copyWith( + side: BorderSide( + color: outlineColor ?? BusyMaxSurfaceColors.of(context).floatingBorder, + width: BusyMaxStroke.outline, + strokeAlign: BorderSide.strokeAlignInside, + ), + ); final paddedChild = Padding( padding: EdgeInsets.only( top: arrowSide == BusyMaxPopoverArrowSide.top ? arrowHeight : 0, @@ -208,29 +297,33 @@ class BusyMaxPopoverSurface extends StatelessWidget { ), child: Padding(padding: padding, child: child), ); - final surfaceChild = CustomPaint( - foregroundPainter: _BusyMaxPopoverOutlinePainter( - clipper: clipper, - color: outlineColor ?? BusyMaxSurfaceColors.of(context).floatingBorder, + return DecoratedBox( + decoration: ShapeDecoration( + color: color, + shadows: BusyMaxShadow.nativePopoverShadowsFor( + context, + role: shadowRole, + ), + shape: shape, + ), + child: ClipPath( + clipper: ShapeBorderClipper(shape: shape), + clipBehavior: Clip.antiAlias, + child: DecoratedBox( + position: DecorationPosition.foreground, + decoration: ShapeDecoration(shape: outlineShape), + child: paddedChild, + ), ), - child: paddedChild, - ); - return PhysicalShape( - clipper: clipper, - color: color, - elevation: BusyMaxElevation.tooltip, - shadowColor: BusyMaxShadow.physicalColor(context), - clipBehavior: Clip.antiAlias, - child: surfaceChild, ); } } -/// A rich anchored content surface, distinct from compact GTK-style menus. +/// A rich anchored-content adapter for the shared GTK-style popover surface. /// -/// Details cards use the shared raised-card fill and the standard floating -/// perimeter. This keeps their rich content surface distinct from compact -/// menus while all popovers retain one native edge, geometry, and shadow. +/// GTK exposes one popover surface role irrespective of the child content. +/// This adapter contributes only details layout; [BusyMaxPopoverSurface] +/// supplies the shared semantic fill, perimeter, and libadwaita shadow. class BusyMaxContentPopoverSurface extends StatelessWidget { const BusyMaxContentPopoverSurface({ super.key, @@ -249,93 +342,141 @@ class BusyMaxContentPopoverSurface extends StatelessWidget { Widget build(BuildContext context) { final colors = BusyMaxSurfaceColors.of(context); return BusyMaxPopoverSurface( - color: colors.card, + color: colors.popover, outlineColor: colors.floatingBorder, arrowSide: arrowSide, arrowAlignment: arrowAlignment, padding: padding, + shadowRole: BusyMaxPopoverShadowRole.details, child: child, ); } } -class _BusyMaxPopoverClipper extends CustomClipper { - const _BusyMaxPopoverClipper({required this.side, required this.alignment}); +class _BusyMaxPopoverBorder extends OutlinedBorder { + const _BusyMaxPopoverBorder({ + required this.arrowSide, + required this.alignment, + super.side = BorderSide.none, + }); - final BusyMaxPopoverArrowSide side; + final BusyMaxPopoverArrowSide arrowSide; final double alignment; @override - Path getClip(Size size) { - final arrowWidth = BusyMaxSizes.popoverArrowWidth; - final arrowHeight = BusyMaxSizes.popoverArrowHeight; - final radius = Radius.circular(BusyMaxRadius.md); - final bodyTop = side == BusyMaxPopoverArrowSide.top ? arrowHeight : 0.0; - final bodyBottom = side == BusyMaxPopoverArrowSide.bottom - ? size.height - arrowHeight - : size.height; - final body = RRect.fromRectAndRadius( - Rect.fromLTRB(0, bodyTop, size.width, bodyBottom), - radius, + _BusyMaxPopoverBorder copyWith({BorderSide? side}) { + return _BusyMaxPopoverBorder( + arrowSide: arrowSide, + alignment: alignment, + side: side ?? this.side, ); - final minArrowCenter = BusyMaxRadius.md + arrowWidth / 2; - final maxArrowCenter = size.width - minArrowCenter; - final arrowCenter = (size.width * alignment) - .clamp(minArrowCenter, maxArrowCenter) - .toDouble(); - - final bodyPath = Path()..addRRect(body); - final arrowPath = Path(); - if (side == BusyMaxPopoverArrowSide.top) { - arrowPath - ..moveTo(arrowCenter - arrowWidth / 2, bodyTop) - ..lineTo(arrowCenter, 0) - ..lineTo(arrowCenter + arrowWidth / 2, bodyTop) - ..close(); - } else { - arrowPath - ..moveTo(arrowCenter - arrowWidth / 2, bodyBottom) - ..lineTo(arrowCenter, size.height) - ..lineTo(arrowCenter + arrowWidth / 2, bodyBottom) - ..close(); - } - return Path.combine(PathOperation.union, bodyPath, arrowPath); } @override - bool shouldReclip(covariant _BusyMaxPopoverClipper oldClipper) { - return oldClipper.side != side || oldClipper.alignment != alignment; + Path getInnerPath(Rect rect, {TextDirection? textDirection}) { + return getOuterPath(rect, textDirection: textDirection); } -} -class _BusyMaxPopoverOutlinePainter extends CustomPainter { - const _BusyMaxPopoverOutlinePainter({ - required this.clipper, - required this.color, - }); + @override + Path getOuterPath(Rect rect, {TextDirection? textDirection}) { + return _busyMaxPopoverPath(rect, side: arrowSide, alignment: alignment); + } - final _BusyMaxPopoverClipper clipper; - final Color color; + @override + void paint(Canvas canvas, Rect rect, {TextDirection? textDirection}) { + if (side.style == BorderStyle.none || side.width <= 0) { + return; + } + final path = getOuterPath(rect, textDirection: textDirection); + canvas + ..save() + ..clipPath(path) + ..drawPath( + path, + side.toPaint() + ..style = PaintingStyle.stroke + ..strokeWidth = side.width * 2, + ) + ..restore(); + } @override - void paint(Canvas canvas, Size size) { - canvas.drawPath( - clipper.getClip(size), - Paint() - ..color = color - ..style = PaintingStyle.stroke - // PhysicalShape clips its child to the same path. Drawing a double - // width leaves one semantic outline pixel inside that clip. - ..strokeWidth = BusyMaxStroke.outline * 2, + _BusyMaxPopoverBorder scale(double t) { + return _BusyMaxPopoverBorder( + arrowSide: arrowSide, + alignment: alignment, + side: side.scale(t), ); } @override - bool shouldRepaint(covariant _BusyMaxPopoverOutlinePainter oldDelegate) { - return oldDelegate.color != color || - oldDelegate.clipper.side != clipper.side || - oldDelegate.clipper.alignment != clipper.alignment; + bool operator ==(Object other) { + return identical(this, other) || + other is _BusyMaxPopoverBorder && + other.arrowSide == arrowSide && + other.alignment == alignment && + other.side == side; + } + + @override + int get hashCode => Object.hash(arrowSide, alignment, side); +} + +Path _busyMaxPopoverPath( + Rect rect, { + required BusyMaxPopoverArrowSide side, + required double alignment, +}) { + if (rect.isEmpty) { + return Path(); } + final arrowHalfWidth = math.min( + BusyMaxSizes.popoverArrowWidth / 2, + rect.width / 2, + ); + final arrowHeight = math.min( + BusyMaxSizes.popoverArrowHeight, + rect.height / 2, + ); + final bodyHeight = rect.height - arrowHeight; + final radius = Radius.circular( + math.min(BusyMaxRadius.md, math.min(rect.width, bodyHeight) / 2), + ); + final bodyTop = side == BusyMaxPopoverArrowSide.top + ? rect.top + arrowHeight + : rect.top; + final bodyBottom = side == BusyMaxPopoverArrowSide.bottom + ? rect.bottom - arrowHeight + : rect.bottom; + final body = RRect.fromRectAndRadius( + Rect.fromLTRB(rect.left, bodyTop, rect.right, bodyBottom), + radius, + ); + final preferredArrowInset = radius.x + arrowHalfWidth; + final minArrowCenter = math.min(rect.width / 2, preferredArrowInset); + final maxArrowCenter = math.max(minArrowCenter, rect.width - minArrowCenter); + final arrowCenter = + (rect.width * alignment) + .clamp(minArrowCenter, maxArrowCenter) + .toDouble() + + rect.left; + + final bodyPath = Path()..addRRect(body); + final arrowPath = Path(); + if (side == BusyMaxPopoverArrowSide.top) { + arrowPath + ..moveTo(arrowCenter - arrowHalfWidth, bodyTop) + ..lineTo(arrowCenter, rect.top) + ..lineTo(arrowCenter + arrowHalfWidth, bodyTop) + ..close(); + } else { + arrowPath + ..moveTo(arrowCenter - arrowHalfWidth, bodyBottom) + ..lineTo(arrowCenter, rect.bottom) + ..lineTo(arrowCenter + arrowHalfWidth, bodyBottom) + ..close(); + } + return Path.combine(PathOperation.union, bodyPath, arrowPath); } RoundedRectangleBorder busyMaxHeaderButtonShape() { @@ -701,10 +842,9 @@ abstract final class BusyMaxPushButton { /// A contained circular action for compact popover toolbars. /// -/// [YaruIconButton] continues to own focus treatment, hover and press feedback, -/// and desktop control metrics. Its built-in style is intentionally flat and -/// cannot be overridden through its `style` argument, so this adapter supplies -/// the semantic contained surface around it once for every popover action. +/// Yaru owns focus, hover, press feedback, and desktop control geometry. The +/// outer semantic surface only adds the persistent containment used by Ubuntu +/// detail-popover actions. class BusyMaxPopoverIconButton extends StatelessWidget { const BusyMaxPopoverIconButton({ super.key, @@ -722,14 +862,11 @@ class BusyMaxPopoverIconButton extends StatelessWidget { @override Widget build(BuildContext context) { final colors = BusyMaxSurfaceColors.of(context); - final foreground = destructive - ? Theme.of(context).colorScheme.error - : colors.foreground; + final foreground = destructive ? Theme.of(context).colorScheme.error : null; final enabled = onPressed != null; return Material( color: enabled ? colors.control : colors.disabledControl, shape: const CircleBorder(), - clipBehavior: Clip.antiAlias, child: YaruIconButton( icon: Icon( icon, @@ -738,17 +875,15 @@ class BusyMaxPopoverIconButton extends StatelessWidget { ), iconSize: BusyMaxSizes.popoverActionButton, style: const ButtonStyle( - fixedSize: WidgetStatePropertyAll( - Size.square(BusyMaxSizes.popoverActionButton), - ), minimumSize: WidgetStatePropertyAll( Size.square(BusyMaxSizes.popoverActionButton), ), maximumSize: WidgetStatePropertyAll( Size.square(BusyMaxSizes.popoverActionButton), ), - padding: WidgetStatePropertyAll(EdgeInsets.zero), tapTargetSize: MaterialTapTargetSize.shrinkWrap, + side: WidgetStatePropertyAll(BorderSide.none), + shape: WidgetStatePropertyAll(CircleBorder()), ), tooltip: tooltip, onPressed: onPressed, @@ -770,17 +905,77 @@ Color busyMaxHoverBackground(BuildContext context) { } Color busyMaxRowHoverColor(BuildContext context) { - return Theme.of(context).hoverColor; + final theme = Theme.of(context); + final hover = theme.hoverColor; + if (theme.colorScheme.isHighContrast || + theme.colorScheme.brightness == Brightness.dark) { + return hover; + } + return hover.withValues( + alpha: hover.a * BusyMaxAlpha.groupedRowLightHoverStrength, + ); } -Color busyMaxEditorRowHoverColor(BuildContext context) { - return busyMaxRowHoverColor(context); +/// Input decoration inherited by controls hosted in a grouped-list row. +/// +/// The enclosing [BusyMaxGroupedList] owns the surface, outline, shadow, +/// padding, and separators. Yaru and Flutter entry controls continue to own +/// their editing behavior while this theme prevents a second input surface +/// from being painted inside the native grouped row. +InputDecorationThemeData busyMaxGroupedInputDecorationTheme( + BuildContext context, +) { + final theme = Theme.of(context); + final labelColor = theme.colorScheme.onSurfaceVariant; + final labelStyle = theme.textTheme.bodyMedium?.copyWith(color: labelColor); + + return theme.inputDecorationTheme.copyWith( + filled: false, + fillColor: Colors.transparent, + hoverColor: Colors.transparent, + border: InputBorder.none, + enabledBorder: InputBorder.none, + focusedBorder: InputBorder.none, + disabledBorder: InputBorder.none, + errorBorder: InputBorder.none, + focusedErrorBorder: InputBorder.none, + contentPadding: EdgeInsets.zero, + labelStyle: labelStyle, + floatingLabelStyle: labelStyle, + floatingLabelBehavior: FloatingLabelBehavior.auto, + ); } -Color busyMaxModalBarrierColor(BuildContext context) { - return Theme.of( +InputDecoration busyMaxGroupedTextFieldDecoration( + BuildContext context, { + required String labelText, + String? errorText, + bool alignLabelWithHint = false, +}) { + final decoration = InputDecoration( + labelText: labelText, + errorText: errorText, + alignLabelWithHint: alignLabelWithHint, + ); + final defaults = busyMaxGroupedInputDecorationTheme(context); + final resolved = decoration.applyDefaults(defaults); + if (errorText == null) { + return resolved; + } + final errorLabelStyle = Theme.of( context, - ).colorScheme.scrim.withValues(alpha: BusyMaxAlpha.modalBarrier); + ).textTheme.bodyMedium?.copyWith(color: Theme.of(context).colorScheme.error); + return resolved.copyWith( + labelStyle: errorLabelStyle, + floatingLabelStyle: errorLabelStyle, + ); +} + +Color busyMaxModalBarrierColor(BuildContext context) { + // The semantic shade already carries the toolkit's intended modal opacity. + // Consumers must not multiply or cap it and thereby create mode-specific + // dimming behavior. + return BusyMaxSurfaceColors.of(context).shade; } Color busyMaxPanelBorder(BuildContext context) { @@ -812,6 +1007,16 @@ TextStyle? busyMaxSectionHeaderStyle(BuildContext context) { ); } +/// The semantic title style used by Flutter-rendered window headers. +/// +/// Linux applies GTK's `title` style class to the native header label. The +/// 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); +} + Widget _busyMaxGroupedRowSubtitle( BuildContext context, Widget child, { @@ -1001,13 +1206,35 @@ class BusyMaxSurface extends StatelessWidget { } else { shape = fallbackShape.copyWith(side: side ?? BorderSide.none); } + final surfaceColor = filled + ? color ?? cardTheme.color ?? surfaceColors.card + : Colors.transparent; + if (filled) { + final shadowShape = shape is OutlinedBorder + ? shape.copyWith(side: BorderSide.none) + : shape; + return DecoratedBox( + decoration: ShapeDecoration( + shape: shadowShape, + shadows: BusyMaxShadow.nativeCardShadowsFor(context), + ), + child: Card( + margin: EdgeInsets.zero, + semanticContainer: false, + color: surfaceColor, + // Native card depth is painted once by the shared ShapeDecoration + // above. Keep Card for Yaru geometry, clipping, and semantics while + // suppressing Material's directional duplicate. + shadowColor: Colors.transparent, + shape: shape, + clipBehavior: clipBehavior, + child: child, + ), + ); + } return Material( - color: filled - ? color ?? cardTheme.color ?? surfaceColors.card - : Colors.transparent, - elevation: filled ? cardTheme.elevation ?? BusyMaxElevation.card : 0, - shadowColor: - cardTheme.shadowColor ?? BusyMaxShadow.physicalColor(context), + color: Colors.transparent, + elevation: 0, surfaceTintColor: cardTheme.surfaceTintColor ?? Colors.transparent, shape: shape, clipBehavior: clipBehavior, @@ -1614,19 +1841,17 @@ class BusyMaxCalendarValueRow extends StatelessWidget { const BusyMaxCalendarValueRow({ super.key, required this.label, - required this.value, + required this.entry, this.leading, this.trailingIcons = const [], - this.onTap, this.enabled = true, this.tooltip, }); final String label; - final String value; + final Widget entry; final Widget? leading; final List trailingIcons; - final VoidCallback? onTap; final bool enabled; final String? tooltip; @@ -1634,12 +1859,7 @@ class BusyMaxCalendarValueRow extends StatelessWidget { Widget build(BuildContext context) { final row = YaruListTile.square( leading: leading, - title: Text(label, maxLines: 1, overflow: TextOverflow.ellipsis), - subtitle: _busyMaxGroupedRowSubtitle( - context, - Text(value, maxLines: 1, overflow: TextOverflow.ellipsis), - enabled: enabled, - ), + title: Semantics(container: true, label: label, child: entry), trailing: trailingIcons.isEmpty ? null : Row( @@ -1653,7 +1873,6 @@ class BusyMaxCalendarValueRow extends StatelessWidget { ], ), enabled: enabled, - onTap: enabled ? onTap : null, ); if (enabled || tooltip == null) { @@ -1662,7 +1881,10 @@ class BusyMaxCalendarValueRow extends StatelessWidget { return Tooltip( message: tooltip!, - child: Opacity(opacity: 0.6, child: IgnorePointer(child: row)), + child: Opacity( + opacity: 0.6, + child: IgnorePointer(child: ExcludeFocus(child: row)), + ), ); } } @@ -2221,14 +2443,16 @@ Future _showBusyMaxFlutterMenu({ ? GlobalKey() : null; final routeKey = session._fallbackRouteKey; + final menuHoverColor = BusyMaxSurfaceColors.of(context).controlHover; final selection = showMenu( context: context, position: RelativeRect.fromRect(menuAnchor, Offset.zero & overlay.size), items: [ for (var index = 0; index < entries.length; index += 1) - PopupMenuItem( + _BusyMaxPopupMenuItem( value: index, enabled: entries[index].enabled, + hoverColor: menuHoverColor, child: _busyMaxFocusableFallbackEntry( context, entries[index], @@ -2267,6 +2491,40 @@ Future _showBusyMaxFlutterMenu({ } } +/// A framework popup row with BusyMax's GTK menu-hover role in scope. +/// +/// [PopupMenuItem] otherwise reads the application-wide Material hover color, +/// which is intentionally much quieter than GTK's menu-row state. Subclassing +/// keeps the framework's menu semantics, focus handling, keyboard navigation, +/// geometry, and ink response intact; only the inherited semantic state color +/// is narrowed to this row. +class _BusyMaxPopupMenuItem extends PopupMenuItem { + const _BusyMaxPopupMenuItem({ + required super.value, + required super.enabled, + required this.hoverColor, + required super.child, + }); + + final Color hoverColor; + + @override + PopupMenuItemState> createState() => + _BusyMaxPopupMenuItemState(); +} + +class _BusyMaxPopupMenuItemState + extends PopupMenuItemState> { + @override + Widget build(BuildContext context) { + final menuTheme = Theme.of(context).copyWith(hoverColor: widget.hoverColor); + return Theme( + data: menuTheme, + child: Builder(builder: (context) => super.build(context)), + ); + } +} + Widget _busyMaxFocusableFallbackEntry( BuildContext context, BusyMaxMenuEntry entry, { @@ -2566,7 +2824,7 @@ class _BusyMaxMenuButtonState extends State> { affordanceContext: _menuAnchorKey.currentContext, ); selection = await showBusyMaxMenu( - context: context, + context: triggerContext, anchorRect: anchor, entries: entries, nativeMenuService: nativeMenuService, @@ -3133,6 +3391,47 @@ class BusyMaxInlineBadge extends StatelessWidget { } } +/// Yaru dialog chrome bound to BusyMax's semantic dialog surface. +/// +/// Yaru otherwise falls back to [ThemeData.scaffoldBackgroundColor], which is +/// the application window role and can differ from [DialogThemeData.backgroundColor]. +/// Keeping this adapter shared prevents a dialog title bar and body from +/// resolving different native surface roles. +Color busyMaxDialogSurfaceColor(BuildContext context) { + return DialogTheme.of(context).backgroundColor ?? + BusyMaxSurfaceColors.of(context).dialog; +} + +class BusyMaxDialogTitleBar extends StatelessWidget { + const BusyMaxDialogTitleBar({ + super.key, + this.title, + this.centerTitle = true, + this.closeSemanticLabel, + }); + + final Widget? title; + final bool centerTitle; + final String? closeSemanticLabel; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colors = BusyMaxSurfaceColors.of(context); + return YaruDialogTitleBar( + title: title, + centerTitle: centerTitle, + isActive: true, + backgroundColor: busyMaxDialogSurfaceColor(context), + border: theme.colorScheme.isHighContrast + ? BorderSide(color: colors.divider) + : BorderSide.none, + closeSemanticLabel: closeSemanticLabel, + heroTag: null, + ); + } +} + class BusyMaxDialogShell extends StatelessWidget { const BusyMaxDialogShell({ super.key, @@ -3149,6 +3448,7 @@ class BusyMaxDialogShell extends StatelessWidget { @override Widget build(BuildContext context) { + final dialogSurface = busyMaxDialogSurfaceColor(context); return Semantics( scopesRoute: true, namesRoute: true, @@ -3157,6 +3457,8 @@ class BusyMaxDialogShell extends StatelessWidget { child: BusyMaxSurfaceScope( role: BusyMaxSurfaceRole.dialog, child: Dialog( + backgroundColor: dialogSurface, + surfaceTintColor: dialogSurface, clipBehavior: Clip.antiAlias, child: ConstrainedBox( constraints: BoxConstraints(maxWidth: maxWidth), @@ -3164,7 +3466,7 @@ class BusyMaxDialogShell extends StatelessWidget { mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - YaruDialogTitleBar(title: Text(title), centerTitle: true), + BusyMaxDialogTitleBar(title: Text(title)), Flexible( child: SingleChildScrollView( padding: const EdgeInsets.all(BusyMaxSpacing.lg), @@ -3215,40 +3517,76 @@ class BusyMaxPromptDialog extends StatefulWidget { } class _BusyMaxPromptDialogState extends State { - late String _value; + late final TextEditingController _controller; + late bool _canSubmit; @override void initState() { super.initState(); - _value = widget.initialValue ?? ''; + final initialValue = widget.initialValue ?? ''; + _controller = TextEditingController(text: initialValue) + ..selection = TextSelection( + baseOffset: 0, + extentOffset: initialValue.length, + ) + ..addListener(_handleTextChanged); + _canSubmit = _hasValue; + } + + bool get _hasValue => _controller.text.trim().isNotEmpty; + + void _handleTextChanged() { + final canSubmit = _hasValue; + if (canSubmit != _canSubmit) { + setState(() => _canSubmit = canSubmit); + } + } + + void _submit() { + if (_hasValue) { + Navigator.of(context).pop(_controller.text); + } + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); } @override Widget build(BuildContext context) { return BusyMaxDialogShell( title: widget.title, - maxWidth: 420, actions: [ BusyMaxPushButton.standard( onPressed: () => Navigator.of(context).pop(), child: Text(context.l10n.cancel), ), BusyMaxPushButton.suggested( - onPressed: () => Navigator.of(context).pop(_value), + onPressed: _canSubmit ? _submit : null, child: Text(widget.actionLabel), ), ], children: [ - if (widget.message != null && widget.message!.isNotEmpty) ...[ + if (widget.message != null && widget.message!.isNotEmpty) Text(widget.message!), - const SizedBox(height: BusyMaxSpacing.lg), - ], - TextFormField( - initialValue: widget.initialValue, - autofocus: true, - decoration: InputDecoration(labelText: widget.label), - onChanged: (value) => _value = value, - onEditingComplete: () => Navigator.of(context).pop(_value), + BusyMaxGroupedList( + filled: true, + children: [ + YaruListTile.square( + title: TextFormField( + controller: _controller, + autofocus: true, + textInputAction: TextInputAction.done, + decoration: busyMaxGroupedTextFieldDecoration( + context, + labelText: widget.label, + ), + onFieldSubmitted: (_) => _submit(), + ), + ), + ], ), ], ); @@ -3271,9 +3609,13 @@ class BusyMaxConfirmDialog extends StatelessWidget { @override Widget build(BuildContext context) { + final dialogSurface = busyMaxDialogSurfaceColor(context); return AlertDialog( + backgroundColor: dialogSurface, + surfaceTintColor: dialogSurface, + scrollable: true, titlePadding: EdgeInsets.zero, - title: YaruDialogTitleBar(title: Text(title), centerTitle: true), + title: BusyMaxDialogTitleBar(title: Text(title)), content: Text(message), actions: [ BusyMaxPushButton.standard( diff --git a/lib/src/app/busymax_dialog_identity.dart b/lib/src/app/busymax_dialog_identity.dart new file mode 100644 index 0000000..59fc4f5 --- /dev/null +++ b/lib/src/app/busymax_dialog_identity.dart @@ -0,0 +1,102 @@ +import 'package:flutter/material.dart'; + +import 'busymax_design.dart'; + +/// Native Yaru chrome shared by BusyMax's informational dialogs. +/// +/// The title bar deliberately lives outside the scroll viewport. This keeps +/// its window control fixed, fully opaque, and at Yaru's native metric while +/// the dialog content remains usable in compact windows and at large text +/// scales. +class BusyMaxInformationalDialog extends StatelessWidget { + const BusyMaxInformationalDialog({ + required this.closeLabel, + required this.maxWidth, + required this.child, + this.maxHeight, + super.key, + }); + + final String closeLabel; + final double maxWidth; + final double? maxHeight; + final Widget child; + + @override + Widget build(BuildContext context) { + final dialogSurface = busyMaxDialogSurfaceColor(context); + return BusyMaxSurfaceScope( + role: BusyMaxSurfaceRole.dialog, + child: Builder( + builder: (context) { + return Dialog( + backgroundColor: dialogSurface, + surfaceTintColor: dialogSurface, + clipBehavior: Clip.antiAlias, + child: ConstrainedBox( + constraints: BoxConstraints( + maxWidth: maxWidth, + maxHeight: maxHeight ?? double.infinity, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + BusyMaxDialogTitleBar(closeSemanticLabel: closeLabel), + Flexible( + child: SingleChildScrollView( + padding: const EdgeInsets.all(BusyMaxSpacing.lg), + child: child, + ), + ), + ], + ), + ), + ); + }, + ), + ); + } +} + +/// Shared application identity treatment for informational dialogs. +/// +/// The 128-pixel visual follows libadwaita's large application-icon metric. +/// Dialogs provide their own visual while this widget keeps its geometry and +/// title hierarchy consistent. +class BusyMaxDialogIdentity extends StatelessWidget { + const BusyMaxDialogIdentity({ + required this.visual, + required this.title, + super.key, + }); + + static const visualExtent = 128.0; + static const titleWeight = FontWeight.bold; + + final Widget visual; + final String title; + + @override + Widget build(BuildContext context) { + final titleStyle = + Theme.of( + context, + ).textTheme.headlineSmall?.copyWith(fontWeight: titleWeight) ?? + const TextStyle(fontWeight: titleWeight); + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Align( + alignment: Alignment.center, + child: Padding( + padding: const EdgeInsets.symmetric(vertical: BusyMaxSpacing.md), + child: SizedBox.square(dimension: visualExtent, child: visual), + ), + ), + const SizedBox(height: BusyMaxSpacing.md), + Text(title, textAlign: TextAlign.center, style: titleStyle), + ], + ); + } +} diff --git a/lib/src/app/busymax_dialogs.dart b/lib/src/app/busymax_dialogs.dart index 7d5c8a5..134257a 100644 --- a/lib/src/app/busymax_dialogs.dart +++ b/lib/src/app/busymax_dialogs.dart @@ -31,6 +31,8 @@ class BusyMaxModalShortcutBoundary extends StatelessWidget { } final _modalDepths = Map.identity(); +final _modalBarrierUpdateTails = + Map>.identity(); Future showBusyMaxModalDialog( BuildContext context, { @@ -122,7 +124,7 @@ Future showBusyMaxTextPrompt( String? message, Color? barrierColor, LinuxHeaderBarService? headerBarService, -}) async { +}) { return showBusyMaxModalDialog( context, headerBarService: headerBarService, @@ -189,8 +191,34 @@ Future acquireBusyMaxModalBarrier(LinuxHeaderBarService? service) async { } final depth = _modalDepths[service] ?? 0; _modalDepths[service] = depth + 1; - if (depth == 0) { - await service.setModalBarrierVisible(true); + final visibilityUpdate = depth == 0 + ? _enqueueBusyMaxModalBarrierUpdate(service, visible: true) + : _modalBarrierUpdateTails[service]; + if (visibilityUpdate == null) { + return; + } + + try { + // Nested callers that arrive while the first native show is pending must + // share its outcome. A dialog must not proceed under a header bar whose + // modal shield failed to open. + await visibilityUpdate; + } on Object catch (error, stackTrace) { + final remainingDepth = (_modalDepths[service] ?? 0) - 1; + if (remainingDepth > 0) { + _modalDepths[service] = remainingDepth; + } else { + _modalDepths.remove(service); + try { + // The platform may have applied the visibility change before its + // response failed. Restore the safe non-modal state, while preserving + // the original acquisition failure for the caller. + await _enqueueBusyMaxModalBarrierUpdate(service, visible: false); + } on Object { + // Best-effort rollback cannot replace the causative exception. + } + } + Error.throwWithStackTrace(error, stackTrace); } } @@ -202,12 +230,35 @@ Future releaseBusyMaxModalBarrier(LinuxHeaderBarService? service) async { final depth = _modalDepths[service] ?? 0; if (depth <= 1) { _modalDepths.remove(service); - await service.setModalBarrierVisible(false); + await _enqueueBusyMaxModalBarrierUpdate(service, visible: false); return; } _modalDepths[service] = depth - 1; } +Future _enqueueBusyMaxModalBarrierUpdate( + LinuxHeaderBarService service, { + required bool visible, +}) { + 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.setModalBarrierVisible(visible)) + .whenComplete(() { + if (identical(_modalBarrierUpdateTails[service], update)) { + _modalBarrierUpdateTails.remove(service); + } + }); + _modalBarrierUpdateTails[service] = update; + return update; +} + LinuxHeaderBarService? _headerBarServiceFrom(BuildContext context) { try { return ProviderScope.containerOf( diff --git a/lib/src/app/busymax_keyboard_shortcuts_dialog.dart b/lib/src/app/busymax_keyboard_shortcuts_dialog.dart index 97f5165..2642566 100644 --- a/lib/src/app/busymax_keyboard_shortcuts_dialog.dart +++ b/lib/src/app/busymax_keyboard_shortcuts_dialog.dart @@ -4,6 +4,7 @@ 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'; @@ -24,201 +25,170 @@ class BusyMaxKeyboardShortcutsDialog extends StatelessWidget { @override Widget build(BuildContext context) { final l10n = context.l10n; - final textTheme = Theme.of(context).textTheme; final colorScheme = Theme.of(context).colorScheme; - return BusyMaxSurfaceScope( - role: BusyMaxSurfaceRole.dialog, - child: Dialog( - child: ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 460, maxHeight: 560), - child: Stack( + return BusyMaxInformationalDialog( + closeLabel: l10n.close, + maxWidth: 460, + maxHeight: 560, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + BusyMaxDialogIdentity( + visual: Icon( + YaruIcons.keyboard_shortcuts, + size: BusyMaxDialogIdentity.visualExtent, + color: colorScheme.primary, + ), + title: l10n.keyboardShortcuts, + ), + const SizedBox(height: BusyMaxSpacing.lg), + BusyMaxGroupedList( + title: l10n.shortcutGroupGeneral, + filled: true, children: [ - SingleChildScrollView( - padding: const EdgeInsets.all(BusyMaxSpacing.lg), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Align( - alignment: Alignment.center, - child: Icon( - Icons.keyboard_alt_outlined, - size: 64, - color: colorScheme.primary, - ), - ), - const SizedBox(height: BusyMaxSpacing.md), - Text( - l10n.keyboardShortcuts, - textAlign: TextAlign.center, - style: textTheme.headlineSmall, - ), - const SizedBox(height: BusyMaxSpacing.lg), - BusyMaxGroupedList( - title: l10n.shortcutGroupGeneral, - filled: true, - children: [ - BusyMaxActionRow( - title: l10n.keyboardShortcuts, - subtitle: l10n.shortcutKeyboardShortcutsDescription, - leading: const Icon(Icons.keyboard_alt_outlined), - trailing: const _KeyboardShortcutBadge( - BusyMaxShortcutLabels.keyboardShortcuts, - ), - ), - BusyMaxActionRow( - title: l10n.settings, - leading: const Icon(Icons.settings_outlined), - trailing: const _KeyboardShortcutBadge( - BusyMaxShortcutLabels.settings, - ), - ), - BusyMaxActionRow( - title: MaterialLocalizations.of( - context, - ).searchFieldLabel, - leading: const Icon(Icons.search), - trailing: const _KeyboardShortcutBadge( - BusyMaxShortcutLabels.search, - ), - ), - ], - ), - BusyMaxGroupedList( - title: l10n.shortcutGroupNavigation, - filled: true, - children: [ - BusyMaxActionRow( - title: l10n.shortcutNextPeriod, - subtitle: l10n.shortcutNextPeriodDescription, - leading: const Icon(Icons.arrow_forward), - trailing: const _KeyboardShortcutBadge('Shift+Right'), - ), - BusyMaxActionRow( - title: l10n.shortcutPreviousPeriod, - subtitle: l10n.shortcutPreviousPeriodDescription, - leading: const Icon(Icons.arrow_back), - trailing: const _KeyboardShortcutBadge('Shift+Left'), - ), - BusyMaxActionRow( - title: l10n.shortcutJumpToToday, - leading: const Icon(Icons.today_outlined), - trailing: const _KeyboardShortcutBadge('Shift+T'), - ), - ], - ), - BusyMaxGroupedList( - title: l10n.shortcutGroupCreateAndEdit, - filled: true, - children: [ - BusyMaxActionRow( - title: l10n.create, - leading: const Icon(Icons.add), - trailing: const _KeyboardShortcutBadge( - BusyMaxShortcutLabels.create, - ), - ), - BusyMaxActionRow( - title: l10n.newEvent, - leading: const Icon(Icons.event_outlined), - trailing: const _KeyboardShortcutBadge('E'), - ), - BusyMaxActionRow( - title: l10n.newTask, - leading: const Icon(Icons.task_alt_outlined), - trailing: const _KeyboardShortcutBadge('T'), - ), - BusyMaxActionRow( - title: l10n.shortcutSaveItem, - leading: const Icon(Icons.save_outlined), - trailing: const _KeyboardShortcutBadge('Ctrl+S'), - ), - BusyMaxActionRow( - title: l10n.shortcutDeleteItem, - leading: const Icon(Icons.delete_outline), - trailing: const _KeyboardShortcutBadge( - 'Backspace / Delete', - ), - ), - ], - ), - BusyMaxGroupedList( - title: l10n.shortcutGroupTaskEditing, - filled: true, - children: [ - BusyMaxActionRow( - title: l10n.shortcutCancelEditing, - subtitle: l10n.shortcutCancelEditingDescription, - leading: const Icon(Icons.close), - trailing: const _KeyboardShortcutBadge('Esc'), - ), - ], - ), - BusyMaxGroupedList( - title: l10n.shortcutGroupView, - filled: true, - children: [ - BusyMaxActionRow( - title: l10n.shortcutDayView, - leading: const Icon(Icons.calendar_view_day_outlined), - trailing: const _KeyboardShortcutBadge('1 / D'), - ), - BusyMaxActionRow( - title: l10n.shortcutWeekView, - leading: const Icon(Icons.view_week_outlined), - trailing: const _KeyboardShortcutBadge('2 / W'), - ), - BusyMaxActionRow( - title: l10n.shortcutMonthView, - leading: const Icon(Icons.calendar_view_month), - trailing: const _KeyboardShortcutBadge('3 / M'), - ), - BusyMaxActionRow( - title: l10n.shortcutYearView, - leading: const Icon(Icons.calendar_today_outlined), - trailing: const _KeyboardShortcutBadge('4 / Y'), - ), - BusyMaxActionRow( - title: l10n.shortcutAgendaView, - leading: const Icon(Icons.view_agenda_outlined), - trailing: const _KeyboardShortcutBadge('0 / A'), - ), - ], - ), - BusyMaxGroupedList( - title: l10n.shortcutGroupCompactAgenda, - filled: true, - children: [ - BusyMaxActionRow( - title: l10n.compactAgendaRefresh, - subtitle: - l10n.shortcutRefreshCompactAgendaDescription, - leading: const Icon(Icons.refresh), - trailing: const _KeyboardShortcutBadge('Ctrl+R'), - ), - BusyMaxActionRow( - title: l10n.compactAgendaHide, - subtitle: l10n.shortcutHideCompactAgendaDescription, - leading: const Icon(Icons.visibility_off_outlined), - trailing: const _KeyboardShortcutBadge('Esc'), - ), - ], - ), - ], + BusyMaxActionRow( + title: l10n.keyboardShortcuts, + subtitle: l10n.shortcutKeyboardShortcutsDescription, + leading: const Icon(YaruIcons.keyboard_shortcuts), + trailing: const _KeyboardShortcutBadge( + BusyMaxShortcutLabels.keyboardShortcuts, + ), + ), + BusyMaxActionRow( + title: l10n.settings, + leading: const Icon(Icons.settings_outlined), + trailing: const _KeyboardShortcutBadge( + BusyMaxShortcutLabels.settings, ), ), - PositionedDirectional( - top: BusyMaxSpacing.sm, - end: BusyMaxSpacing.sm, - child: YaruIconButton( - icon: const Icon(Icons.close, size: BusyMaxSizes.iconSm), - tooltip: l10n.close, - onPressed: () => Navigator.of(context).pop(), + BusyMaxActionRow( + title: MaterialLocalizations.of(context).searchFieldLabel, + leading: const Icon(Icons.search), + trailing: const _KeyboardShortcutBadge( + BusyMaxShortcutLabels.search, ), ), ], ), - ), + BusyMaxGroupedList( + title: l10n.shortcutGroupNavigation, + filled: true, + children: [ + BusyMaxActionRow( + title: l10n.shortcutNextPeriod, + subtitle: l10n.shortcutNextPeriodDescription, + leading: const Icon(Icons.arrow_forward), + trailing: const _KeyboardShortcutBadge('Shift+Right'), + ), + BusyMaxActionRow( + title: l10n.shortcutPreviousPeriod, + subtitle: l10n.shortcutPreviousPeriodDescription, + leading: const Icon(Icons.arrow_back), + trailing: const _KeyboardShortcutBadge('Shift+Left'), + ), + BusyMaxActionRow( + title: l10n.shortcutJumpToToday, + leading: const Icon(Icons.today_outlined), + trailing: const _KeyboardShortcutBadge('Shift+T'), + ), + ], + ), + BusyMaxGroupedList( + title: l10n.shortcutGroupCreateAndEdit, + filled: true, + children: [ + BusyMaxActionRow( + title: l10n.create, + leading: const Icon(Icons.add), + trailing: const _KeyboardShortcutBadge( + BusyMaxShortcutLabels.create, + ), + ), + BusyMaxActionRow( + title: l10n.newEvent, + leading: const Icon(Icons.event_outlined), + trailing: const _KeyboardShortcutBadge('E'), + ), + BusyMaxActionRow( + title: l10n.newTask, + leading: const Icon(Icons.task_alt_outlined), + trailing: const _KeyboardShortcutBadge('T'), + ), + BusyMaxActionRow( + title: l10n.shortcutSaveItem, + leading: const Icon(Icons.save_outlined), + trailing: const _KeyboardShortcutBadge('Ctrl+S'), + ), + BusyMaxActionRow( + title: l10n.shortcutDeleteItem, + leading: const Icon(Icons.delete_outline), + trailing: const _KeyboardShortcutBadge('Backspace / Delete'), + ), + ], + ), + BusyMaxGroupedList( + title: l10n.shortcutGroupTaskEditing, + filled: true, + children: [ + BusyMaxActionRow( + title: l10n.shortcutCancelEditing, + subtitle: l10n.shortcutCancelEditingDescription, + leading: const Icon(Icons.close), + trailing: const _KeyboardShortcutBadge('Esc'), + ), + ], + ), + BusyMaxGroupedList( + title: l10n.shortcutGroupView, + filled: true, + children: [ + BusyMaxActionRow( + title: l10n.shortcutDayView, + leading: const Icon(Icons.calendar_view_day_outlined), + trailing: const _KeyboardShortcutBadge('1 / D'), + ), + BusyMaxActionRow( + title: l10n.shortcutWeekView, + leading: const Icon(Icons.view_week_outlined), + trailing: const _KeyboardShortcutBadge('2 / W'), + ), + BusyMaxActionRow( + title: l10n.shortcutMonthView, + leading: const Icon(Icons.calendar_view_month), + trailing: const _KeyboardShortcutBadge('3 / M'), + ), + BusyMaxActionRow( + title: l10n.shortcutYearView, + leading: const Icon(Icons.calendar_today_outlined), + trailing: const _KeyboardShortcutBadge('4 / Y'), + ), + BusyMaxActionRow( + title: l10n.shortcutAgendaView, + leading: const Icon(Icons.view_agenda_outlined), + trailing: const _KeyboardShortcutBadge('0 / A'), + ), + ], + ), + BusyMaxGroupedList( + title: l10n.shortcutGroupCompactAgenda, + filled: true, + children: [ + BusyMaxActionRow( + title: l10n.compactAgendaRefresh, + subtitle: l10n.shortcutRefreshCompactAgendaDescription, + leading: const Icon(Icons.refresh), + trailing: const _KeyboardShortcutBadge('Ctrl+R'), + ), + BusyMaxActionRow( + title: l10n.compactAgendaHide, + subtitle: l10n.shortcutHideCompactAgendaDescription, + leading: const Icon(Icons.visibility_off_outlined), + trailing: const _KeyboardShortcutBadge('Esc'), + ), + ], + ), + ], ), ); } @@ -232,22 +202,27 @@ class _KeyboardShortcutBadge extends StatelessWidget { @override Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; - return DecoratedBox( - decoration: BoxDecoration( - color: colorScheme.surfaceContainerHighest, - borderRadius: BorderRadius.circular(BusyMaxRadius.sm), - border: Border.all(color: colorScheme.outlineVariant), - ), - child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: BusyMaxSpacing.sm, - vertical: BusyMaxSpacing.xxs, - ), - child: Text( - label, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.labelMedium, + return Flexible( + child: Align( + alignment: AlignmentDirectional.centerEnd, + child: DecoratedBox( + decoration: BoxDecoration( + color: colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(BusyMaxRadius.sm), + border: Border.all(color: colorScheme.outlineVariant), + ), + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: BusyMaxSpacing.sm, + vertical: BusyMaxSpacing.xxs, + ), + child: Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.labelMedium, + ), + ), ), ), ); diff --git a/lib/src/app/busymax_surface_colors.dart b/lib/src/app/busymax_surface_colors.dart index b65f300..2ce32d9 100644 --- a/lib/src/app/busymax_surface_colors.dart +++ b/lib/src/app/busymax_surface_colors.dart @@ -209,15 +209,15 @@ BusyMaxSurfaceColors busyMaxFallbackSurfaceColors(Brightness brightness) { return switch (brightness) { Brightness.light => BusyMaxSurfaceColors( - // Modern Yaru/libadwaita semantic surface roles. GTK 3 does not publish - // every modern role, so named theme values replace these fallbacks only - // when the bridge can identify the role and the resolver can read it. + // Modern Yaru/libadwaita semantic surface roles. Application workspaces + // choose the window role explicitly; the view role remains available to + // editable and list content instead of being redefined globally. window: window, view: view, sidebar: Color(0xFFEBEBEB), secondarySidebar: Color(0xFFF0F0F0), headerbar: Color(0xFFFAFAFA), - headerbarFlat: Color(0xFFFFFFFF), + headerbarFlat: view, card: Color(0xFFFFFFFF), groupedSurface: Color(0xFFFFFFFF), dialog: const Color(0xFFFAFAFA), diff --git a/lib/src/app/busymax_yaru_theme.dart b/lib/src/app/busymax_yaru_theme.dart index 66f5f19..b5eda3c 100644 --- a/lib/src/app/busymax_yaru_theme.dart +++ b/lib/src/app/busymax_yaru_theme.dart @@ -179,15 +179,13 @@ class BusyMaxYaruTheme { side: popoverSurfaceSide, ); final cardTheme = base.cardTheme.copyWith( - // Elevated Flutter surfaces must be opaque. A translucent card layer - // lets PhysicalShape's shadow show through its own fill on Linux, - // darkening the card well below the native Yaru result. [colors.card] - // is the same semantic GTK layer precomposited over the window/editor - // surface by the resolver. + // Filled Flutter surfaces must be opaque. [colors.card] is the semantic + // GTK layer precomposited over the window/editor surface by the resolver. + // Card and Yaru continue to own elevation and shadow geometry. color: colors.card, surfaceTintColor: Colors.transparent, shadowColor: colorScheme.shadow, - elevation: BusyMaxElevation.card, + elevation: BusyMaxElevation.groupedCard, shape: base.cardTheme.shape ?? RoundedRectangleBorder( @@ -228,6 +226,10 @@ class BusyMaxYaruTheme { dialogTheme: base.dialogTheme.copyWith( backgroundColor: colors.dialog, surfaceTintColor: colors.dialog, + // Material 3 makes its default dialog shadow transparent. Restore the + // semantic theme shadow while retaining the framework-owned elevation + // and Yaru-owned dialog geometry. + shadowColor: colorScheme.shadow, // Retain Yaru's dialog radius and geometry, but use the modern // libadwaita dialog outline instead of Yaru Flutter's conspicuous // dark-mode white outline. Popovers have a separate perimeter role. @@ -333,6 +335,7 @@ class BusyMaxYaruTheme { color: colors.popover, surfaceTintColor: colors.popover, shadowColor: colorScheme.shadow, + elevation: menuStyle.elevation?.resolve(const {}), textStyle: normalizer.apply( base.popupMenuTheme.textStyle, fallback: textTheme.bodyMedium, @@ -363,19 +366,7 @@ class BusyMaxYaruTheme { fallback: textTheme.labelLarge, ), ), - tooltipTheme: base.tooltipTheme.copyWith( - decoration: BoxDecoration( - color: colors.popover, - borderRadius: BorderRadius.circular(BusyMaxRadius.headerButton), - border: highContrast ? Border.all(color: colors.border) : null, - boxShadow: BusyMaxShadow.tooltipShadows(colors.shade), - ), - padding: const EdgeInsets.symmetric( - horizontal: BusyMaxSpacing.tooltipHorizontal, - vertical: BusyMaxSpacing.tooltipVertical, - ), - textStyle: textTheme.bodyMedium?.copyWith(color: colors.foreground), - ), + tooltipTheme: base.tooltipTheme, snackBarTheme: base.snackBarTheme.copyWith( contentTextStyle: normalizer.apply( base.snackBarTheme.contentTextStyle, @@ -587,7 +578,7 @@ BusyMaxSurfaceColors _highContrastSurfaceColors(Brightness brightness) { dialogOutline: foreground, floatingBorder: foreground, sidebarBorder: foreground, - shade: Colors.black, + shade: Colors.black.withValues(alpha: 0.50), ); } diff --git a/lib/src/features/auth/presentation/sign_in_screen.dart b/lib/src/features/auth/presentation/sign_in_screen.dart index 831802d..1f1af4b 100644 --- a/lib/src/features/auth/presentation/sign_in_screen.dart +++ b/lib/src/features/auth/presentation/sign_in_screen.dart @@ -91,7 +91,7 @@ class _SignInScreenState extends ConsumerState { return Scaffold( body: ColoredBox( - color: BusyMaxSurfaceColors.of(context).view, + color: BusyMaxSurfaceColors.of(context).window, child: SafeArea( top: !Platform.isLinux || !_nativeHeaderBarAvailable, child: LayoutBuilder( diff --git a/lib/src/features/calendar/presentation/event_editor.dart b/lib/src/features/calendar/presentation/event_editor.dart index 87a3f00..fe79e21 100644 --- a/lib/src/features/calendar/presentation/event_editor.dart +++ b/lib/src/features/calendar/presentation/event_editor.dart @@ -95,6 +95,8 @@ class _EventEditorState extends State { var _addingGuest = false; var _addingCategory = false; var _confirmingCancel = false; + var _startTimeValid = true; + var _endTimeValid = true; @override void initState() { @@ -112,7 +114,7 @@ class _EventEditorState extends State { @override Widget build(BuildContext context) { final l10n = context.l10n; - final dirty = _draft != widget.initialDraft; + final dirty = _hasUnsavedChanges; CalendarSourceEntity? currentSource; for (final source in widget.sources) { if (source.id == _draft.sourceId) { @@ -124,7 +126,9 @@ class _EventEditorState extends State { final title = widget.initialDraft.eventId == null ? l10n.newEvent : l10n.editEvent; - final canSave = dirty && _draft.canSave; + final timeFieldsValid = + _draft.allDay || (_startTimeValid && _endTimeValid); + final canSave = dirty && _draft.canSave && timeFieldsValid; return CallbackShortcuts( bindings: { const SingleActivator(LogicalKeyboardKey.escape): () { @@ -151,11 +155,10 @@ class _EventEditorState extends State { filled: true, children: [ YaruListTile.square( - hoverColor: busyMaxEditorRowHoverColor(context), title: TextFormField( initialValue: _draft.title, autofocus: true, - decoration: _plainEventFieldDecoration( + decoration: busyMaxGroupedTextFieldDecoration( context, labelText: l10n.title, ), @@ -167,10 +170,9 @@ class _EventEditorState extends State { ), ), YaruListTile.square( - hoverColor: busyMaxEditorRowHoverColor(context), title: TextFormField( initialValue: _draft.location, - decoration: _plainEventFieldDecoration( + decoration: busyMaxGroupedTextFieldDecoration( context, labelText: l10n.location, ), @@ -194,6 +196,7 @@ class _EventEditorState extends State { ], ), BusyMaxGroupedList( + title: l10n.startDateTime, filled: true, children: [ DesktopDateValueRow( @@ -202,7 +205,6 @@ class _EventEditorState extends State { onChanged: (value) { _setStart(_withDate(_draft.start, value), provider); }, - emptyLabel: l10n.noneValue, ), if (!_draft.allDay) DesktopTimeValueRow( @@ -211,12 +213,17 @@ class _EventEditorState extends State { onChanged: (value) { _setStart(_withTime(_draft.start, value), provider); }, - emptyLabel: '--:--', allowEmpty: false, + onValidityChanged: (valid) { + if (_startTimeValid != valid) { + setState(() => _startTimeValid = valid); + } + }, ), ], ), BusyMaxGroupedList( + title: l10n.endDateTime, filled: true, children: [ DesktopDateValueRow( @@ -225,7 +232,6 @@ class _EventEditorState extends State { onChanged: (value) { _setEnd(_withDate(_draft.end, value)); }, - emptyLabel: l10n.noneValue, ), if (!_draft.allDay) DesktopTimeValueRow( @@ -234,8 +240,12 @@ class _EventEditorState extends State { onChanged: (value) { _setEnd(_withTime(_draft.end, value)); }, - emptyLabel: '--:--', allowEmpty: false, + onValidityChanged: (valid) { + if (_endTimeValid != valid) { + setState(() => _endTimeValid = valid); + } + }, ), ], ), @@ -264,7 +274,6 @@ class _EventEditorState extends State { filled: true, children: [ YaruListTile.square( - hoverColor: busyMaxEditorRowHoverColor(context), title: EventDescriptionEditor( provider: provider, text: _draft.description, @@ -325,7 +334,7 @@ class _EventEditorState extends State { if (_confirmingCancel) { return; } - if (_draft == widget.initialDraft) { + if (!_hasUnsavedChanges) { widget.onCancel(); return; } @@ -584,22 +593,21 @@ class _EventEditorState extends State { rows.add( YaruListTile.square( leading: const Icon(Icons.person_add_alt_outlined), - hoverColor: busyMaxEditorRowHoverColor(context), + trailing: YaruIconButton( + tooltip: context.l10n.addGuest, + icon: const Icon(YaruIcons.plus), + onPressed: _addGuest, + ), title: TextField( controller: _guestController, autofocus: true, - decoration: _plainEventFieldDecoration( + decoration: busyMaxGroupedTextFieldDecoration( context, labelText: context.l10n.addGuestEmail, errorText: _guestError, ), onSubmitted: (_) => _addGuest(), ), - trailing: YaruIconButton( - tooltip: context.l10n.addGuest, - icon: const Icon(YaruIcons.plus), - onPressed: _addGuest, - ), ), ); } @@ -732,6 +740,8 @@ class _EventEditorState extends State { final start = _draft.start; final end = _draft.end; setState(() { + _startTimeValid = true; + _endTimeValid = true; _draft = _draft.copyWith( allDay: allDay, end: start != null && !_isValidEventEnd(start, end, allDay) @@ -741,6 +751,12 @@ class _EventEditorState extends State { }); } + bool get _hasUnsavedChanges { + final hasInvalidVisibleTime = + !_draft.allDay && (!_startTimeValid || !_endTimeValid); + return _draft != widget.initialDraft || hasInvalidVisibleTime; + } + void _setStart(DateTime start, BusyProvider provider) { final end = _draft.end; final recurrenceType = _recurrenceType(_draft.recurrence); @@ -792,39 +808,6 @@ TextStyle? _eventEditorProminentActionStyle( ).textTheme.labelLarge?.copyWith(color: color, fontWeight: fontWeight); } -InputDecoration _plainEventFieldDecoration( - BuildContext context, { - required String labelText, - String? errorText, - bool alignLabelWithHint = false, -}) { - final colorScheme = Theme.of(context).colorScheme; - final labelColor = errorText == null - ? colorScheme.onSurfaceVariant - : colorScheme.error; - final labelStyle = Theme.of( - context, - ).textTheme.bodyMedium?.copyWith(color: labelColor); - return InputDecoration( - filled: false, - fillColor: Colors.transparent, - hoverColor: Colors.transparent, - border: InputBorder.none, - enabledBorder: InputBorder.none, - focusedBorder: InputBorder.none, - disabledBorder: InputBorder.none, - errorBorder: InputBorder.none, - focusedErrorBorder: InputBorder.none, - contentPadding: EdgeInsets.zero, - labelText: labelText, - labelStyle: labelStyle, - floatingLabelStyle: labelStyle, - floatingLabelBehavior: FloatingLabelBehavior.auto, - alignLabelWithHint: alignLabelWithHint, - errorText: errorText, - ); -} - String? _dateString(DateTime? value) { return value == null ? null : encodeDateOnly(value); } diff --git a/lib/src/features/feedback/presentation/feedback_dialog.dart b/lib/src/features/feedback/presentation/feedback_dialog.dart index bcafd76..7a44c1e 100644 --- a/lib/src/features/feedback/presentation/feedback_dialog.dart +++ b/lib/src/features/feedback/presentation/feedback_dialog.dart @@ -156,7 +156,8 @@ class _BusyMaxFeedbackDialogState extends State { controller: _subjectController, enabled: !_submitting, textInputAction: TextInputAction.next, - decoration: InputDecoration( + decoration: busyMaxGroupedTextFieldDecoration( + context, labelText: l10n.feedbackSubject, errorText: subjectInvalid ? l10n.feedbackSubjectLengthError @@ -173,7 +174,8 @@ class _BusyMaxFeedbackDialogState extends State { minLines: 4, maxLines: 8, keyboardType: TextInputType.multiline, - decoration: InputDecoration( + decoration: busyMaxGroupedTextFieldDecoration( + context, labelText: l10n.feedbackDetailedMessage, alignLabelWithHint: true, errorText: messageInvalid @@ -190,7 +192,8 @@ class _BusyMaxFeedbackDialogState extends State { enabled: !_submitting, keyboardType: TextInputType.emailAddress, textInputAction: TextInputAction.done, - decoration: InputDecoration( + decoration: busyMaxGroupedTextFieldDecoration( + context, labelText: l10n.feedbackReplyEmail, errorText: replyEmailInvalid ? l10n.feedbackInvalidEmail diff --git a/lib/src/features/schedule/presentation/schedule_agenda_view.dart b/lib/src/features/schedule/presentation/schedule_agenda_view.dart index 7f9782c..d268b15 100644 --- a/lib/src/features/schedule/presentation/schedule_agenda_view.dart +++ b/lib/src/features/schedule/presentation/schedule_agenda_view.dart @@ -92,7 +92,7 @@ class _ScheduleAgendaViewState extends State { return NotificationListener( onNotification: _handleScroll, child: ColoredBox( - color: Theme.of(context).colorScheme.surface, + color: BusyMaxSurfaceColors.of(context).window, child: ListView( padding: const EdgeInsets.fromLTRB( BusyMaxSpacing.lg, diff --git a/lib/src/features/schedule/presentation/schedule_anchored_popover.dart b/lib/src/features/schedule/presentation/schedule_anchored_popover.dart index d650c5f..54f5a08 100644 --- a/lib/src/features/schedule/presentation/schedule_anchored_popover.dart +++ b/lib/src/features/schedule/presentation/schedule_anchored_popover.dart @@ -221,6 +221,7 @@ class _ScheduleAnchoredPopoverRoute extends StatelessWidget { explicitChildNodes: true, child: BlockSemantics( child: Stack( + clipBehavior: Clip.none, children: [ Positioned.fill( child: GestureDetector( @@ -262,6 +263,8 @@ class _SchedulePopoverLayout { required this.left, required this.width, required this.maximumHeight, + required this.horizontalMargin, + required this.verticalMargin, required this.arrowSide, required this.arrowAlignment, }); @@ -274,21 +277,33 @@ class _SchedulePopoverLayout { required double minimumWidth, required double preferredMinimumHeight, }) { - const margin = BusyMaxSpacing.md; const gap = BusyMaxSpacing.xs; - final availableWidth = math.max(0.0, viewport.width - margin * 2); + final horizontalMargin = _adaptivePopoverMargin( + viewport.width, + preferredMinimumExtent: minimumWidth, + ); + final verticalMargin = _adaptivePopoverMargin( + viewport.height, + preferredMinimumExtent: preferredMinimumHeight, + ); + final availableWidth = math.max(0.0, viewport.width - horizontalMargin * 2); final width = availableWidth < minimumWidth ? availableWidth : math.min(preferredWidth, availableWidth); - final maximumLeft = math.max(margin, viewport.width - width - margin); + final maximumLeft = math.max( + horizontalMargin, + viewport.width - width - horizontalMargin, + ); if (anchor == null) { return _SchedulePopoverLayout( anchor: null, left: ((viewport.width - width) / 2) - .clamp(margin, maximumLeft) + .clamp(horizontalMargin, maximumLeft) .toDouble(), width: width, - maximumHeight: math.max(0, viewport.height - margin * 2), + maximumHeight: math.max(0, viewport.height - verticalMargin * 2), + horizontalMargin: horizontalMargin, + verticalMargin: verticalMargin, arrowSide: BusyMaxPopoverArrowSide.top, arrowAlignment: 0.5, ); @@ -297,11 +312,11 @@ class _SchedulePopoverLayout { final preferredLeft = textDirection == TextDirection.rtl ? anchor.right - width : anchor.left; - final left = preferredLeft.clamp(margin, maximumLeft).toDouble(); - final spaceAbove = math.max(0.0, anchor.top - gap - margin); + final left = preferredLeft.clamp(horizontalMargin, maximumLeft).toDouble(); + final spaceAbove = math.max(0.0, anchor.top - gap - verticalMargin); final spaceBelow = math.max( 0.0, - viewport.height - anchor.bottom - gap - margin, + viewport.height - anchor.bottom - gap - verticalMargin, ); final showBelow = spaceBelow >= math.min(preferredMinimumHeight, spaceAbove) || @@ -314,6 +329,8 @@ class _SchedulePopoverLayout { left: left, width: width, maximumHeight: showBelow ? spaceBelow : spaceAbove, + horizontalMargin: horizontalMargin, + verticalMargin: verticalMargin, arrowSide: showBelow ? BusyMaxPopoverArrowSide.top : BusyMaxPopoverArrowSide.bottom, @@ -325,6 +342,8 @@ class _SchedulePopoverLayout { final double left; final double width; final double maximumHeight; + final double horizontalMargin; + final double verticalMargin; final BusyMaxPopoverArrowSide arrowSide; final double arrowAlignment; } @@ -345,18 +364,17 @@ class _SchedulePopoverPositionDelegate extends SingleChildLayoutDelegate { @override Offset getPositionForChild(Size size, Size childSize) { - const margin = BusyMaxSpacing.md; const gap = BusyMaxSpacing.xs; final maximumTop = math.max( - margin, - size.height - childSize.height - margin, + layout.verticalMargin, + size.height - childSize.height - layout.verticalMargin, ); final anchor = layout.anchor; if (anchor == null) { return Offset( layout.left, ((size.height - childSize.height) / 2) - .clamp(margin, maximumTop) + .clamp(layout.verticalMargin, maximumTop) .toDouble(), ); } @@ -366,7 +384,7 @@ class _SchedulePopoverPositionDelegate extends SingleChildLayoutDelegate { }; return Offset( layout.left, - preferredTop.clamp(margin, maximumTop).toDouble(), + preferredTop.clamp(layout.verticalMargin, maximumTop).toDouble(), ); } @@ -376,7 +394,20 @@ class _SchedulePopoverPositionDelegate extends SingleChildLayoutDelegate { layout.left != oldDelegate.layout.left || layout.width != oldDelegate.layout.width || layout.maximumHeight != oldDelegate.layout.maximumHeight || + layout.horizontalMargin != oldDelegate.layout.horizontalMargin || + layout.verticalMargin != oldDelegate.layout.verticalMargin || layout.arrowSide != oldDelegate.layout.arrowSide || layout.arrowAlignment != oldDelegate.layout.arrowAlignment; } } + +double _adaptivePopoverMargin( + double extent, { + required double preferredMinimumExtent, +}) { + final availableSurplus = extent - preferredMinimumExtent; + if (availableSurplus <= 0) { + return 0; + } + return math.min(BusyMaxShadow.nativePopoverPaintMargin, availableSurplus / 2); +} 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 66f316e..dce5e8f 100644 --- a/lib/src/features/schedule/presentation/schedule_day_week_view.dart +++ b/lib/src/features/schedule/presentation/schedule_day_week_view.dart @@ -96,11 +96,12 @@ class _ScheduleDayWeekViewState extends State { Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; final surfaceColors = BusyMaxSurfaceColors.of(context); + final workspaceColor = surfaceColors.window; final gridColor = busyMaxCalendarGridColor(context); final todayOverlayAlpha = widget.daysShowed == 1 ? 0.0 : 0.035; final todayColor = Color.alphaBlend( surfaceColors.controlActive.withValues(alpha: todayOverlayAlpha), - colorScheme.surface, + workspaceColor, ); final showFullDayBar = _hasRenderedFullDayEvents(context, widget); final fullDayBarHeight = showFullDayBar ? _fullDayBarHeight : 0.0; @@ -124,7 +125,7 @@ class _ScheduleDayWeekViewState extends State { onDayChange: (day) => widget.onDaySelected(_day(day)), daysHeaderParam: icv.DaysHeaderParam( daysHeaderHeight: widget.daysShowed == 1 ? 0 : 50, - daysHeaderColor: colorScheme.surface, + daysHeaderColor: workspaceColor, dayHeaderBuilder: (day, isToday) => widget.daysShowed == 1 ? const SizedBox.shrink() : _PlannerDayHeader(day: day, isToday: isToday), @@ -144,10 +145,10 @@ class _ScheduleDayWeekViewState extends State { ), ), fullDayEventsBarDecoration: BoxDecoration( - color: colorScheme.surface, + color: workspaceColor, border: Border(bottom: BorderSide(color: gridColor)), ), - fullDayBackgroundColor: colorScheme.surface, + fullDayBackgroundColor: workspaceColor, fullDayEventsBuilder: (events, width) { return _FullDayScrollPane( events: events, @@ -195,7 +196,7 @@ class _ScheduleDayWeekViewState extends State { ), dayParam: icv.DayParam( todayColor: todayColor, - dayColor: colorScheme.surface, + dayColor: workspaceColor, dayTopPadding: 8, dayBottomPadding: 16, onSlotMinutesRound: 15, @@ -277,7 +278,7 @@ class _ScheduleDayWeekViewState extends State { ), offTimesColor: Color.alphaBlend( colorScheme.onSurface.withValues(alpha: 0.025), - colorScheme.surface, + workspaceColor, ), offTimesAllDaysPainter: (column, day, isToday, heightPerMinute, ranges, color) => @@ -487,7 +488,7 @@ class _PlannerDayHeader extends StatelessWidget { return Container( alignment: Alignment.center, decoration: BoxDecoration( - color: colorScheme.surface, + color: surfaceColors.window, border: Border( bottom: BorderSide(color: busyMaxCalendarGridColor(context)), ), diff --git a/lib/src/features/schedule/presentation/schedule_month_view.dart b/lib/src/features/schedule/presentation/schedule_month_view.dart index 48efcb2..b74739f 100644 --- a/lib/src/features/schedule/presentation/schedule_month_view.dart +++ b/lib/src/features/schedule/presentation/schedule_month_view.dart @@ -46,12 +46,13 @@ class ScheduleMonthView extends StatelessWidget { final month = DateTime(selectedDate.year, selectedDate.month); final grouped = ScheduleProjection.groupByDay(items); final theme = Theme.of(context); + final workspaceColor = BusyMaxSurfaceColors.of(context).window; final border = busyMaxCalendarGridColor(context); return Column( children: [ ColoredBox( - color: theme.colorScheme.surface, + color: workspaceColor, child: SizedBox( height: 34, child: Row( @@ -148,8 +149,8 @@ class _MonthDayCell extends StatelessWidget { @override Widget build(BuildContext context) { - final colorScheme = Theme.of(context).colorScheme; final surfaceColors = BusyMaxSurfaceColors.of(context); + final workspaceColor = surfaceColors.window; final today = DateUtils.isSameDay(day, DateTime.now()); return BusyMaxCalendarDaySemantics( @@ -158,8 +159,8 @@ class _MonthDayCell extends StatelessWidget { onTap: onSelect, child: Material( color: selected - ? Color.alphaBlend(surfaceColors.control, colorScheme.surface) - : colorScheme.surface, + ? Color.alphaBlend(surfaceColors.control, workspaceColor) + : workspaceColor, child: InkWell( onTap: onSelect, onDoubleTap: onCreate, diff --git a/lib/src/features/schedule/presentation/schedule_sidebar.dart b/lib/src/features/schedule/presentation/schedule_sidebar.dart index 6ae0d8a..c75291b 100644 --- a/lib/src/features/schedule/presentation/schedule_sidebar.dart +++ b/lib/src/features/schedule/presentation/schedule_sidebar.dart @@ -645,7 +645,10 @@ Future _renameCalendar( initialValue: source.summary, headerBarService: ref.read(linuxHeaderBarServiceProvider), ); - if (title == null || title.trim().isEmpty || title.trim() == source.summary) { + if (!context.mounted || + title == null || + title.trim().isEmpty || + title.trim() == source.summary) { return; } await ref @@ -685,7 +688,10 @@ Future _renameTaskList( initialValue: list.title, headerBarService: ref.read(linuxHeaderBarServiceProvider), ); - if (title == null || title.trim().isEmpty || title.trim() == list.title) { + if (!context.mounted || + title == null || + title.trim().isEmpty || + title.trim() == list.title) { return; } await ref diff --git a/lib/src/features/schedule/presentation/schedule_toolbar.dart b/lib/src/features/schedule/presentation/schedule_toolbar.dart index d7ac066..63a24d3 100644 --- a/lib/src/features/schedule/presentation/schedule_toolbar.dart +++ b/lib/src/features/schedule/presentation/schedule_toolbar.dart @@ -101,7 +101,7 @@ class ScheduleToolbar extends StatelessWidget { _rangeLabel(context, mode, range, selectedDate), maxLines: 1, overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.titleMedium, + style: busyMaxHeaderTitleStyle(context), ), ), BusyMaxMenuButton( diff --git a/lib/src/features/schedule/presentation/schedule_workspace.dart b/lib/src/features/schedule/presentation/schedule_workspace.dart index 5979b4f..e66862b 100644 --- a/lib/src/features/schedule/presentation/schedule_workspace.dart +++ b/lib/src/features/schedule/presentation/schedule_workspace.dart @@ -553,7 +553,7 @@ class _ScheduleWorkspaceState extends ConsumerState { ], ); return Scaffold( - backgroundColor: BusyMaxSurfaceColors.of(context).view, + backgroundColor: BusyMaxSurfaceColors.of(context).window, body: LayoutBuilder( builder: (context, constraints) { final showSidebar = BusyMaxLayoutRules.showSidebar( diff --git a/lib/src/features/schedule/presentation/schedule_year_view.dart b/lib/src/features/schedule/presentation/schedule_year_view.dart index 82c005a..f43b438 100644 --- a/lib/src/features/schedule/presentation/schedule_year_view.dart +++ b/lib/src/features/schedule/presentation/schedule_year_view.dart @@ -43,7 +43,7 @@ class ScheduleYearView extends StatelessWidget { final monthHeight = _monthPanelHeight(monthWidth); return ColoredBox( - color: Theme.of(context).colorScheme.surface, + color: BusyMaxSurfaceColors.of(context).window, child: GridView.builder( padding: const EdgeInsets.all(BusyMaxSpacing.md), gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( diff --git a/lib/src/features/settings/presentation/settings_screen.dart b/lib/src/features/settings/presentation/settings_screen.dart index d6b5f36..8fb9e9f 100644 --- a/lib/src/features/settings/presentation/settings_screen.dart +++ b/lib/src/features/settings/presentation/settings_screen.dart @@ -260,7 +260,7 @@ class _SettingsScreenState extends ConsumerState { }; return Scaffold( - backgroundColor: BusyMaxSurfaceColors.of(context).view, + backgroundColor: BusyMaxSurfaceColors.of(context).window, body: LayoutBuilder( builder: (context, constraints) { final showSidebar = BusyMaxLayoutRules.showSettingsSidebar( @@ -703,7 +703,7 @@ class _SettingsFallbackHeader extends StatelessWidget { textAlign: TextAlign.center, maxLines: 1, overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.titleMedium, + style: busyMaxHeaderTitleStyle(context), ), ), const SizedBox(width: BusyMaxSizes.headerIconButton), diff --git a/lib/src/features/tasks/presentation/desktop_date_time_fields.dart b/lib/src/features/tasks/presentation/desktop_date_time_fields.dart index b0bc85b..5c9038f 100644 --- a/lib/src/features/tasks/presentation/desktop_date_time_fields.dart +++ b/lib/src/features/tasks/presentation/desktop_date_time_fields.dart @@ -60,7 +60,6 @@ class DesktopDateField extends StatefulWidget { required this.onChanged, this.enabled = true, this.onClear, - this.emptyLabel, this.useNativePicker = true, }); @@ -69,7 +68,6 @@ class DesktopDateField extends StatefulWidget { final ValueChanged onChanged; final bool enabled; final VoidCallback? onClear; - final String? emptyLabel; final bool useNativePicker; @override @@ -84,7 +82,6 @@ class DesktopDateValueRow extends StatelessWidget { required this.onChanged, this.enabled = true, this.onClear, - this.emptyLabel, this.useNativePicker = true, }); @@ -93,155 +90,92 @@ class DesktopDateValueRow extends StatelessWidget { final ValueChanged onChanged; final bool enabled; final VoidCallback? onClear; - final String? emptyLabel; final bool useNativePicker; @override Widget build(BuildContext context) { - final formatted = formatDesktopDate(context, date); - final displayValue = formatted.isEmpty - ? emptyLabel ?? context.l10n.noneValue - : formatted; - final canClear = date != null && date!.isNotEmpty && onClear != null; - - return BusyMaxCalendarValueRow( + return DesktopDateField( label: label, - value: displayValue, - leading: const Icon(YaruIcons.calendar), + date: date, + onChanged: onChanged, enabled: enabled, - onTap: () => _pickNativeDate(context), - trailingIcons: [ - if (canClear) - YaruIconButton( - tooltip: MaterialLocalizations.of(context).deleteButtonTooltip, - iconSize: BusyMaxSizes.iconMd, - icon: const Icon(YaruIcons.window_close), - onPressed: enabled ? onClear : null, - ), - const Icon(Icons.edit_outlined, size: BusyMaxSizes.iconMd), - const Icon(YaruIcons.calendar, size: BusyMaxSizes.iconMd), - ], + onClear: onClear, + useNativePicker: useNativePicker, ); } - - Future _pickNativeDate(BuildContext context) async { - if (!enabled) { - return; - } - if (!useNativePicker) { - final fallbackPicked = await showBusyMaxDateValueDialog( - context, - label: label, - initialDate: date, - ); - if (context.mounted && fallbackPicked != null) { - onChanged(fallbackPicked); - } - return; - } - final localizations = MaterialLocalizations.of(context); - final picked = await _nativeDateTimePicker.pickDate( - title: label, - initialDate: date, - cancelLabel: localizations.cancelButtonLabel, - okLabel: localizations.okButtonLabel, - ); - if (!context.mounted) { - return; - } - if (picked.date != null) { - onChanged(picked.date!); - return; - } - if (picked.available) { - return; - } - final fallbackPicked = await showBusyMaxDateValueDialog( - context, - label: label, - initialDate: date, - ); - if (context.mounted && fallbackPicked != null) { - onChanged(fallbackPicked); - } - } } class _DesktopDateFieldState extends State { - late final YaruDateTimeEntryController _controller; - var _syncingController = false; + late final TextEditingController _controller; @override void initState() { super.initState(); - _controller = YaruDateTimeEntryController( - dateTime: parseDateOnly(widget.date), - ); + _controller = TextEditingController(); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + _syncVisibleValue(); } @override void didUpdateWidget(covariant DesktopDateField oldWidget) { super.didUpdateWidget(oldWidget); if (oldWidget.date != widget.date) { - final nextDate = parseDateOnly(widget.date); - final currentDate = _controller.dateTime; - if (!isSameDate(currentDate, nextDate)) { - _syncingController = true; - _controller.dateTime = nextDate; - _syncingController = false; - } + _syncVisibleValue(); } } + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + @override Widget build(BuildContext context) { - final dateEntry = _withoutFloatingEntryLabel( - context, - YaruDateTimeEntry( + final canClear = widget.date?.isNotEmpty ?? false; + return BusyMaxCalendarValueRow( + label: widget.label, + entry: TextFormField( controller: _controller, - includeTime: false, - firstDateTime: DateTime(1900), - lastDateTime: DateTime(2100, 12, 31), - acceptEmpty: true, - clearIconSemanticLabel: widget.label, - onChanged: (date) { - if (_syncingController) { - return; - } - if (date == null) { - widget.onClear?.call(); - return; - } - widget.onChanged(encodeDateOnly(date)); - }, + readOnly: true, + showCursor: false, + enableInteractiveSelection: false, + enabled: widget.enabled, + decoration: busyMaxGroupedTextFieldDecoration( + context, + labelText: widget.label, + ), + onTap: widget.enabled ? () => _pickNativeDate(context) : null, ), - ); - - return YaruListTile.square( - leading: const Icon(YaruIcons.calendar), - titleText: widget.label, - trailing: Row( - mainAxisSize: MainAxisSize.min, - children: [ - SizedBox( - width: 190, - child: widget.enabled - ? dateEntry - : Opacity( - opacity: 0.6, - child: ExcludeFocus(child: IgnorePointer(child: dateEntry)), - ), - ), + trailingIcons: [ + if (canClear && widget.onClear != null) YaruIconButton( - tooltip: widget.label, - iconSize: 28, - onPressed: widget.enabled ? () => _pickNativeDate(context) : null, - icon: const Icon(YaruIcons.calendar), + tooltip: MaterialLocalizations.of(context).deleteButtonTooltip, + onPressed: widget.enabled ? widget.onClear : null, + icon: const Icon(YaruIcons.window_close), ), - ], - ), + YaruIconButton( + tooltip: widget.label, + onPressed: widget.enabled ? () => _pickNativeDate(context) : null, + icon: const Icon(YaruIcons.calendar), + ), + ], enabled: widget.enabled, - onTap: widget.enabled ? () => _pickNativeDate(context) : null, + ); + } + + void _syncVisibleValue() { + final formatted = formatDesktopDate(context, widget.date); + if (_controller.text == formatted) { + return; + } + _controller.value = TextEditingValue( + text: formatted, + selection: TextSelection.collapsed(offset: formatted.length), ); } @@ -288,21 +222,12 @@ class _DesktopDateFieldState extends State { } void _applyPickedDate(String picked) { - final pickedDate = parseDateOnly(picked); - if (!isSameDate(_controller.dateTime, pickedDate)) { - _syncingController = true; - _controller.dateTime = pickedDate; - _syncingController = false; - } + final formatted = formatDesktopDate(context, picked); + _controller.value = TextEditingValue( + text: formatted, + selection: TextSelection.collapsed(offset: formatted.length), + ); widget.onChanged(picked); - WidgetsBinding.instance.addPostFrameCallback((_) { - if (!mounted || isSameDate(_controller.dateTime, pickedDate)) { - return; - } - _syncingController = true; - _controller.dateTime = pickedDate; - _syncingController = false; - }); } } @@ -334,14 +259,16 @@ class _DesktopDateValueDialog extends StatefulWidget { } class _DesktopDateValueDialogState extends State<_DesktopDateValueDialog> { - late final YaruDateTimeEntryController _controller; - DateTime? _selected; + static final _firstDate = DateTime(1900); + static final _lastDate = DateTime(2100, 12, 31); + + final _formKey = GlobalKey(); + late DateTime _selected; @override void initState() { super.initState(); - _selected = parseDateOnly(widget.initialDate) ?? _today(); - _controller = YaruDateTimeEntryController(dateTime: _selected); + _selected = _supportedInitialDate(widget.initialDate); } @override @@ -355,25 +282,20 @@ class _DesktopDateValueDialogState extends State<_DesktopDateValueDialog> { child: Text(context.l10n.cancel), ), BusyMaxPushButton.suggested( - onPressed: _selected == null ? null : _submit, + onPressed: _submit, child: Text(MaterialLocalizations.of(context).okButtonLabel), ), ], children: [ - _withoutFloatingEntryLabel( - context, - YaruDateTimeEntry( - controller: _controller, - includeTime: false, - firstDateTime: DateTime(1900), - lastDateTime: DateTime(2100, 12, 31), - acceptEmpty: false, - clearIconSemanticLabel: widget.label, - onChanged: (date) { - setState(() { - _selected = date; - }); - }, + Form( + key: _formKey, + child: InputDatePickerFormField( + initialDate: _selected, + firstDate: _firstDate, + lastDate: _lastDate, + fieldLabelText: widget.label, + onDateSaved: (date) => _selected = date, + onDateSubmitted: _finish, ), ), ], @@ -381,12 +303,28 @@ class _DesktopDateValueDialogState extends State<_DesktopDateValueDialog> { } void _submit() { - final selected = _selected; - if (selected == null) { + final form = _formKey.currentState; + if (form == null || !form.validate()) { return; } + form.save(); + _finish(_selected); + } + + void _finish(DateTime selected) { Navigator.of(context).pop(encodeDateOnly(selected)); } + + DateTime _supportedInitialDate(String? encodedDate) { + final date = parseDateOnly(encodedDate) ?? _today(); + if (date.isBefore(_firstDate)) { + return _firstDate; + } + if (date.isAfter(_lastDate)) { + return _lastDate; + } + return date; + } } class DesktopTimeField extends StatefulWidget { @@ -396,12 +334,16 @@ class DesktopTimeField extends StatefulWidget { required this.time, required this.onChanged, this.enabled = true, + this.allowEmpty = true, + this.onValidityChanged, }); final String label; final String? time; final ValueChanged onChanged; final bool enabled; + final bool allowEmpty; + final ValueChanged? onValidityChanged; @override State createState() => _DesktopTimeFieldState(); @@ -414,251 +356,249 @@ class DesktopTimeValueRow extends StatelessWidget { required this.time, required this.onChanged, this.enabled = true, - this.emptyLabel, this.allowEmpty = true, + this.onValidityChanged, }); final String label; final String? time; final ValueChanged onChanged; final bool enabled; - final String? emptyLabel; final bool allowEmpty; + final ValueChanged? onValidityChanged; @override Widget build(BuildContext context) { - final formatted = formatDesktopTime(context, time); - final displayValue = formatted.isEmpty - ? emptyLabel ?? context.l10n.noneValue - : formatted; - - return BusyMaxCalendarValueRow( + return DesktopTimeField( label: label, - value: displayValue, - leading: const Icon(Icons.schedule), + time: time, + onChanged: onChanged, enabled: enabled, - onTap: () => _editTime(context), - trailingIcons: const [ - Icon(Icons.edit_outlined, size: BusyMaxSizes.iconMd), - Icon(Icons.schedule, size: BusyMaxSizes.iconMd), - ], + allowEmpty: allowEmpty, + onValidityChanged: onValidityChanged, ); } - - Future _editTime(BuildContext context) async { - if (!enabled) { - return; - } - await showBusyMaxModalDialog( - context, - builder: (dialogContext) { - return _DesktopTimeValueDialog( - label: label, - time: time, - onChanged: onChanged, - allowEmpty: allowEmpty, - ); - }, - ); - } -} - -class _DesktopTimeValueDialog extends StatefulWidget { - const _DesktopTimeValueDialog({ - required this.label, - required this.time, - required this.onChanged, - required this.allowEmpty, - }); - - final String label; - final String? time; - final ValueChanged onChanged; - final bool allowEmpty; - - @override - State<_DesktopTimeValueDialog> createState() => - _DesktopTimeValueDialogState(); } -class _DesktopTimeValueDialogState extends State<_DesktopTimeValueDialog> { - late final YaruTimeEntryController _controller; - final _formKey = GlobalKey(); - TimeOfDay? _selected; +class _DesktopTimeFieldState extends State { + late final TextEditingController _controller; + late final FocusNode _focusNode; + var _syncingText = false; + var _inputValid = true; + bool? _reportedValidity; + var _hasPendingEmission = false; + String? _pendingEmission; @override void initState() { super.initState(); - _selected = parseTimeOfDay(widget.time); - _controller = YaruTimeEntryController(timeOfDay: _selected); + _inputValid = _storedTimeIsValid(widget.time, widget.allowEmpty); + _controller = TextEditingController(); + _focusNode = FocusNode(debugLabel: widget.label) + ..addListener(_handleFocusChanged); } @override - Widget build(BuildContext context) { - final timeEntry = _BusyMaxTimeEntry( - controller: _controller, - label: widget.label, - acceptEmpty: widget.allowEmpty, - autofocus: true, - onChanged: (time) { - setState(() { - _selected = time; - }); - }, - onSubmitted: (_) => _submit(), - ); - return BusyMaxDialogShell( - title: widget.label, - maxWidth: 360, - actions: [ - BusyMaxPushButton.standard( - onPressed: () => Navigator.of(context).pop(), - child: Text(context.l10n.cancel), - ), - BusyMaxPushButton.suggested( - onPressed: widget.allowEmpty || _selected != null ? _submit : null, - child: Text(MaterialLocalizations.of(context).okButtonLabel), - ), - ], - children: [Form(key: _formKey, child: timeEntry)], - ); + void didChangeDependencies() { + super.didChangeDependencies(); + if (!_focusNode.hasFocus) { + _syncVisibleValue(); + } + _reportValidityAfterBuild(); } - void _submit() { - if (!(_formKey.currentState?.validate() ?? false) || - (!widget.allowEmpty && _selected == null)) { + @override + void didUpdateWidget(covariant DesktopTimeField oldWidget) { + super.didUpdateWidget(oldWidget); + final timeChanged = oldWidget.time != widget.time; + final policyChanged = oldWidget.allowEmpty != widget.allowEmpty; + final availabilityChanged = oldWidget.enabled != widget.enabled; + final validityCallbackAdded = + oldWidget.onValidityChanged == null && widget.onValidityChanged != null; + if (validityCallbackAdded) { + _reportedValidity = null; + } + if (!timeChanged && + !policyChanged && + !availabilityChanged && + !validityCallbackAdded) { + return; + } + if (!timeChanged && !policyChanged && !availabilityChanged) { + _reportValidityAfterBuild(); return; } - widget.onChanged(_selected == null ? null : encodeTimeOfDay(_selected!)); - Navigator.of(context).pop(); - } -} - -class _DesktopTimeFieldState extends State { - late final YaruTimeEntryController _controller; - var _syncingController = false; - @override - void initState() { - super.initState(); - _controller = YaruTimeEntryController( - timeOfDay: parseTimeOfDay(widget.time), - ); + final acceptedLocalEmission = + timeChanged && + !availabilityChanged && + _hasPendingEmission && + widget.time == _pendingEmission; + _hasPendingEmission = false; + _pendingEmission = null; + + if (!acceptedLocalEmission || availabilityChanged) { + _inputValid = _storedTimeIsValid(widget.time, widget.allowEmpty); + _syncVisibleValue(); + _reportValidityAfterBuild(); + } else if (!_focusNode.hasFocus) { + _syncVisibleValue(); + } } @override - void didUpdateWidget(covariant DesktopTimeField oldWidget) { - super.didUpdateWidget(oldWidget); - if (oldWidget.time != widget.time) { - final nextTime = parseTimeOfDay(widget.time); - if (_controller.timeOfDay != nextTime) { - _syncingController = true; - _controller.timeOfDay = nextTime; - _syncingController = false; - } - } + void dispose() { + _focusNode + ..removeListener(_handleFocusChanged) + ..dispose(); + _controller.dispose(); + super.dispose(); } @override Widget build(BuildContext context) { - final timeEntry = _BusyMaxTimeEntry( - controller: _controller, + return BusyMaxCalendarValueRow( label: widget.label, - acceptEmpty: true, - onChanged: (time) { - if (_syncingController) { - return; - } - widget.onChanged(time == null ? null : encodeTimeOfDay(time)); - }, - ); - return YaruListTile.square( - leading: const Icon(Icons.schedule), - titleText: widget.label, - trailing: SizedBox( - width: 168, - child: widget.enabled - ? timeEntry - : Opacity( - opacity: 0.6, - child: ExcludeFocus(child: IgnorePointer(child: timeEntry)), - ), + entry: TextFormField( + controller: _controller, + focusNode: _focusNode, + enabled: widget.enabled, + keyboardType: TextInputType.datetime, + textInputAction: TextInputAction.done, + decoration: busyMaxGroupedTextFieldDecoration( + context, + labelText: widget.label, + errorText: _inputValid + ? null + : MaterialLocalizations.of(context).invalidTimeLabel, + ), + onChanged: _handleTextChanged, + onFieldSubmitted: (_) => _normalizeOrRestore(), ), enabled: widget.enabled, ); } -} -class _BusyMaxTimeEntry extends StatelessWidget { - const _BusyMaxTimeEntry({ - required this.controller, - required this.label, - required this.acceptEmpty, - required this.onChanged, - this.autofocus = false, - this.onSubmitted, - }); + void _handleTextChanged(String input) { + if (_syncingText) { + return; + } + final trimmed = input.trim(); + if (trimmed.isEmpty) { + _setInputValidity(widget.allowEmpty); + if (widget.allowEmpty) { + _emitTime(null); + } + return; + } + final parsed = parseDesktopTimeInput(context, trimmed); + if (parsed == null) { + _setInputValidity(false); + return; + } + _setInputValidity(true); + _emitTime(encodeTimeOfDay(parsed)); + } - final YaruTimeEntryController controller; - final String label; - final bool acceptEmpty; - final bool autofocus; - final ValueChanged onChanged; - final ValueChanged? onSubmitted; + void _handleFocusChanged() { + if (_focusNode.hasFocus) { + return; + } + if (_restoreRejectedPendingEmission()) { + return; + } + _normalizeOrRestore(); + } - @override - Widget build(BuildContext context) { - final localizations = MaterialLocalizations.of(context); - return _withoutFloatingEntryLabel( - context, - YaruTimeEntry( - controller: controller, - autofocus: autofocus, - force24HourFormat: MediaQuery.alwaysUse24HourFormatOf(context) - ? true - : null, - acceptEmpty: acceptEmpty, - clearIconSemanticLabel: label, - errorFormatText: localizations.invalidTimeLabel, - errorInvalidText: localizations.invalidTimeLabel, - onChanged: onChanged, - onFieldSubmitted: onSubmitted, - ), + void _normalizeOrRestore() { + final input = _controller.text.trim(); + if (input.isEmpty && widget.allowEmpty) { + _setInputValidity(true); + _emitTime(null); + _syncVisibleValue(); + return; + } + final parsed = parseDesktopTimeInput(context, input); + if (parsed == null) { + _setInputValidity(false); + return; + } + _setInputValidity(true); + _emitTime(encodeTimeOfDay(parsed)); + _syncVisibleValue(time: parsed); + } + + void _syncVisibleValue({TimeOfDay? time}) { + final parsed = time ?? parseTimeOfDay(widget.time); + final formatted = parsed == null ? '' : formatMaterialTime(context, parsed); + if (_controller.text == formatted) { + return; + } + _syncingText = true; + _controller.value = TextEditingValue( + text: formatted, + selection: TextSelection.collapsed(offset: formatted.length), ); + _syncingText = false; } -} -Widget _withoutFloatingEntryLabel(BuildContext context, Widget child) { - // The date/time rows already provide the visible label through - // YaruListTile.square, so field labels are hidden here to avoid duplicates. - final theme = Theme.of(context); + void _emitTime(String? value) { + if (value == widget.time || + _hasPendingEmission && value == _pendingEmission) { + return; + } + _hasPendingEmission = true; + _pendingEmission = value; + widget.onChanged(value); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted || + !_hasPendingEmission || + _focusNode.hasFocus || + widget.time == _pendingEmission) { + return; + } + _restoreRejectedPendingEmission(); + }); + } - return Theme( - data: theme.copyWith( - inputDecorationTheme: theme.inputDecorationTheme.copyWith( - floatingLabelBehavior: FloatingLabelBehavior.never, - ), - ), - child: child, - ); -} + bool _restoreRejectedPendingEmission() { + if (!_hasPendingEmission || widget.time == _pendingEmission) { + return false; + } + _hasPendingEmission = false; + _pendingEmission = null; + _inputValid = _storedTimeIsValid(widget.time, widget.allowEmpty); + _syncVisibleValue(); + _reportValidity(_inputValid); + setState(() {}); + return true; + } -String formatDesktopDate(BuildContext context, String? date) { - final parsed = parseDateOnly(date); - if (parsed == null) { - return ''; + void _setInputValidity(bool valid) { + if (_inputValid != valid) { + setState(() { + _inputValid = valid; + }); + } + _reportValidity(valid); } - return DateFormat.yMMMd( - Localizations.localeOf(context).toLanguageTag(), - ).format(parsed); -} -String formatDesktopTime(BuildContext context, String? time) { - final parsed = parseTimeOfDay(time); - if (parsed == null) { - return ''; + void _reportValidityAfterBuild() { + final validity = _inputValid; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted && _inputValid == validity) { + _reportValidity(validity); + } + }); + } + + void _reportValidity(bool valid) { + if (_reportedValidity == valid) { + return; + } + _reportedValidity = valid; + widget.onValidityChanged?.call(valid); } - return formatMaterialTime(context, parsed); } String formatDesktopDateTime(BuildContext context, String? dateTime) { @@ -680,6 +620,49 @@ String formatMaterialTime(BuildContext context, TimeOfDay time) { ); } +String formatDesktopDate(BuildContext context, String? date) { + final parsed = parseDateOnly(date); + if (parsed == null) { + return ''; + } + return DateFormat.yMMMd( + Localizations.localeOf(context).toLanguageTag(), + ).format(parsed); +} + +@visibleForTesting +TimeOfDay? parseDesktopTimeInput(BuildContext context, String input) { + final trimmed = input.trim(); + if (trimmed.isEmpty) { + return null; + } + if (_providerTimePattern.hasMatch(trimmed)) { + return parseTimeOfDay(trimmed); + } + + final normalized = trimmed + .replaceAll('\u00A0', ' ') + .replaceAll('\u202F', ' ') + .replaceAll(RegExp(r'\s+'), ' '); + final locale = Localizations.localeOf(context).toLanguageTag(); + for (final candidate in {trimmed, normalized}) { + for (final format in [ + DateFormat.Hm(locale), + DateFormat.jm(locale), + DateFormat('H:mm', locale), + DateFormat('h:mm a', locale), + ]) { + try { + final parsed = format.parseStrict(candidate); + return TimeOfDay(hour: parsed.hour, minute: parsed.minute); + } on FormatException { + // Try the other native locale representation. + } + } + } + return null; +} + DateTime? parseDateOnly(String? date) { if (date == null || date.length < 10) { return null; @@ -703,11 +686,15 @@ DateTime? parseGraphLocalDateTime(String? dateTime) { } TimeOfDay? parseTimeOfDay(String? time) { - if (time == null || time.length < 5) { + if (time == null) { + return null; + } + final match = _providerTimePattern.firstMatch(time); + if (match == null) { return null; } - final hour = int.tryParse(time.substring(0, 2)); - final minute = int.tryParse(time.substring(3, 5)); + final hour = int.tryParse(match.group(1)!); + final minute = int.tryParse(match.group(2)!); if (hour == null || minute == null || hour < 0 || @@ -719,6 +706,12 @@ TimeOfDay? parseTimeOfDay(String? time) { return TimeOfDay(hour: hour, minute: minute); } +final _providerTimePattern = RegExp(r'^(\d{2}):(\d{2})$'); + +bool _storedTimeIsValid(String? time, bool allowEmpty) { + return time == null ? allowEmpty : parseTimeOfDay(time) != null; +} + String encodeDateOnly(DateTime date) { return '${date.year.toString().padLeft(4, '0')}-' '${date.month.toString().padLeft(2, '0')}-' diff --git a/lib/src/features/tasks/presentation/task_details_editor.dart b/lib/src/features/tasks/presentation/task_details_editor.dart index f882b1d..f065c7d 100644 --- a/lib/src/features/tasks/presentation/task_details_editor.dart +++ b/lib/src/features/tasks/presentation/task_details_editor.dart @@ -98,6 +98,7 @@ class _TaskDetailsEditorState extends State { final _titleController = TextEditingController(); final _notesController = TextEditingController(); final _shortcutFocusNode = FocusNode(debugLabel: 'Task editor shortcuts'); + final _invalidTimeFields = <_TaskTimeField>{}; TaskDetailsDraft? _draft; TaskDetailsDraft? _cleanDraftBaseline; @@ -120,7 +121,7 @@ class _TaskDetailsEditorState extends State { super.didUpdateWidget(oldWidget); final nextKey = _taskKey(widget.task); final sameKey = _loadedTaskKey == nextKey; - final hasChanges = _hasDraftChanges(_draft); + final hasChanges = _hasEditorChanges(_draft); if (!sameKey) { if (hasChanges && widget.confirmTaskSwitch) { @@ -149,14 +150,15 @@ class _TaskDetailsEditorState extends State { final draft = _draft ?? TaskDetailsDraft.fromTask(_editingTask, widget.localTimeZone); final l10n = context.l10n; - final hasChanges = _hasDraftChanges(draft); + final hasChanges = _hasEditorChanges(draft); + final scheduledAllDay = _isScheduledAllDay(draft); final canSave = draft.title.trim().isNotEmpty && hasChanges && !_saving && + _timeFieldsAreValid(draft, scheduledAllDay) && (widget.canSaveDraft?.call(draft) ?? true); final currentList = _listTitle(draft.taskListId); - final scheduledAllDay = _isScheduledAllDay(draft); final listValue = [ currentList, widget.accountLabel, @@ -201,10 +203,9 @@ class _TaskDetailsEditorState extends State { filled: true, children: [ YaruListTile.square( - hoverColor: busyMaxEditorRowHoverColor(context), title: TextField( controller: _titleController, - decoration: _plainTaskFieldDecoration( + decoration: busyMaxGroupedTextFieldDecoration( context, labelText: l10n.title, ), @@ -236,7 +237,6 @@ class _TaskDetailsEditorState extends State { DesktopDateValueRow( label: l10n.dueDate, date: draft.dueDate, - emptyLabel: l10n.noneValue, onChanged: (value) => _updateDraft(draft.copyWith(dueDate: value)), useNativePicker: widget.useNativeDatePicker, @@ -255,10 +255,13 @@ class _TaskDetailsEditorState extends State { DesktopTimeValueRow( label: l10n.dueTime, time: draft.microsoftDueTime, - emptyLabel: l10n.noneValue, onChanged: (value) => _updateDraft( draft.copyWith(microsoftDueTime: value), ), + onValidityChanged: (valid) => _setTimeFieldValidity( + _TaskTimeField.due, + valid, + ), ), ], ), @@ -295,12 +298,11 @@ class _TaskDetailsEditorState extends State { filled: true, children: [ YaruListTile.square( - hoverColor: busyMaxEditorRowHoverColor(context), title: TextField( controller: _notesController, minLines: 3, maxLines: 5, - decoration: _plainTaskFieldDecoration( + decoration: busyMaxGroupedTextFieldDecoration( context, labelText: l10n.notes, alignLabelWithHint: true, @@ -450,7 +452,6 @@ class _TaskDetailsEditorState extends State { DesktopDateValueRow( label: l10n.startDate, date: draft.microsoftStartDate, - emptyLabel: l10n.noneValue, onChanged: (value) => _updateDraft(draft.copyWith(microsoftStartDate: value)), useNativePicker: widget.useNativeDatePicker, @@ -462,9 +463,10 @@ class _TaskDetailsEditorState extends State { DesktopTimeValueRow( label: l10n.startTime, time: draft.microsoftStartTime, - emptyLabel: l10n.noneValue, onChanged: (value) => _updateDraft(draft.copyWith(microsoftStartTime: value)), + onValidityChanged: (valid) => + _setTimeFieldValidity(_TaskTimeField.start, valid), ), ]; } @@ -485,6 +487,9 @@ class _TaskDetailsEditorState extends State { void _setScheduledAllDay(TaskDetailsDraft draft, bool allDay) { if (allDay) { + _invalidTimeFields + ..remove(_TaskTimeField.due) + ..remove(_TaskTimeField.start); _updateDraft( draft.copyWith( microsoftDueTime: widget.capabilities.supportsDueTime @@ -607,7 +612,9 @@ class _TaskDetailsEditorState extends State { Future _save() async { final draft = _draft; - if (draft == null || _saving) { + if (draft == null || + _saving || + !_timeFieldsAreValid(draft, _isScheduledAllDay(draft))) { return; } final patch = draft.toPatch( @@ -637,7 +644,7 @@ class _TaskDetailsEditorState extends State { Future _cancel() async { final draft = _draft; - final hasChanges = _hasDraftChanges(draft); + final hasChanges = _hasEditorChanges(draft); if (hasChanges) { final discard = await showBusyMaxConfirm( context, @@ -730,6 +737,7 @@ class _TaskDetailsEditorState extends State { TaskDetailsDraft.fromTask(task, widget.localTimeZone); _editingTask = task; _loadedTaskKey = taskKey; + _invalidTimeFields.clear(); _draft = draft; _cleanDraftBaseline = draft; _titleController.text = draft.title; @@ -742,7 +750,43 @@ class _TaskDetailsEditorState extends State { _draft = draft; }); widget.onDraftChanged?.call(draft); - widget.onDirtyChanged?.call(_hasDraftChanges(draft)); + widget.onDirtyChanged?.call(_hasEditorChanges(draft)); + } + + void _setTimeFieldValidity(_TaskTimeField field, bool valid) { + final changed = valid + ? _invalidTimeFields.remove(field) + : _invalidTimeFields.add(field); + if (changed) { + setState(() {}); + widget.onDirtyChanged?.call(_hasEditorChanges(_draft)); + } + } + + bool _hasEditorChanges(TaskDetailsDraft? draft) { + if (draft == null) { + return false; + } + return _hasDraftChanges(draft) || + !_timeFieldsAreValid(draft, _isScheduledAllDay(draft)); + } + + bool _timeFieldsAreValid(TaskDetailsDraft draft, bool scheduledAllDay) { + if (!scheduledAllDay && + widget.capabilities.supportsDueTime && + _invalidTimeFields.contains(_TaskTimeField.due)) { + return false; + } + if (!scheduledAllDay && + widget.capabilities.supportsStartDateTime && + _invalidTimeFields.contains(_TaskTimeField.start)) { + return false; + } + if (draft.microsoftReminderEnabled && + _invalidTimeFields.contains(_TaskTimeField.reminder)) { + return false; + } + return true; } bool _hasDraftChanges(TaskDetailsDraft? draft) { @@ -804,7 +848,6 @@ class _TaskDetailsEditorState extends State { DesktopDateValueRow( label: l10n.reminderDate, date: draft.microsoftReminderDate, - emptyLabel: l10n.noneValue, onChanged: (value) => _updateDraft(draft.copyWith(microsoftReminderDate: value)), useNativePicker: widget.useNativeDatePicker, @@ -814,58 +857,29 @@ class _TaskDetailsEditorState extends State { DesktopTimeValueRow( label: l10n.reminderTime, time: draft.microsoftReminderTime, - emptyLabel: l10n.noneValue, onChanged: (value) => _updateDraft(draft.copyWith(microsoftReminderTime: value)), + onValidityChanged: (valid) => + _setTimeFieldValidity(_TaskTimeField.reminder, valid), ), BusyMaxActionRow( title: l10n.removeReminder, leading: const Icon(YaruIcons.window_close), - onTap: () => _updateDraft( - draft.copyWith( - microsoftReminderEnabled: false, - microsoftReminderDate: null, - microsoftReminderTime: null, - ), - ), + onTap: () { + _invalidTimeFields.remove(_TaskTimeField.reminder); + _updateDraft( + draft.copyWith( + microsoftReminderEnabled: false, + microsoftReminderDate: null, + microsoftReminderTime: null, + ), + ); + }, ), ]; } } -InputDecoration _plainTaskFieldDecoration( - BuildContext context, { - required String labelText, - String? errorText, - bool alignLabelWithHint = false, -}) { - final colorScheme = Theme.of(context).colorScheme; - final labelColor = errorText == null - ? colorScheme.onSurfaceVariant - : colorScheme.error; - final labelStyle = Theme.of( - context, - ).textTheme.bodyMedium?.copyWith(color: labelColor); - return InputDecoration( - filled: false, - fillColor: Colors.transparent, - hoverColor: Colors.transparent, - border: InputBorder.none, - enabledBorder: InputBorder.none, - focusedBorder: InputBorder.none, - disabledBorder: InputBorder.none, - errorBorder: InputBorder.none, - focusedErrorBorder: InputBorder.none, - contentPadding: EdgeInsets.zero, - labelText: labelText, - labelStyle: labelStyle, - floatingLabelStyle: labelStyle, - floatingLabelBehavior: FloatingLabelBehavior.auto, - alignLabelWithHint: alignLabelWithHint, - errorText: errorText, - ); -} - TextStyle? _taskEditorProminentActionStyle( BuildContext context, { Color? color, @@ -876,6 +890,8 @@ TextStyle? _taskEditorProminentActionStyle( ).textTheme.labelLarge?.copyWith(color: color, fontWeight: fontWeight); } +enum _TaskTimeField { due, start, reminder } + class _TaskDetailsHeader extends StatelessWidget { const _TaskDetailsHeader({ required this.title, diff --git a/lib/src/platform/linux_header_bar_service.dart b/lib/src/platform/linux_header_bar_service.dart index ec9e8d6..464de31 100644 --- a/lib/src/platform/linux_header_bar_service.dart +++ b/lib/src/platform/linux_header_bar_service.dart @@ -186,6 +186,9 @@ class BusyMaxHeaderBarTheme { required this.foregroundColor, required this.sidebarBorderColor, required this.popoverBackgroundColor, + required this.menuHoverColor, + required this.popoverShadowColor, + required this.dialogBackgroundColor, required this.dialogOutlineColor, required this.modalBarrierColor, }); @@ -198,6 +201,9 @@ class BusyMaxHeaderBarTheme { final Color foregroundColor; final Color sidebarBorderColor; final Color popoverBackgroundColor; + final Color menuHoverColor; + final Color popoverShadowColor; + final Color dialogBackgroundColor; final Color dialogOutlineColor; final Color modalBarrierColor; @@ -211,6 +217,9 @@ class BusyMaxHeaderBarTheme { 'foregroundColor': busyMaxCssColor(foregroundColor), 'sidebarBorderColor': busyMaxCssColor(sidebarBorderColor), 'popoverBackgroundColor': busyMaxCssColor(popoverBackgroundColor), + 'menuHoverColor': busyMaxCssColor(menuHoverColor), + 'popoverShadowColor': busyMaxCssColor(popoverShadowColor), + 'dialogBackgroundColor': busyMaxCssColor(dialogBackgroundColor), 'dialogOutlineColor': busyMaxCssColor(dialogOutlineColor), 'modalBarrierColor': busyMaxCssColor(modalBarrierColor), }; @@ -228,6 +237,9 @@ class BusyMaxHeaderBarTheme { other.foregroundColor == foregroundColor && other.sidebarBorderColor == sidebarBorderColor && other.popoverBackgroundColor == popoverBackgroundColor && + other.menuHoverColor == menuHoverColor && + other.popoverShadowColor == popoverShadowColor && + other.dialogBackgroundColor == dialogBackgroundColor && other.dialogOutlineColor == dialogOutlineColor && other.modalBarrierColor == modalBarrierColor; } @@ -242,6 +254,9 @@ class BusyMaxHeaderBarTheme { foregroundColor, sidebarBorderColor, popoverBackgroundColor, + menuHoverColor, + popoverShadowColor, + dialogBackgroundColor, dialogOutlineColor, modalBarrierColor, ); diff --git a/linux/runner/my_application.cc b/linux/runner/my_application.cc index 69ca6c5..411e44e 100644 --- a/linux/runner/my_application.cc +++ b/linux/runner/my_application.cc @@ -35,6 +35,14 @@ 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; @@ -59,10 +67,17 @@ constexpr char kDefaultHeaderBarBackgroundColor[] = "#272727"; constexpr char kDefaultHeaderBarSidebarBackgroundColor[] = "#393939"; constexpr char kDefaultHeaderBarSidebarBorderColor[] = "rgba(16,16,16,0.35)"; +constexpr char kDefaultHeaderMenuShadowColor[] = "rgba(0,0,0,0.3)"; constexpr char kDefaultDialogOutlineColor[] = "rgba(255,255,255,0.07)"; +constexpr char kDefaultModalBarrierColor[] = "rgba(0,0,0,0.25)"; constexpr char kHeaderControlStyleClass[] = "busymax-header-control"; +constexpr char kHeaderSearchEntryStyleClass[] = + "busymax-header-search-entry"; +constexpr char kHeaderModalOpenStyleClass[] = "busymax-modal-open"; +constexpr char kHeaderModalBarrierStyleClass[] = "busymax-modal-barrier"; constexpr char kNativeDialogStyleClass[] = "busymax-native-dialog"; constexpr char kNativePopoverStyleClass[] = "busymax-native-popover"; +constexpr char kHeaderMenuDepthStyleClass[] = "busymax-header-menu-depth"; struct _MyApplication { GtkApplication parent_instance; @@ -87,6 +102,9 @@ struct _MyApplication { gchar* header_bar_sidebar_border_color; gchar* header_bar_foreground_color; gchar* header_bar_popover_background_color; + gchar* header_bar_popover_shadow_color; + gchar* header_bar_menu_hover_color; + gchar* header_bar_dialog_background_color; gchar* header_bar_dialog_outline_color; gchar* header_bar_modal_barrier_color; gboolean header_bar_high_contrast; @@ -97,6 +115,8 @@ struct _MyApplication { GtkWindow* main_window; GtkWidget* flutter_view; GtkWidget* titlebar_handle; + GtkWidget* titlebar_overlay; + GtkWidget* titlebar_modal_barrier; GtkWidget* titlebar_box; GtkHeaderBar* header_bar; GtkWidget* header_start_box; @@ -160,6 +180,15 @@ static void style_native_popover(GtkWidget* popover) { kNativePopoverStyleClass); } +static void style_header_menu_popover(GtkWidget* popover) { + style_native_popover(popover); + if (popover == nullptr || !GTK_IS_POPOVER(popover)) { + return; + } + gtk_style_context_add_class(gtk_widget_get_style_context(popover), + kHeaderMenuDepthStyleClass); +} + static GdkPixbuf* load_application_icon_at_size(gint size) { g_autofree gchar* executable_path = g_file_read_link("/proc/self/exe", nullptr); @@ -536,7 +565,6 @@ static void native_menu_session_dispose(NativeMenuSession* session) { session->closed_signal_id); session->closed_signal_id = 0; } - gtk_popover_bind_model(GTK_POPOVER(session->popover), nullptr, nullptr); gtk_widget_destroy(session->popover); g_clear_object(&session->popover); } @@ -873,16 +901,15 @@ static void show_native_menu(NativeMenuHandlerData* data, gtk_popover_set_constrain_to(GTK_POPOVER(session->popover), GTK_POPOVER_CONSTRAINT_WINDOW); gtk_popover_set_modal(GTK_POPOVER(session->popover), TRUE); - gtk_widget_set_can_focus(session->popover, TRUE); session->closed_signal_id = g_signal_connect(session->popover, "closed", G_CALLBACK(native_menu_closed_cb), session); - gtk_widget_show_all(session->popover); + // gtk_popover_popup() is the canonical mapper. Mapping the complete + // popover first via gtk_widget_show_all() leaves it in GTK's SHOWN state, + // so popup() returns before completing its normal presentation lifecycle. gtk_popover_popup(GTK_POPOVER(session->popover)); if (focus_first) { gtk_widget_child_focus(session->popover, GTK_DIR_TAB_FORWARD); - } else { - gtk_widget_grab_focus(session->popover); } } @@ -1032,45 +1059,6 @@ static const gchar* css_color_or(const gchar* value, const gchar* fallback) { return is_css_color_token(value) ? value : fallback; } -static GdkRGBA composite_rgba(const GdkRGBA& foreground, - const GdkRGBA& background) { - const gdouble inverse_foreground_alpha = 1.0 - foreground.alpha; - const gdouble alpha = - foreground.alpha + background.alpha * inverse_foreground_alpha; - if (alpha <= 0) { - return GdkRGBA{0, 0, 0, 0}; - } - return GdkRGBA{ - (foreground.red * foreground.alpha + - background.red * background.alpha * inverse_foreground_alpha) / - alpha, - (foreground.green * foreground.alpha + - background.green * background.alpha * inverse_foreground_alpha) / - alpha, - (foreground.blue * foreground.alpha + - background.blue * background.alpha * inverse_foreground_alpha) / - alpha, - alpha, - }; -} - -static gchar* modal_sidebar_border_css_color(const gchar* border_color, - const gchar* sidebar_color, - const gchar* barrier_color) { - GdkRGBA border; - GdkRGBA sidebar; - GdkRGBA barrier; - if (!gdk_rgba_parse(&border, border_color) || - !gdk_rgba_parse(&sidebar, sidebar_color) || - !gdk_rgba_parse(&barrier, barrier_color)) { - return g_strdup(border_color); - } - - const GdkRGBA visible_border = composite_rgba(border, sidebar); - const GdkRGBA dimmed_border = composite_rgba(barrier, visible_border); - return gdk_rgba_to_string(&dimmed_border); -} - static void set_flutter_view_background_color(MyApplication* self, const gchar* color) { if (self->flutter_view == nullptr || !FL_IS_VIEW(self->flutter_view) || @@ -1137,6 +1125,8 @@ static void refresh_header_bar_css(MyApplication* self) { 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_popover_css = is_css_color_token(self->header_bar_popover_background_color) ? g_strdup_printf( @@ -1153,6 +1143,11 @@ static void refresh_header_bar_css(MyApplication* self) { "background-color: %s;" "background-image: none;" "}" + ".%s headerbar," + ".%s headerbar:backdrop {" + "background-color: %s;" + "background-image: none;" + "}" ".%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 @@ -1160,20 +1155,50 @@ static void refresh_header_bar_css(MyApplication* self) { "box-shadow: inset 0 0 0 1px %s;" "}", kNativeDialogStyleClass, kNativeDialogStyleClass, - window_background_color, kNativeDialogStyleClass, + dialog_background_color, kNativeDialogStyleClass, + kNativeDialogStyleClass, dialog_background_color, + kNativeDialogStyleClass, css_color_or(self->header_bar_dialog_outline_color, kDefaultDialogOutlineColor)); const gchar* modal_barrier_color = css_color_or( - self->header_bar_modal_barrier_color, "rgba(0,0,0,0.32)"); - g_autofree gchar* modal_sidebar_border_color = - modal_sidebar_border_css_color(sidebar_border_color, - sidebar_background_color, - modal_barrier_color); - const gboolean use_yaru_window_decoration_compatibility = + self->header_bar_modal_barrier_color, kDefaultModalBarrierColor); + 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(""); + g_autofree gchar* native_menu_state_css = + !self->header_bar_high_contrast && + is_css_color_token(self->header_bar_menu_hover_color) + ? g_strdup_printf( + "popover.background.%s " + "modelbutton:hover:not(:disabled) {" + "background-color: %s;" + "background-image: none;" + "}", + kNativePopoverStyleClass, + self->header_bar_menu_hover_color) + : g_strdup(""); + g_autofree gchar* header_menu_shadow_css = + use_legacy_yaru_compatibility + ? g_strdup_printf( + "popover.background.%s.%s:not(:backdrop) {" + // Preserve Yaru's semantic shadow strength and native + // one-pixel offset, softening only its legacy two-pixel blur. + "box-shadow: 0 1px 3px %s;" + "}", + kNativePopoverStyleClass, kHeaderMenuDepthStyleClass, + css_color_or(self->header_bar_popover_shadow_color, + kDefaultHeaderMenuShadowColor)) + : g_strdup(""); g_autofree gchar* yaru_window_decoration_css = - use_yaru_window_decoration_compatibility + use_legacy_yaru_compatibility ? g_strdup_printf( "window#busymax-window.csd:not(.solid-csd):" "not(.maximized):not(.fullscreen):not(.tiled):" @@ -1228,6 +1253,7 @@ static void refresh_header_bar_css(MyApplication* self) { "}" "%s" "%s" + "%s" "headerbar.busymax-flat-headerbar," "headerbar.busymax-flat-headerbar:backdrop {" "background-color: %s;" @@ -1249,9 +1275,48 @@ static void refresh_header_bar_css(MyApplication* self) { ".busymax-titlebar .busymax-header-brand label {" "color: %s;" "}" + ".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;" + "}" // 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 @@ -1291,28 +1356,50 @@ static void refresh_header_bar_css(MyApplication* self) { "background-color: alpha(currentColor, 0.19);" "background-image: none;" "}" - "%s" - ".busymax-titlebar.busymax-modal-barrier .busymax-header-brand," - ".busymax-titlebar.busymax-modal-barrier " - ".busymax-header-brand:backdrop {" - "background-color: %s;" - "background-image: linear-gradient(%s, %s);" - "border-right-color: %s;" + // 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.busymax-modal-barrier " - "headerbar.busymax-flat-headerbar," - ".busymax-titlebar.busymax-modal-barrier " - "headerbar.busymax-flat-headerbar:backdrop {" + "%s" + "%s" + "%s" + ".busymax-titlebar .%s," + ".busymax-titlebar .%s:backdrop {" "background-color: %s;" - "background-image: linear-gradient(%s, %s);" + "background-image: none;" "}", window_background_color, yaru_window_decoration_css, native_dialog_css, + native_search_geometry_css, background_color, foreground_color, sidebar_background_color, foreground_color, sidebar_border_color, - foreground_color, foreground_color, native_popover_css, - sidebar_background_color, modal_barrier_color, modal_barrier_color, - modal_sidebar_border_color, background_color, modal_barrier_color, - modal_barrier_color); + foreground_color, foreground_color, kHeaderBackdropForegroundOpacity, + foreground_color, foreground_color, kHeaderBackdropForegroundOpacity, + foreground_color, foreground_color, kHeaderBackdropForegroundOpacity, + foreground_color, kHeaderDisabledForegroundOpacity, foreground_color, + kHeaderDisabledBackdropForegroundOpacity, + kHeaderModalOpenStyleClass, kHeaderModalOpenStyleClass, + kHeaderModalOpenStyleClass, kHeaderModalOpenStyleClass, + kHeaderModalOpenStyleClass, kHeaderModalOpenStyleClass, + native_popover_css, native_menu_state_css, header_menu_shadow_css, + kHeaderModalBarrierStyleClass, + kHeaderModalBarrierStyleClass, modal_barrier_color); g_autoptr(GError) error = nullptr; GtkCssProvider* provider = gtk_css_provider_new(); @@ -1365,6 +1452,12 @@ static void set_header_bar_theme(MyApplication* self, FlValue* args) { fl_lookup_string_arg(args, "foregroundColor")); set_css_color_field(&self->header_bar_popover_background_color, fl_lookup_string_arg(args, "popoverBackgroundColor")); + set_css_color_field(&self->header_bar_popover_shadow_color, + fl_lookup_string_arg(args, "popoverShadowColor")); + set_css_color_field(&self->header_bar_menu_hover_color, + fl_lookup_string_arg(args, "menuHoverColor")); + 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, @@ -1373,6 +1466,9 @@ static void set_header_bar_theme(MyApplication* self, FlValue* args) { refresh_header_bar_css(self); } +static void close_header_menu_button(GtkWidget* menu_button); +static void focus_flutter_view(MyApplication* self); + static void set_header_bar_modal_barrier_visible(MyApplication* self, gboolean visible) { self->header_bar_modal_barrier_visible = visible; @@ -1381,11 +1477,20 @@ static void set_header_bar_modal_barrier_visible(MyApplication* self, GtkStyleContext* context = gtk_widget_get_style_context(self->titlebar_handle); if (visible) { - gtk_style_context_add_class(context, "busymax-modal-barrier"); + gtk_style_context_add_class(context, kHeaderModalOpenStyleClass); } else { - gtk_style_context_remove_class(context, "busymax-modal-barrier"); + gtk_style_context_remove_class(context, kHeaderModalOpenStyleClass); } - gtk_widget_set_sensitive(self->titlebar_handle, !visible); + } + 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); } } @@ -1408,7 +1513,8 @@ static void clear_widget_pointer(GtkWidget** target) { static void invoke_header_bar_action(MyApplication* self, const gchar* action) { - if (self->header_bar_channel == nullptr || action == nullptr) { + 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, @@ -1418,7 +1524,8 @@ static void invoke_header_bar_action(MyApplication* self, static void invoke_header_bar_string_action(MyApplication* self, const gchar* action, const gchar* value) { - if (self->header_bar_channel == nullptr || action == nullptr) { + if (self->header_bar_modal_barrier_visible || + self->header_bar_channel == nullptr || action == nullptr) { return; } g_autoptr(FlValue) args = fl_value_new_string(value == nullptr ? "" : value); @@ -1429,7 +1536,8 @@ static void invoke_header_bar_string_action(MyApplication* self, static void invoke_header_bar_bool_action(MyApplication* self, const gchar* action, gboolean value) { - if (self->header_bar_channel == nullptr || action == nullptr) { + if (self->header_bar_modal_barrier_visible || + self->header_bar_channel == nullptr || action == nullptr) { return; } g_autoptr(FlValue) args = fl_value_new_bool(value); @@ -1518,7 +1626,9 @@ static void set_header_search_query(MyApplication* self, 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_search_active) { + 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)); @@ -1532,16 +1642,20 @@ static void header_search_entry_search_changed_cb(GtkSearchEntry* entry, static gboolean header_search_entry_focus_in_cb(GtkWidget*, GdkEventFocus*, gpointer user_data) { - invoke_header_bar_bool_action(MY_APPLICATION(user_data), - "searchFocusChanged", TRUE); + 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) { - invoke_header_bar_bool_action(MY_APPLICATION(user_data), - "searchFocusChanged", FALSE); + MyApplication* self = MY_APPLICATION(user_data); + if (!self->header_bar_modal_barrier_visible) { + invoke_header_bar_bool_action(self, "searchFocusChanged", FALSE); + } return FALSE; } @@ -1551,7 +1665,9 @@ static void header_search_entry_icon_release_cb( GdkEvent*, gpointer user_data) { MyApplication* self = MY_APPLICATION(user_data); - if (self->suppress_header_bar_actions || !self->header_search_active || + 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; @@ -1566,7 +1682,9 @@ static void header_search_entry_icon_release_cb( 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_search_active) { + if (self->suppress_header_bar_actions || + self->header_bar_modal_barrier_visible || + !self->header_search_active) { return; } invoke_header_bar_action(self, "searchEscapePressed"); @@ -1581,7 +1699,8 @@ static void focus_flutter_view(MyApplication* self) { static void header_bar_action_clicked_cb(GtkWidget* widget, gpointer user_data) { MyApplication* self = MY_APPLICATION(user_data); - if (self->suppress_header_bar_actions) { + if (self->suppress_header_bar_actions || + self->header_bar_modal_barrier_visible) { return; } const gchar* action = static_cast( @@ -1674,7 +1793,7 @@ static void set_header_menu_button_model(GtkWidget* button, return; } track_widget_pointer(tracked_popover, GTK_WIDGET(popover)); - style_native_popover(GTK_WIDGET(popover)); + style_header_menu_popover(GTK_WIDGET(popover)); gtk_popover_set_position(popover, GTK_POS_BOTTOM); } @@ -1736,7 +1855,8 @@ 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) { + if (self->suppress_header_bar_actions || + self->header_bar_modal_barrier_visible) { return; } const gchar* bridge_action = static_cast( @@ -1749,7 +1869,8 @@ 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 || parameter == nullptr || + 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; } @@ -2368,6 +2489,9 @@ static GtkWidget* create_busymax_titlebar(MyApplication* self) { 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_END); gtk_label_set_max_width_chars(GTK_LABEL(self->header_title_label), @@ -2377,6 +2501,8 @@ static GtkWidget* create_busymax_titlebar(MyApplication* self) { 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); @@ -2482,14 +2608,63 @@ static GtkWidget* create_busymax_titlebar(MyApplication* self) { 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"); - gtk_container_add(GTK_CONTAINER(self->titlebar_handle), + + 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; } @@ -2752,7 +2927,6 @@ static const gchar* brightness_for_color(const GdkRGBA* color) { static FlValue* get_gtk_theme_colors() { GtkWidget* window = gtk_window_new(GTK_WINDOW_TOPLEVEL); - GtkWidget* view = gtk_text_view_new(); GtkWidget* control = gtk_button_new(); GtkWidget* separator = gtk_separator_new(GTK_ORIENTATION_HORIZONTAL); GtkWidget* dim_label = gtk_label_new(nullptr); @@ -2785,10 +2959,12 @@ static FlValue* get_gtk_theme_colors() { lookup_context_color(window_context, "theme_bg_color", &window_color) || sample_widget_background(window, GTK_STYLE_CLASS_BACKGROUND, GTK_STATE_FLAG_NORMAL, &window_color); - lookup_context_color(window_context, "view_bg_color", &view_color) || - lookup_context_color(window_context, "theme_base_color", &view_color) || - sample_widget_background(view, GTK_STYLE_CLASS_VIEW, - GTK_STATE_FLAG_NORMAL, &view_color); + // Publish only the explicit modern view role. GTK 3's theme_base_color and + // computed .view background describe editable/list content (normally pure + // white), not the application workspace. When the named role is absent, + // Dart keeps its semantic view fallback; application workspaces choose the + // native window role explicitly. + lookup_context_color(window_context, "view_bg_color", &view_color); lookup_context_color(window_context, "window_fg_color", &foreground_color) || lookup_context_color(window_context, "theme_fg_color", &foreground_color) || @@ -2800,8 +2976,11 @@ static FlValue* get_gtk_theme_colors() { lookup_context_color(window_context, "borders", &border_color); lookup_context_color(window_context, "sidebar_border_color", &sidebar_border_color); - lookup_context_color(window_context, "shade_color", &shade_color) || - lookup_context_color(window_context, "wm_shadow", &shade_color); + // `shade_color` is the semantic modal/floating shade. `wm_shadow` is a + // substantially stronger window-decoration shadow in GTK 3 Yaru and must + // not be exported under this role. If the theme does not publish the modern + // name, Dart supplies the matching brightness-aware semantic fallback. + lookup_context_color(window_context, "shade_color", &shade_color); lookup_context_color(window_context, "accent_bg_color", &accent_color) || lookup_context_color(window_context, "theme_selected_bg_color", &accent_color); @@ -2874,7 +3053,6 @@ static FlValue* get_gtk_theme_colors() { gtk_widget_destroy(dim_label); gtk_widget_destroy(separator); gtk_widget_destroy(control); - gtk_widget_destroy(view); gtk_widget_destroy(window); return result; } @@ -3712,6 +3890,8 @@ static void my_application_dispose(GObject* object) { 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); @@ -3746,6 +3926,9 @@ static void my_application_dispose(GObject* object) { 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_popover_background_color, g_free); + g_clear_pointer(&self->header_bar_popover_shadow_color, g_free); + g_clear_pointer(&self->header_bar_menu_hover_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_view_mode, g_free); @@ -3802,6 +3985,9 @@ static void my_application_init(MyApplication* self) { self->header_bar_sidebar_border_color = nullptr; self->header_bar_foreground_color = nullptr; self->header_bar_popover_background_color = nullptr; + self->header_bar_popover_shadow_color = nullptr; + self->header_bar_menu_hover_color = nullptr; + self->header_bar_dialog_background_color = nullptr; self->header_bar_dialog_outline_color = g_strdup(kDefaultDialogOutlineColor); self->header_bar_modal_barrier_color = nullptr; @@ -3813,6 +3999,8 @@ static void my_application_init(MyApplication* self) { self->main_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; diff --git a/test/app/about_dialog_test.dart b/test/app/about_dialog_test.dart index ec21855..17d1675 100644 --- a/test/app/about_dialog_test.dart +++ b/test/app/about_dialog_test.dart @@ -2,27 +2,246 @@ import 'dart:io'; import 'package:busymax/src/app/busymax_about_dialog.dart'; 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'; import '../test_localized_app.dart'; void main() { + setUp(() { + _setPackageInfo(version: '1.2.3', buildNumber: '45'); + }); + testWidgets('about dialog shows app identity and app links', (tester) async { await tester.pumpWidget( localizedTestApp(child: const BusyMaxAboutDialog()), ); + await tester.pumpAndSettle(); expect(find.text('BusyMax'), findsOneWidget); expect(find.text('ToDo and Calendar'), findsOneWidget); expect(find.text('Website'), findsOneWidget); expect(find.text('Send feedback'), findsOneWidget); expect(find.text('Report an issue'), findsOneWidget); - expect(find.byIcon(Icons.close), findsOneWidget); + expect(find.text('v1.2.3+45'), findsOneWidget); + expect(find.byType(YaruDialogTitleBar), findsOneWidget); + expect(find.byType(YaruWindowControl), findsOneWidget); expect(find.text('Close'), findsNothing); + + final identity = find.byType(BusyMaxDialogIdentity); + final title = tester.widget( + find.descendant(of: identity, matching: find.text('BusyMax')), + ); + expect(identity, findsOneWidget); + expect( + tester.getSize( + find.descendant(of: identity, matching: find.byType(Image)), + ), + const Size.square(BusyMaxDialogIdentity.visualExtent), + ); + expect(title.style?.fontWeight, BusyMaxDialogIdentity.titleWeight); + + final titleBar = tester.widget( + find.byType(YaruDialogTitleBar), + ); + final closeButton = tester.widget( + find.byType(YaruWindowControl), + ); + expect(titleBar.isActive, isTrue); + expect(titleBar.border, BorderSide.none); + expect(closeButton.type, YaruWindowControlType.close); + expect( + tester.getSize(find.byType(YaruWindowControl)), + const Size.square(kYaruWindowControlSize), + ); }); + testWidgets( + 'informational titlebar retains its semantic high-contrast divider', + (tester) async { + final theme = BusyMaxYaruTheme.build( + brightness: Brightness.light, + accentColor: Colors.black, + highContrast: true, + ); + + await tester.pumpWidget( + localizedTestApp(theme: theme, child: const BusyMaxAboutDialog()), + ); + await tester.pumpAndSettle(); + + final titleBar = tester.widget( + find.byType(YaruDialogTitleBar), + ); + final colors = theme.extension()!; + expect(titleBar.border, BorderSide(color: colors.divider)); + }, + ); + + for (final textScale in [1.0, 2.0]) { + testWidgets( + 'about dialog remains scrollable in a short window at ${textScale}x text', + (tester) async { + tester.view + ..devicePixelRatio = 1 + ..physicalSize = const Size(480, 320); + addTearDown(tester.view.resetDevicePixelRatio); + addTearDown(tester.view.resetPhysicalSize); + + await tester.pumpWidget( + localizedTestApp( + textScaler: TextScaler.linear(textScale), + child: const BusyMaxAboutDialog(), + ), + ); + await tester.pumpAndSettle(); + + expect(tester.takeException(), isNull); + final scrollView = find.byType(SingleChildScrollView); + final closeButton = find.byType(YaruWindowControl); + expect(scrollView, findsOneWidget); + expect(closeButton.hitTestable(), findsOneWidget); + final closePosition = tester.getTopLeft(closeButton); + + await tester.drag(scrollView, const Offset(0, -400)); + await tester.pumpAndSettle(); + + expect(tester.takeException(), isNull); + expect(find.text('Report an issue').hitTestable(), findsOneWidget); + expect(closeButton.hitTestable(), findsOneWidget); + expect(tester.getTopLeft(closeButton), closePosition); + }, + ); + } + + testWidgets( + 'about modal route stays responsive and restores the native 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); + addTearDown(tester.view.resetDevicePixelRatio); + addTearDown(tester.view.resetPhysicalSize); + late BuildContext hostContext; + await tester.pumpWidget( + localizedTestApp( + textScaler: const TextScaler.linear(2), + child: Builder( + builder: (context) { + hostContext = context; + return const SizedBox.shrink(); + }, + ), + ), + ); + + late BuildContext dialogContext; + final result = showBusyMaxModalDialog( + hostContext, + headerBarService: service, + builder: (context) { + dialogContext = context; + return const BusyMaxAboutDialog(); + }, + ); + await tester.pumpAndSettle(); + + expect(tester.takeException(), isNull); + expect(find.byType(BusyMaxAboutDialog), findsOneWidget); + expect(MediaQuery.textScalerOf(dialogContext).scale(10), 20); + expect( + tester.widget(find.byType(Dialog)).clipBehavior, + Clip.antiAlias, + ); + expect( + tester + .state( + find.descendant( + of: find.byType(BusyMaxAboutDialog), + matching: find.byType(Scrollable), + ), + ) + .position + .maxScrollExtent, + greaterThan(0), + ); + expect( + calls + .where((call) => call.method == 'setModalBarrierVisible') + .single + .arguments, + isTrue, + ); + + await tester.tap(find.byType(YaruWindowControl)); + await tester.pumpAndSettle(); + + await result; + expect(find.byType(BusyMaxAboutDialog), findsNothing); + final barrierCalls = calls + .where((call) => call.method == 'setModalBarrierVisible') + .toList(); + expect(barrierCalls, hasLength(2)); + expect(barrierCalls.last.arguments, isFalse); + expect(tester.takeException(), isNull); + }, + ); + + for (final brightness in Brightness.values) { + testWidgets( + 'about version badge keeps accent identity and readable text in ' + '$brightness', + (tester) async { + const accent = Color(0xFF3584E4); + _setPackageInfo(version: '1.2.3', buildNumber: ''); + final theme = BusyMaxYaruTheme.build( + brightness: brightness, + accentColor: accent, + ); + + await tester.pumpWidget( + localizedTestApp(theme: theme, child: const BusyMaxAboutDialog()), + ); + await tester.pumpAndSettle(); + + expect(find.text('v1.2.3'), findsOneWidget); + expect(find.text('v1.2.3+'), findsNothing); + final badge = tester.widget( + find.byType(YaruTranslucentContainer), + ); + expect(badge.color, theme.colorScheme.primary); + expect(badge.opacity, 1); + expect((badge.border! as Border).dimensions, EdgeInsets.zero); + final versionText = tester.widget(find.text('v1.2.3')); + final textColor = versionText.style!.color!; + expect(textColor, theme.colorScheme.onPrimary); + expect(versionText.style?.fontWeight, FontWeight.w600); + }, + ); + } + for (final (brightness, dialogColor, popoverColor) in const [ (Brightness.light, Color(0xFFFAFAFA), Color(0xFFFAFAFA)), (Brightness.dark, Color(0xFF3E3E3E), Color(0xFF3E3E3E)), @@ -46,20 +265,20 @@ void main() { matching: find.byType(Material), ), ); + final expectedGroupedColor = Color.alphaBlend( + colors.groupedSurface, + colors.dialog, + ); final groupedMaterial = tester.widget( find.descendant( of: find.byType(BusyMaxGroupedSurface), matching: find.byWidgetPredicate( (widget) => - widget is Material && widget.elevation == BusyMaxElevation.card, + widget is Material && + widget.color?.toARGB32() == expectedGroupedColor.toARGB32(), ), ), ); - final expectedGroupedColor = Color.alphaBlend( - colors.groupedSurface, - colors.dialog, - ); - expect(colors.dialog, dialogColor); expect(colors.popover, popoverColor); expect(colors.dialog, isNot(colors.sidebar)); @@ -71,6 +290,14 @@ void main() { groupedMaterial.color?.toARGB32(), expectedGroupedColor.toARGB32(), ); + expect(groupedMaterial.elevation, theme.cardTheme.elevation); + expect( + find.descendant( + of: find.byType(BusyMaxGroupedSurface), + matching: find.byType(Card), + ), + findsOneWidget, + ); expect(groupedMaterial.color, isNot(dialogColor)); if (brightness == Brightness.dark) { expect( @@ -95,22 +322,34 @@ void main() { expect(source, isNot(contains('https://github.com/albertgee/busymax'))); }); - test('about dialog uses native headerbar dimming and Yaru close button', () { - 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('setModalBarrierVisible(true)')); - expect(dialogs, contains('setModalBarrierVisible(false)')); - expect(source, isNot(contains('barrierColor: Colors.transparent'))); - expect(source, contains('YaruIconButton(')); - expect(source, isNot(contains('BusyMaxDialogCloseButton'))); - }); + 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(headerBarService)'), + ); + expect( + dialogs, + contains('await releaseBusyMaxModalBarrier(headerBarService)'), + ); + 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( @@ -126,3 +365,13 @@ void main() { expect(source, isNot(contains('YaruIcons.calendar'))); }); } + +void _setPackageInfo({required String version, required String buildNumber}) { + PackageInfo.setMockInitialValues( + appName: 'BusyMax', + packageName: 'com.busystack.busymax', + version: version, + buildNumber: buildNumber, + buildSignature: '', + ); +} diff --git a/test/app/busymax_dialogs_test.dart b/test/app/busymax_dialogs_test.dart index 0846b0e..182f341 100644 --- a/test/app/busymax_dialogs_test.dart +++ b/test/app/busymax_dialogs_test.dart @@ -1,6 +1,9 @@ +import 'dart:async'; + 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:busymax/src/platform/native_dialog_service.dart'; @@ -8,6 +11,7 @@ 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 '../test_localized_app.dart'; @@ -25,6 +29,57 @@ void main() { .setMockMethodCallHandler(nativeDialogChannel, null); }); + testWidgets( + 'prompt and confirmation title bars share the semantic dialog surface', + (tester) async { + final theme = BusyMaxYaruTheme.build( + brightness: Brightness.dark, + accentColor: const Color(0xFFE95420), + ); + final colors = theme.extension()!; + expect(colors.window, isNot(colors.dialog)); + + await tester.pumpWidget( + localizedTestApp( + theme: theme, + child: const BusyMaxPromptDialog( + title: 'Rename calendar', + label: 'Name', + actionLabel: 'Rename', + ), + ), + ); + + var titleBar = tester.widget( + find.byType(YaruDialogTitleBar), + ); + final dialog = tester.widget(find.byType(Dialog)); + expect(titleBar.backgroundColor, colors.dialog); + expect(dialog.backgroundColor, colors.dialog); + expect(dialog.surfaceTintColor, colors.dialog); + + await tester.pumpWidget( + localizedTestApp( + theme: theme, + child: const BusyMaxConfirmDialog( + title: 'Discard changes?', + message: 'Unsaved changes will be lost.', + confirmLabel: 'Discard', + destructive: true, + ), + ), + ); + + titleBar = tester.widget( + find.byType(YaruDialogTitleBar), + ); + final confirmation = tester.widget(find.byType(AlertDialog)); + expect(titleBar.backgroundColor, colors.dialog); + expect(confirmation.backgroundColor, colors.dialog); + expect(confirmation.surfaceTintColor, colors.dialog); + }, + ); + testWidgets('confirmation uses the native host when available', ( tester, ) async { @@ -149,6 +204,59 @@ void main() { expect(barrierCalls.last.arguments, isFalse); }); + testWidgets('confirmation fallback scrolls in a short window at 2x text', ( + tester, + ) async { + tester.view + ..devicePixelRatio = 1 + ..physicalSize = const Size(480, 320); + addTearDown(tester.view.resetDevicePixelRatio); + addTearDown(tester.view.resetPhysicalSize); + + const message = + 'Unsaved changes will be permanently discarded. ' + 'This cannot be undone, and any edits made since the last save will ' + 'be lost. Review the warning carefully before choosing an action.'; + await tester.pumpWidget( + localizedTestApp( + child: Builder( + builder: (context) => MediaQuery( + data: MediaQuery.of( + context, + ).copyWith(textScaler: const TextScaler.linear(2)), + child: const BusyMaxConfirmDialog( + title: 'Discard all unsaved changes?', + message: message, + confirmLabel: 'Discard changes', + destructive: true, + ), + ), + ), + ), + ); + + expect(tester.takeException(), isNull); + final scrollView = find.descendant( + of: find.byType(AlertDialog), + matching: find.byType(SingleChildScrollView), + ); + expect(scrollView, findsOneWidget); + final scrollable = find.descendant( + of: find.byType(AlertDialog), + matching: find.byType(Scrollable), + ); + final position = tester.state(scrollable).position; + expect(position.maxScrollExtent, greaterThan(0)); + + await tester.drag(scrollView, const Offset(0, -120)); + await tester.pumpAndSettle(); + + expect(position.pixels, greaterThan(0)); + expect(tester.takeException(), isNull); + expect(find.text('Cancel'), findsOneWidget); + expect(find.text('Discard changes'), findsOneWidget); + }); + testWidgets('nested modals keep the native barrier active', (tester) async { const channel = MethodChannel('busymax_test/nested_modal_barrier'); final calls = []; @@ -215,6 +323,77 @@ void main() { expect(barrierCalls.last.arguments, isFalse); }); + testWidgets('serializes rapid manual native barrier transitions', ( + tester, + ) async { + const channel = MethodChannel('busymax_test/serialized_modal_barrier'); + final firstUpdate = Completer(); + final transitions = []; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + if (call.method == 'initialize') { + return true; + } + if (call.method == 'setModalBarrierVisible') { + transitions.add(call.arguments! as bool); + 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, [true]); + + final release = releaseBusyMaxModalBarrier(service); + await tester.pump(); + expect( + transitions, + [true], + reason: 'the native hide must wait for the in-flight native show', + ); + + firstUpdate.complete(); + await Future.wait([acquire, release]); + + expect(transitions, [true, false]); + }); + + testWidgets('failed native barrier acquisition rolls back and can retry', ( + tester, + ) async { + final service = _FailingModalBarrierService(); + addTearDown(service.dispose); + + await expectLater( + acquireBusyMaxModalBarrier(service), + throwsA(isA()), + ); + expect( + service.transitions, + [true, false], + reason: 'a failed native show requires a best-effort native rollback', + ); + + await acquireBusyMaxModalBarrier(service); + await releaseBusyMaxModalBarrier(service); + + expect(service.transitions, [true, false, true, false]); + }); + testWidgets('modal coordinator resolves the service from ProviderScope', ( tester, ) async { @@ -318,42 +497,100 @@ void main() { expect(await result, 'cancelled'); }); - testWidgets('text prompt preserves input until an explicit action', ( - tester, - ) async { - late BuildContext hostContext; - await tester.pumpWidget( - localizedTestApp( - child: Builder( - builder: (context) { - hostContext = context; - return const SizedBox(); - }, + testWidgets( + 'text prompt uses the shared grouped input and selects its value', + (tester) async { + late BuildContext hostContext; + await tester.pumpWidget( + localizedTestApp( + child: Builder( + builder: (context) { + hostContext = context; + return const SizedBox(); + }, + ), ), - ), - ); - - final result = showBusyMaxTextPrompt( - hostContext, - title: 'Rename item', - label: 'Name', - actionLabel: 'Rename', - initialValue: 'Draft name', - ); - await tester.pumpAndSettle(); - - await tester.enterText(find.byType(TextField), 'Edited name'); - await tester.tapAt(const Offset(2, 2)); - await tester.sendKeyEvent(LogicalKeyboardKey.escape); - await tester.pumpAndSettle(); - - expect(find.text('Rename item'), findsOneWidget); - expect(find.text('Edited name'), findsOneWidget); - - await tester.tap(find.text('Cancel')); - await tester.pumpAndSettle(); - expect(await result, isNull); - }); + ); + + final result = showBusyMaxTextPrompt( + hostContext, + title: 'Rename item', + label: 'Name', + actionLabel: 'Rename', + initialValue: 'Draft name', + message: 'Choose a distinctive name.', + ); + await tester.pumpAndSettle(); + + expect(find.byType(BusyMaxGroupedList), findsOneWidget); + expect(find.text('Choose a distinctive name.'), findsOneWidget); + final textField = tester.widget(find.byType(TextField)); + expect(textField.controller?.text, 'Draft name'); + expect( + textField.controller?.selection, + const TextSelection(baseOffset: 0, extentOffset: 10), + ); + expect(textField.decoration?.labelText, 'Name'); + expect(textField.decoration?.filled, isFalse); + expect(textField.decoration?.border, InputBorder.none); + + await tester.enterText(find.byType(TextField), 'Edited name'); + await tester.tapAt(const Offset(2, 2)); + await tester.sendKeyEvent(LogicalKeyboardKey.escape); + await tester.pumpAndSettle(); + + expect(find.text('Rename item'), findsOneWidget); + expect(find.text('Edited name'), findsOneWidget); + + await tester.tap(find.text('Cancel')); + await tester.pumpAndSettle(); + expect(await result, isNull); + }, + ); + + testWidgets( + 'text prompt rejects blank input and submits valid text on Enter', + (tester) async { + late BuildContext hostContext; + await tester.pumpWidget( + localizedTestApp( + child: Builder( + builder: (context) { + hostContext = context; + return const SizedBox(); + }, + ), + ), + ); + + final result = showBusyMaxTextPrompt( + hostContext, + title: 'Rename item', + label: 'Name', + actionLabel: 'Rename', + ); + await tester.pumpAndSettle(); + + final renameButton = find.widgetWithText(ElevatedButton, 'Rename'); + expect(tester.widget(renameButton).onPressed, isNull); + + await tester.enterText(find.byType(TextField), ' '); + await tester.pump(); + expect(tester.widget(renameButton).onPressed, isNull); + await tester.testTextInput.receiveAction(TextInputAction.done); + await tester.pumpAndSettle(); + expect(find.byType(BusyMaxPromptDialog), findsOneWidget); + + await tester.enterText(find.byType(TextField), 'Work'); + await tester.pump(); + expect(tester.widget(renameButton).onPressed, isNotNull); + await tester.testTextInput.receiveAction(TextInputAction.done); + await tester.pumpAndSettle(); + + expect(await result, 'Work'); + expect(find.byType(BusyMaxPromptDialog), findsNothing); + }, + ); testWidgets('modal shortcut boundary blocks application navigation', ( tester, @@ -399,3 +636,19 @@ void main() { class _ApplicationNavigationIntent extends Intent { const _ApplicationNavigationIntent(); } + +class _FailingModalBarrierService extends LinuxHeaderBarService { + _FailingModalBarrierService() : super(isLinux: false); + + final transitions = []; + var _failNextShow = true; + + @override + Future setModalBarrierVisible(bool value) async { + transitions.add(value); + if (value && _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 f768799..c5a278e 100644 --- a/test/app/busymax_grouped_surface_test.dart +++ b/test/app/busymax_grouped_surface_test.dart @@ -2,6 +2,7 @@ import 'dart:async'; 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_yaru_theme.dart'; import 'package:busymax/src/platform/native_menu_service.dart'; @@ -29,6 +30,169 @@ void main() { .setMockMethodCallHandler(_nativeMenuChannel, null); }); + testWidgets('filled surfaces use the shared native card shadow profile', ( + tester, + ) async { + const semanticShadow = Color(0xFF123456); + const semanticElevation = 3.0; + final baseTheme = BusyMaxYaruTheme.build( + brightness: Brightness.light, + accentColor: const Color(0xFF3584E4), + ); + final theme = baseTheme.copyWith( + cardTheme: baseTheme.cardTheme.copyWith( + elevation: semanticElevation, + shadowColor: semanticShadow, + ), + ); + + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: const BusyMaxSurface(child: SizedBox(width: 120, height: 48)), + ), + ); + + final card = tester.widget( + find.descendant( + of: find.byType(BusyMaxSurface), + matching: find.byType(Card), + ), + ); + final material = tester.widget( + find.descendant(of: find.byType(Card), matching: find.byType(Material)), + ); + final shadowDecoration = _nativeCardDecoration( + tester, + find.byType(BusyMaxSurface), + ); + expect(card.elevation, isNull); + expect(card.shadowColor, Colors.transparent); + expect(material.elevation, semanticElevation); + expect(material.shadowColor, Colors.transparent); + expect( + shadowDecoration.shadows, + BusyMaxShadow.nativeCardShadows(semanticShadow), + ); + }); + + testWidgets( + 'Flutter popover surfaces use the native layered shadow profile', + (tester) async { + const semanticShadow = Color.fromRGBO(32, 80, 128, 0.8); + const menuKey = ValueKey('menu-popover'); + final baseTheme = BusyMaxYaruTheme.build( + brightness: Brightness.light, + accentColor: const Color(0xFF3584E4), + ); + final theme = baseTheme.copyWith( + colorScheme: baseTheme.colorScheme.copyWith(shadow: semanticShadow), + ); + final colors = theme.extension()!; + + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: Scaffold( + body: Column( + children: [ + BusyMaxPopoverSurface( + key: menuKey, + color: colors.popover, + child: const SizedBox(width: 120, height: 48), + ), + const BusyMaxContentPopoverSurface( + child: SizedBox(width: 120, height: 48), + ), + ], + ), + ), + ), + ); + + final menuDecorationFinder = find.descendant( + of: find.byKey(menuKey), + matching: find.byWidgetPredicate( + (widget) => + widget is DecoratedBox && + widget.decoration is ShapeDecoration && + ((widget.decoration as ShapeDecoration).shadows?.isNotEmpty ?? + false), + ), + ); + final contentDecorationFinder = find.descendant( + of: find.byType(BusyMaxContentPopoverSurface), + matching: find.byWidgetPredicate( + (widget) => + widget is DecoratedBox && + widget.decoration is ShapeDecoration && + ((widget.decoration as ShapeDecoration).shadows?.isNotEmpty ?? + false), + ), + ); + + expect(menuDecorationFinder, findsOneWidget); + expect(contentDecorationFinder, findsOneWidget); + expect( + find.descendant( + of: find.byType(BusyMaxPopoverSurface), + matching: find.byType(PhysicalShape), + ), + findsNothing, + ); + final expectedMenuShadows = BusyMaxShadow.nativePopoverShadows( + semanticShadow, + ); + final expectedDetailsShadows = BusyMaxShadow.nativePopoverShadows( + semanticShadow, + role: BusyMaxPopoverShadowRole.details, + ); + for (final (finder, expectedShadows) in [ + (menuDecorationFinder, expectedMenuShadows), + (contentDecorationFinder, expectedDetailsShadows), + ]) { + final decoratedBox = tester.widget(finder); + final decoration = decoratedBox.decoration as ShapeDecoration; + expect(decoration.shadows, expectedShadows); + for (final shadow in decoration.shadows!) { + expect(shadow.color.r, semanticShadow.r); + expect(shadow.color.g, semanticShadow.g); + expect(shadow.color.b, semanticShadow.b); + } + } + expect(expectedMenuShadows[0].blurRadius, 14); + expect(expectedMenuShadows[1].blurRadius, 5); + expect(expectedDetailsShadows[0].blurRadius, 13.5); + expect(expectedDetailsShadows[1].blurRadius, 4.5); + expect(expectedMenuShadows[0].color.a, closeTo(0.8 * 0.05, 0.0001)); + expect(expectedMenuShadows[1].color.a, closeTo(0.8 * 0.09, 0.0001)); + expect( + expectedDetailsShadows.map((shadow) => shadow.color), + expectedMenuShadows.map((shadow) => shadow.color), + ); + expect(BusyMaxShadow.nativePopoverPaintMargin, 31); + }, + ); + + test( + 'Settings and Year view delegate card shadows to the shared surface', + () { + final settings = File( + 'lib/src/features/settings/presentation/settings_screen.dart', + ).readAsStringSync(); + final yearView = File( + 'lib/src/features/schedule/presentation/schedule_year_view.dart', + ).readAsStringSync(); + + expect(settings, contains('BusyMaxGroupedList(')); + expect(yearView, contains('BusyMaxGroupedSurface(')); + for (final source in [settings, yearView]) { + expect(source, isNot(contains('BoxShadow('))); + expect(source, isNot(contains('elevation:'))); + } + }, + ); + for (final brightness in Brightness.values) { testWidgets( 'grouped list uses the semantic $brightness surface and Yaru rows', @@ -72,13 +236,20 @@ void main() { ), ), ); - expect(BusyMaxElevation.card, 2); expect(materialSurface.color, theme.cardTheme.color); expect(materialSurface.color?.a, 1); - expect(materialSurface.elevation, BusyMaxElevation.card); - expect(materialSurface.shadowColor, theme.colorScheme.shadow); + expect(materialSurface.elevation, theme.cardTheme.elevation); + expect(materialSurface.shadowColor, Colors.transparent); + expect( + _nativeCardDecoration(tester, groupedSurface).shadows, + BusyMaxShadow.nativeCardShadows(theme.colorScheme.shadow), + ); final shape = materialSurface.shape! as RoundedRectangleBorder; expect(shape.side, BorderSide.none); + expect( + find.descendant(of: groupedSurface, matching: find.byType(Card)), + findsOneWidget, + ); expect( find.descendant( of: groupedSurface, @@ -124,6 +295,41 @@ void main() { ); } + testWidgets('unfilled surfaces stay flat and borderless', (tester) async { + final theme = BusyMaxYaruTheme.build( + brightness: Brightness.light, + accentColor: const Color(0xFF3584E4), + ); + + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: const BusyMaxSurface( + filled: false, + child: SizedBox(width: 120, height: 48), + ), + ), + ); + + final material = tester.widget( + find.descendant( + of: find.byType(BusyMaxSurface), + matching: find.byType(Material), + ), + ); + final shape = material.shape! as RoundedRectangleBorder; + expect(material.color, Colors.transparent); + expect(material.elevation, 0); + expect(shape.side, BorderSide.none); + expect( + find.descendant( + of: find.byType(BusyMaxSurface), + matching: find.byType(Card), + ), + findsNothing, + ); + }); + testWidgets('disabled grouped subtitles use the semantic disabled role', ( tester, ) async { @@ -313,6 +519,52 @@ void main() { expect(shape.side, inheritedSide); }); + testWidgets('grouped cards inherit the semantic card shadow color', ( + tester, + ) async { + const semanticShadow = Color(0x80123456); + final baseTheme = BusyMaxYaruTheme.build( + brightness: Brightness.light, + accentColor: const Color(0xFF3584E4), + ); + final theme = baseTheme.copyWith( + cardTheme: baseTheme.cardTheme.copyWith(shadowColor: semanticShadow), + ); + + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: const BusyMaxGroupedSurface( + child: SizedBox(width: 120, height: 48), + ), + ), + ); + + final decoration = _nativeCardDecoration( + tester, + find.byType(BusyMaxGroupedSurface), + ); + final expectedShadows = BusyMaxShadow.nativeCardShadows(semanticShadow); + expect(decoration.shadows, expectedShadows); + for (final shadow in decoration.shadows!) { + expect(shadow.color.r, semanticShadow.r); + expect(shadow.color.g, semanticShadow.g); + expect(shadow.color.b, semanticShadow.b); + } + expect( + expectedShadows[0].color.a, + closeTo(semanticShadow.a * 0.03, 0.0001), + ); + expect( + expectedShadows[1].color.a, + closeTo(semanticShadow.a * 0.07, 0.0001), + ); + expect( + expectedShadows[2].color.a, + closeTo(semanticShadow.a * 0.03, 0.0001), + ); + }); + testWidgets( 'grouped card paints its opaque semantic role in a dark editor sheet', (tester) async { @@ -368,22 +620,28 @@ void main() { ); expect(groupedMaterial.color, colors.card); expect(groupedMaterial.color?.a, 1); - expect(groupedMaterial.elevation, BusyMaxElevation.card); - expect(groupedMaterial.shadowColor, theme.colorScheme.shadow); + expect(groupedMaterial.elevation, theme.cardTheme.elevation); + expect(groupedMaterial.shadowColor, Colors.transparent); + expect( + _nativeCardDecoration( + tester, + find.byType(BusyMaxGroupedSurface), + ).shadows, + BusyMaxShadow.nativeCardShadows(theme.colorScheme.shadow), + ); }, ); for (final brightness in Brightness.values) { - testWidgets('rows use the subtle Yaru $brightness hover role', ( + testWidgets('rows use the native $brightness boxed-row hover role', ( tester, ) async { - final theme = BusyMaxYaruTheme.build( + final baseTheme = BusyMaxYaruTheme.build( brightness: brightness, accentColor: const Color(0xFF3584E4), ); - final yaruBase = brightness == Brightness.light - ? createYaruLightTheme(primaryColor: BusyMaxLinuxPalette.light4) - : createYaruDarkTheme(primaryColor: BusyMaxLinuxPalette.light2); + const rowHover = Color(0x1A2A7FFF); + final theme = baseTheme.copyWith(hoverColor: rowHover); await tester.pumpWidget( MaterialApp( @@ -403,11 +661,12 @@ void main() { ), ); - expect(theme.hoverColor, yaruBase.hoverColor); - expect( - theme.hoverColor, - isNot(theme.extension()!.controlHover), - ); + expect(theme.hoverColor, rowHover); + final expectedHover = brightness == Brightness.dark + ? rowHover + : rowHover.withValues( + alpha: rowHover.a * BusyMaxAlpha.groupedRowLightHoverStrength, + ); final actionTile = tester.widget( find.descendant( of: find.byType(BusyMaxActionRow), @@ -420,17 +679,116 @@ void main() { matching: find.byType(YaruListTile), ), ); - expect(actionTile.hoverColor, theme.hoverColor); - expect(switchTile.hoverColor, theme.hoverColor); - expect( - busyMaxEditorRowHoverColor( - tester.element(find.byType(BusyMaxActionRow)), + expect(actionTile.hoverColor, expectedHover); + expect(switchTile.hoverColor, expectedHover); + if (brightness == Brightness.light) { + expect(expectedHover.a, lessThan(rowHover.a)); + } else { + expect(expectedHover, rowHover); + } + expect(expectedHover.r, rowHover.r); + expect(expectedHover.g, rowHover.g); + expect(expectedHover.b, rowHover.b); + }); + + testWidgets('rows apply the native $brightness boxed-row hover strength', ( + tester, + ) async { + final theme = BusyMaxYaruTheme.build( + brightness: brightness, + accentColor: const Color(0xFF3584E4), + ); + final colors = theme.extension()!; + + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: Scaffold( + body: BusyMaxActionRow(title: 'Calendar', onTap: () {}), + ), ), - theme.hoverColor, ); + + final tile = tester.widget( + find.descendant( + of: find.byType(BusyMaxActionRow), + matching: find.byType(YaruListTile), + ), + ); + expect(theme.hoverColor.a, closeTo(10 / 255, 0.0001)); + final expectedAlpha = brightness == Brightness.dark + ? theme.hoverColor.a + : theme.hoverColor.a * BusyMaxAlpha.groupedRowLightHoverStrength; + expect(tile.hoverColor?.a, closeTo(expectedAlpha, 0.0001)); + expect( + tile.hoverColor?.a, + closeTo(brightness == Brightness.dark ? 10 / 255 : 0.02, 0.001), + ); + expect(tile.hoverColor, isNot(colors.controlHover)); }); } + testWidgets('high-contrast grouped rows retain the full hover signal', ( + tester, + ) async { + final theme = buildBusyMaxTheme( + brightness: Brightness.dark, + accentColor: const Color(0xFF3584E4), + highContrast: true, + ); + + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: Scaffold( + body: BusyMaxActionRow(title: 'Calendar', onTap: () {}), + ), + ), + ); + + final tile = tester.widget( + find.descendant( + of: find.byType(BusyMaxActionRow), + matching: find.byType(YaruListTile), + ), + ); + expect(theme.colorScheme.isHighContrast, isTrue); + expect(tile.hoverColor, theme.hoverColor); + }); + + testWidgets('grouped entry keeps interaction inside its field', ( + tester, + ) async { + final theme = BusyMaxYaruTheme.build( + brightness: Brightness.light, + accentColor: const Color(0xFF3584E4), + ); + + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: Scaffold( + body: Builder( + builder: (context) => YaruListTile.square( + title: TextField( + decoration: busyMaxGroupedTextFieldDecoration( + context, + labelText: 'Title', + ), + ), + ), + ), + ), + ), + ); + + final entryTile = tester.widget(find.byType(YaruListTile)); + final field = tester.widget(find.byType(TextField)); + expect(entryTile.onTap, isNull); + expect(entryTile.hoverColor, isNull); + expect(field.decoration?.hoverColor, Colors.transparent); + }); + testWidgets('sidebar surface draws the semantic directional end boundary', ( tester, ) async { @@ -660,7 +1018,12 @@ void main() { expect(trailingActivations, 1); expect(selections, isEmpty); - expect(find.byType(PopupMenuItem).hitTestable(), findsNothing); + expect( + find + .byWidgetPredicate((widget) => widget is PopupMenuItem) + .hitTestable(), + findsNothing, + ); semanticsHandle.dispose(); }); @@ -691,7 +1054,12 @@ void main() { await tester.sendKeyEvent(LogicalKeyboardKey.enter); await tester.pumpAndSettle(); - expect(find.byType(PopupMenuItem).hitTestable(), findsNothing); + expect( + find + .byWidgetPredicate((widget) => widget is PopupMenuItem) + .hitTestable(), + findsNothing, + ); expect(find.byType(YaruRadio).hitTestable(), findsNothing); expect(selected, isEmpty); final disabledSemantics = tester.widget( @@ -783,7 +1151,9 @@ void main() { tester.getRect(firstChoice).top, greaterThanOrEqualTo(arrowRect.bottom), ); - final visibleMenuItems = find.byType(PopupMenuItem).hitTestable(); + final visibleMenuItems = find + .byWidgetPredicate((widget) => widget is PopupMenuItem) + .hitTestable(); expect(visibleMenuItems, findsNWidgets(2)); expect(find.byType(YaruFocusBorder), findsNothing); final radioItems = tester.widgetList>( @@ -955,7 +1325,12 @@ void main() { await tester.tap(trigger); await tester.pumpAndSettle(); - expect(find.byType(PopupMenuItem).hitTestable(), findsNWidgets(2)); + expect( + find + .byWidgetPredicate((widget) => widget is PopupMenuItem) + .hitTestable(), + findsNWidgets(2), + ); expect(tester.takeException(), isNull); }); @@ -983,7 +1358,12 @@ void main() { await tester.sendKeyEvent(LogicalKeyboardKey.enter); await tester.pumpAndSettle(); - expect(find.byType(PopupMenuItem).hitTestable(), findsNWidgets(2)); + expect( + find + .byWidgetPredicate((widget) => widget is PopupMenuItem) + .hitTestable(), + findsNWidgets(2), + ); expect( tester.getRect(find.text('Personal').last).top, greaterThanOrEqualTo(tester.getRect(triggerFinder).bottom), @@ -994,7 +1374,12 @@ void main() { await tester.sendKeyEvent(LogicalKeyboardKey.enter); await tester.pumpAndSettle(); expect(selections, ['Work']); - expect(find.byType(PopupMenuItem).hitTestable(), findsNothing); + expect( + find + .byWidgetPredicate((widget) => widget is PopupMenuItem) + .hitTestable(), + findsNothing, + ); }); testWidgets('combo row accepts unbounded horizontal constraints', ( @@ -1152,24 +1537,39 @@ void main() { expect(modalDialog.shadowColor, isNull); expect(modalDialog.shape, isNull); expect(modalMaterial.shape, theme.dialogTheme.shape); + expect(modalMaterial.shadowColor, theme.dialogTheme.shadowColor); + expect(modalMaterial.elevation, greaterThan(0)); - final physicalShape = tester.widget( + final popoverDecorationFinder = find.descendant( + of: find.byType(BusyMaxPopoverSurface), + matching: find.byWidgetPredicate( + (widget) => + widget is DecoratedBox && + widget.decoration is ShapeDecoration && + (widget.decoration as ShapeDecoration).color == + expectedPopover && + ((widget.decoration as ShapeDecoration).shadows?.isNotEmpty ?? + false), + ), + ); + expect(popoverDecorationFinder, findsOneWidget); + expect( find.descendant( of: find.byType(BusyMaxPopoverSurface), matching: find.byType(PhysicalShape), ), + findsNothing, ); - expect(physicalShape.color, expectedPopover); - expect(physicalShape.elevation, BusyMaxElevation.tooltip); - expect(physicalShape.shadowColor, theme.colorScheme.shadow); - final outlinePaint = find.descendant( + final outlineDecorationFinder = find.descendant( of: find.byType(BusyMaxPopoverSurface), matching: find.byWidgetPredicate( (widget) => - widget is CustomPaint && widget.foregroundPainter != null, + widget is DecoratedBox && + widget.position == DecorationPosition.foreground && + widget.decoration is ShapeDecoration, ), ); - expect(outlinePaint, findsOneWidget); + expect(outlineDecorationFinder, findsOneWidget); expect(tester.takeException(), isNull); await tester.pumpWidget( @@ -1200,6 +1600,8 @@ void main() { expect(theme.dialogTheme.backgroundColor, expectedDialog); expect(alertMaterial.color, expectedDialog); expect(alertMaterial.shape, theme.dialogTheme.shape); + expect(alertMaterial.shadowColor, theme.dialogTheme.shadowColor); + expect(alertMaterial.elevation, modalMaterial.elevation); expect(tester.takeException(), isNull); }, ); @@ -1228,15 +1630,22 @@ void main() { ), ); - expect( - find.descendant( - of: find.byType(BusyMaxPopoverSurface), - matching: find.byWidgetPredicate( - (widget) => widget is CustomPaint && widget.foregroundPainter != null, - ), + final outlineFinder = find.descendant( + of: find.byType(BusyMaxPopoverSurface), + matching: find.byWidgetPredicate( + (widget) => + widget is DecoratedBox && + widget.position == DecorationPosition.foreground && + widget.decoration is ShapeDecoration, ), - findsOneWidget, ); + expect(outlineFinder, findsOneWidget); + final outlineDecoration = + tester.widget(outlineFinder).decoration + as ShapeDecoration; + final outlineShape = outlineDecoration.shape as OutlinedBorder; + expect(outlineShape.side.color, colors.floatingBorder); + expect(outlineShape.side.width, BusyMaxStroke.outline); }); testWidgets('combo row keeps its value inline for large text', ( @@ -1675,6 +2084,22 @@ void main() { }); } +ShapeDecoration _nativeCardDecoration(WidgetTester tester, Finder surface) { + final decoratedBox = tester.widget( + find.descendant( + of: surface, + matching: find.byWidgetPredicate( + (widget) => + widget is DecoratedBox && + widget.decoration is ShapeDecoration && + ((widget.decoration as ShapeDecoration).shadows?.isNotEmpty ?? + false), + ), + ), + ); + return decoratedBox.decoration as ShapeDecoration; +} + void _ignoreBool(bool value) {} Finder _comboTriggerFinder() { @@ -1685,7 +2110,12 @@ Finder _comboTriggerFinder() { Finder _menuItemWithLabel(String label) { return find - .ancestor(of: find.text(label), matching: find.byType(PopupMenuItem)) + .ancestor( + of: find.text(label), + matching: find.byWidgetPredicate( + (widget) => widget is PopupMenuItem, + ), + ) .hitTestable(); } diff --git a/test/app/busymax_menu_button_test.dart b/test/app/busymax_menu_button_test.dart index d1dffff..0e43672 100644 --- a/test/app/busymax_menu_button_test.dart +++ b/test/app/busymax_menu_button_test.dart @@ -1,9 +1,12 @@ import 'dart:async'; +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/platform/native_menu_service.dart'; +import 'package:flutter/gestures.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'; @@ -25,10 +28,13 @@ void main() { ) async { String? selected; final controller = BusyMaxMenuController(); - final theme = BusyMaxYaruTheme.build( + final boundaryKey = GlobalKey(); + final baseTheme = BusyMaxYaruTheme.build( brightness: Brightness.dark, accentColor: YaruColors.orange, ); + const inheritedHover = Color(0x1A2A7FFF); + final theme = baseTheme.copyWith(hoverColor: inheritedHover); TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger .setMockMethodCallHandler( channel, @@ -36,28 +42,31 @@ void main() { ); await tester.pumpWidget( - localizedTestApp( - child: Theme( - data: theme, - child: Scaffold( - body: Center( - child: BusyMaxMenuButton( - tooltip: 'Options', - controller: controller, - nativeMenuService: const NativeMenuService(channel: channel), - onSelected: (value) => selected = value, - entries: const [ - BusyMaxMenuEntry( - value: 'refresh', - label: 'Refresh calendar', - icon: YaruIcons.refresh, - ), - BusyMaxMenuEntry( - value: 'open', - label: 'Open in provider', - icon: Icons.open_in_browser_outlined, - ), - ], + RepaintBoundary( + key: boundaryKey, + child: localizedTestApp( + child: Theme( + data: theme, + child: Scaffold( + body: Center( + child: BusyMaxMenuButton( + tooltip: 'Options', + controller: controller, + nativeMenuService: const NativeMenuService(channel: channel), + onSelected: (value) => selected = value, + entries: const [ + BusyMaxMenuEntry( + value: 'refresh', + label: 'Refresh calendar', + icon: YaruIcons.refresh, + ), + BusyMaxMenuEntry( + value: 'open', + label: 'Open in provider', + icon: Icons.open_in_browser_outlined, + ), + ], + ), ), ), ), @@ -71,6 +80,7 @@ void main() { ); var trigger = tester.widget(triggerFinder); final colors = theme.extension()!; + expect(Theme.of(tester.element(triggerFinder)).hoverColor, inheritedHover); expect(trigger.isSelected, isFalse); expect(trigger.style, isNull); final yaruStyle = trigger.defaultStyleOf(tester.element(triggerFinder)); @@ -84,6 +94,7 @@ void main() { expect(find.text('Refresh calendar'), findsOneWidget); expect(find.text('Open in provider'), findsOneWidget); trigger = tester.widget(triggerFinder); + expect(Theme.of(tester.element(triggerFinder)).hoverColor, inheritedHover); expect(trigger.isSelected, isTrue); expect( trigger @@ -104,12 +115,50 @@ void main() { .where((material) => material.color == colors.popover), isNotEmpty, ); + final firstFallbackItem = find + .ancestor( + of: find.text('Refresh calendar'), + matching: find.byWidgetPredicate( + (widget) => widget is PopupMenuItem, + ), + ) + .first; + final firstFallbackInkWell = find.descendant( + of: firstFallbackItem, + matching: find.byType(InkWell), + ); + expect(firstFallbackInkWell, findsOneWidget); + expect( + Theme.of(tester.element(firstFallbackInkWell)).hoverColor, + colors.controlHover, + ); + expect(colors.controlHover, isNot(inheritedHover)); + + final firstItemRect = tester.getRect(firstFallbackItem); + final hoverProbe = Offset( + firstItemRect.right - 12, + firstItemRect.center.dy, + ); + final idlePixel = await _capturePixel(tester, boundaryKey, hoverProbe); + final mouse = await tester.createGesture(kind: PointerDeviceKind.mouse); + addTearDown(mouse.removePointer); + await mouse.addPointer(location: Offset.zero); + await mouse.moveTo(tester.getCenter(firstFallbackItem)); + await tester.pumpAndSettle(); + expect(firstFallbackInkWell, findsOneWidget); + final hoveredPixel = await _capturePixel(tester, boundaryKey, hoverProbe); + expect(hoveredPixel, isNot(idlePixel)); + expect( + hoveredPixel, + _colorCloseTo(Color.alphaBlend(colors.controlHover, colors.popover)), + ); controller.close(); await tester.pumpAndSettle(); trigger = tester.widget(triggerFinder); expect(trigger.isSelected, isFalse); + expect(Theme.of(tester.element(triggerFinder)).hoverColor, inheritedHover); expect(find.text('Refresh calendar'), findsNothing); expect(selected, isNull); @@ -349,3 +398,45 @@ void main() { await tester.pumpAndSettle(); }); } + +Future _capturePixel( + WidgetTester tester, + GlobalKey boundaryKey, + Offset globalPosition, +) async { + final boundary = + boundaryKey.currentContext!.findRenderObject()! as RenderRepaintBoundary; + final localPosition = boundary.globalToLocal(globalPosition); + final image = (await tester.binding.runAsync( + () => boundary.toImage(pixelRatio: 1), + ))!; + try { + final data = (await tester.binding.runAsync( + () => image.toByteData(format: ui.ImageByteFormat.rawStraightRgba), + ))!; + final bytes = data.buffer.asUint8List( + data.offsetInBytes, + data.lengthInBytes, + ); + final x = localPosition.dx.floor().clamp(0, image.width - 1).toInt(); + final y = localPosition.dy.floor().clamp(0, image.height - 1).toInt(); + final offset = (y * image.width + x) * 4; + return Color.fromARGB( + bytes[offset + 3], + bytes[offset], + bytes[offset + 1], + bytes[offset + 2], + ); + } finally { + image.dispose(); + } +} + +Matcher _colorCloseTo(Color expected) => predicate( + (actual) => + (actual.r - expected.r).abs() <= 1 / 255 && + (actual.g - expected.g).abs() <= 1 / 255 && + (actual.b - expected.b).abs() <= 1 / 255 && + (actual.a - expected.a).abs() <= 1 / 255, + 'a color within one 8-bit channel step of $expected', +); diff --git a/test/app/high_contrast_theme_test.dart b/test/app/high_contrast_theme_test.dart index 015d587..2881cd4 100644 --- a/test/app/high_contrast_theme_test.dart +++ b/test/app/high_contrast_theme_test.dart @@ -84,8 +84,16 @@ void main() { surfaces.border, ); - final tooltipDecoration = theme.tooltipTheme.decoration! as BoxDecoration; - expect(tooltipDecoration.border, isNotNull); + final yaruTooltipTheme = switch (theme.brightness) { + Brightness.light => createYaruLightTheme( + primaryColor: theme.colorScheme.primary, + ).tooltipTheme, + Brightness.dark => createYaruDarkTheme( + primaryColor: theme.colorScheme.primary, + highContrast: true, + ).tooltipTheme, + }; + expect(theme.tooltipTheme, yaruTooltipTheme); } }); diff --git a/test/app/keyboard_shortcuts_dialog_test.dart b/test/app/keyboard_shortcuts_dialog_test.dart index 42c8b27..522d3a5 100644 --- a/test/app/keyboard_shortcuts_dialog_test.dart +++ b/test/app/keyboard_shortcuts_dialog_test.dart @@ -1,10 +1,12 @@ import 'dart:io'; import 'package:busymax/src/app/busymax_design.dart'; +import 'package:busymax/src/app/busymax_dialog_identity.dart'; import 'package:busymax/src/app/busymax_keyboard_shortcuts_dialog.dart'; import 'package:busymax/src/app/busymax_yaru_theme.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:yaru/yaru.dart'; import '../test_localized_app.dart'; @@ -42,9 +44,88 @@ void main() { expect(find.text('Ctrl+R'), findsOneWidget); expect(find.text('Esc'), findsNWidgets(2)); expect(find.byIcon(Icons.close), findsWidgets); + expect(find.byType(YaruDialogTitleBar), findsOneWidget); + expect(find.byType(YaruWindowControl), findsOneWidget); expect(find.text('Close'), findsNothing); + + final identity = find.byType(BusyMaxDialogIdentity); + final title = tester.widget( + find.descendant(of: identity, matching: find.text('Keyboard Shortcuts')), + ); + final hero = tester.widget( + find.descendant( + of: identity, + matching: find.byIcon(YaruIcons.keyboard_shortcuts), + ), + ); + expect(identity, findsOneWidget); + expect(hero.size, BusyMaxDialogIdentity.visualExtent); + expect(title.style?.fontWeight, BusyMaxDialogIdentity.titleWeight); + + final titleBar = tester.widget( + find.byType(YaruDialogTitleBar), + ); + final closeButton = tester.widget( + find.byType(YaruWindowControl), + ); + expect(titleBar.isActive, isTrue); + expect(titleBar.border, BorderSide.none); + expect(closeButton.type, YaruWindowControlType.close); + expect( + tester.getSize(find.byType(YaruWindowControl)), + const Size.square(kYaruWindowControlSize), + ); + + final badgeEnds = [ + 'Ctrl+/', + 'Ctrl+,', + 'Ctrl+F', + ].map((label) => tester.getTopRight(find.text(label)).dx).toList(); + expect(badgeEnds.every((end) => end == badgeEnds.first), isTrue); }); + for (final textScale in [1.0, 2.0]) { + testWidgets('keyboard shortcuts remain scrollable in a short window at ' + '${textScale}x text', (tester) async { + tester.view + ..devicePixelRatio = 1 + ..physicalSize = const Size(480, 320); + addTearDown(tester.view.resetDevicePixelRatio); + addTearDown(tester.view.resetPhysicalSize); + + await tester.pumpWidget( + localizedTestApp( + textScaler: TextScaler.linear(textScale), + child: const BusyMaxKeyboardShortcutsDialog(), + ), + ); + await tester.pumpAndSettle(); + + expect(tester.takeException(), isNull); + expect( + tester.widget(find.byType(Dialog)).clipBehavior, + Clip.antiAlias, + ); + final scrollView = find.byType(SingleChildScrollView); + final closeButton = find.byType(YaruWindowControl); + expect(scrollView, findsOneWidget); + expect(closeButton.hitTestable(), findsOneWidget); + final closePosition = tester.getTopLeft(closeButton); + + await tester.scrollUntilVisible( + find.text('Compact agenda'), + 400, + scrollable: find.byType(Scrollable), + ); + await tester.pumpAndSettle(); + + expect(tester.takeException(), isNull); + expect(find.text('Compact agenda').hitTestable(), findsOneWidget); + expect(closeButton.hitTestable(), findsOneWidget); + expect(tester.getTopLeft(closeButton), closePosition); + }); + } + for (final brightness in Brightness.values) { testWidgets( 'keyboard shortcut groups resolve the contextual $brightness dialog card', @@ -74,7 +155,18 @@ void main() { matching: find.byType(Material), ), ) - .where((material) => material.elevation == BusyMaxElevation.card) + .where( + (material) => + material.color?.toARGB32() == expectedGroupedColor.toARGB32(), + ) + .toList(); + final groupedCards = tester + .widgetList( + find.descendant( + of: find.byType(BusyMaxGroupedSurface), + matching: find.byType(Card), + ), + ) .toList(); expect(groupedMaterials, hasLength(6)); @@ -85,6 +177,13 @@ void main() { ), isTrue, ); + expect( + groupedMaterials.every( + (material) => material.elevation == theme.cardTheme.elevation, + ), + isTrue, + ); + expect(groupedCards, hasLength(6)); final dialog = tester.widget(find.byType(Dialog)); final dialogShape = (dialog.shape ?? theme.dialogTheme.shape)! diff --git a/test/app/modal_barrier_test.dart b/test/app/modal_barrier_test.dart new file mode 100644 index 0000000..7d0872d --- /dev/null +++ b/test/app/modal_barrier_test.dart @@ -0,0 +1,124 @@ +import 'dart:io'; + +import 'package:busymax/src/app/busymax_design.dart'; +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 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); + + 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( + 'self->header_bar_modal_barrier_color, kDefaultModalBarrierColor', + ), + ); + }); + + for (final (brightness, expectedAlpha) in [ + (Brightness.light, 0.07), + (Brightness.dark, 0.25), + ]) { + testWidgets( + '$brightness modal barrier follows the native semantic shade role', + (tester) async { + final theme = BusyMaxYaruTheme.build( + brightness: brightness, + accentColor: const Color(0xFF3584E4), + ); + late Color barrier; + + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: Builder( + builder: (context) { + barrier = busyMaxModalBarrierColor(context); + return const SizedBox.shrink(); + }, + ), + ), + ); + + final shade = theme.extension()!.shade; + expect(barrier.a, closeTo(expectedAlpha, 0.0001)); + expect(barrier.r, shade.r); + expect(barrier.g, shade.g); + expect(barrier.b, shade.b); + }, + ); + } + + testWidgets('high-contrast shade remains a translucent modal layer', ( + tester, + ) async { + final theme = BusyMaxYaruTheme.build( + brightness: Brightness.light, + accentColor: const Color(0xFF3584E4), + highContrast: true, + ); + late Color barrier; + + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: Builder( + builder: (context) { + barrier = busyMaxModalBarrierColor(context); + return const SizedBox.shrink(); + }, + ), + ), + ); + + expect(barrier, theme.extension()!.shade); + expect(barrier.a, 0.50); + expect(barrier.a, lessThan(1)); + }); + + testWidgets( + 'a GTK3 palette without semantic shade keeps restrained light dimming', + (tester) async { + const gtkColors = GtkThemeColors( + brightness: Brightness.light, + window: Color(0xFFFAFAFA), + popover: Color(0xFFFAFAFA), + ); + final theme = BusyMaxYaruTheme.build( + brightness: Brightness.light, + accentColor: const Color(0xFF3584E4), + gtkThemeColors: gtkColors, + ); + late Color barrier; + + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: Builder( + builder: (context) { + barrier = busyMaxModalBarrierColor(context); + return const SizedBox.shrink(); + }, + ), + ), + ); + + expect( + theme.extension()!.shade, + busyMaxFallbackSurfaceColors(Brightness.light).shade, + ); + expect(barrier.a, closeTo(0.07, 0.0001)); + }, + ); +} diff --git a/test/app/native_ui_audit_test.dart b/test/app/native_ui_audit_test.dart index c6c620b..de40510 100644 --- a/test/app/native_ui_audit_test.dart +++ b/test/app/native_ui_audit_test.dart @@ -84,7 +84,6 @@ void main() { expect(source, isNot(contains('configure_rounded_window_shape'))); expect(source, isNot(contains('CAIRO_OPERATOR_CLEAR'))); expect(source, isNot(contains('kNativeWindowRadius'))); - expect(source, isNot(contains('border-radius: %dpx;'))); expect(source, isNot(contains('"unified"'))); expect(app, isNot(contains('_BusyMaxWindowCornerClip'))); }, @@ -195,8 +194,11 @@ void main() { expect(design, contains('class BusyMaxDialogShell')); expect(design, contains('class BusyMaxModalEditorSurface')); expect(design, contains('Color busyMaxModalBarrierColor')); - expect(design, contains('abstract final class BusyMaxElevation')); - expect(design, contains('BusyMaxShadow.physicalColor(context)')); + expect(design, contains('decoration: ShapeDecoration(')); + expect(design, contains('BusyMaxShadow.nativePopoverShadowsFor(')); + expect(design, contains('ShapeBorderClipper(shape: shape)')); + expect(design, isNot(contains('_BusyMaxPopoverShadowPainter'))); + expect(design, isNot(contains('return PhysicalShape('))); expect(design, isNot(contains('lightSurfaceShadowMinimum'))); expect(design, contains('final bool filled;')); expect(design, contains('BusyMaxSurfaceColors.of(context)')); @@ -205,6 +207,18 @@ void main() { expect(design, contains('surfaceColors.control')); expect(design, contains('YaruListTile.square(')); expect(design, isNot(contains('class _BusyMaxRowTile'))); + final calendarRowStart = design.indexOf( + 'class BusyMaxCalendarValueRow', + ); + final calendarRowEnd = design.indexOf( + 'class BusyMaxCalendarNotesCard', + calendarRowStart, + ); + expect(calendarRowStart, isNonNegative); + expect(calendarRowEnd, greaterThan(calendarRowStart)); + final calendarRow = design.substring(calendarRowStart, calendarRowEnd); + expect(calendarRow, contains('required this.entry')); + expect(calendarRow, isNot(contains('TextField('))); expect(taskDetails, contains('BusyMaxClamp')); expect(taskDetails, contains('BusyMaxGroupedList')); @@ -258,11 +272,22 @@ void main() { expect(compactAgenda, contains('ScheduleProjection.colorForItem')); expect(compactAgenda, contains('leading: _CompactAgendaRowMarker')); - expect(dateTimeFields, contains('YaruDateTimeEntry')); - expect(dateTimeFields, contains('YaruTimeEntry(')); - expect(dateTimeFields, contains('YaruTimeEntryController')); + expect(dateTimeFields, contains('InputDatePickerFormField')); + expect(dateTimeFields, contains('fieldLabelText: widget.label')); + expect(dateTimeFields, contains('entry: TextFormField(')); + expect( + 'busyMaxGroupedTextFieldDecoration'.allMatches(dateTimeFields).length, + greaterThanOrEqualTo(2), + ); + expect(dateTimeFields, contains('labelText: widget.label')); + expect(dateTimeFields, contains('parseDesktopTimeInput')); + expect(dateTimeFields, isNot(contains('_withoutFloatingEntryLabel'))); expect(dateTimeFields, isNot(contains('_BusyMaxTimeTextEntry'))); - expect(dateTimeFields, isNot(contains('parseTimeInput'))); + expect(dateTimeFields, isNot(contains('YaruDateTimeEntry'))); + expect(dateTimeFields, isNot(contains('YaruTimeEntry('))); + expect(dateTimeFields, isNot(contains('YaruTimeEntryController'))); + expect(dateTimeFields, isNot(contains("'Enter date'"))); + expect(dateTimeFields, isNot(contains("'Enter time'"))); expect(dateTimeFields, isNot(contains('fontSize: 0'))); expect(dateTimeFields, isNot(contains('showDatePicker'))); expect(dateTimeFields, isNot(contains('showTimePicker'))); @@ -535,6 +560,19 @@ void main() { 'gtk_label_set_xalign(GTK_LABEL(self->header_title_label), 0.5)', ), ); + 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'), + ); expect(source, isNot(contains('header_brand_logo'))); expect(source, contains('header_brand_label')); expect(source, contains('settings_menu_button')); @@ -691,13 +729,15 @@ void main() { 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('border-radius: %dpx;'))); expect(source, isNot(contains('kHeaderButtonRadius'))); expect(source, isNot(contains('tooltip.background'))); - expect(source, isNot(contains('tooltip > box'))); - expect(source, isNot(contains('tooltip label'))); + expect(source, isNot(contains('"tooltip > box,"'))); + expect(source, isNot(contains('"tooltip label {"'))); 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'))); @@ -760,9 +800,15 @@ void main() { expect(source, isNot(contains('gtk_widget_get_visible(popup)'))); expect(source, isNot(contains('"busymax-header-popover"'))); expect(source, contains('"busymax-native-popover"')); + expect(source, contains('"busymax-header-menu-depth"')); expect(source, contains('header_bar_popover_background_color')); + expect(source, contains('header_bar_popover_shadow_color')); + expect(source, contains('header_bar_menu_hover_color')); expect(source, isNot(contains('header_bar_floating_border_color'))); expect(source, contains('"popoverBackgroundColor"')); + expect(source, contains('"menuHoverColor"')); + expect(source, contains('"popoverShadowColor"')); + expect(source, contains('"dialogBackgroundColor"')); expect(source, contains('"dialogOutlineColor"')); expect(source, isNot(contains('"floatingBorderColor"'))); expect(source, isNot(contains('"busymax-header-popover-row"'))); @@ -851,14 +897,59 @@ void main() { 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, isNot(contains('busymax-search-entry'))); + 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('busymax-modal-barrier')); expect( source, - contains('gtk_widget_set_sensitive(self->titlebar_handle'), + 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'))); @@ -875,8 +966,8 @@ void main() { 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, contains('modal_sidebar_border_css_color')); - expect(source, contains('composite_rgba')); + 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'))); @@ -918,7 +1009,7 @@ void main() { ); expect(source, isNot(contains('create_header_popup_box'))); expect(source, isNot(contains('draw_header_popup_background_cb'))); - expect(source, isNot(contains('gtk_event_box_new()'))); + 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'))); @@ -950,15 +1041,32 @@ void main() { expect(source, contains('get_gtk_theme_colors')); expect(source, contains('lookup_context_color')); expect(source, contains('theme_bg_color')); - expect(source, contains('theme_base_color')); expect( source, - contains('lookup_context_color(window_context, "wm_shadow"'), + 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, contains('GTK_STYLE_CLASS_VIEW')); + 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('"view_bg_color"')); expect(source, contains('"sidebar_bg_color"')); expect(source, contains('"secondary_sidebar_bg_color"')); expect(source, contains('"headerbar_bg_color"')); @@ -1079,6 +1187,26 @@ void main() { expect(nativeMenu, contains('g_simple_action_new_stateful(')); expect(nativeMenu, contains('g_object_ref(G_OBJECT(method_call))')); expect(nativeMenu, contains('gtk_popover_popup(')); + expect( + nativeMenu, + isNot(contains('gtk_widget_show_all(session->popover)')), + ); + expect(nativeMenu, isNot(contains('gtk_popover_bind_model('))); + expect(nativeMenu, contains('gtk_widget_destroy(session->popover)')); + expect( + nativeMenu, + contains( + 'gtk_widget_child_focus(session->popover, GTK_DIR_TAB_FORWARD)', + ), + ); + expect( + nativeMenu, + isNot(contains('gtk_widget_grab_focus(session->popover)')), + ); + expect( + nativeMenu, + isNot(contains('gtk_widget_set_can_focus(session->popover')), + ); expect(nativeMenu, isNot(contains('gtk_dialog_run('))); expect(nativeMenu, isNot(contains('gtk_menu_new('))); expect(nativeMenu, isNot(contains('gtk_widget_override'))); @@ -1129,6 +1257,73 @@ void main() { expect(confirmBody, isNot(contains('return BusyMaxDialogShell('))); }); + test( + 'text prompts reuse the shared Yaru grouped form without native reinvention', + () { + final runner = File( + 'linux/runner/my_application.cc', + ).readAsStringSync(); + final service = File( + 'lib/src/platform/native_dialog_service.dart', + ).readAsStringSync(); + final dialogs = File( + 'lib/src/app/busymax_dialogs.dart', + ).readAsStringSync(); + final design = File( + 'lib/src/app/busymax_design.dart', + ).readAsStringSync(); + final nativeDialogsStart = runner.indexOf('static void respond_bool('); + final nativeDialogsEnd = runner.indexOf( + 'struct NativeDialogHandlerData', + nativeDialogsStart, + ); + final promptStart = design.indexOf('class BusyMaxPromptDialog'); + final promptEnd = design.indexOf( + 'class BusyMaxConfirmDialog', + promptStart, + ); + + expect(nativeDialogsStart, isNonNegative); + expect(nativeDialogsEnd, greaterThan(nativeDialogsStart)); + final nativeDialogs = runner.substring( + nativeDialogsStart, + nativeDialogsEnd, + ); + expect(nativeDialogs, isNot(contains('handle_native_prompt'))); + expect(nativeDialogs, isNot(contains('respond_native_prompt'))); + expect(nativeDialogs, isNot(contains('gtk_entry_new()'))); + expect(nativeDialogs, isNot(contains('gtk_dialog_new_with_buttons('))); + expect(nativeDialogs, contains('gtk_message_dialog_new(')); + expect(runner, contains('strcmp(method, "confirm") == 0')); + expect(runner, isNot(contains('strcmp(method, "prompt")'))); + + expect(service, isNot(contains('NativeTextPromptResult'))); + expect(service, isNot(contains('Future'))); + expect(service, isNot(contains('invokeMapMethod'))); + expect(service, isNot(contains("'prompt'"))); + expect(service, contains('on MissingPluginException')); + expect(service, contains('on PlatformException')); + expect(dialogs, isNot(contains('nativeDialogService.prompt('))); + expect(dialogs, contains('showBusyMaxModalDialog(')); + expect(dialogs, contains('BusyMaxPromptDialog(')); + + expect(promptStart, isNonNegative); + expect(promptEnd, greaterThan(promptStart)); + final prompt = design.substring(promptStart, promptEnd); + expect(prompt, contains('BusyMaxDialogShell(')); + expect(prompt, contains('BusyMaxGroupedList(')); + expect(prompt, contains('filled: true')); + expect(prompt, contains('YaruListTile.square(')); + expect(prompt, contains('busyMaxGroupedTextFieldDecoration(')); + expect(prompt, contains('TextEditingController(')); + expect(prompt, contains('_canSubmit ? _submit : null')); + expect(prompt, contains('onFieldSubmitted: (_) => _submit()')); + expect(prompt, isNot(contains('maxWidth:'))); + expect(prompt, isNot(contains('InputDecoration('))); + expect(prompt, isNot(contains('AlertDialog('))); + }, + ); + test('modal editors use the semantic window role with themed geometry', () { final design = File('lib/src/app/busymax_design.dart').readAsStringSync(); final start = design.indexOf('class BusyMaxModalEditorSurface'); @@ -1200,8 +1395,48 @@ void main() { nativeDialogCssStart, nativeDialogCssEnd, ); + final nativeSearchGeometryCssStart = source.indexOf( + 'g_autofree gchar* native_search_geometry_css =', + ); + final nativeSearchGeometryCssEnd = source.indexOf( + 'g_autofree gchar* native_menu_state_css =', + nativeSearchGeometryCssStart, + ); + expect(nativeSearchGeometryCssStart, isNonNegative); + expect( + nativeSearchGeometryCssEnd, + greaterThan(nativeSearchGeometryCssStart), + ); + final nativeSearchGeometryCss = source.substring( + nativeSearchGeometryCssStart, + nativeSearchGeometryCssEnd, + ); + final nativeMenuStateCssStart = nativeSearchGeometryCssEnd; + final nativeMenuStateCssEnd = source.indexOf( + 'g_autofree gchar* header_menu_shadow_css =', + nativeMenuStateCssStart, + ); + expect(nativeMenuStateCssStart, isNonNegative); + expect(nativeMenuStateCssEnd, greaterThan(nativeMenuStateCssStart)); + final nativeMenuStateCss = source.substring( + nativeMenuStateCssStart, + nativeMenuStateCssEnd, + ); + final headerMenuShadowCssStart = source.indexOf( + 'g_autofree gchar* header_menu_shadow_css =', + ); + final headerMenuShadowCssEnd = source.indexOf( + 'g_autofree gchar* yaru_window_decoration_css =', + headerMenuShadowCssStart, + ); + expect(headerMenuShadowCssStart, isNonNegative); + expect(headerMenuShadowCssEnd, isNonNegative); + final headerMenuShadowCss = source.substring( + headerMenuShadowCssStart, + headerMenuShadowCssEnd, + ); final yaruDecorationCssStart = source.indexOf( - 'const gboolean use_yaru_window_decoration_compatibility =', + 'const gboolean use_legacy_yaru_compatibility =', ); final yaruDecorationCssEnd = source.indexOf( 'GtkWidget* header_bar =', @@ -1222,21 +1457,47 @@ void main() { contains('".busymax-titlebar .busymax-header-brand {"'), ); expect(headerCss, contains('"border-right: 1px solid %s;"')); - expect(headerCss, contains('"border-right-color: %s;"')); + expect(headerCss, isNot(contains('"border-right-color: %s;"'))); + expect( + headerCss, + contains( + '".busymax-titlebar.%s "\n' + ' ".busymax-header-control,"', + ), + ); expect( headerCss, contains( - '".busymax-titlebar.busymax-modal-barrier ' - '.busymax-header-brand,"', + '".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( - '".busymax-titlebar.busymax-modal-barrier "\n' - ' "headerbar.busymax-flat-headerbar,"', + 'self->header_bar_modal_barrier_color, kDefaultModalBarrierColor', ), ); + expect(headerCss, isNot(contains('linear-gradient(%s, %s)'))); expect(headerCss, isNot(contains('".busymax-titlebar,"'))); expect(source, contains('kDefaultWindowBackgroundColor[] = "#2C2C2C"')); expect( @@ -1263,6 +1524,9 @@ void main() { expect(source, contains('"sidebarBorderColor"')); expect(source, contains('"foregroundColor"')); expect(source, contains('"popoverBackgroundColor"')); + expect(source, contains('"menuHoverColor"')); + expect(source, contains('"popoverShadowColor"')); + expect(source, contains('"dialogBackgroundColor"')); expect(source, isNot(contains('"floatingBorderColor"'))); expect(source, contains('"highContrast"')); expect(source, isNot(contains('"shadeColor"'))); @@ -1283,6 +1547,15 @@ void main() { source, contains('fl_lookup_string_arg(args, "popoverBackgroundColor")'), ); + expect(source, contains('fl_lookup_string_arg(args, "menuHoverColor")')); + expect( + source, + 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")'), @@ -1305,11 +1578,72 @@ void main() { ); expect(nativePopoverCss, isNot(contains('g_strdup("border: none;")'))); expect(nativePopoverCss, isNot(contains('box-shadow'))); + expect(source, isNot(contains('kNativePopoverShadowCss'))); expect(nativePopoverCss, isNot(contains('border-radius'))); expect(nativePopoverCss, isNot(contains('padding'))); expect(nativePopoverCss, isNot(contains('outline'))); expect(nativePopoverCss, isNot(contains('modelbutton'))); expect(nativePopoverCss, isNot(contains('#'))); + 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(nativeMenuStateCss, contains('!self->header_bar_high_contrast')); + expect( + nativeMenuStateCss, + isNot(contains('use_legacy_yaru_compatibility')), + ); + expect( + nativeMenuStateCss, + contains('is_css_color_token(self->header_bar_menu_hover_color)'), + ); + expect( + nativeMenuStateCss, + contains( + '"popover.background.%s "\n' + ' ' + '"modelbutton:hover:not(:disabled) {"', + ), + ); + expect(nativeMenuStateCss, isNot(contains(':not(:backdrop)'))); + expect(nativeMenuStateCss, isNot(contains('modelbutton.flat'))); + expect(nativeMenuStateCss, contains('"background-color: %s;"')); + expect(nativeMenuStateCss, contains('"background-image: none;"')); + expect(nativeMenuStateCss, contains('self->header_bar_menu_hover_color')); + expect(nativeMenuStateCss, contains('kNativePopoverStyleClass')); + expect(nativeMenuStateCss, isNot(contains('border-radius'))); + expect(nativeMenuStateCss, isNot(contains('"border:'))); + expect(nativeMenuStateCss, isNot(contains('box-shadow'))); + expect(nativeMenuStateCss, isNot(contains('padding'))); + expect(nativeMenuStateCss, isNot(contains('margin'))); + expect(nativeMenuStateCss, isNot(contains('min-height'))); + expect(nativeMenuStateCss, isNot(contains('#'))); + expect(nativeMenuStateCss, isNot(contains('rgba('))); + expect( + headerMenuShadowCss, + contains('"popover.background.%s.%s:not(:backdrop) {"'), + ); + expect(headerMenuShadowCss, contains('"box-shadow: 0 1px 3px %s;"')); + expect( + headerMenuShadowCss, + contains('self->header_bar_popover_shadow_color'), + ); + expect(headerMenuShadowCss, contains('kDefaultHeaderMenuShadowColor')); + expect(headerMenuShadowCss, contains('kNativePopoverStyleClass')); + expect(headerMenuShadowCss, contains('kHeaderMenuDepthStyleClass')); + expect(headerMenuShadowCss, isNot(contains('"border:'))); + expect(headerMenuShadowCss, isNot(contains('border-radius'))); expect(source, contains('"busymax-native-dialog"')); expect(source, contains('style_native_dialog(GtkWidget* dialog)')); expect('style_native_dialog(dialog);'.allMatches(source).length, 2); @@ -1317,6 +1651,19 @@ void main() { nativeDialogCss, contains('g_autofree gchar* native_dialog_css ='), ); + expect( + nativeDialogCss, + contains( + '".%s headerbar,"\n' + ' ".%s headerbar:backdrop {"', + ), + ); + expect( + 'dialog_background_color'.allMatches(nativeDialogCss).length, + 2, + reason: 'the native dialog body and titlebar share one surface role', + ); + expect(nativeDialogCss, isNot(contains('window_background_color'))); expect(nativeDialogCss, contains('"box-shadow: inset 0 0 0 1px %s;"')); expect( yaruDecorationCss, @@ -1329,6 +1676,7 @@ void main() { yaruDecorationCss, contains('current_gtk_theme_uses_legacy_yaru_shadow()'), ); + expect(yaruDecorationCss, contains('use_legacy_yaru_compatibility')); expect(yaruDecorationCss, contains('!self->header_bar_high_contrast')); expect( yaruDecorationCss, @@ -1342,14 +1690,19 @@ void main() { nativeDialogCss, contains( 'kNativeDialogStyleClass, kNativeDialogStyleClass,\n' - ' window_background_color', + ' dialog_background_color', ), ); expect(nativeDialogCss, isNot(contains('"border:'))); expect(nativeDialogCss, isNot(contains('border-radius'))); expect(source, contains('style_native_popover(session->popover)')); - expect(source, contains('style_native_popover(GTK_WIDGET(popover))')); - expect(headerCss, isNot(contains('.busymax-titlebar button'))); + expect(source, isNot(contains('activate_native_menu_host('))); + expect( + source, + contains('style_header_menu_popover(GTK_WIDGET(popover))'), + ); + expect(source, contains('style_native_popover(popover)')); + expect(headerCss, contains('headerbar button.titlebutton')); expect(source, contains('kHeaderControlStyleClass')); expect(source, isNot(contains('kHeaderMenuControlStyleClass'))); expect(source, contains('style_header_control(button)')); @@ -1498,10 +1851,10 @@ void main() { expect(headerBarService, contains("'preferDark': preferDark")); expect( app, - contains( - 'final preferDark = Theme.of(context).brightness == Brightness.dark', - ), + contains('final preferDark = theme.brightness == Brightness.dark'), ); + expect(app, contains('popoverShadowColor: theme.colorScheme.shadow')); + expect(app, contains('BusyMaxAlpha.nativeHeaderMenuShadowOpacity')); expect(app, contains('preferDark: preferDark')); expect(source, contains('static void set_gtk_theme_preference')); expect( @@ -1640,7 +1993,10 @@ void main() { expect(fallbackBody, contains('final selection = showMenu(')); expect(fallbackBody, contains('return await selection;')); expect(fallbackBody, contains('session._releaseFallbackRoute();')); - expect(fallbackBody, contains('PopupMenuItem(')); + expect(fallbackBody, contains('_BusyMaxPopupMenuItem(')); + expect(fallbackBody, contains('extends PopupMenuItem')); + expect(fallbackBody, contains('super.build(context)')); + expect(fallbackBody, contains('hoverColor: widget.hoverColor')); expect(fallbackBody, contains('YaruRadio(')); expect(fallbackBody, contains('inMutuallyExclusiveGroup: true')); expect(fallbackBody, isNot(contains('YaruCheckedPopupMenuItem'))); diff --git a/test/app/surface_palette_render_test.dart b/test/app/surface_palette_render_test.dart index fbc3cb4..07f940a 100644 --- a/test/app/surface_palette_render_test.dart +++ b/test/app/surface_palette_render_test.dart @@ -174,7 +174,7 @@ void main() { } expect( _pixelAtProbe(tester, pixels, contentPopoverProbe), - baseline.card, + baseline.popover, ); }, ); diff --git a/test/app/theme_localization_test.dart b/test/app/theme_localization_test.dart index 9885561..473c1f1 100644 --- a/test/app/theme_localization_test.dart +++ b/test/app/theme_localization_test.dart @@ -164,11 +164,12 @@ void main() { expect(theme.cardTheme.color?.a, 1); expect(theme.cardTheme.surfaceTintColor, Colors.transparent); expect(theme.cardTheme.shadowColor, theme.colorScheme.shadow); - expect(theme.cardTheme.elevation, BusyMaxElevation.card); + expect(theme.cardTheme.elevation, BusyMaxElevation.groupedCard); expect(theme.cardTheme.margin, base.cardTheme.margin); expect(theme.cardTheme.clipBehavior, base.cardTheme.clipBehavior); final cardShape = theme.cardTheme.shape! as RoundedRectangleBorder; expect(cardShape.borderRadius, BorderRadius.circular(BusyMaxRadius.md)); + expect(cardShape.side, BorderSide.none); expect( theme.dropdownMenuTheme.inputDecorationTheme?.constraints, @@ -177,6 +178,8 @@ void main() { final dialogShape = theme.dialogTheme.shape! as RoundedRectangleBorder; final baseDialogShape = base.dialogTheme.shape! as RoundedRectangleBorder; + expect(theme.dialogTheme.elevation, base.dialogTheme.elevation); + expect(theme.dialogTheme.shadowColor, theme.colorScheme.shadow); expect(dialogShape.borderRadius, baseDialogShape.borderRadius); expect(dialogShape.borderRadius, BorderRadius.circular(kYaruWindowRadius)); expect(dialogShape.side, BorderSide(color: colors.dialogOutline)); @@ -211,7 +214,14 @@ void main() { final basePopupShape = base.popupMenuTheme.shape! as OutlineInputBorder; expect(popupShape.borderRadius, basePopupShape.borderRadius); expect(popupShape.borderSide, BorderSide(color: colors.floatingBorder)); - expect(theme.popupMenuTheme.elevation, base.popupMenuTheme.elevation); + expect( + theme.popupMenuTheme.elevation, + theme.menuTheme.style?.elevation?.resolve(const {}), + ); + expect( + theme.menuTheme.style?.elevation?.resolve(const {}), + base.menuTheme.style?.elevation?.resolve(const {}), + ); expect(theme.popupMenuTheme.menuPadding, base.popupMenuTheme.menuPadding); expect(theme.popupMenuTheme.position, base.popupMenuTheme.position); expect( @@ -308,6 +318,7 @@ void main() { expect(lightColors.sidebar, const Color(0xFFEBEBEB)); expect(lightColors.secondarySidebar, const Color(0xFFF0F0F0)); expect(lightColors.headerbar, const Color(0xFFFAFAFA)); + expect(lightColors.headerbarFlat, lightColors.view); expect(lightColors.card, const Color(0xFFFFFFFF)); expect(lightColors.groupedSurface, const Color(0xFFFFFFFF)); expect(lightColors.dialog, const Color(0xFFFAFAFA)); @@ -409,12 +420,14 @@ void main() { expect(dark.cardTheme.color, darkColors.card); expect(light.cardTheme.color?.a, 1); expect(dark.cardTheme.color?.a, 1); - expect(light.cardTheme.elevation, BusyMaxElevation.card); - expect(dark.cardTheme.elevation, BusyMaxElevation.card); + expect(light.cardTheme.elevation, BusyMaxElevation.groupedCard); + expect(dark.cardTheme.elevation, BusyMaxElevation.groupedCard); expect(light.cardTheme.shadowColor, light.colorScheme.shadow); expect(dark.cardTheme.shadowColor, dark.colorScheme.shadow); expect(light.dialogTheme.backgroundColor, lightColors.dialog); expect(dark.dialogTheme.backgroundColor, darkColors.dialog); + expect(light.dialogTheme.shadowColor, light.colorScheme.shadow); + expect(dark.dialogTheme.shadowColor, dark.colorScheme.shadow); expect(light.popupMenuTheme.color, lightColors.popover); expect(dark.popupMenuTheme.color, darkColors.popover); expect( @@ -457,36 +470,9 @@ void main() { pair.$2?.padding?.resolve(const {}), ); } - expect(light.tooltipTheme.decoration, isA()); - expect(dark.tooltipTheme.decoration, isA()); - expect( - (light.tooltipTheme.decoration! as BoxDecoration).color, - lightColors.popover, - ); - expect((light.tooltipTheme.decoration! as BoxDecoration).border, isNull); - expect( - (light.tooltipTheme.decoration! as BoxDecoration).boxShadow, - BusyMaxShadow.tooltipShadows(lightColors.shade), - ); - expect( - (dark.tooltipTheme.decoration! as BoxDecoration).color, - darkColors.popover, - ); - expect((dark.tooltipTheme.decoration! as BoxDecoration).border, isNull); - expect( - (dark.tooltipTheme.decoration! as BoxDecoration).boxShadow, - BusyMaxShadow.tooltipShadows(darkColors.shade), - ); - expect(light.tooltipTheme.textStyle?.color, lightColors.foreground); - expect(dark.tooltipTheme.textStyle?.color, darkColors.foreground); - expect( - light.tooltipTheme.textStyle?.fontSize, - light.textTheme.bodyMedium?.fontSize, - ); - expect( - dark.tooltipTheme.textStyle?.fontSize, - dark.textTheme.bodyMedium?.fontSize, - ); + final yaruLight = createYaruLightTheme(primaryColor: _testAccentColor); + expect(light.tooltipTheme, yaruLight.tooltipTheme); + expect(dark.tooltipTheme, yaruDark.tooltipTheme); }); test('BusyMaxSurfaceColors copyWith preserves and overrides fields', () { @@ -704,13 +690,9 @@ void main() { family: gtkFamily, scale: scale, ); - _expectComponentStyleUsesTypography( - theme.tooltipTheme.textStyle, - baseStyle: null, - fallback: textTheme.bodyMedium, - family: gtkFamily, - scale: scale, - ); + // Yaru leaves tooltip textStyle unset, so Flutter resolves it from the + // already-normalized ambient TextTheme together with its inverse palette. + expect(theme.tooltipTheme.textStyle, base.tooltipTheme.textStyle); _expectComponentStyleUsesTypography( theme.snackBarTheme.contentTextStyle, baseStyle: base.snackBarTheme.contentTextStyle, @@ -896,6 +878,54 @@ void main() { expect(colors.sidebar, gtkColors.sidebar); }); + test('light view keeps its semantic fallback when GTK omits it', () { + const gtkColors = GtkThemeColors( + brightness: Brightness.light, + window: Color(0xFFF4F4F4), + ); + final colors = _buildBusyMaxTheme( + brightness: Brightness.light, + gtkThemeColors: gtkColors, + ).extension()!; + final fallback = busyMaxFallbackSurfaceColors(Brightness.light); + + expect(colors.window, gtkColors.window); + expect(colors.view, fallback.view); + expect(colors.headerbarFlat, fallback.view); + }); + + test('an explicit GTK workspace view remains authoritative', () { + const gtkColors = GtkThemeColors( + brightness: Brightness.light, + window: Color(0xFFF4F4F4), + view: Color(0xFFFFFFFF), + ); + final colors = _buildBusyMaxTheme( + brightness: Brightness.light, + gtkThemeColors: gtkColors, + ).extension()!; + + expect(colors.window, gtkColors.window); + expect(colors.view, gtkColors.view); + expect(colors.headerbarFlat, gtkColors.view); + }); + + test('dark workspace keeps its distinct fallback when GTK omits view', () { + const gtkColors = GtkThemeColors( + brightness: Brightness.dark, + window: Color(0xFF303030), + ); + final colors = _buildBusyMaxTheme( + brightness: Brightness.dark, + gtkThemeColors: gtkColors, + ).extension()!; + final fallback = busyMaxFallbackSurfaceColors(Brightness.dark); + + expect(colors.window, gtkColors.window); + expect(colors.view, fallback.view); + expect(colors.headerbarFlat, fallback.view); + }); + test('BusyMax preserves a distinct light GTK sidebar sample', () { const gtkColors = GtkThemeColors( brightness: Brightness.light, @@ -1574,12 +1604,15 @@ void main() { expect(source, contains('_headerBarConfigurationSynchronizer.schedule(')); expect(synchronizer, contains('await service.setTheme(')); expect(source, contains('windowBackgroundColor: colors.window')); - expect(source, contains('backgroundColor: colors.headerbarFlat')); + 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, contains('popoverBackgroundColor: colors.popover')); + expect(source, contains('menuHoverColor: colors.controlHover')); + expect(source, contains('dialogBackgroundColor: colors.dialog')); expect(source, isNot(contains('floatingBorderColor:'))); expect(source, contains('modalBarrierColor: modalBarrierColor')); expect(source, isNot(contains('controlHoverColor: colors.controlHover'))); @@ -1611,10 +1644,10 @@ void main() { final shellEnd = source.indexOf('child: Column(', shellStart); final shellSource = source.substring(shellStart, shellEnd); - expect(source, contains('color: BusyMaxSurfaceColors.of(context).view')); + expect(source, contains('color: BusyMaxSurfaceColors.of(context).window')); expect( source, - isNot(contains('color: BusyMaxSurfaceColors.of(context).window')), + isNot(contains('color: BusyMaxSurfaceColors.of(context).view')), ); expect(shellSource, contains('BusyMaxSurface(')); expect(shellSource, contains('filled: false')); diff --git a/test/features/calendar/presentation/event_editor_test.dart b/test/features/calendar/presentation/event_editor_test.dart index 0cd1545..2a1c82a 100644 --- a/test/features/calendar/presentation/event_editor_test.dart +++ b/test/features/calendar/presentation/event_editor_test.dart @@ -190,8 +190,8 @@ void main() { ), ); - expect(find.text('Start time'), findsNothing); - expect(find.text('End Time'), findsNothing); + expect(_timeRowFinder('Start time'), findsNothing); + expect(_timeRowFinder('End Time'), findsNothing); expect(_plainTextFinder('All day'), findsOneWidget); expect(_plainTextFinder('Time slot'), findsOneWidget); expect(find.text('No conference'), findsNothing); @@ -239,7 +239,7 @@ void main() { ); }); - testWidgets('timed event shows separated end date and end time labels', ( + testWidgets('timed event gives date and time fields contextual labels', ( tester, ) async { await tester.pumpWidget( @@ -261,14 +261,80 @@ void main() { ), ); - expect(find.text('Start date'), findsOneWidget); - expect(find.text('Start time'), findsOneWidget); - expect(find.text('End Date'), findsOneWidget); - expect(find.text('End Time'), findsOneWidget); - expect(find.text('End date/time'), findsNothing); + expect(find.text('Start date/time'), findsOneWidget); + expect(find.text('End date/time'), findsOneWidget); + expect( + find.byWidgetPredicate( + (widget) => + widget is DesktopDateValueRow && widget.label == 'Start date', + ), + findsOneWidget, + ); + expect( + find.byWidgetPredicate( + (widget) => + widget is DesktopTimeValueRow && widget.label == 'Start time', + ), + findsOneWidget, + ); + expect( + find.byWidgetPredicate( + (widget) => widget is DesktopDateValueRow && widget.label == 'End Date', + ), + findsOneWidget, + ); + expect( + find.byWidgetPredicate( + (widget) => widget is DesktopTimeValueRow && widget.label == 'End Time', + ), + findsOneWidget, + ); + }); + + testWidgets('existing timed event renders all date and time values', ( + tester, + ) async { + await tester.pumpWidget( + localizedTestApp( + alwaysUse24HourFormat: true, + child: Scaffold( + body: EventEditor( + initialDraft: EventEditorDraft.existing( + eventId: 'event-1', + accountId: 'account', + sourceId: 'source', + providerCalendarId: 'cal-1', + title: 'Planning', + allDay: false, + start: DateTime(2026, 6, 8, 9, 15), + end: DateTime(2026, 6, 8, 10, 45), + ), + sources: _sources, + onCancel: () {}, + onSave: (_) {}, + ), + ), + ), + ); + + final startDate = _dateTextField(tester, 'Start date'); + final startTime = _timeTextField(tester, 'Start time'); + final endDate = _dateTextField(tester, 'End Date'); + final endTime = _timeTextField(tester, 'End Time'); + + expect(startDate.controller?.text, isNotEmpty); + expect(startTime.controller?.text, '09:15'); + expect(endDate.controller?.text, isNotEmpty); + expect(endTime.controller?.text, '10:45'); + expect(startDate.decoration?.labelText, 'Start date'); + expect(startTime.decoration?.labelText, 'Start time'); + expect(endDate.decoration?.labelText, 'End Date'); + expect(endTime.decoration?.labelText, 'End Time'); + expect(find.text('Enter date'), findsNothing); + expect(find.text('Enter time'), findsNothing); }); - testWidgets('event time popup opens with current time and requires a value', ( + testWidgets('event time entry reports an empty required value', ( tester, ) async { await tester.pumpWidget( @@ -290,37 +356,151 @@ void main() { ), ); - await tester.ensureVisible(find.text('Start time')); - await tester.tap(find.text('Start time')); - await tester.pumpAndSettle(); - - final fieldFinder = _timeEntryFinder(); - final entry = tester.widget(fieldFinder); - expect(entry.controller?.timeOfDay, const TimeOfDay(hour: 9, minute: 0)); + final fieldFinder = _timeEntryFinder('Start time'); + await tester.ensureVisible(fieldFinder); + var entry = tester.widget(fieldFinder); + expect( + parseDesktopTimeInput( + tester.element(fieldFinder), + entry.controller?.text ?? '', + ), + const TimeOfDay(hour: 9, minute: 0), + reason: 'visible value: ${entry.controller?.text}', + ); - await tester.tap(find.byIcon(YaruIcons.edit_clear)); + await tester.tap(fieldFinder); await tester.pump(); + await _clearTimeEntry(tester, 'Start time'); expect(tester.takeException(), isNull); - expect(entry.controller?.timeOfDay, isNull); + entry = tester.widget(fieldFinder); + expect(entry.controller?.text, isEmpty); + FocusManager.instance.primaryFocus?.unfocus(); + await tester.pump(); + entry = tester.widget(fieldFinder); + expect(entry.controller?.text, isEmpty); expect( - tester - .widget( - find - .ancestor( - of: find.text('OK'), - matching: find.byWidgetPredicate( - (widget) => widget is ButtonStyleButton, - ), - ) - .first, - ) - .onPressed, + find.text( + MaterialLocalizations.of(tester.element(fieldFinder)).invalidTimeLabel, + ), + findsOneWidget, + ); + expect(find.text('OK'), findsNothing); + }); + + testWidgets( + 'invalid visible start time disables Save and Ctrl+S and makes Cancel confirm', + (tester) async { + var saveCalls = 0; + var cancelled = false; + await tester.pumpWidget( + localizedTestApp( + child: Scaffold( + body: EventEditor( + initialDraft: EventEditorDraft.existing( + eventId: 'event-1', + accountId: 'account', + sourceId: 'source', + providerCalendarId: 'cal-1', + title: 'Planning', + allDay: false, + start: DateTime.utc(2026, 6, 8, 9), + end: DateTime.utc(2026, 6, 8, 10), + ), + sources: _sources, + onCancel: () => cancelled = true, + onSave: (_) => saveCalls += 1, + ), + ), + ), + ); + + final startTime = _timeEntryFinder('Start time'); + await tester.enterText(startTime, 'not a time'); + await tester.pump(); + + final saveButton = tester.widget( + _headerButtonFinder('Save'), + ); + expect(saveButton.onPressed, isNull); + expect( + find.text( + MaterialLocalizations.of(tester.element(startTime)).invalidTimeLabel, + ), + findsOneWidget, + ); + + await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft); + await tester.sendKeyEvent(LogicalKeyboardKey.keyS); + await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft); + await tester.pump(); + + expect(saveCalls, 0); + + await tester.tap(_headerButtonFinder('Cancel')); + await tester.pumpAndSettle(); + + expect(cancelled, isFalse); + expect(find.text('Discard changes?'), findsOneWidget); + + await tester.tap(find.text('Discard')); + await tester.pumpAndSettle(); + + expect(cancelled, isTrue); + expect(saveCalls, 0); + }, + ); + + testWidgets('switching an invalid timed event to all-day clears validity', ( + tester, + ) async { + EventEditorDraft? saved; + await tester.pumpWidget( + localizedTestApp( + child: Scaffold( + body: EventEditor( + initialDraft: EventEditorDraft.existing( + eventId: 'event-1', + accountId: 'account', + sourceId: 'source', + providerCalendarId: 'cal-1', + title: 'Planning', + allDay: false, + start: DateTime.utc(2026, 6, 8, 9), + end: DateTime.utc(2026, 6, 8, 10), + ), + sources: _sources, + onCancel: () {}, + onSave: (draft) => saved = draft, + ), + ), + ), + ); + + await tester.enterText(_timeEntryFinder('Start time'), 'not a time'); + await tester.pump(); + expect( + tester.widget(_headerButtonFinder('Save')).onPressed, isNull, ); + + await tester.tap(_plainTextFinder('All day')); + await tester.pumpAndSettle(); + + expect(_timeRowFinder('Start time'), findsNothing); + expect(_timeRowFinder('End Time'), findsNothing); + expect( + tester.widget(_headerButtonFinder('Save')).onPressed, + isNotNull, + ); + + await tester.tap(_headerButtonFinder('Save')); + await tester.pump(); + + expect(saved?.allDay, isTrue); }); - testWidgets('event time popup accepts midnight input', (tester) async { + testWidgets('event time field accepts midnight input', (tester) async { EventEditorDraft? saved; await tester.pumpWidget( localizedTestApp( @@ -342,13 +522,12 @@ void main() { ), ); - await tester.ensureVisible(find.text('Start time')); - await tester.tap(find.text('Start time')); - await tester.pumpAndSettle(); - await tester.tap(find.byIcon(YaruIcons.edit_clear)); - await _enterTime(tester, hour: '00', minute: '00'); - await tester.tap(find.text('OK')); - await tester.pumpAndSettle(); + final fieldFinder = _timeEntryFinder('Start time'); + await tester.ensureVisible(fieldFinder); + await tester.tap(fieldFinder); + await tester.pump(); + await _clearTimeEntry(tester, 'Start time'); + await _enterTime(tester, label: 'Start time', hour: '00', minute: '00'); await tester.tap(_headerButtonFinder('Save')); @@ -1230,7 +1409,14 @@ void main() { expect(editor, contains('showBusyMaxEventEditorDialog')); expect(editor, contains('showBusyMaxModalEditorDialog')); expect(editor, isNot(contains('showDialog'))); - expect(dialogs, contains('setModalBarrierVisible(true)')); + expect( + dialogs, + contains('await acquireBusyMaxModalBarrier(headerBarService)'), + ); + expect( + dialogs, + contains('await releaseBusyMaxModalBarrier(headerBarService)'), + ); expect( dialogs, contains('barrierColor ?? busyMaxModalBarrierColor(context)'), @@ -1272,18 +1458,34 @@ void main() { expect(editor, isNot(contains('BusyMaxDialogCloseButton'))); }); - test('editor rows reuse the shared Yaru hover role, not a control fill', () { - final design = File('lib/src/app/busymax_design.dart').readAsStringSync(); - final hoverStart = design.indexOf('Color busyMaxRowHoverColor'); - final hoverEnd = design.indexOf('Color busyMaxPanelBorder'); - final hoverSource = design.substring(hoverStart, hoverEnd); - - expect(hoverSource, contains('return Theme.of(context).hoverColor')); - expect(hoverSource, contains('return busyMaxRowHoverColor(context)')); - expect(hoverSource, isNot(contains('.controlHover'))); - expect(hoverSource, isNot(contains('primaryContainer'))); - expect(hoverSource, isNot(contains('colorScheme.primary'))); - }); + test( + 'editor rows reuse the shared native hover role, not a control fill', + () { + final design = File('lib/src/app/busymax_design.dart').readAsStringSync(); + final editor = File( + 'lib/src/features/calendar/presentation/event_editor.dart', + ).readAsStringSync(); + final hoverStart = design.indexOf('Color busyMaxRowHoverColor'); + final hoverEnd = design.indexOf('Color busyMaxPanelBorder'); + final hoverSource = design.substring(hoverStart, hoverEnd); + + expect(hoverSource, contains('final theme = Theme.of(context)')); + expect(hoverSource, contains('final hover = theme.hoverColor')); + expect( + hoverSource, + contains('BusyMaxAlpha.groupedRowLightHoverStrength'), + ); + expect(design, contains('groupedRowLightHoverStrength = 0.50')); + expect( + hoverSource, + contains('theme.colorScheme.brightness == Brightness.dark'), + ); + expect(hoverSource, isNot(contains('.controlHover'))); + expect(hoverSource, isNot(contains('primaryContainer'))); + expect(hoverSource, isNot(contains('colorScheme.primary'))); + expect(editor, isNot(contains('hoverColor:'))); + }, + ); test('event editor text fields do not render duplicate section labels', () { final editor = File( @@ -1341,26 +1543,28 @@ void main() { ); }); - test('event editor text fields use plain borderless decoration', () { + test('event editor text fields use shared grouped-row decoration', () { final editor = File( 'lib/src/features/calendar/presentation/event_editor.dart', ).readAsStringSync(); + final design = File('lib/src/app/busymax_design.dart').readAsStringSync(); - expect(editor, contains('_plainEventFieldDecoration')); - expect(editor, contains('filled: false')); - expect(editor, contains('fillColor: Colors.transparent')); - expect(editor, contains('hoverColor: Colors.transparent')); - expect(editor, contains('labelText: labelText')); - expect(editor, contains('labelStyle: labelStyle')); - expect(editor, contains('floatingLabelStyle: labelStyle')); + expect(editor, contains('busyMaxGroupedTextFieldDecoration')); + expect(editor, isNot(contains('_plainEventFieldDecoration'))); + expect(design, contains('filled: false')); + expect(design, contains('fillColor: Colors.transparent')); + expect(design, contains('hoverColor: Colors.transparent')); + expect(design, contains('labelText: labelText')); + expect(design, contains('labelStyle: labelStyle')); + expect(design, contains('floatingLabelStyle: labelStyle')); expect( - editor, + design, contains('floatingLabelBehavior: FloatingLabelBehavior.auto'), ); - expect(editor, contains('enabledBorder: InputBorder.none')); - expect(editor, contains('focusedBorder: InputBorder.none')); - expect(editor, contains('errorBorder: InputBorder.none')); - expect(editor, contains('focusedErrorBorder: InputBorder.none')); + expect(design, contains('enabledBorder: InputBorder.none')); + expect(design, contains('focusedBorder: InputBorder.none')); + expect(design, contains('errorBorder: InputBorder.none')); + expect(design, contains('focusedErrorBorder: InputBorder.none')); }); test( @@ -1526,18 +1730,50 @@ BusyMaxComboRow _comboRow(WidgetTester tester, String title) { ); } -Finder _timeEntryFinder() => find.byType(YaruTimeEntry); +Finder _timeRowFinder(String label) { + return find.byWidgetPredicate( + (widget) => widget is DesktopTimeValueRow && widget.label == label, + ); +} + +TextField _dateTextField(WidgetTester tester, String label) { + final row = find.byWidgetPredicate( + (widget) => widget is DesktopDateValueRow && widget.label == label, + ); + return tester.widget( + find.descendant(of: row, matching: find.byType(TextField)), + ); +} + +TextField _timeTextField(WidgetTester tester, String label) { + return tester.widget( + find.descendant( + of: _timeRowFinder(label), + matching: find.byType(TextField), + ), + ); +} + +Finder _timeEntryFinder(String label) { + return find.descendant( + of: _timeRowFinder(label), + matching: find.byType(TextFormField), + ); +} + +Future _clearTimeEntry(WidgetTester tester, String label) async { + await tester.enterText(_timeEntryFinder(label), ''); + await tester.pump(); +} Future _enterTime( WidgetTester tester, { + required String label, required String hour, required String minute, }) async { - final entry = _timeEntryFinder(); - await tester.tap(entry); - await tester.enterText(entry, hour); - await tester.pump(); - await tester.enterText(entry, minute); + final entry = _timeEntryFinder(label); + await tester.enterText(entry, '$hour:$minute'); await tester.pump(); } diff --git a/test/features/feedback/presentation/feedback_dialog_test.dart b/test/features/feedback/presentation/feedback_dialog_test.dart index 78112dd..8492d84 100644 --- a/test/features/feedback/presentation/feedback_dialog_test.dart +++ b/test/features/feedback/presentation/feedback_dialog_test.dart @@ -34,6 +34,44 @@ void main() { .setMockMethodCallHandler(_nativeMenuChannel, null); }); + testWidgets('text inputs delegate their surface to the Yaru grouped rows', ( + tester, + ) async { + final service = _FakeFeedbackService((_) async { + return const FeedbackReceipt(id: 'unused'); + }); + await _pumpDialog(tester, service); + + for (final key in const [ + 'feedback-subject', + 'feedback-message', + 'feedback-reply-email', + ]) { + final field = tester.widget(find.byKey(Key(key))); + final decoration = field.decoration!; + + expect(decoration.filled, isFalse); + expect(decoration.fillColor, Colors.transparent); + expect(decoration.hoverColor, Colors.transparent); + expect(decoration.border, InputBorder.none); + expect(decoration.enabledBorder, InputBorder.none); + expect(decoration.focusedBorder, InputBorder.none); + expect(decoration.disabledBorder, InputBorder.none); + expect(decoration.errorBorder, InputBorder.none); + expect(decoration.focusedErrorBorder, InputBorder.none); + expect(decoration.contentPadding, EdgeInsets.zero); + + final tile = tester.widget( + find.ancestor( + of: find.byKey(Key(key)), + matching: find.byType(YaruListTile), + ), + ); + expect(tile.onTap, isNull); + expect(tile.hoverColor, isNull); + } + }); + testWidgets('shows required-field validation without sending', ( tester, ) async { @@ -445,7 +483,7 @@ Finder _feedbackCategoryTrigger() { Finder _feedbackCategoryMenuItem(String label) { return find.ancestor( of: find.text(label).last, - matching: find.byType(PopupMenuItem), + matching: find.byWidgetPredicate((widget) => widget is PopupMenuItem), ); } diff --git a/test/features/schedule/presentation/schedule_create_menu_test.dart b/test/features/schedule/presentation/schedule_create_menu_test.dart index bccdc5e..0aca96c 100644 --- a/test/features/schedule/presentation/schedule_create_menu_test.dart +++ b/test/features/schedule/presentation/schedule_create_menu_test.dart @@ -80,7 +80,10 @@ void main() { {'label': 'Task', 'enabled': true, 'selected': false}, ]); expect(arguments['focusFirst'], isFalse); - expect(find.byType(PopupMenuItem), findsNothing); + expect( + find.byWidgetPredicate((widget) => widget is PopupMenuItem), + findsNothing, + ); }); testWidgets('unavailable native host uses an anchored popup-menu fallback', ( @@ -120,7 +123,10 @@ void main() { await tester.pumpAndSettle(); expect(find.byType(Dialog), findsNothing); - expect(find.byType(PopupMenuItem), findsNWidgets(2)); + expect( + find.byWidgetPredicate((widget) => widget is PopupMenuItem), + findsNWidgets(2), + ); expect(find.text('Event'), findsOneWidget); expect(find.text('Task'), findsOneWidget); @@ -166,13 +172,17 @@ void main() { final eventItem = tester.widget>( find.ancestor( of: find.text('Event'), - matching: find.byType(PopupMenuItem), + matching: find.byWidgetPredicate( + (widget) => widget is PopupMenuItem, + ), ), ); final taskItem = tester.widget>( find.ancestor( of: find.text('Task'), - matching: find.byType(PopupMenuItem), + matching: find.byWidgetPredicate( + (widget) => widget is PopupMenuItem, + ), ), ); expect(eventItem.enabled, isFalse); @@ -181,7 +191,10 @@ void main() { await tester.tap(find.text('Event')); await tester.pump(); - expect(find.byType(PopupMenuItem), findsNWidgets(2)); + expect( + find.byWidgetPredicate((widget) => widget is PopupMenuItem), + findsNWidgets(2), + ); await tester.tap(find.text('Task')); await tester.pumpAndSettle(); @@ -225,7 +238,10 @@ void main() { ); await tester.pumpAndSettle(); - expect(find.byType(PopupMenuItem), findsNWidgets(2)); + expect( + find.byWidgetPredicate((widget) => widget is PopupMenuItem), + findsNWidgets(2), + ); expect(Focus.of(tester.element(find.text('Event'))).hasFocus, isTrue); expect(focusNode.hasFocus, isFalse); @@ -233,7 +249,10 @@ void main() { await tester.pumpAndSettle(); expect(await result, isNull); - expect(find.byType(PopupMenuItem), findsNothing); + expect( + find.byWidgetPredicate((widget) => widget is PopupMenuItem), + findsNothing, + ); expect(focusNode.hasFocus, isTrue); }); @@ -258,7 +277,10 @@ void main() { await tester.pump(); expect(result, isNull); - expect(find.byType(PopupMenuItem), findsNothing); + expect( + find.byWidgetPredicate((widget) => widget is PopupMenuItem), + findsNothing, + ); }); testWidgets('create chooser does not open without an available choice', ( @@ -291,7 +313,10 @@ void main() { expect(result, isNull); expect(nativeCalls, 0); - expect(find.byType(PopupMenuItem), findsNothing); + expect( + find.byWidgetPredicate((widget) => widget is PopupMenuItem), + findsNothing, + ); }); test('single available creation kind is resolved for direct creation', () { diff --git a/test/features/schedule/presentation/schedule_toolbar_test.dart b/test/features/schedule/presentation/schedule_toolbar_test.dart index 15ff836..8c4cb24 100644 --- a/test/features/schedule/presentation/schedule_toolbar_test.dart +++ b/test/features/schedule/presentation/schedule_toolbar_test.dart @@ -30,6 +30,52 @@ void main() { .setMockMethodCallHandler(_nativeMenuChannel, null); }); + testWidgets('fallback toolbar uses the semantic header title style', ( + tester, + ) async { + const inheritedTitleStyle = TextStyle( + color: Color(0xFF123456), + fontSize: 17, + fontWeight: FontWeight.normal, + ); + + await tester.pumpWidget( + localizedTestApp( + theme: ThemeData( + textTheme: const TextTheme(titleMedium: inheritedTitleStyle), + ), + child: Scaffold( + body: SizedBox( + width: 1000, + child: ScheduleToolbar( + mode: ScheduleViewMode.week, + range: ScheduleRange.week(DateTime(2026, 7, 22)), + selectedDate: DateTime(2026, 7, 22), + onToday: () {}, + onPrevious: () {}, + onNext: () {}, + onModeChanged: (_) {}, + canCreateEvent: true, + canCreateTask: true, + onCreateEvent: () {}, + onCreateTask: () {}, + onRefresh: () {}, + ), + ), + ), + ), + ); + + final titleFinder = find.byWidgetPredicate( + (widget) => widget is Text && (widget.data?.contains('2026') ?? false), + ); + expect(titleFinder, findsOneWidget); + final title = tester.widget(titleFinder); + expect(title.style?.color, inheritedTitleStyle.color); + expect(title.style?.fontSize, inheritedTitleStyle.fontSize); + expect(title.style?.fontWeight, FontWeight.bold); + }); + testWidgets('toolbar delegates create selection to the native menu host', ( tester, ) async { @@ -76,7 +122,10 @@ void main() { {'label': 'Event', 'enabled': true, 'selected': false}, {'label': 'Task', 'enabled': true, 'selected': false}, ]); - expect(find.byType(PopupMenuItem), findsNothing); + expect( + find.byWidgetPredicate((widget) => widget is PopupMenuItem), + findsNothing, + ); }); testWidgets('fallback toolbar exposes the complete shell command set', ( @@ -125,7 +174,10 @@ void main() { await tester.tap(find.byTooltip('Create')); await tester.pumpAndSettle(); - expect(find.byType(PopupMenuItem), findsNWidgets(2)); + expect( + find.byWidgetPredicate((widget) => widget is PopupMenuItem), + findsNWidgets(2), + ); expect(find.byType(YaruRadio), findsNothing); expect(find.text('Event'), findsOneWidget); expect(find.text('Task'), findsOneWidget); @@ -137,7 +189,7 @@ void main() { await tester.tap(find.byTooltip('Week')); await tester.pumpAndSettle(); expect( - find.byType(PopupMenuItem), + find.byWidgetPredicate((widget) => widget is PopupMenuItem), findsNWidgets(ScheduleViewMode.values.length), ); expect( @@ -147,7 +199,9 @@ void main() { await tester.tap( find.ancestor( of: find.text('Month'), - matching: find.byType(PopupMenuItem), + matching: find.byWidgetPredicate( + (widget) => widget is PopupMenuItem, + ), ), ); await tester.pumpAndSettle(); @@ -155,7 +209,10 @@ void main() { await tester.tap(find.byTooltip('Main Menu')); await tester.pumpAndSettle(); - expect(find.byType(PopupMenuItem), findsNWidgets(3)); + expect( + find.byWidgetPredicate((widget) => widget is PopupMenuItem), + findsNWidgets(3), + ); expect(find.byType(YaruRadio), findsNothing); await tester.tap(find.text('Settings')); await tester.pumpAndSettle(); @@ -201,7 +258,10 @@ void main() { expect(find.byTooltip('Refresh all'), findsNothing); await tester.tap(find.byTooltip('Main Menu')); await tester.pumpAndSettle(); - expect(find.byType(PopupMenuItem), findsNWidgets(4)); + expect( + find.byWidgetPredicate((widget) => widget is PopupMenuItem), + findsNWidgets(4), + ); await tester.tap(find.text('Refresh all')); await tester.pumpAndSettle(); @@ -242,18 +302,25 @@ void main() { await tester.tap(find.byTooltip('Create')); await tester.pumpAndSettle(); - expect(find.byType(PopupMenuItem), findsNWidgets(2)); + expect( + find.byWidgetPredicate((widget) => widget is PopupMenuItem), + findsNWidgets(2), + ); expect(find.byType(YaruRadio), findsNothing); final eventItem = tester.widget>( find.ancestor( of: find.text('Event'), - matching: find.byType(PopupMenuItem), + matching: find.byWidgetPredicate( + (widget) => widget is PopupMenuItem, + ), ), ); final taskItem = tester.widget>( find.ancestor( of: find.text('Task'), - matching: find.byType(PopupMenuItem), + matching: find.byWidgetPredicate( + (widget) => widget is PopupMenuItem, + ), ), ); expect(eventItem.enabled, isFalse); @@ -299,7 +366,10 @@ void main() { expect(controller.isOpen, isTrue); await tester.pumpAndSettle(); - expect(find.byType(PopupMenuItem), findsNWidgets(2)); + expect( + find.byWidgetPredicate((widget) => widget is PopupMenuItem), + findsNWidgets(2), + ); expect(find.text('Event'), findsOneWidget); expect(find.text('Task'), findsOneWidget); @@ -315,7 +385,10 @@ void main() { await tester.sendKeyEvent(LogicalKeyboardKey.escape); await tester.pumpAndSettle(); expect(controller.isOpen, isFalse); - expect(find.byType(PopupMenuItem), findsNothing); + expect( + find.byWidgetPredicate((widget) => widget is PopupMenuItem), + findsNothing, + ); expect(find.text('Event'), findsNothing); expect(find.text('Task'), findsNothing); }, @@ -374,14 +447,20 @@ void main() { expect(controller.isOpen, isTrue); expect(showCalls, 1); - expect(find.byType(PopupMenuItem), findsNothing); + expect( + find.byWidgetPredicate((widget) => widget is PopupMenuItem), + findsNothing, + ); controller.close(); await tester.pumpAndSettle(); expect(dismissCalls, 1); expect(controller.isOpen, isFalse); - expect(find.byType(PopupMenuItem), findsNothing); + expect( + find.byWidgetPredicate((widget) => widget is PopupMenuItem), + findsNothing, + ); }); testWidgets('keyboard controller follows a responsive toolbar replacement', ( @@ -437,7 +516,10 @@ void main() { expect(controller.openForKeyboard(), isTrue); await tester.pumpAndSettle(); - expect(find.byType(PopupMenuItem), findsNWidgets(2)); + expect( + find.byWidgetPredicate((widget) => widget is PopupMenuItem), + findsNWidgets(2), + ); expect(find.text('Event'), findsOneWidget); expect(find.text('Task'), findsOneWidget); expect(Focus.of(tester.element(find.text('Event'))).hasFocus, isTrue); @@ -445,7 +527,10 @@ void main() { controller.close(); await tester.pumpAndSettle(); expect(controller.isOpen, isFalse); - expect(find.byType(PopupMenuItem), findsNothing); + expect( + find.byWidgetPredicate((widget) => widget is PopupMenuItem), + findsNothing, + ); await tester.pumpWidget(const SizedBox.shrink()); expect(controller.isAttached, isFalse); diff --git a/test/features/schedule/presentation/schedule_views_test.dart b/test/features/schedule/presentation/schedule_views_test.dart index 3de9d56..e34d123 100644 --- a/test/features/schedule/presentation/schedule_views_test.dart +++ b/test/features/schedule/presentation/schedule_views_test.dart @@ -1,5 +1,4 @@ import 'dart:io'; -import 'dart:math' as math; import 'dart:ui' as ui; import 'package:busymax/src/app/busymax_design.dart'; @@ -107,9 +106,10 @@ void main() { ); final painter = planner.dayParam.dayCustomPainter!(1, false) as icv.LinesPainter; + final workspaceColor = theme.extension()!.window; final effectiveGridColor = Color.alphaBlend( painter.lineColor, - theme.colorScheme.surface, + workspaceColor, ); // GTK's generic separator remains a distinct, recessed native role. @@ -120,12 +120,185 @@ void main() { expect(painter.lineColor, isNot(_recessedGtkDivider)); expect( effectiveGridColor.computeLuminance(), - greaterThan(theme.colorScheme.surface.computeLuminance()), + greaterThan(workspaceColor.computeLuminance()), ); }, ); } + testWidgets('planner canvas matches the semantic window surface', ( + tester, + ) async { + final selectedDate = DateTime(2026, 1, 15); + final theme = BusyMaxYaruTheme.build( + brightness: Brightness.light, + accentColor: const Color(0xFF3584E4), + ); + final workspaceColor = theme.extension()!.window; + expect(workspaceColor, isNot(theme.colorScheme.surface)); + + await tester.pumpWidget( + localizedTestApp( + theme: theme, + child: Scaffold( + body: SizedBox( + width: 1000, + height: 720, + child: ScheduleDayWeekView( + range: ScheduleRange.week(selectedDate), + selectedDate: selectedDate, + daysShowed: 7, + items: _itemsFor(selectedDate), + onDaySelected: (_) {}, + onEmptySlot: (_) {}, + onItemSelected: (_, _, [_]) {}, + onTaskCompletionChanged: (_, _) {}, + ), + ), + ), + ), + ); + await tester.pump(const Duration(milliseconds: 100)); + + final planner = tester.widget( + find.byType(icv.EventsPlanner), + ); + expect(planner.daysHeaderParam.daysHeaderColor, workspaceColor); + expect(planner.fullDayParam.fullDayBackgroundColor, workspaceColor); + expect( + (planner.fullDayParam.fullDayEventsBarDecoration as BoxDecoration).color, + workspaceColor, + ); + expect(planner.dayParam.dayColor, workspaceColor); + }); + + testWidgets('calendar and agenda panes match the semantic window surface', ( + tester, + ) async { + final selectedDate = DateTime(2026, 1, 15); + final theme = BusyMaxYaruTheme.build( + brightness: Brightness.light, + accentColor: const Color(0xFF3584E4), + ); + final workspaceColor = theme.extension()!.window; + + await tester.pumpWidget( + localizedTestApp( + theme: theme, + child: Scaffold( + body: SizedBox( + width: 1000, + height: 720, + child: ScheduleMonthView( + range: ScheduleRange.month(selectedDate), + selectedDate: selectedDate, + items: _itemsFor(selectedDate), + firstWeekday: DateTime.monday, + onDaySelected: (_) {}, + onCreateAtDay: (_) {}, + onItemSelected: (_, _, [_]) {}, + onTaskCompletionChanged: (_, _) {}, + ), + ), + ), + ), + ); + await tester.pump(); + + final monthHeader = tester.widget( + find + .descendant( + of: find.byType(ScheduleMonthView), + matching: find.byWidgetPredicate( + (widget) => + widget is ColoredBox && + widget.child is SizedBox && + (widget.child as SizedBox).height == 34, + ), + ) + .first, + ); + expect(monthHeader.color, workspaceColor); + expect( + tester + .widgetList( + find.descendant( + of: find.byType(ScheduleMonthView), + matching: find.byType(Material), + ), + ) + .any((material) => material.color == workspaceColor), + isTrue, + ); + + await tester.pumpWidget( + localizedTestApp( + theme: theme, + child: Scaffold( + body: SizedBox( + width: 1000, + height: 720, + child: ScheduleYearView( + selectedDate: selectedDate, + items: _itemsFor(selectedDate), + firstWeekday: DateTime.monday, + onDaySelected: (_) {}, + onMonthSelected: (_) {}, + onCreateAtDay: (_) {}, + ), + ), + ), + ), + ); + await tester.pump(); + + final yearCanvas = tester.widget( + find + .descendant( + of: find.byType(ScheduleYearView), + matching: find.byWidgetPredicate( + (widget) => widget is ColoredBox && widget.child is GridView, + ), + ) + .first, + ); + expect(yearCanvas.color, workspaceColor); + + await tester.pumpWidget( + localizedTestApp( + theme: theme, + child: Scaffold( + body: SizedBox( + width: 1000, + height: 720, + child: ScheduleAgendaView( + range: ScheduleRange( + start: selectedDate, + end: selectedDate.add(const Duration(days: 7)), + ), + items: _itemsFor(selectedDate), + onItemSelected: (_, _, [_]) {}, + onTaskCompletionChanged: (_, _) {}, + ), + ), + ), + ), + ); + await tester.pump(); + + final agendaCanvas = tester.widget( + find + .descendant( + of: find.byType(ScheduleAgendaView), + matching: find.byWidgetPredicate( + (widget) => widget is ColoredBox && widget.child is ListView, + ), + ) + .first, + ); + expect(agendaCanvas.color, workspaceColor); + }); + testWidgets('dark month grid uses the shared neutral grid color', ( tester, ) async { @@ -751,38 +924,56 @@ void main() { ), ) .toList(); - final actionSurfaces = tester - .widgetList( - find.descendant( - of: find.byType(BusyMaxPopoverIconButton), - matching: find.byWidgetPredicate( - (widget) => widget is Material && widget.shape is CircleBorder, - ), - ), - ) - .toList(); final actionContext = tester.element( find.byType(BusyMaxPopoverIconButton).first, ); final actionColors = BusyMaxSurfaceColors.of(actionContext); expect(BusyMaxSizes.popoverActionButton, kYaruTitleBarItemHeight); expect(BusyMaxSizes.popoverActionIcon, BusyMaxSizes.iconSm); - expect(actionSurfaces, hasLength(4)); + expect(actionButtons, hasLength(4)); for (final button in actionButtons) { expect(button.iconSize, BusyMaxSizes.popoverActionButton); + expect(button.style?.backgroundColor, isNull); + expect(button.style?.overlayColor, isNull); expect(button.style?.tapTargetSize, MaterialTapTargetSize.shrinkWrap); + expect( + button.style?.minimumSize?.resolve({}), + const Size.square(BusyMaxSizes.popoverActionButton), + ); + expect( + button.style?.maximumSize?.resolve({}), + const Size.square(BusyMaxSizes.popoverActionButton), + ); + final style = button.defaultStyleOf( + tester.element(find.byWidget(button)), + ); + expect( + style.fixedSize?.resolve({}), + const Size.square(BusyMaxSizes.popoverActionButton), + ); + expect(style.overlayColor?.resolve({WidgetState.hovered}), isNotNull); + expect(style.overlayColor?.resolve({WidgetState.pressed}), isNotNull); } + final restingSurfaces = tester + .widgetList( + find.descendant( + of: find.byType(BusyMaxPopoverIconButton), + matching: find.byWidgetPredicate( + (widget) => + widget is Material && + widget.color == actionColors.control && + widget.shape == const CircleBorder(), + ), + ), + ) + .toList(); + expect(restingSurfaces, hasLength(4)); for (final button in find.byType(BusyMaxPopoverIconButton).evaluate()) { expect( tester.getSize(find.byWidget(button.widget)), const Size.square(BusyMaxSizes.popoverActionButton), ); } - for (final surface in actionSurfaces) { - expect(surface.color, actionColors.control); - expect(surface.shape, const CircleBorder()); - expect(surface.clipBehavior, Clip.antiAlias); - } for (final icon in tester.widgetList( find.descendant( of: find.byType(BusyMaxPopoverIconButton), @@ -795,17 +986,12 @@ void main() { tester.widget(find.byIcon(YaruIcons.trash)).color, Theme.of(actionContext).colorScheme.error, ); - expect( - tester.widget(find.byIcon(YaruIcons.share)).color, - actionColors.foreground, - ); + expect(tester.widget(find.byIcon(YaruIcons.share)).color, isNull); - final popoverSurfaceFinder = find.byWidgetPredicate( - (widget) => - widget is PhysicalShape && - widget.elevation == BusyMaxElevation.tooltip, + final popoverSurfaceFinder = _popoverDecorationFinder( + find.byType(BusyMaxContentPopoverSurface), ); - final popoverSurface = tester.widget(popoverSurfaceFinder); + expect(popoverSurfaceFinder, findsOneWidget); final popoverContext = tester.element(popoverSurfaceFinder); final popoverRoute = ModalRoute.of(popoverContext)!; expect( @@ -816,12 +1002,6 @@ void main() { popoverRoute.directionalTraversalEdgeBehavior, TraversalEdgeBehavior.stop, ); - expect( - popoverSurface.shadowColor, - Theme.of(popoverContext).colorScheme.shadow, - ); - expect(popoverSurface.shadowColor.a, 1); - expect(popoverSurface.color, BusyMaxSurfaceColors.of(popoverContext).card); final contentSurface = tester.widget( find.descendant( of: find.byType(BusyMaxContentPopoverSurface), @@ -829,8 +1009,9 @@ void main() { ), ); final contentColors = BusyMaxSurfaceColors.of(popoverContext); - expect(contentSurface.color, contentColors.card); + expect(contentSurface.color, contentColors.popover); expect(contentSurface.outlineColor, contentColors.floatingBorder); + expect(contentSurface.shadowRole, BusyMaxPopoverShadowRole.details); final editCenter = tester.getCenter(find.byIcon(Icons.edit_outlined)); final deleteCenter = tester.getCenter(find.byIcon(YaruIcons.trash)); @@ -847,27 +1028,24 @@ void main() { for (final baseline in const [ ( brightness: Brightness.light, - interior: Color(0xFFFFFFFF), + interior: Color(0xFFFAFAFA), outline: Color.fromRGBO(0, 0, 0, 0.14), - wrongOutline: Color.fromRGBO(24, 24, 24, 0.08), ), ( brightness: Brightness.dark, - interior: Color(0xFF3D3D3D), + interior: Color(0xFF3E3E3E), outline: Color.fromRGBO(0, 0, 0, 0.14), - wrongOutline: Color.fromRGBO(0, 0, 0, 0.36), ), ]) { testWidgets( 'details popover paints the reviewed ${baseline.brightness.name} ' - 'content surface and native floating edge', + 'popover surface and native floating edge', (tester) async { tester.view ..physicalSize = const Size(800, 600) ..devicePixelRatio = 1; addTearDown(tester.view.reset); - final boundaryKey = GlobalKey(); final selectedDate = DateTime(2026, 1, 15); final event = _itemsFor( selectedDate, @@ -878,42 +1056,39 @@ void main() { ); await tester.pumpWidget( - RepaintBoundary( - key: boundaryKey, - child: localizedTestApp( - theme: theme, - child: Builder( - builder: (context) { - final mediaQuery = MediaQuery.of( - context, - ).copyWith(disableAnimations: true); - return MediaQuery( - data: mediaQuery, - child: Scaffold( - body: Align( - alignment: Alignment.topCenter, - child: Padding( - padding: const EdgeInsets.only(top: 40), - child: Builder( - builder: (anchorContext) { - return TextButton( - onPressed: () { - showScheduleItemDetailsPopover( - context: anchorContext, - anchorContext: anchorContext, - item: event, - ); - }, - child: const Text('Open details palette probe'), - ); - }, - ), + localizedTestApp( + theme: theme, + child: Builder( + builder: (context) { + final mediaQuery = MediaQuery.of( + context, + ).copyWith(disableAnimations: true); + return MediaQuery( + data: mediaQuery, + child: Scaffold( + body: Align( + alignment: Alignment.topCenter, + child: Padding( + padding: const EdgeInsets.only(top: 40), + child: Builder( + builder: (anchorContext) { + return TextButton( + onPressed: () { + showScheduleItemDetailsPopover( + context: anchorContext, + anchorContext: anchorContext, + item: event, + ); + }, + child: const Text('Open details palette probe'), + ); + }, ), ), ), - ); - }, - ), + ), + ); + }, ), ), ); @@ -921,13 +1096,8 @@ void main() { await tester.tap(find.text('Open details palette probe')); await tester.pumpAndSettle(); - final shapeFinder = find.descendant( - of: find.byType(BusyMaxContentPopoverSurface), - matching: find.byWidgetPredicate( - (widget) => - widget is PhysicalShape && - widget.elevation == BusyMaxElevation.tooltip, - ), + final shapeFinder = _popoverDecorationFinder( + find.byType(BusyMaxContentPopoverSurface), ); final popoverFinder = find.descendant( of: find.byType(BusyMaxContentPopoverSurface), @@ -936,70 +1106,11 @@ void main() { expect(shapeFinder, findsOneWidget); expect(popoverFinder, findsOneWidget); + final shapeContext = tester.element(shapeFinder); + expect(ModalRoute.of(shapeContext), isNotNull); final popover = tester.widget(popoverFinder); expect(popover.color, baseline.interior); expect(popover.outlineColor, baseline.outline); - - final shapeBox = tester.renderObject(shapeFinder); - final boundary = - boundaryKey.currentContext!.findRenderObject()! - as RenderRepaintBoundary; - final pixels = await _capturePixels(tester, boundaryKey); - final bodyMidpoint = shapeBox.size.height / 2; - final interior = _pixelAtGlobalPosition( - pixels, - boundary: boundary, - globalPosition: shapeBox.localToGlobal(Offset(8, bodyMidpoint)), - ); - // The route's PhysicalShape anti-aliases its one-pixel perimeter at - // half coverage. Compare the rendered pixel to that visible result, - // while the separate palette test locks the unmodified source token. - final expectedEdge = Color.alphaBlend( - baseline.outline.withValues(alpha: baseline.outline.a * 0.5), - baseline.interior, - ); - final wrongEdge = Color.alphaBlend( - baseline.wrongOutline.withValues( - alpha: baseline.wrongOutline.a * 0.5, - ), - baseline.interior, - ); - final edgeCandidates = [ - for (final x in const [0.5, 1.0, 1.5, 2.0]) - for (final yOffset in const [-2.0, 0.0, 2.0]) - _pixelAtGlobalPosition( - pixels, - boundary: boundary, - globalPosition: shapeBox.localToGlobal( - Offset(x, bodyMidpoint + yOffset), - ), - ), - ]; - final edge = edgeCandidates.reduce( - (closest, candidate) => - _rgbDistance(candidate, expectedEdge) < - _rgbDistance(closest, expectedEdge) - ? candidate - : closest, - ); - - expect(interior, baseline.interior); - expect(edgeCandidates, contains(isNot(interior))); - expect( - _rgbDistance(edge, expectedEdge), - lessThanOrEqualTo(25), - reason: 'edge candidates: $edgeCandidates', - ); - expect( - _rgbDistance(edge, expectedEdge), - lessThan(_rgbDistance(edge, wrongEdge)), - ); - if (baseline.brightness == Brightness.dark) { - expect( - edge.computeLuminance(), - lessThan(interior.computeLuminance()), - ); - } }, ); } @@ -1067,6 +1178,38 @@ void main() { ); } + testWidgets('popover action retains Yaru keyboard-focus treatment', ( + tester, + ) async { + await tester.pumpWidget( + MaterialApp( + home: YaruTheme( + data: const YaruThemeData(focusBorders: true), + child: Scaffold( + body: Center( + child: BusyMaxPopoverIconButton( + icon: YaruIcons.share, + tooltip: 'Export', + onPressed: () {}, + ), + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + final action = find.byType(BusyMaxPopoverIconButton); + expect( + find.descendant(of: action, matching: find.byType(YaruFocusBorder)), + findsOneWidget, + ); + expect( + tester.getSize(action), + const Size.square(BusyMaxSizes.popoverActionButton), + ); + }); + testWidgets('direct details popover registers for native-header dismissal', ( tester, ) async { @@ -1236,17 +1379,67 @@ void main() { await tester.tap(find.text('Open details')); await tester.pump(); - final popover = find.byWidgetPredicate( - (widget) => - widget is PhysicalShape && - widget.elevation == BusyMaxElevation.tooltip, - ); + final popover = find + .descendant( + of: find.byType(BusyMaxContentPopoverSurface), + matching: find.byType(BusyMaxPopoverSurface), + ) + .first; expect(popover, findsOneWidget); expect(tester.getSize(popover).height, lessThanOrEqualTo(276)); expect(find.byType(SingleChildScrollView), findsOneWidget); expect(tester.takeException(), isNull); }); + testWidgets('anchored popover keeps nonzero layout in a tiny viewport', ( + tester, + ) async { + tester.view.devicePixelRatio = 1; + tester.view.physicalSize = const Size(20, 20); + addTearDown(tester.view.resetDevicePixelRatio); + addTearDown(tester.view.resetPhysicalSize); + + late BuildContext hostContext; + await tester.pumpWidget( + localizedTestApp( + child: Scaffold( + body: Builder( + builder: (context) { + hostContext = context; + return const SizedBox.expand(); + }, + ), + ), + ), + ); + + final completion = showScheduleAnchoredPopover( + context: hostContext, + anchorContext: hostContext, + anchorPoint: const Offset(10, 10), + semanticLabel: 'Tiny popover', + builder: (_, _, _) => + const SizedBox(key: Key('tiny-popover-child'), height: 1), + ); + await tester.pumpAndSettle(); + + final childRect = tester.getRect( + find.byKey(const Key('tiny-popover-child')), + ); + final viewport = tester.view.physicalSize / tester.view.devicePixelRatio; + expect(childRect.width, greaterThan(0)); + expect(childRect.height, greaterThan(0)); + expect(childRect.left, greaterThanOrEqualTo(0)); + expect(childRect.top, greaterThanOrEqualTo(0)); + expect(childRect.right, lessThanOrEqualTo(viewport.width)); + expect(childRect.bottom, lessThanOrEqualTo(viewport.height)); + expect(tester.takeException(), isNull); + + Navigator.of(hostContext, rootNavigator: true).pop(); + await tester.pumpAndSettle(); + await completion; + }); + testWidgets('schedule item details popover shows categories', (tester) async { final selectedDate = DateTime(2026, 1, 15); final task = TaskScheduleItem( @@ -1459,15 +1652,37 @@ void main() { await tester.tap(find.text('Open details')); await tester.pumpAndSettle(); - final popover = find.byWidgetPredicate( - (widget) => - widget is PhysicalShape && - widget.elevation == BusyMaxElevation.tooltip, - ); - final topLeft = tester.getTopLeft(popover); + final popover = find + .descendant( + of: find.byType(BusyMaxContentPopoverSurface), + matching: find.byType(BusyMaxPopoverSurface), + ) + .first; + final rect = tester.getRect(popover); + final viewport = tester.view.physicalSize / tester.view.devicePixelRatio; - expect(topLeft.dx, greaterThan(300)); - expect(topLeft.dy, greaterThan(100)); + expect(rect.left, greaterThan(300)); + expect(rect.top, greaterThan(100)); + expect( + rect.left, + greaterThanOrEqualTo(BusyMaxShadow.nativePopoverPaintMargin), + ); + expect( + rect.right, + lessThanOrEqualTo( + viewport.width - BusyMaxShadow.nativePopoverPaintMargin, + ), + ); + expect( + rect.top, + greaterThanOrEqualTo(BusyMaxShadow.nativePopoverPaintMargin), + ); + expect( + rect.bottom, + lessThanOrEqualTo( + viewport.height - BusyMaxShadow.nativePopoverPaintMargin, + ), + ); }); testWidgets('schedule item details popover shown above stays near click', ( @@ -1506,11 +1721,12 @@ void main() { await tester.tap(find.text('Open details')); await tester.pumpAndSettle(); - final popover = find.byWidgetPredicate( - (widget) => - widget is PhysicalShape && - widget.elevation == BusyMaxElevation.tooltip, - ); + final popover = find + .descendant( + of: find.byType(BusyMaxContentPopoverSurface), + matching: find.byType(BusyMaxPopoverSurface), + ) + .first; final rect = tester.getRect(popover); expect(rect.bottom, greaterThan(500)); @@ -1602,13 +1818,13 @@ void main() { expect(scheduleExportFileName(event), endsWith('.ics')); }); - test('month weekday header uses calendar surface background', () { + test('month weekday header uses the semantic window background', () { final source = File( 'lib/src/features/schedule/presentation/schedule_month_view.dart', ).readAsStringSync(); expect(source, contains('ColoredBox(')); - expect(source, contains('color: theme.colorScheme.surface')); + expect(source, contains('color: workspaceColor')); }); testWidgets('agenda view is custom and keeps no-date tasks', (tester) async { @@ -2027,20 +2243,27 @@ void main() { expect(more, contains('BusyMaxPopoverSurface(')); expect(more, isNot(contains('showDialog('))); expect(more, isNot(contains('Dialog('))); + expect(more, isNot(contains('elevation:'))); + expect(more, isNot(contains('BoxShadow('))); }); test('schedule item details actions use the shared contained Yaru role', () { final design = File('lib/src/app/busymax_design.dart').readAsStringSync(); + final adapter = design.substring( + design.indexOf('class BusyMaxPopoverIconButton'), + design.indexOf('Color busyMaxSelectedBackground'), + ); final popover = File( 'lib/src/features/schedule/presentation/schedule_item_details_popover.dart', ).readAsStringSync(); - expect(design, contains('class BusyMaxPopoverIconButton')); - expect(design, contains('return Material(')); - expect(design, contains('shape: const CircleBorder()')); + expect(adapter, contains('class BusyMaxPopoverIconButton')); + expect(adapter, contains('child: YaruIconButton(')); + expect(adapter, contains('shape: const CircleBorder()')); expect(design, contains('class BusyMaxHeaderIconButton')); - expect(design, contains('iconSize: BusyMaxSizes.popoverActionButton')); - expect(design, contains('color: enabled ? colors.control')); + expect(adapter, contains('iconSize: BusyMaxSizes.popoverActionButton')); + expect(adapter, isNot(contains('final button = IconButton('))); + expect(adapter, isNot(contains('busyMaxHeaderButtonBackground(context)'))); expect(popover, contains('BusyMaxPopoverIconButton(')); expect(popover, contains('BusyMaxContentPopoverSurface(')); expect(popover, isNot(contains('surfaceColors.popover'))); @@ -2048,6 +2271,8 @@ void main() { expect(popover, isNot(contains('backgroundColor:'))); expect(popover, isNot(contains('foregroundColor:'))); expect(popover, isNot(contains('hoverColor:'))); + expect(popover, isNot(contains('elevation:'))); + expect(popover, isNot(contains('BoxShadow('))); }); test( @@ -2076,7 +2301,10 @@ void main() { expect(repository, contains('!searching && !_intersects')); expect(agenda, contains('groups.keys')); expect(agenda, contains('ColoredBox(')); - expect(agenda, contains('color: Theme.of(context).colorScheme.surface')); + expect( + agenda, + contains('color: BusyMaxSurfaceColors.of(context).window'), + ); expect(agenda, isNot(contains('_daysInRange'))); }, ); @@ -2107,7 +2335,6 @@ void main() { expect(design, isNot(contains('final Color? surfaceColor;'))); expect(design, isNot(contains('color: color ?? surfaceColors.control'))); expect(design, contains('CardTheme.of(context)')); - expect(design, contains('BusyMaxShadow.physicalColor(context)')); expect(design, isNot(contains('lightSurfaceShadowMinimum'))); expect(design, isNot(contains('class _BusyMaxRowTile'))); }); @@ -2966,7 +3193,10 @@ void main() { expect(yearView, contains('_monthPanelHeight(monthWidth)')); expect(yearView, contains('mainAxisExtent: monthHeight')); expect(yearView, contains('ColoredBox(')); - expect(yearView, contains('color: Theme.of(context).colorScheme.surface')); + expect( + yearView, + contains('color: BusyMaxSurfaceColors.of(context).window'), + ); expect(yearView, contains('double _monthPanelHeight(double width)')); expect(yearView, contains('BusyMaxGroupedSurface(')); expect(yearView, isNot(contains('BusyMaxSurfaceColors.of(context).card'))); @@ -2995,6 +3225,21 @@ void main() { }); } +Finder _popoverDecorationFinder(Finder ancestor) { + return find + .descendant( + of: ancestor, + matching: find.byWidgetPredicate( + (widget) => + widget is DecoratedBox && + widget.decoration is ShapeDecoration && + ((widget.decoration as ShapeDecoration).shadows?.isNotEmpty ?? + false), + ), + ) + .first; +} + Future<({Uint8List bytes, int width})> _capturePixels( WidgetTester tester, GlobalKey key, @@ -3032,22 +3277,6 @@ Color _pixelAt( ); } -Color _pixelAtGlobalPosition( - ({Uint8List bytes, int width}) pixels, { - required RenderRepaintBoundary boundary, - required Offset globalPosition, -}) { - final local = boundary.globalToLocal(globalPosition); - return _pixelAt(pixels, x: local.dx.floor(), y: local.dy.floor()); -} - -double _rgbDistance(Color first, Color second) { - final red = (first.r - second.r) * 255; - final green = (first.g - second.g) * 255; - final blue = (first.b - second.b) * 255; - return math.sqrt(red * red + green * green + blue * blue); -} - double _luminanceDistance(Color first, Color second) { return (first.computeLuminance() - second.computeLuminance()).abs(); } diff --git a/test/features/schedule/presentation/schedule_workspace_states_test.dart b/test/features/schedule/presentation/schedule_workspace_states_test.dart index 814fdc9..b256eb3 100644 --- a/test/features/schedule/presentation/schedule_workspace_states_test.dart +++ b/test/features/schedule/presentation/schedule_workspace_states_test.dart @@ -29,7 +29,7 @@ void main() { final loadingContext = tester.element(find.byType(ScheduleLoadingState)); expect( tester.widget(find.byType(Scaffold)).backgroundColor, - BusyMaxSurfaceColors.of(loadingContext).view, + BusyMaxSurfaceColors.of(loadingContext).window, ); }); diff --git a/test/features/settings/presentation/settings_screen_test.dart b/test/features/settings/presentation/settings_screen_test.dart index b78f0e6..b3ef0d4 100644 --- a/test/features/settings/presentation/settings_screen_test.dart +++ b/test/features/settings/presentation/settings_screen_test.dart @@ -16,6 +16,7 @@ import 'package:busymax/src/features/auth/data/auth_repository.dart'; import 'package:busymax/src/features/settings/presentation/settings_screen.dart'; import 'package:busymax/src/features/sync/sync_auth_error.dart'; import 'package:busymax/src/platform/gtk_font_service.dart'; +import 'package:busymax/src/platform/linux_header_bar_service.dart'; import 'package:busymax/src/platform/native_menu_service.dart'; import 'package:busymax/src/features/task_lists/data/task_lists_repository.dart'; import 'package:busymax/src/features/tasks/presentation/desktop_date_time_fields.dart'; @@ -41,6 +42,25 @@ void main() { .setMockMethodCallHandler(_nativeMenuChannel, null); }); + testWidgets('Settings fallback header uses the semantic 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)); + }); + testWidgets('Settings removes the selected Microsoft account', ( tester, ) async { @@ -295,7 +315,9 @@ void main() { expect(find.text('Add Google account'), findsNothing); }); - testWidgets('Settings content uses the native view surface', (tester) async { + testWidgets('Settings workspace uses the native window surface', ( + tester, + ) async { final container = _container( selectedAccountId: 'google:g', authRepository: _FakeAuthRepository(), @@ -328,8 +350,8 @@ void main() { await tester.pumpAndSettle(); final scaffold = tester.widget(find.byType(Scaffold)); - expect(scaffold.backgroundColor, gtkColors.view); - expect(scaffold.backgroundColor, isNot(gtkColors.window)); + expect(scaffold.backgroundColor, gtkColors.window); + expect(scaffold.backgroundColor, isNot(gtkColors.view)); }); testWidgets('Settings uses Yaru master-detail rows with selected semantics', ( @@ -488,11 +510,13 @@ void main() { await tester.tap(find.text('Notifications')); await tester.pumpAndSettle(); - expect(find.text('Quiet hours start'), findsOneWidget); - expect(find.text('Quiet hours end'), findsOneWidget); var timeRows = tester.widgetList( find.byType(DesktopTimeValueRow), ); + expect( + timeRows.map((row) => row.label), + containsAll(['Quiet hours start', 'Quiet hours end']), + ); expect(timeRows.every((row) => !row.enabled), isTrue); await tester.ensureVisible(find.text('Quiet hours')); @@ -550,8 +574,14 @@ void main() { await tester.ensureVisible(newListButtons.at(1)); await tester.tap(newListButtons.at(1)); await tester.pumpAndSettle(); - await tester.enterText(find.byType(TextField), 'Client work'); - await tester.tap(find.text('Create')); + + final promptField = find.descendant( + of: find.byType(BusyMaxPromptDialog), + matching: find.byType(TextField), + ); + expect(promptField, findsOneWidget); + await tester.enterText(promptField, 'Client work'); + await tester.testTextInput.receiveAction(TextInputAction.done); await tester.pumpAndSettle(); expect(googleLists.createdTitles, isEmpty); @@ -660,7 +690,7 @@ void main() { Finder _settingsMenuItemWithLabel(String label) { return find.ancestor( of: find.text(label).last, - matching: find.byType(PopupMenuItem), + matching: find.byWidgetPredicate((widget) => widget is PopupMenuItem), ); } @@ -677,6 +707,7 @@ ProviderContainer _container({ BuildConfig buildConfig = _emptyBuildConfig, Map? taskListRepositories, String? activeAccountIdOverride = _useDefaultActiveAccountId, + bool useFlutterHeader = false, }) { return ProviderContainer( overrides: [ @@ -694,6 +725,12 @@ ProviderContainer _container({ localSettingsStoreProvider.overrideWithValue(_MemorySettingsStore()), syncEngineProvider.overrideWithValue(null), buildConfigProvider.overrideWithValue(buildConfig), + if (useFlutterHeader) + linuxHeaderBarServiceProvider.overrideWith((ref) { + final service = LinuxHeaderBarService(isLinux: false); + ref.onDispose(service.dispose); + return service; + }), taskListsRepositoryForAccountProvider.overrideWith((ref, accountId) { return taskListRepositories?[accountId] ?? _FakeTaskListsRepository(); }), diff --git a/test/features/tasks/presentation/desktop_date_time_fields_test.dart b/test/features/tasks/presentation/desktop_date_time_fields_test.dart index bb776ae..04980e1 100644 --- a/test/features/tasks/presentation/desktop_date_time_fields_test.dart +++ b/test/features/tasks/presentation/desktop_date_time_fields_test.dart @@ -1,12 +1,123 @@ +import 'dart:async'; + import 'package:busymax/src/app/busymax_design.dart'; import 'package:busymax/src/features/tasks/presentation/desktop_date_time_fields.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:yaru/yaru.dart'; import '../../../test_localized_app.dart'; void main() { + test('provider time parsing accepts only exact HH:mm values', () { + expect(parseTimeOfDay('00:00'), const TimeOfDay(hour: 0, minute: 0)); + expect(parseTimeOfDay('09:30'), const TimeOfDay(hour: 9, minute: 30)); + expect(parseTimeOfDay('23:59'), const TimeOfDay(hour: 23, minute: 59)); + + expect(parseTimeOfDay('9:30'), isNull); + expect(parseTimeOfDay('09:3'), isNull); + expect(parseTimeOfDay('09x30'), isNull); + expect(parseTimeOfDay('09:30 UTC'), isNull); + }); + + testWidgets('localized time parsing is strict and encodes provider time', ( + tester, + ) async { + late BuildContext parserContext; + await tester.pumpWidget( + localizedTestApp( + locale: const Locale('en'), + alwaysUse24HourFormat: false, + child: Builder( + builder: (context) { + parserContext = context; + return const SizedBox(); + }, + ), + ), + ); + + expect( + encodeTimeOfDay(parseDesktopTimeInput(parserContext, '01:30 PM')!), + '13:30', + ); + expect(parseDesktopTimeInput(parserContext, '09x30'), isNull); + expect( + parseDesktopTimeInput(parserContext, '09:30 trailing garbage'), + isNull, + ); + }); + + testWidgets( + 'calendar values render populated contextual fields before focus', + (tester) async { + await tester.pumpWidget( + localizedTestApp( + child: Scaffold( + body: Column( + children: [ + DesktopDateValueRow( + label: 'Start date', + date: '2026-07-22', + onChanged: _ignoreString, + ), + DesktopTimeValueRow( + label: 'Start time', + time: '09:30', + onChanged: _ignoreNullableString, + ), + ], + ), + ), + ), + ); + + final dateTextField = tester.widget( + find + .descendant( + of: find.byType(DesktopDateValueRow), + matching: find.byType(TextField), + ) + .first, + ); + final timeTextField = tester.widget( + find + .descendant( + of: find.byType(DesktopTimeValueRow), + matching: find.byType(TextField), + ) + .first, + ); + final dateContext = tester.element(find.byType(DesktopDateValueRow)); + final timeContext = tester.element(find.byType(DesktopTimeValueRow)); + + expect( + dateTextField.controller?.text, + formatDesktopDate(dateContext, '2026-07-22'), + ); + expect( + timeTextField.controller?.text, + formatMaterialTime(timeContext, const TimeOfDay(hour: 9, minute: 30)), + ); + expect(dateTextField.decoration?.labelText, 'Start date'); + expect(timeTextField.decoration?.labelText, 'Start time'); + expect( + dateTextField.decoration?.floatingLabelBehavior, + FloatingLabelBehavior.auto, + ); + expect( + timeTextField.decoration?.floatingLabelBehavior, + FloatingLabelBehavior.auto, + ); + expect(find.text('Enter date'), findsNothing); + expect(find.text('Enter time'), findsNothing); + expect(find.byIcon(YaruIcons.calendar), findsOneWidget); + expect(find.byIcon(Icons.schedule), findsNothing); + expect(find.byIcon(Icons.edit_outlined), findsNothing); + }, + ); + testWidgets('disabled date and time entries cannot receive focus', ( tester, ) async { @@ -67,6 +178,42 @@ void main() { ); }); + testWidgets('disposing a date field safely ignores an in-flight picker', ( + tester, + ) async { + const channel = MethodChannel(nativeDateTimePickerChannelName); + final response = Completer(); + final changes = []; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (_) => response.future); + addTearDown( + () => TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, null), + ); + + await tester.pumpWidget( + localizedTestApp( + child: Scaffold( + body: DesktopDateField( + label: 'Due date', + date: '2026-07-22', + onChanged: changes.add, + ), + ), + ), + ); + + await tester.tap(find.byIcon(YaruIcons.calendar)); + await tester.pump(); + await tester.pumpWidget(const SizedBox()); + + response.complete('2026-07-23'); + await tester.pump(); + + expect(tester.takeException(), isNull); + expect(changes, isEmpty); + }); + testWidgets('fallback date picker follows the shared modal policy', ( tester, ) async { @@ -103,57 +250,479 @@ void main() { expect(await result, isNull); }); - testWidgets('time entry follows locale and explicit 24-hour preference', ( + testWidgets( + 'fallback date entry is populated and contextual before receiving focus', + (tester) async { + late BuildContext hostContext; + await tester.pumpWidget( + localizedTestApp( + child: Builder( + builder: (context) { + hostContext = context; + return const Scaffold(body: SizedBox()); + }, + ), + ), + ); + + final result = showBusyMaxDateValueDialog( + hostContext, + label: 'Due date', + initialDate: '2026-07-22', + ); + await tester.pumpAndSettle(); + + final entryFinder = find.descendant( + of: find.byType(InputDatePickerFormField), + matching: find.byType(TextFormField), + ); + final entry = tester.widget(entryFinder); + final textField = tester.widget( + find.descendant( + of: find.byType(InputDatePickerFormField), + matching: find.byType(TextField), + ), + ); + final localizations = MaterialLocalizations.of( + tester.element(find.byType(InputDatePickerFormField)), + ); + + expect( + entry.controller?.text, + localizations.formatCompactDate(DateTime(2026, 7, 22)), + ); + expect(textField.decoration?.labelText, 'Due date'); + expect(textField.focusNode?.hasFocus ?? false, isFalse); + expect( + textField.decoration?.labelText, + isNot(localizations.dateInputLabel), + ); + expect(find.text(localizations.dateInputLabel), findsNothing); + + await tester.tap(find.text(localizations.cancelButtonLabel)); + await tester.pumpAndSettle(); + expect(await result, isNull); + }, + ); + + testWidgets('fallback date dialog submits a valid edited date', ( tester, ) async { - Future entryText({ - required Locale locale, - required bool alwaysUse24HourFormat, - }) async { + late BuildContext hostContext; + await tester.pumpWidget( + localizedTestApp( + child: Builder( + builder: (context) { + hostContext = context; + return const Scaffold(body: SizedBox()); + }, + ), + ), + ); + + final result = showBusyMaxDateValueDialog( + hostContext, + label: 'Due date', + initialDate: '2026-07-22', + ); + await tester.pumpAndSettle(); + + final entryFinder = find.descendant( + of: find.byType(InputDatePickerFormField), + matching: find.byType(TextFormField), + ); + final localizations = MaterialLocalizations.of( + tester.element(find.byType(InputDatePickerFormField)), + ); + await tester.enterText( + entryFinder, + localizations.formatCompactDate(DateTime(2027, 8, 14)), + ); + await tester.tap(find.text(localizations.okButtonLabel)); + await tester.pumpAndSettle(); + + expect(await result, '2027-08-14'); + }); + + testWidgets( + 'fallback date dialog safely bounds an unsupported initial date', + (tester) async { + late BuildContext hostContext; await tester.pumpWidget( localizedTestApp( - locale: locale, - alwaysUse24HourFormat: alwaysUse24HourFormat, + child: Builder( + builder: (context) { + hostContext = context; + return const Scaffold(body: SizedBox()); + }, + ), + ), + ); + + final result = showBusyMaxDateValueDialog( + hostContext, + label: 'Due date', + initialDate: '2200-01-01', + ); + await tester.pumpAndSettle(); + + final entry = tester.widget( + find.descendant( + of: find.byType(InputDatePickerFormField), + matching: find.byType(TextFormField), + ), + ); + final localizations = MaterialLocalizations.of( + tester.element(find.byType(InputDatePickerFormField)), + ); + expect( + entry.controller?.text, + localizations.formatCompactDate(DateTime(2100, 12, 31)), + ); + expect(tester.takeException(), isNull); + + await tester.tap(find.text(localizations.cancelButtonLabel)); + await tester.pumpAndSettle(); + expect(await result, isNull); + }, + ); + + testWidgets('fallback date dialog rejects malformed and out-of-range dates', ( + tester, + ) async { + late BuildContext hostContext; + await tester.pumpWidget( + localizedTestApp( + child: Builder( + builder: (context) { + hostContext = context; + return const Scaffold(body: SizedBox()); + }, + ), + ), + ); + + final result = showBusyMaxDateValueDialog( + hostContext, + label: 'Due date', + initialDate: '2026-07-22', + ); + await tester.pumpAndSettle(); + + final entryFinder = find.descendant( + of: find.byType(InputDatePickerFormField), + matching: find.byType(TextFormField), + ); + final localizations = MaterialLocalizations.of( + tester.element(find.byType(InputDatePickerFormField)), + ); + + await tester.enterText(entryFinder, 'not a date'); + await tester.tap(find.text(localizations.okButtonLabel)); + await tester.pumpAndSettle(); + expect(find.byType(BusyMaxDialogShell), findsOneWidget); + expect(find.text(localizations.invalidDateFormatLabel), findsOneWidget); + + await tester.enterText( + entryFinder, + localizations.formatCompactDate(DateTime(1800, 1, 1)), + ); + await tester.tap(find.text(localizations.okButtonLabel)); + await tester.pumpAndSettle(); + expect(find.byType(BusyMaxDialogShell), findsOneWidget); + expect(find.text(localizations.dateOutOfRangeLabel), findsOneWidget); + + await tester.tap(find.text(localizations.cancelButtonLabel)); + await tester.pumpAndSettle(); + expect(await result, isNull); + }); + + testWidgets('invalid time reports validity and uses the native error label', ( + tester, + ) async { + final changes = []; + final validityChanges = []; + await tester.pumpWidget( + localizedTestApp( + child: Scaffold( + body: DesktopTimeField( + label: 'Due time', + time: '09:30', + onChanged: changes.add, + onValidityChanged: validityChanges.add, + ), + ), + ), + ); + validityChanges.clear(); + + final entryFinder = find.descendant( + of: find.byType(DesktopTimeField), + matching: find.byType(TextFormField), + ); + final localizations = MaterialLocalizations.of( + tester.element(find.byType(DesktopTimeField)), + ); + + await tester.enterText(entryFinder, '09x30'); + await tester.pump(); + expect(find.text(localizations.invalidTimeLabel), findsOneWidget); + expect(validityChanges, [false]); + expect(changes, isEmpty); + + await tester.enterText(entryFinder, '10:45'); + await tester.pump(); + expect(find.text(localizations.invalidTimeLabel), findsNothing); + expect(validityChanges, [false, true]); + expect(changes, ['10:45']); + }); + + testWidgets( + 'external time update replaces focused text without echoing the value', + (tester) async { + late StateSetter updateHost; + var suppliedTime = '09:30'; + final changes = []; + await tester.pumpWidget( + localizedTestApp( + child: StatefulBuilder( + builder: (context, setState) { + updateHost = setState; + return Scaffold( + body: DesktopTimeField( + label: 'Due time', + time: suppliedTime, + onChanged: changes.add, + ), + ); + }, + ), + ), + ); + + final entryFinder = find.descendant( + of: find.byType(DesktopTimeField), + matching: find.byType(TextFormField), + ); + await tester.tap(entryFinder); + await tester.pump(); + await tester.enterText(entryFinder, 'stale invalid text'); + await tester.pump(); + changes.clear(); + + updateHost(() { + suppliedTime = '18:45'; + }); + await tester.pump(); + + final entry = tester.widget(entryFinder); + final editableText = tester.widget( + find.descendant( + of: find.byType(DesktopTimeField), + matching: find.byType(EditableText), + ), + ); + final fieldContext = tester.element(find.byType(DesktopTimeField)); + expect( + entry.controller?.text, + formatMaterialTime(fieldContext, const TimeOfDay(hour: 18, minute: 45)), + ); + expect(editableText.focusNode.hasFocus, isTrue); + expect(changes, isEmpty); + expect( + find.text(MaterialLocalizations.of(fieldContext).invalidTimeLabel), + findsNothing, + ); + editableText.focusNode.unfocus(); + await tester.pump(); + expect(changes, isEmpty); + expect( + tester.widget(entryFinder).controller?.text, + formatMaterialTime(fieldContext, const TimeOfDay(hour: 18, minute: 45)), + ); + }, + ); + + testWidgets( + 'rejected controlled time restores the authoritative value on blur', + (tester) async { + final changes = []; + await tester.pumpWidget( + localizedTestApp( + alwaysUse24HourFormat: true, child: Scaffold( body: DesktopTimeField( - key: ValueKey('${locale.languageCode}-$alwaysUse24HourFormat'), - label: 'Due time', - time: '14:30', - onChanged: _ignoreNullableString, + label: 'Quiet hours start', + time: '09:30', + onChanged: changes.add, ), ), ), ); - final entry = find.byType(YaruTimeEntry); - await tester.tap(entry); + final fieldFinder = find.descendant( + of: find.byType(DesktopTimeField), + matching: find.byType(TextFormField), + ); + final editableText = tester.widget( + find.descendant( + of: find.byType(DesktopTimeField), + matching: find.byType(EditableText), + ), + ); + + await tester.tap(fieldFinder); + await tester.enterText(fieldFinder, '10:45'); + await tester.pump(); + expect(changes, ['10:45']); + expect( + tester.widget(fieldFinder).controller?.text, + '10:45', + ); + + editableText.focusNode.unfocus(); await tester.pump(); - return tester + expect(changes, ['10:45']); + expect( + tester.widget(fieldFinder).controller?.text, + '09:30', + ); + }, + ); + + testWidgets('disabling a time field discards transient invalid input', ( + tester, + ) async { + late StateSetter updateHost; + var enabled = true; + final changes = []; + final validityChanges = []; + await tester.pumpWidget( + localizedTestApp( + alwaysUse24HourFormat: true, + child: StatefulBuilder( + builder: (context, setState) { + updateHost = setState; + return Scaffold( + body: DesktopTimeField( + label: 'Quiet hours start', + time: '09:30', + enabled: enabled, + allowEmpty: false, + onChanged: changes.add, + onValidityChanged: validityChanges.add, + ), + ); + }, + ), + ), + ); + validityChanges.clear(); + + final entryFinder = find.descendant( + of: find.byType(DesktopTimeField), + matching: find.byType(TextFormField), + ); + await tester.enterText(entryFinder, 'invalid'); + await tester.pump(); + expect(validityChanges, [false]); + + updateHost(() => enabled = false); + await tester.pump(); + + final entry = tester.widget(entryFinder); + final fieldContext = tester.element(find.byType(DesktopTimeField)); + expect(entry.controller?.text, '09:30'); + expect( + find.text(MaterialLocalizations.of(fieldContext).invalidTimeLabel), + findsNothing, + ); + expect(validityChanges, [false, true]); + expect(changes, isEmpty); + }); + + testWidgets('malformed stored time is blank instead of becoming midnight', ( + tester, + ) async { + final changes = []; + await tester.pumpWidget( + localizedTestApp( + child: Scaffold( + body: DesktopTimeField( + label: 'Due time', + time: 'not-a-provider-time', + onChanged: changes.add, + ), + ), + ), + ); + + final entry = tester.widget( + find.descendant( + of: find.byType(DesktopTimeField), + matching: find.byType(TextFormField), + ), + ); + expect(entry.controller?.text, isEmpty); + expect(changes, isEmpty); + }); + + testWidgets( + 'same time field state follows locale and 24-hour preference changes', + (tester) async { + final changes = []; + + Future pumpEntry({ + required Locale locale, + required bool alwaysUse24HourFormat, + }) async { + await tester.pumpWidget( + localizedTestApp( + locale: locale, + alwaysUse24HourFormat: alwaysUse24HourFormat, + child: Scaffold( + body: DesktopTimeField( + label: 'Due time', + time: '14:30', + onChanged: changes.add, + ), + ), + ), + ); + await tester.pump(); + } + + String visibleText() => + tester .widget( find.descendant( - of: entry, + of: find.byType(DesktopTimeField), matching: find.byType(TextFormField), ), ) .controller ?.text ?? ''; - } - expect( - await entryText(locale: const Locale('en'), alwaysUse24HourFormat: false), - '02:30 pm', - ); - expect( - await entryText(locale: const Locale('en'), alwaysUse24HourFormat: true), - '14:30', - ); - expect( - await entryText(locale: const Locale('de'), alwaysUse24HourFormat: false), - '14:30', - ); - }); + await pumpEntry(locale: const Locale('en'), alwaysUse24HourFormat: false); + final originalState = tester.state(find.byType(DesktopTimeField)); + expect(visibleText(), '2:30 PM'); + + await pumpEntry(locale: const Locale('de'), alwaysUse24HourFormat: false); + expect(tester.state(find.byType(DesktopTimeField)), same(originalState)); + expect(visibleText(), '14:30'); + + await pumpEntry(locale: const Locale('en'), alwaysUse24HourFormat: false); + expect(tester.state(find.byType(DesktopTimeField)), same(originalState)); + expect(visibleText(), '2:30 PM'); + + await pumpEntry(locale: const Locale('en'), alwaysUse24HourFormat: true); + expect(tester.state(find.byType(DesktopTimeField)), same(originalState)); + expect(visibleText(), '14:30'); + expect(changes, isEmpty); + }, + ); } void _ignoreString(String value) {} diff --git a/test/features/tasks/presentation/task_details_pane_test.dart b/test/features/tasks/presentation/task_details_pane_test.dart index c59de8e..e581a87 100644 --- a/test/features/tasks/presentation/task_details_pane_test.dart +++ b/test/features/tasks/presentation/task_details_pane_test.dart @@ -327,6 +327,52 @@ void main() { expect(repository.patches.single.fields, {'title': 'Renamed task'}); }); + testWidgets( + 'invalid visible due time disables Save and Ctrl+S and makes Cancel confirm', + (tester) async { + final repository = _FakeTasksRepository(); + var closed = false; + await _pumpDetails( + tester, + microsoftTaskProviderCapabilities, + repository: repository, + onClose: () => closed = true, + ); + + final dueTime = _labeledTextFormFieldFinder('Due time'); + await tester.enterText(dueTime, 'not a time'); + await tester.pump(); + + expect(_headerButtonOnPressed(tester, 'Save'), isNull); + expect( + find.text( + MaterialLocalizations.of(tester.element(dueTime)).invalidTimeLabel, + ), + findsOneWidget, + ); + + await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft); + await tester.sendKeyEvent(LogicalKeyboardKey.keyS); + await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft); + await tester.pumpAndSettle(); + + expect(repository.patches, isEmpty); + expect(closed, isFalse); + + await tester.tap(_headerButtonFinder(tester, 'Cancel')); + await tester.pumpAndSettle(); + + expect(closed, isFalse); + expect(find.text('Discard changes?'), findsOneWidget); + + await tester.tap(_confirmDialogButton('Discard')); + await tester.pumpAndSettle(); + + expect(closed, isTrue); + expect(repository.patches, isEmpty); + }, + ); + testWidgets('Save closes editor after successful save', (tester) async { final repository = _FakeTasksRepository(); var closed = false; @@ -458,6 +504,44 @@ void main() { expect(_firstTextFieldText(tester), 'New task'); }); + testWidgets('invalid visible due time makes a task switch confirm discard', ( + tester, + ) async { + final repository = _SwitchingTasksRepository(); + addTearDown(repository.dispose); + TaskEntity? restoredTask; + await _pumpSwitchingDetails( + tester, + repository, + taskId: 'task-1', + onTaskSwitchCancelled: (task) => restoredTask = task, + ); + repository.emit(_switchTimedTask('task-1', 'Old task')); + await tester.pumpAndSettle(); + + await tester.enterText( + _labeledTextFormFieldFinder('Due time'), + 'not a time', + ); + await tester.pump(); + + await _pumpSwitchingDetails( + tester, + repository, + taskId: 'task-2', + onTaskSwitchCancelled: (task) => restoredTask = task, + ); + repository.emit(_switchTimedTask('task-2', 'New task')); + await tester.pumpAndSettle(); + + expect(find.text('Discard changes?'), findsOneWidget); + await tester.tap(_confirmDialogButton('Cancel')); + await tester.pumpAndSettle(); + + expect(restoredTask?.id, 'task-1'); + expect(_labeledFieldText(tester, 'Due time'), 'not a time'); + }); + testWidgets('status controls are absent from Task Details', (tester) async { await _pumpDetails(tester, microsoftTaskProviderCapabilities); @@ -568,10 +652,11 @@ void main() { alwaysUse24HourFormat: true, ); - expect(find.text('Jun 6, 2026'), findsOneWidget); - expect(find.text('14:30'), findsOneWidget); - expect(find.byType(YaruDateTimeEntry), findsNothing); - expect(find.byType(YaruTimeEntry), findsNothing); + expect(_labeledFieldText(tester, 'Due date'), 'Jun 6, 2026'); + expect(_labeledFieldText(tester, 'Start date'), 'Jun 4, 2026'); + expect(_labeledFieldText(tester, 'Due time'), '14:30'); + expect(_labeledTextFormFieldFinder('Due date'), findsOneWidget); + expect(_labeledTextFormFieldFinder('Due time'), findsOneWidget); expect(find.text('14:30:00'), findsNothing); }); @@ -584,10 +669,10 @@ void main() { alwaysUse24HourFormat: false, ); - expect(find.text('2:30 PM'), findsOneWidget); - expect(find.text('Jun 4, 2026'), findsOneWidget); - expect(find.byType(YaruDateTimeEntry), findsNothing); - expect(find.byType(YaruTimeEntry), findsNothing); + expect(_labeledFieldText(tester, 'Start date'), 'Jun 4, 2026'); + expect(_labeledFieldText(tester, 'Due time'), '2:30 PM'); + expect(_labeledTextFormFieldFinder('Start date'), findsOneWidget); + expect(_labeledTextFormFieldFinder('Due time'), findsOneWidget); expect(find.text('Jun 4, 2026 · 7:00 AM'), findsNothing); }); @@ -601,7 +686,7 @@ void main() { alwaysUse24HourFormat: true, ); - expect(find.byType(YaruDateTimeEntry), findsNothing); + expect(_renderedDateFieldTexts(tester), ['6. Juni 2026', '4. Juni 2026']); expect(find.textContaining('June'), findsNothing); expect(find.textContaining('Jun 4'), findsNothing); }); @@ -616,7 +701,7 @@ void main() { alwaysUse24HourFormat: true, ); - expect(find.byType(YaruDateTimeEntry), findsNothing); + expect(_renderedDateFieldTexts(tester), ['6 juin 2026', '4 juin 2026']); expect(find.textContaining('June'), findsNothing); expect(find.textContaining('Jun 4'), findsNothing); }); @@ -631,7 +716,7 @@ void main() { alwaysUse24HourFormat: true, ); - expect(find.byType(YaruDateTimeEntry), findsNothing); + expect(_renderedDateFieldTexts(tester), ['6 jun 2026', '4 jun 2026']); expect(find.textContaining('June'), findsNothing); expect(find.textContaining('Jun 4'), findsNothing); }); @@ -663,12 +748,12 @@ void main() { await _pumpDetails(tester, googleTaskProviderCapabilities); expect(find.text('Due'), findsOneWidget); - expect(find.text('Due date'), findsOneWidget); + expect(_dateRowFinder('Due date'), findsOneWidget); expect(find.text('All day'), findsNothing); expect(find.text('Time slot'), findsNothing); - expect(find.text('Due time'), findsNothing); + expect(_timeRowFinder('Due time'), findsNothing); expect(find.text('Start'), findsNothing); - expect(find.text('Start date'), findsNothing); + expect(_dateRowFinder('Start date'), findsNothing); expect(find.text('Reminder'), findsNothing); expect(find.text('Repeat'), findsNothing); expect(find.text('Organization'), findsNothing); @@ -782,8 +867,8 @@ void main() { expect(find.text('Provider features'), findsNothing); expect(find.text('Not supported by Google Tasks.'), findsNothing); - expect(find.text('Start date'), findsNothing); - expect(find.text('Start time'), findsNothing); + expect(_dateRowFinder('Start date'), findsNothing); + expect(_timeRowFinder('Start time'), findsNothing); expect(find.text('Add Reminder'), findsNothing); expect(find.text('Importance'), findsNothing); expect(find.text('Categories'), findsNothing); @@ -882,8 +967,8 @@ void main() { final dueTop = tester.getTopLeft(find.text('Due')).dy; final startTop = tester.getTopLeft(find.text('Start')).dy; - final dueDateTop = tester.getTopLeft(find.text('Due date')).dy; - final startDateTop = tester.getTopLeft(find.text('Start date')).dy; + final dueDateTop = tester.getTopLeft(_dateRowFinder('Due date')).dy; + final startDateTop = tester.getTopLeft(_dateRowFinder('Start date')).dy; expect(dueTop, lessThan(dueDateTop)); expect(dueDateTop, lessThan(startTop)); @@ -914,12 +999,12 @@ void main() { alwaysUse24HourFormat: true, ); - expect(find.text('Reminder date'), findsOneWidget); - expect(find.text('Reminder time'), findsOneWidget); - expect(find.text('Jun 5, 2026'), findsOneWidget); - expect(find.text('09:15'), findsOneWidget); - expect(find.byType(YaruDateTimeEntry), findsNothing); - expect(find.byType(YaruTimeEntry), findsNothing); + expect(_dateRowFinder('Reminder date'), findsOneWidget); + expect(_timeRowFinder('Reminder time'), findsOneWidget); + expect(_labeledFieldText(tester, 'Reminder date'), 'Jun 5, 2026'); + expect(_labeledFieldText(tester, 'Reminder time'), '09:15'); + expect(_labeledTextFormFieldFinder('Reminder date'), findsOneWidget); + expect(_labeledTextFormFieldFinder('Reminder time'), findsOneWidget); expect(find.textContaining('Time zone:'), findsNothing); expect(find.text('UTC'), findsNothing); }); @@ -1037,12 +1122,12 @@ void main() { }); await _pumpDetails(tester, microsoftTaskProviderCapabilities); - await _openRowMenu(tester, 'Due date'); + await _openDatePicker(tester, 'Due date'); expect(tester.takeException(), isNull); expect(calls, hasLength(1)); expect(find.text('June 2026'), findsNothing); - expect(find.text('Jun 15, 2026'), findsOneWidget); + expect(_labeledFieldText(tester, 'Due date'), 'Jun 15, 2026'); }); testWidgets('date value row can use in-window picker', (tester) async { @@ -1061,10 +1146,11 @@ void main() { ), ); - await _openRowMenu(tester, 'Due date'); + await _openDatePicker(tester, 'Due date'); - expect(find.byType(YaruDateTimeEntry), findsOneWidget); + expect(find.byType(BusyMaxDialogShell), findsOneWidget); expect(find.byType(CalendarDatePicker), findsNothing); + expect(_labeledFieldText(tester, 'Due date'), 'Jun 6, 2026'); await tester.tap(find.text('OK')); await tester.pumpAndSettle(); @@ -1096,7 +1182,7 @@ void main() { ), ); - await _openRowMenu(tester, 'Due date'); + await _openDatePicker(tester, 'Due date'); await tester.tap(find.text('OK')); await tester.pumpAndSettle(); @@ -1105,7 +1191,7 @@ void main() { }); testWidgets( - 'due time uses in-app time entry instead of custom picker channel', + 'due time uses an inline labeled field without a picker channel', (tester) async { final calls = []; TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger @@ -1119,10 +1205,9 @@ void main() { alwaysUse24HourFormat: false, ); - expect(_timeEntryFinder(), findsNothing); - await _openRowMenu(tester, 'Due time'); - - expect(_timeEntryFinder(), findsOneWidget); + expect(_labeledTextFormFieldFinder('Due time'), findsOneWidget); + expect(_labeledFieldText(tester, 'Due time'), '2:30 PM'); + expect(find.byType(BusyMaxDialogShell), findsNothing); expect(tester.takeException(), isNull); expect(calls, isEmpty); @@ -1142,14 +1227,14 @@ void main() { expect(find.byType(BusyMaxTimeModeRow), findsOneWidget); expect(find.text('All day'), findsOneWidget); expect(find.text('Time slot'), findsOneWidget); - expect(find.text('Due time'), findsOneWidget); - expect(find.text('Start time'), findsOneWidget); + expect(_timeRowFinder('Due time'), findsOneWidget); + expect(_timeRowFinder('Start time'), findsOneWidget); await tester.tap(find.text('All day')); await tester.pumpAndSettle(); - expect(find.text('Due time'), findsNothing); - expect(find.text('Start time'), findsNothing); + expect(_timeRowFinder('Due time'), findsNothing); + expect(_timeRowFinder('Start time'), findsNothing); await tester.tap(find.text('Save')); await tester.pumpAndSettle(); @@ -1167,6 +1252,36 @@ void main() { }); }); + testWidgets('all-day mode clears stale invalid scheduled-time state', ( + tester, + ) async { + final repository = _FakeTasksRepository(); + await _pumpDetails( + tester, + microsoftTaskProviderCapabilities, + repository: repository, + ); + + await tester.enterText( + _labeledTextFormFieldFinder('Due time'), + 'not a time', + ); + await tester.pump(); + expect(_headerButtonOnPressed(tester, 'Save'), isNull); + + await tester.tap(find.text('All day')); + await tester.pumpAndSettle(); + + expect(_timeRowFinder('Due time'), findsNothing); + expect(_timeRowFinder('Start time'), findsNothing); + expect(_headerButtonOnPressed(tester, 'Save'), isNotNull); + + await tester.tap(_headerButtonFinder(tester, 'Save')); + await tester.pumpAndSettle(); + + expect(repository.patches, hasLength(1)); + }); + testWidgets( 'Microsoft all-day scheduled tasks can be switched to time slot', (tester) async { @@ -1181,14 +1296,14 @@ void main() { ); expect(find.byType(BusyMaxTimeModeRow), findsOneWidget); - expect(find.text('Due time'), findsNothing); - expect(find.text('Start time'), findsNothing); + expect(_timeRowFinder('Due time'), findsNothing); + expect(_timeRowFinder('Start time'), findsNothing); await tester.tap(find.text('Time slot')); await tester.pumpAndSettle(); - expect(find.text('Due time'), findsWidgets); - expect(find.text('Start time'), findsOneWidget); + expect(_timeRowFinder('Due time'), findsOneWidget); + expect(_timeRowFinder('Start time'), findsOneWidget); await tester.tap(find.text('Save')); await tester.pumpAndSettle(); @@ -1219,8 +1334,8 @@ void main() { ); expect(find.byType(BusyMaxTimeModeRow), findsOneWidget); - expect(find.text('Due time'), findsOneWidget); - expect(find.text('Start time'), findsOneWidget); + expect(_timeRowFinder('Due time'), findsOneWidget); + expect(_timeRowFinder('Start time'), findsOneWidget); expect(find.text('All day'), findsOneWidget); expect(find.text('Time slot'), findsOneWidget); @@ -1230,22 +1345,21 @@ void main() { expect(repository.patches, isEmpty); }); - testWidgets('time entries suppress the redundant floating label cleanly', ( + testWidgets('populated time field renders its floating label and value', ( tester, ) async { await _pumpDetails(tester, microsoftTaskProviderCapabilities); - await _openRowMenu(tester, 'Due time'); + final field = _labeledTextFormFieldFinder('Due time'); + final label = find.text('Due time'); - final entryContext = tester.element(_timeEntryFinder().first); - final decorationTheme = Theme.of(entryContext).inputDecorationTheme; - - expect(decorationTheme.floatingLabelBehavior, FloatingLabelBehavior.never); - expect(decorationTheme.labelStyle?.fontSize, isNot(0)); - expect(decorationTheme.floatingLabelStyle?.fontSize, isNot(0)); + expect(field, findsOneWidget); + expect(label, findsOneWidget); + expect(_labeledFieldText(tester, 'Due time'), '2:30 PM'); + expect(tester.getCenter(label).dy, lessThan(tester.getCenter(field).dy)); }); testWidgets( - 'empty time field uses time placeholder instead of None subtitle', + 'empty time field stays empty and floats its label when focused', (tester) async { await tester.pumpWidget( localizedTestApp( @@ -1260,21 +1374,19 @@ void main() { ), ); - expect(find.text('Due time'), findsWidgets); + expect(find.byType(DesktopTimeField), findsOneWidget); expect(find.text('None'), findsNothing); - final entry = tester.widget(_timeEntryFinder()); - expect(entry.controller?.timeOfDay, isNull); + final field = _labeledTextFormFieldFinder('Due time'); + final label = find.text('Due time'); + final restingLabelTop = tester.getTopLeft(label).dy; - await tester.tap(_timeEntryFinder()); - await tester.pump(); + expect(_labeledFieldText(tester, 'Due time'), isEmpty); - final textEntry = tester.widget( - find.descendant( - of: _timeEntryFinder(), - matching: find.byType(TextFormField), - ), - ); - expect(textEntry.controller?.text, '--:--'); + await tester.tap(field); + await tester.pumpAndSettle(); + + expect(_labeledFieldText(tester, 'Due time'), isEmpty); + expect(tester.getTopLeft(label).dy, lessThan(restingLabelTop)); }, ); @@ -1284,23 +1396,23 @@ void main() { localizedTestApp( alwaysUse24HourFormat: true, child: Scaffold( - body: DesktopTimeField( + body: _ControlledTimeField( label: 'Due time', - time: '09:30', + initialTime: '09:30', onChanged: (time) => changed = time, ), ), ), ); - await tester.tap(find.byIcon(YaruIcons.edit_clear)); - await _enterTime(tester, hour: '00', minute: '00'); + await _enterTime(tester, label: 'Due time', value: '00:00'); expect(changed, '00:00'); + expect(_labeledFieldText(tester, 'Due time'), '00:00'); expect(tester.takeException(), isNull); }); - testWidgets('time field uses segmented hour and minute entry', ( + testWidgets('time field accepts a complete localized time value', ( tester, ) async { String? changed; @@ -1308,20 +1420,19 @@ void main() { localizedTestApp( alwaysUse24HourFormat: true, child: Scaffold( - body: DesktopTimeField( + body: _ControlledTimeField( label: 'Due time', - time: null, + initialTime: null, onChanged: (time) => changed = time, ), ), ), ); - await _enterTime(tester, hour: '05', minute: '17'); + await _enterTime(tester, label: 'Due time', value: '05:17'); - final entry = tester.widget(_timeEntryFinder()); - expect(entry.controller?.timeOfDay, const TimeOfDay(hour: 5, minute: 17)); expect(changed, '05:17'); + expect(_labeledFieldText(tester, 'Due time'), '05:17'); expect(tester.takeException(), isNull); }); @@ -1341,7 +1452,7 @@ void main() { repository: repository, ); - await _openRowMenu(tester, 'Due date'); + await _openDatePicker(tester, 'Due date'); await tester.pumpAndSettle(); expect(repository.patches, isEmpty); @@ -1367,7 +1478,7 @@ void main() { }); await _pumpDetails(tester, microsoftTaskProviderCapabilities); - await _openRowMenu(tester, 'Due date'); + await _openDatePicker(tester, 'Due date'); expect(calls.single.method, 'pickDate'); expect(find.text('June 2026'), findsNothing); @@ -1574,34 +1685,102 @@ void _focusEditorShortcuts(WidgetTester tester) { focusWidget.focusNode!.requestFocus(); } -Future _openRowMenu(WidgetTester tester, String label) async { - final row = find - .ancestor( - of: find.text(label).first, - matching: find.byType(BusyMaxCalendarValueRow), - ) - .first; +Finder _dateRowFinder(String label) { + return find.byWidgetPredicate( + (widget) => widget is DesktopDateValueRow && widget.label == label, + ); +} + +Finder _timeRowFinder(String label) { + return find.byWidgetPredicate( + (widget) => widget is DesktopTimeValueRow && widget.label == label, + ); +} + +Finder _labeledTextFormFieldFinder(String label) { + return find.ancestor( + of: find.text(label), + matching: find.byType(TextFormField), + ); +} + +String _labeledFieldText(WidgetTester tester, String label) { + return tester + .widget(_labeledTextFormFieldFinder(label).first) + .controller + ?.text ?? + ''; +} + +List _renderedDateFieldTexts(WidgetTester tester) { + final fields = find.descendant( + of: find.byType(DesktopDateField), + matching: find.byType(TextFormField), + ); + return [ + for (final field in tester.widgetList(fields)) + field.controller?.text ?? '', + ]; +} + +Future _openDatePicker(WidgetTester tester, String label) async { + final row = _dateRowFinder(label); await tester.ensureVisible(row); await tester.pumpAndSettle(); - await tester.tap(row); + final calendarIcon = find.descendant( + of: row, + matching: find.byIcon(YaruIcons.calendar), + ); + final button = tester.widget( + find.ancestor(of: calendarIcon, matching: find.byType(YaruIconButton)), + ); + button.onPressed?.call(); await tester.pumpAndSettle(); } -Finder _timeEntryFinder() => find.byType(YaruTimeEntry); - Future _enterTime( WidgetTester tester, { - required String hour, - required String minute, + required String label, + required String value, }) async { - final entry = _timeEntryFinder(); - await tester.tap(entry); - await tester.enterText(entry, hour); - await tester.pump(); - await tester.enterText(entry, minute); + final field = _labeledTextFormFieldFinder(label); + await tester.tap(field); + await tester.enterText(field, value); + await tester.testTextInput.receiveAction(TextInputAction.done); await tester.pump(); } +class _ControlledTimeField extends StatefulWidget { + const _ControlledTimeField({ + required this.label, + required this.initialTime, + required this.onChanged, + }); + + final String label; + final String? initialTime; + final ValueChanged onChanged; + + @override + State<_ControlledTimeField> createState() => _ControlledTimeFieldState(); +} + +class _ControlledTimeFieldState extends State<_ControlledTimeField> { + late String? _time = widget.initialTime; + + @override + Widget build(BuildContext context) { + return DesktopTimeField( + label: widget.label, + time: _time, + onChanged: (value) { + setState(() => _time = value); + widget.onChanged(value); + }, + ); + } +} + class _FakeTasksRepository implements TasksRepository { _FakeTasksRepository({ this.accountId = 'microsoft:m', @@ -1733,6 +1912,26 @@ TaskEntity _switchTask(String id, String title) { ); } +TaskEntity _switchTimedTask(String id, String title) { + return TaskEntity( + accountId: 'microsoft:m', + taskListId: 'list-1', + id: id, + title: title, + localDirty: false, + pendingDelete: false, + pendingMove: false, + rawJson: '{}', + updatedLocalAtUtc: '2026-06-04T00:00:00.000Z', + status: 'needsAction', + dueUtc: '2026-06-06', + microsoftDueDateTime: '2026-06-06T14:30:00', + microsoftDueTimeZone: 'America/Vancouver', + microsoftStartDateTime: '2026-06-04T07:00:00', + microsoftStartTimeZone: 'UTC', + ); +} + class _FakeTaskListsRepository implements TaskListsRepository { const _FakeTaskListsRepository({this.accountId = 'microsoft:m'}); diff --git a/test/platform/gtk_font_service_test.dart b/test/platform/gtk_font_service_test.dart index f9f96f7..68da640 100644 --- a/test/platform/gtk_font_service_test.dart +++ b/test/platform/gtk_font_service_test.dart @@ -301,8 +301,10 @@ void main() { expect(colors?.brightness, Brightness.dark); expect(colors?.window, const Color(0xFF202020)); + expect(colors?.view, const Color(0xFF212121)); expect(colors?.sidebar, const Color(0xFF303030)); expect(colors?.headerbar, const Color(0xFF242424)); + expect(colors?.headerbarFlat, const Color(0xFF212121)); expect(colors?.popover, const Color(0xFF383838)); expect(colors?.control, const Color(0x1AFFFFFF)); expect(colors?.controlActive, const Color(0x33FFFFFF)); diff --git a/test/platform/linux_header_bar_configuration_synchronizer_test.dart b/test/platform/linux_header_bar_configuration_synchronizer_test.dart index 912de26..22114e0 100644 --- a/test/platform/linux_header_bar_configuration_synchronizer_test.dart +++ b/test/platform/linux_header_bar_configuration_synchronizer_test.dart @@ -131,6 +131,9 @@ BusyMaxHeaderBarConfiguration _configuration({required bool dark}) { foregroundColor: dark ? Colors.white : Colors.black, sidebarBorderColor: Colors.grey, popoverBackgroundColor: dark ? Colors.black : Colors.white, + menuHoverColor: dark ? Colors.white12 : Colors.black12, + popoverShadowColor: Colors.black38, + dialogBackgroundColor: dark ? Colors.black : Colors.white, dialogOutlineColor: dark ? Colors.white : Colors.white10, modalBarrierColor: Colors.black54, ), diff --git a/test/platform/linux_header_bar_service_test.dart b/test/platform/linux_header_bar_service_test.dart index fa353a8..2117604 100644 --- a/test/platform/linux_header_bar_service_test.dart +++ b/test/platform/linux_header_bar_service_test.dart @@ -103,6 +103,9 @@ void main() { foregroundColor: Color(0xFFFFFFFF), sidebarBorderColor: Color.fromRGBO(0, 0, 6, 0.75), popoverBackgroundColor: Color(0xFF36363A), + menuHoverColor: Color.fromRGBO(255, 255, 255, 0.14), + popoverShadowColor: Color.fromRGBO(0, 0, 0, 0.3), + dialogBackgroundColor: Color(0xFF36363A), dialogOutlineColor: Color.fromRGBO(255, 255, 255, 0.07), modalBarrierColor: Color.fromRGBO(0, 0, 0, 0.32), ), @@ -149,6 +152,9 @@ void main() { 'foregroundColor': '#FFFFFF', 'sidebarBorderColor': 'rgba(0,0,6,0.75)', 'popoverBackgroundColor': '#36363A', + 'menuHoverColor': 'rgba(255,255,255,0.14)', + 'popoverShadowColor': 'rgba(0,0,0,0.30)', + 'dialogBackgroundColor': '#36363A', 'dialogOutlineColor': 'rgba(255,255,255,0.07)', 'modalBarrierColor': 'rgba(0,0,0,0.32)', }), @@ -544,8 +550,15 @@ void main() { expect(calls.where((call) => call.method == 'focusSearch'), hasLength(1)); }); - test('native search uses a responsive theme-owned GTK entry', () { + 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( + 'g_autofree gchar* native_menu_state_css =', + geometryCssStart, + ); expect(source, contains('gtk_search_entry_new()')); expect( @@ -576,7 +589,37 @@ void main() { source, isNot(contains('gtk_widget_set_size_request(self->search_entry')), ); - expect(source, isNot(contains('busymax-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')); + + 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', () { @@ -638,7 +681,7 @@ void main() { expect(source, isNot(contains('transition: none'))); expect(source, isNot(contains('popover.busymax-header-popover'))); expect(source, contains('kNativePopoverStyleClass')); - expect(source, contains('style_native_popover(GTK_WIDGET(popover))')); + expect(source, contains('style_header_menu_popover(GTK_WIDGET(popover))')); expect(source, isNot(contains('tooltip.background'))); expect(source, isNot(contains('button.busymax-header-popover-row'))); expect(source, isNot(contains('busymax-keyboard-focus'))); diff --git a/test/test_localized_app.dart b/test/test_localized_app.dart index 897746f..a8e6880 100644 --- a/test/test_localized_app.dart +++ b/test/test_localized_app.dart @@ -6,6 +6,7 @@ Widget localizedTestApp({ required Widget child, Locale locale = const Locale('en'), bool? alwaysUse24HourFormat, + TextScaler? textScaler, ThemeData? theme, }) { return MaterialApp( @@ -16,12 +17,15 @@ Widget localizedTestApp({ ...GlobalUbuntuLocalizations.delegates, ], supportedLocales: AppLocalizations.supportedLocales, - builder: alwaysUse24HourFormat == null + builder: alwaysUse24HourFormat == null && textScaler == null ? null : (context, child) { - final data = MediaQuery.of( - context, - ).copyWith(alwaysUse24HourFormat: alwaysUse24HourFormat); + final mediaQuery = MediaQuery.of(context); + final data = mediaQuery.copyWith( + alwaysUse24HourFormat: + alwaysUse24HourFormat ?? mediaQuery.alwaysUse24HourFormat, + textScaler: textScaler ?? mediaQuery.textScaler, + ); return MediaQuery(data: data, child: child ?? const SizedBox()); }, home: child, From 8b766d08120a74a400c28bfca452d1f13e99d0f9 Mon Sep 17 00:00:00 2001 From: albert Date: Sun, 26 Jul 2026 22:43:43 -0700 Subject: [PATCH 17/73] Enhance BusyMaxDialogShell by introducing a customizable header. Replace static title bar with a flexible header widget that includes cancel and save actions, improving dialog usability and consistency across the UI. --- lib/src/app/busymax_design.dart | 21 ++++++++++----------- test/app/busymax_dialogs_test.dart | 22 ++++++++++++++++------ test/app/native_ui_audit_test.dart | 7 +++++++ 3 files changed, 33 insertions(+), 17 deletions(-) diff --git a/lib/src/app/busymax_design.dart b/lib/src/app/busymax_design.dart index c58b6fc..edfeaad 100644 --- a/lib/src/app/busymax_design.dart +++ b/lib/src/app/busymax_design.dart @@ -3438,12 +3438,14 @@ class BusyMaxDialogShell extends StatelessWidget { required this.title, required this.children, this.maxWidth = 520, + this.header, this.actions = const [], }); final String title; final List children; final double maxWidth; + final Widget? header; final List actions; @override @@ -3466,7 +3468,7 @@ class BusyMaxDialogShell extends StatelessWidget { mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - BusyMaxDialogTitleBar(title: Text(title)), + header ?? BusyMaxDialogTitleBar(title: Text(title)), Flexible( child: SingleChildScrollView( padding: const EdgeInsets.all(BusyMaxSpacing.lg), @@ -3558,16 +3560,13 @@ class _BusyMaxPromptDialogState extends State { Widget build(BuildContext context) { return BusyMaxDialogShell( title: widget.title, - actions: [ - BusyMaxPushButton.standard( - onPressed: () => Navigator.of(context).pop(), - child: Text(context.l10n.cancel), - ), - BusyMaxPushButton.suggested( - onPressed: _canSubmit ? _submit : null, - child: Text(widget.actionLabel), - ), - ], + header: BusyMaxEditorHeader( + title: widget.title, + cancelLabel: context.l10n.cancel, + saveLabel: widget.actionLabel, + onCancel: () => Navigator.of(context).pop(), + onSave: _canSubmit ? _submit : null, + ), children: [ if (widget.message != null && widget.message!.isNotEmpty) Text(widget.message!), diff --git a/test/app/busymax_dialogs_test.dart b/test/app/busymax_dialogs_test.dart index 182f341..8f4569c 100644 --- a/test/app/busymax_dialogs_test.dart +++ b/test/app/busymax_dialogs_test.dart @@ -30,7 +30,7 @@ void main() { }); testWidgets( - 'prompt and confirmation title bars share the semantic dialog surface', + 'prompt action header and confirmation title bar use the dialog surface', (tester) async { final theme = BusyMaxYaruTheme.build( brightness: Brightness.dark, @@ -50,11 +50,9 @@ void main() { ), ); - var titleBar = tester.widget( - find.byType(YaruDialogTitleBar), - ); + expect(find.byType(BusyMaxEditorHeader), findsOneWidget); + expect(find.byType(YaruDialogTitleBar), findsNothing); final dialog = tester.widget(find.byType(Dialog)); - expect(titleBar.backgroundColor, colors.dialog); expect(dialog.backgroundColor, colors.dialog); expect(dialog.surfaceTintColor, colors.dialog); @@ -70,7 +68,7 @@ void main() { ), ); - titleBar = tester.widget( + final titleBar = tester.widget( find.byType(YaruDialogTitleBar), ); final confirmation = tester.widget(find.byType(AlertDialog)); @@ -533,6 +531,18 @@ void main() { expect(textField.decoration?.labelText, 'Name'); expect(textField.decoration?.filled, isFalse); expect(textField.decoration?.border, InputBorder.none); + expect(find.byType(BusyMaxEditorHeader), findsOneWidget); + expect(find.byType(YaruDialogTitleBar), findsNothing); + expect(find.byType(OverflowBar), findsNothing); + final inputGroupRect = tester.getRect(find.byType(BusyMaxGroupedList)); + final cancelRect = tester.getRect( + find.widgetWithText(FilledButton, 'Cancel'), + ); + final renameRect = tester.getRect( + find.widgetWithText(ElevatedButton, 'Rename'), + ); + expect(cancelRect.bottom, lessThan(inputGroupRect.top)); + expect(renameRect.bottom, lessThan(inputGroupRect.top)); await tester.enterText(find.byType(TextField), 'Edited name'); await tester.tapAt(const Offset(2, 2)); diff --git a/test/app/native_ui_audit_test.dart b/test/app/native_ui_audit_test.dart index de40510..f020824 100644 --- a/test/app/native_ui_audit_test.dart +++ b/test/app/native_ui_audit_test.dart @@ -1311,6 +1311,9 @@ void main() { expect(promptEnd, greaterThan(promptStart)); final prompt = design.substring(promptStart, promptEnd); expect(prompt, contains('BusyMaxDialogShell(')); + expect(prompt, contains('header: BusyMaxEditorHeader(')); + expect(prompt, isNot(contains('actions: ['))); + expect(prompt, isNot(contains('BusyMaxDialogTitleBar('))); expect(prompt, contains('BusyMaxGroupedList(')); expect(prompt, contains('filled: true')); expect(prompt, contains('YaruListTile.square(')); @@ -1318,6 +1321,10 @@ void main() { expect(prompt, contains('TextEditingController(')); expect(prompt, contains('_canSubmit ? _submit : null')); expect(prompt, contains('onFieldSubmitted: (_) => _submit()')); + expect( + design, + contains('header ?? BusyMaxDialogTitleBar(title: Text(title))'), + ); expect(prompt, isNot(contains('maxWidth:'))); expect(prompt, isNot(contains('InputDecoration('))); expect(prompt, isNot(contains('AlertDialog('))); From 7c06bd4fc7cd8bff57dbc3c7aa179e76df34e59a Mon Sep 17 00:00:00 2001 From: albert Date: Mon, 27 Jul 2026 13:38:08 -0700 Subject: [PATCH 18/73] Refactor menu session handling for improved responsiveness. Ensure synchronous release of menu triggers to prevent unintended dismissals and enhance user experience. Update key handling in schedule sidebar components for better state management and consistency. --- lib/src/app/busymax_design.dart | 13 +- .../presentation/schedule_sidebar.dart | 37 ++++- linux/runner/my_application.cc | 26 ++-- test/app/busymax_menu_button_test.dart | 137 ++++++++++++++++++ test/app/native_ui_audit_test.dart | 37 ++++- .../presentation/schedule_views_test.dart | 6 + 6 files changed, 235 insertions(+), 21 deletions(-) diff --git a/lib/src/app/busymax_design.dart b/lib/src/app/busymax_design.dart index edfeaad..ca049b7 100644 --- a/lib/src/app/busymax_design.dart +++ b/lib/src/app/busymax_design.dart @@ -2855,9 +2855,18 @@ class _BusyMaxMenuButtonState extends State> { void _closeMenu() { final session = _activeMenuSession; - if (session != null) { - unawaited(session.dismiss()); + if (session == null) { + return; } + // Release the trigger synchronously. Native dismissal and the matching + // `show` response travel over separate platform messages, so waiting for + // that response would leave this button looking open and make a quick + // second click dismiss the same session again instead of reopening. + setState(() { + _activeMenuSession = null; + _menuOpen = false; + }); + unawaited(session.dismiss()); } void _attachExternalController() { diff --git a/lib/src/features/schedule/presentation/schedule_sidebar.dart b/lib/src/features/schedule/presentation/schedule_sidebar.dart index c75291b..e1219ca 100644 --- a/lib/src/features/schedule/presentation/schedule_sidebar.dart +++ b/lib/src/features/schedule/presentation/schedule_sidebar.dart @@ -61,7 +61,10 @@ class ScheduleSidebar extends ConsumerWidget { padding: const EdgeInsets.symmetric(vertical: BusyMaxSpacing.sm), children: [ for (final account in accounts) - _AccountSourcesGroup(account: account), + _AccountSourcesGroup( + key: ValueKey(('schedule-account', account.id)), + account: account, + ), ], ), ), @@ -72,7 +75,7 @@ class ScheduleSidebar extends ConsumerWidget { } class _SourceRow extends ConsumerWidget { - const _SourceRow({required this.source}); + const _SourceRow({super.key, required this.source}); final CalendarSourceEntity source; @@ -251,7 +254,7 @@ class _SourceVisibilityButton extends StatelessWidget { } class _AccountSourcesGroup extends ConsumerStatefulWidget { - const _AccountSourcesGroup({required this.account}); + const _AccountSourcesGroup({super.key, required this.account}); final AccountEntity account; @@ -296,7 +299,15 @@ class _AccountSourcesGroupState extends ConsumerState<_AccountSourcesGroup> { return Column( children: [ for (final list in lists) - _TaskListScheduleRow(account: account, list: list), + _TaskListScheduleRow( + key: ValueKey(( + 'schedule-task-list', + list.accountId, + list.id, + )), + account: account, + list: list, + ), ], ); }, @@ -407,7 +418,17 @@ class _AccountCalendarSources extends ConsumerWidget { ); } return Column( - children: [for (final source in sources) _SourceRow(source: source)], + children: [ + for (final source in sources) + _SourceRow( + key: ValueKey(( + 'schedule-calendar', + source.accountId, + source.id, + )), + source: source, + ), + ], ); }, ); @@ -439,7 +460,11 @@ class _SourceDot extends StatelessWidget { } class _TaskListScheduleRow extends ConsumerWidget { - const _TaskListScheduleRow({required this.account, required this.list}); + const _TaskListScheduleRow({ + super.key, + required this.account, + required this.list, + }); final AccountEntity account; final TaskListEntity list; diff --git a/linux/runner/my_application.cc b/linux/runner/my_application.cc index 411e44e..2d13a27 100644 --- a/linux/runner/my_application.cc +++ b/linux/runner/my_application.cc @@ -524,6 +524,7 @@ struct NativeMenuSession { FlMethodCall* method_call; gulong closed_signal_id; guint cleanup_source_id; + gint pending_selected_index; }; struct NativeMenuHandlerData { @@ -557,7 +558,6 @@ static void native_menu_session_dispose(NativeMenuSession* session) { g_source_remove(session->cleanup_source_id); session->cleanup_source_id = 0; } - native_menu_session_respond(session, -1); if (session->popover != nullptr) { if (session->closed_signal_id != 0) { @@ -578,6 +578,9 @@ static void native_menu_session_dispose(NativeMenuSession* session) { } g_clear_object(&session->model); g_clear_object(&session->action_group); + // Resolve the Dart future only after the native session is fully retired. + // A resumed caller may immediately open another menu. + native_menu_session_respond(session, session->pending_selected_index); g_free(session); } @@ -591,9 +594,9 @@ static gboolean native_menu_cleanup_idle_cb(gpointer user_data) { static void native_menu_closed_cb(GtkPopover*, gpointer user_data) { auto* session = static_cast(user_data); if (session->cleanup_source_id == 0) { - // GtkModelButton normally activates its GAction before closing the - // popover. Deferring final cleanup also covers themes/backends that emit - // "closed" first, so the action can still win with a selected index. + // GtkModelButton can activate just before close begins. Deferring final + // cleanup avoids consuming the method response before the selected index is + // finalized. session->cleanup_source_id = g_idle_add_full( G_PRIORITY_DEFAULT_IDLE, native_menu_cleanup_idle_cb, session, nullptr); } @@ -607,7 +610,7 @@ static void native_menu_action_activated_cb(GSimpleAction* action, GPOINTER_TO_INT( g_object_get_data(G_OBJECT(action), kNativeMenuActionIndexKey)) - 1; - native_menu_session_respond(session, selected_index); + session->pending_selected_index = selected_index; if (session->popover != nullptr) { gtk_popover_popdown(GTK_POPOVER(session->popover)); } @@ -631,7 +634,7 @@ static void native_menu_selection_activated_cb(GSimpleAction* action, } g_simple_action_set_state(action, parameter); - native_menu_session_respond(session, static_cast(parsed)); + session->pending_selected_index = static_cast(parsed); if (session->popover != nullptr) { gtk_popover_popdown(GTK_POPOVER(session->popover)); } @@ -644,7 +647,6 @@ static gboolean native_menu_dismiss_active(NativeMenuHandlerData* data, return FALSE; } - native_menu_session_respond(session, -1); if (session->popover != nullptr && gtk_widget_get_visible(session->popover)) { gtk_popover_popdown(GTK_POPOVER(session->popover)); @@ -839,6 +841,7 @@ static void show_native_menu(NativeMenuHandlerData* data, session->owner = data; session->id = session_id; session->entry_count = fl_value_get_length(entries); + session->pending_selected_index = -1; session->method_call = FL_METHOD_CALL(g_object_ref(G_OBJECT(method_call))); session->action_group = g_simple_action_group_new(); @@ -902,11 +905,10 @@ static void show_native_menu(NativeMenuHandlerData* data, GTK_POPOVER_CONSTRAINT_WINDOW); gtk_popover_set_modal(GTK_POPOVER(session->popover), TRUE); session->closed_signal_id = - g_signal_connect(session->popover, "closed", - G_CALLBACK(native_menu_closed_cb), session); - // gtk_popover_popup() is the canonical mapper. Mapping the complete - // popover first via gtk_widget_show_all() leaves it in GTK's SHOWN state, - // so popup() returns before completing its normal presentation lifecycle. + g_signal_connect(session->popover, "closed", G_CALLBACK(native_menu_closed_cb), + session); + // Use GTK's popover lifecycle so model-button pseudo states (hover) are + // dispatched correctly. gtk_popover_popup(GTK_POPOVER(session->popover)); if (focus_first) { gtk_widget_child_focus(session->popover, GTK_DIR_TAB_FORWARD); diff --git a/test/app/busymax_menu_button_test.dart b/test/app/busymax_menu_button_test.dart index 0e43672..1076619 100644 --- a/test/app/busymax_menu_button_test.dart +++ b/test/app/busymax_menu_button_test.dart @@ -288,6 +288,72 @@ void main() { expect(controller.isOpen, isFalse); }); + testWidgets('a dismissed native session cannot keep the trigger stuck open', ( + tester, + ) async { + final nativeSelections = >[]; + final calls = []; + final selections = []; + final controller = BusyMaxMenuController(); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + calls.add(call); + if (call.method == 'show') { + final selection = Completer(); + nativeSelections.add(selection); + return selection.future; + } + if (call.method == 'dismiss') { + return true; + } + throw MissingPluginException(); + }); + + await tester.pumpWidget( + localizedTestApp( + child: Scaffold( + body: BusyMaxMenuButton( + tooltip: 'Options', + controller: controller, + nativeMenuService: const NativeMenuService(channel: channel), + entries: const [ + BusyMaxMenuEntry(value: 'refresh', label: 'Refresh'), + ], + onSelected: selections.add, + ), + ), + ), + ); + + await tester.tap(find.byTooltip('Options')); + await tester.pump(); + expect(controller.isOpen, isTrue); + expect(nativeSelections, hasLength(1)); + + controller.close(); + expect(controller.isOpen, isFalse); + await tester.pump(); + + await tester.tap(find.byTooltip('Options')); + await tester.pump(); + expect(controller.isOpen, isTrue); + expect(nativeSelections, hasLength(2)); + + // A late result from the dismissed presentation must neither select an + // entry nor retire the replacement session. + nativeSelections.first.complete(0); + await tester.pump(); + expect(selections, isEmpty); + expect(controller.isOpen, isTrue); + + nativeSelections.last.complete(0); + await tester.pumpAndSettle(); + expect(selections, ['refresh']); + expect(controller.isOpen, isFalse); + expect(calls.where((call) => call.method == 'show'), hasLength(2)); + expect(calls.where((call) => call.method == 'dismiss'), hasLength(1)); + }); + testWidgets('an open menu keeps its entry and callback snapshot', ( tester, ) async { @@ -346,6 +412,77 @@ void main() { expect(replacementSelections, isEmpty); }); + testWidgets('keyed menu state follows its owner through a row reorder', ( + tester, + ) async { + final nativeSelection = Completer(); + final selections = []; + late StateSetter rebuild; + var reversed = false; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + if (call.method == 'show') { + return nativeSelection.future; + } + if (call.method == 'dismiss') { + return true; + } + throw MissingPluginException(); + }); + + await tester.pumpWidget( + localizedTestApp( + child: Scaffold( + body: StatefulBuilder( + builder: (context, setState) { + rebuild = setState; + final owners = reversed + ? const ['second', 'first'] + : const ['first', 'second']; + return Column( + children: [ + for (final owner in owners) + BusyMaxMenuButton( + key: ValueKey(owner), + tooltip: 'Options $owner', + nativeMenuService: const NativeMenuService( + channel: channel, + ), + entries: [BusyMaxMenuEntry(value: owner, label: owner)], + onSelected: selections.add, + ), + ], + ); + }, + ), + ), + ), + ); + + YaruIconButton triggerFor(String owner) { + return tester.widget( + find.ancestor( + of: find.byTooltip('Options $owner'), + matching: find.byType(YaruIconButton), + ), + ); + } + + await tester.tap(find.byTooltip('Options first')); + await tester.pump(); + expect(triggerFor('first').isSelected, isTrue); + expect(triggerFor('second').isSelected, isFalse); + + rebuild(() => reversed = true); + await tester.pump(); + expect(triggerFor('first').isSelected, isTrue); + expect(triggerFor('second').isSelected, isFalse); + + nativeSelection.complete(0); + await tester.pumpAndSettle(); + expect(selections, ['first']); + }); + testWidgets('fallback dismissal removes its menu, not a newer route', ( tester, ) async { diff --git a/test/app/native_ui_audit_test.dart b/test/app/native_ui_audit_test.dart index f020824..bda200c 100644 --- a/test/app/native_ui_audit_test.dart +++ b/test/app/native_ui_audit_test.dart @@ -1178,6 +1178,17 @@ void main() { 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, contains('gtk_popover_new_from_model(')); expect(nativeMenu, contains('gtk_popover_set_pointing_to(')); @@ -1186,13 +1197,37 @@ void main() { expect(nativeMenu, contains('g_simple_action_set_enabled(')); expect(nativeMenu, contains('g_simple_action_new_stateful(')); expect(nativeMenu, contains('g_object_ref(G_OBJECT(method_call))')); - expect(nativeMenu, contains('gtk_popover_popup(')); + expect(nativeMenu, contains('gtk_popover_popup(session->popover)')); + expect(nativeMenu, isNot(contains('gtk_widget_show(session->popover)'))); expect( nativeMenu, isNot(contains('gtk_widget_show_all(session->popover)')), ); expect(nativeMenu, isNot(contains('gtk_popover_bind_model('))); expect(nativeMenu, contains('gtk_widget_destroy(session->popover)')); + final destroyIndex = dispose.indexOf( + 'gtk_widget_destroy(session->popover)', + ); + final clearActionsIndex = dispose.indexOf( + 'g_clear_object(&session->action_group)', + ); + final respondIndex = dispose.indexOf('native_menu_session_respond('); + final freeIndex = dispose.indexOf('g_free(session)'); + expect(destroyIndex, isNonNegative); + expect(clearActionsIndex, isNonNegative); + expect(respondIndex, isNonNegative); + expect(freeIndex, isNonNegative); + expect(clearActionsIndex, lessThan(respondIndex)); + expect(respondIndex, lessThan(freeIndex)); + expect( + 'native_menu_session_respond('.allMatches(nativeMenu), + hasLength(2), + ); + expect( + nativeMenu, + contains('g_signal_connect(session->popover, "closed"'), + ); + expect(nativeMenu, isNot(contains('"unmap"'))); expect( nativeMenu, contains( diff --git a/test/features/schedule/presentation/schedule_views_test.dart b/test/features/schedule/presentation/schedule_views_test.dart index e34d123..d9599c1 100644 --- a/test/features/schedule/presentation/schedule_views_test.dart +++ b/test/features/schedule/presentation/schedule_views_test.dart @@ -2364,6 +2364,12 @@ void main() { expect(sidebar, contains('class _AccountSourcesGroupState')); expect(sidebar, contains('var _expanded = true')); expect(sidebar, contains('class _AccountHeaderRow')); + expect( + sidebar, + contains("key: ValueKey(('schedule-account', account.id))"), + ); + expect(sidebar, contains("'schedule-calendar'")); + expect(sidebar, contains("'schedule-task-list'")); expect(sidebar, contains('AnimatedRotation')); expect(sidebar, contains('YaruIcons.pan_end')); expect(sidebar, contains('if (_expanded)')); From 8e5414eb98becd6f2cf7bdbacf42b18256f0311f Mon Sep 17 00:00:00 2001 From: albert Date: Mon, 27 Jul 2026 18:28:29 -0700 Subject: [PATCH 19/73] Refactor date and time picker components. Introduce compact mode for schedule year view and enhance native date/time picker integration. Update MiniCalendar and DesktopDateField to support optional header display and improved layout handling. --- .../schedule/presentation/mini_calendar.dart | 93 +- .../presentation/schedule_year_view.dart | 90 +- .../desktop_date_time_fields.dart | 945 ++++++++++++++++-- .../tasks/presentation/new_task_dialog.dart | 2 +- .../presentation/task_details_editor.dart | 5 +- linux/runner/my_application.cc | 136 ++- test/app/native_ui_audit_test.dart | 28 +- .../presentation/schedule_views_test.dart | 14 +- .../desktop_date_time_fields_test.dart | 425 +++++--- .../presentation/task_details_pane_test.dart | 56 +- 10 files changed, 1488 insertions(+), 306 deletions(-) diff --git a/lib/src/features/schedule/presentation/mini_calendar.dart b/lib/src/features/schedule/presentation/mini_calendar.dart index f8978f1..81b8f02 100644 --- a/lib/src/features/schedule/presentation/mini_calendar.dart +++ b/lib/src/features/schedule/presentation/mini_calendar.dart @@ -18,8 +18,9 @@ class MiniCalendar extends StatelessWidget { required this.firstWeekday, this.items = const [], required this.onSelected, - required this.onMonthSelected, - required this.onYearSelected, + this.showHeader = true, + this.onMonthSelected, + this.onYearSelected, required this.onWeekSelected, }); @@ -27,8 +28,9 @@ class MiniCalendar extends StatelessWidget { final int firstWeekday; final List items; final ValueChanged onSelected; - final ValueChanged onMonthSelected; - final ValueChanged onYearSelected; + final bool showHeader; + final ValueChanged? onMonthSelected; + final ValueChanged? onYearSelected; final ValueChanged onWeekSelected; @override @@ -48,43 +50,48 @@ class MiniCalendar extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - Row( - children: [ - Expanded( - child: _MiniCalendarStepper( - label: DateFormat.MMMM(locale).format(selectedDate), - previousTooltip: l10n.previousMonth, - nextTooltip: l10n.nextMonth, - onPrevious: () => onSelected( - DateTime(selectedDate.year, selectedDate.month - 1), - ), - onNext: () => onSelected( - DateTime(selectedDate.year, selectedDate.month + 1), - ), - labelTooltip: l10n.openMonthView, - onLabelPressed: () => onMonthSelected(first), + if (showHeader) ...[ + Row( + children: [ + Expanded( + child: _MiniCalendarStepper( + label: DateFormat.MMMM(locale).format(selectedDate), + previousTooltip: l10n.previousMonth, + nextTooltip: l10n.nextMonth, + onPrevious: () => onSelected( + DateTime(selectedDate.year, selectedDate.month - 1), + ), + onNext: () => onSelected( + DateTime(selectedDate.year, selectedDate.month + 1), + ), + labelTooltip: l10n.openMonthView, + onLabelPressed: onMonthSelected == null + ? null + : () => onMonthSelected!(first), ), ), - const SizedBox(width: BusyMaxSpacing.sm), - Expanded( - child: _MiniCalendarStepper( - label: '${selectedDate.year}', - previousTooltip: l10n.previousYear, - nextTooltip: l10n.nextYear, - onPrevious: () => onSelected( - DateTime(selectedDate.year - 1, selectedDate.month), - ), - onNext: () => onSelected( - DateTime(selectedDate.year + 1, selectedDate.month), + const SizedBox(width: BusyMaxSpacing.sm), + Expanded( + child: _MiniCalendarStepper( + label: '${selectedDate.year}', + previousTooltip: l10n.previousYear, + nextTooltip: l10n.nextYear, + onPrevious: () => onSelected( + DateTime(selectedDate.year - 1, selectedDate.month), + ), + onNext: () => onSelected( + DateTime(selectedDate.year + 1, selectedDate.month), + ), + labelTooltip: l10n.openYearView, + onLabelPressed: onYearSelected == null + ? null + : () => onYearSelected!(DateTime(selectedDate.year)), ), - labelTooltip: l10n.openYearView, - onLabelPressed: () => - onYearSelected(DateTime(selectedDate.year)), ), - ), - ], - ), - const SizedBox(height: BusyMaxSpacing.sm), + ], + ), + const SizedBox(height: BusyMaxSpacing.sm), + ], LayoutBuilder( builder: (context, constraints) { final weekNumberExtent = math.min( @@ -483,24 +490,28 @@ class _MiniCalendarStepper extends StatelessWidget { Widget _label(BuildContext context) { final action = onLabelPressed; + final colorScheme = Theme.of(context).colorScheme; + final labelStyle = + (busyMaxSectionHeaderStyle(context) ?? + Theme.of(context).textTheme.titleSmall) + ?.copyWith(color: colorScheme.onSurface); if (action == null) { return Text( label, textAlign: TextAlign.center, maxLines: 1, overflow: TextOverflow.ellipsis, - style: busyMaxSectionHeaderStyle(context), + style: labelStyle, ); } - final colorScheme = Theme.of(context).colorScheme; return Tooltip( message: labelTooltip ?? label, child: TextButton( onPressed: action, style: busyMaxHeaderTextButtonStyle( context, - foregroundColor: colorScheme.onSurfaceVariant, + foregroundColor: colorScheme.onSurface, backgroundColor: busyMaxHeaderButtonBackground(context), overlayColor: const WidgetStatePropertyAll(Colors.transparent), ), @@ -508,7 +519,7 @@ class _MiniCalendarStepper extends StatelessWidget { label, maxLines: 1, overflow: TextOverflow.ellipsis, - style: busyMaxSectionHeaderStyle(context), + style: labelStyle, ), ), ); diff --git a/lib/src/features/schedule/presentation/schedule_year_view.dart b/lib/src/features/schedule/presentation/schedule_year_view.dart index f43b438..f7f75bd 100644 --- a/lib/src/features/schedule/presentation/schedule_year_view.dart +++ b/lib/src/features/schedule/presentation/schedule_year_view.dart @@ -19,6 +19,7 @@ class ScheduleYearView extends StatelessWidget { required this.onDaySelected, required this.onMonthSelected, required this.onCreateAtDay, + this.compact = false, }); final DateTime selectedDate; @@ -27,6 +28,7 @@ class ScheduleYearView extends StatelessWidget { final ValueChanged onDaySelected; final ValueChanged onMonthSelected; final ValueChanged onCreateAtDay; + final bool compact; @override Widget build(BuildContext context) { @@ -35,37 +37,46 @@ class ScheduleYearView extends StatelessWidget { return LayoutBuilder( builder: (context, constraints) { - final columns = _columnCount(constraints.maxWidth); + final columns = _columnCount(constraints.maxWidth, compact: compact); final horizontalPadding = BusyMaxSpacing.md * 2; final columnGaps = BusyMaxSpacing.md * (columns - 1); final monthWidth = (constraints.maxWidth - horizontalPadding - columnGaps) / columns; - final monthHeight = _monthPanelHeight(monthWidth); + final rows = (DateTime.monthsPerYear + columns - 1) ~/ columns; + final monthHeight = constraints.maxHeight.isFinite + ? _compactMonthPanelHeight( + monthWidth: monthWidth, + availableHeight: constraints.maxHeight - horizontalPadding, + rows: rows, + compact: compact, + ) + : _monthPanelHeight(monthWidth); return ColoredBox( color: BusyMaxSurfaceColors.of(context).window, - child: GridView.builder( + child: Padding( padding: const EdgeInsets.all(BusyMaxSpacing.md), - gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: columns, - mainAxisSpacing: BusyMaxSpacing.md, - crossAxisSpacing: BusyMaxSpacing.md, - mainAxisExtent: monthHeight, + child: Wrap( + spacing: BusyMaxSpacing.md, + runSpacing: BusyMaxSpacing.md, + children: [ + for (var index = 0; index < DateTime.monthsPerYear; index++) + SizedBox( + width: monthWidth, + height: monthHeight, + child: _YearMonthPanel( + month: DateTime(selectedDate.year, index + 1), + selectedDate: selectedDate, + groupedItems: grouped, + firstWeekday: firstWeekday, + locale: locale, + onDaySelected: onDaySelected, + onMonthSelected: onMonthSelected, + onCreateAtDay: onCreateAtDay, + ), + ), + ], ), - itemCount: DateTime.monthsPerYear, - itemBuilder: (context, index) { - final month = DateTime(selectedDate.year, index + 1); - return _YearMonthPanel( - month: month, - selectedDate: selectedDate, - groupedItems: grouped, - firstWeekday: firstWeekday, - locale: locale, - onDaySelected: onDaySelected, - onMonthSelected: onMonthSelected, - onCreateAtDay: onCreateAtDay, - ); - }, ), ); }, @@ -96,11 +107,20 @@ class _YearMonthPanel extends StatelessWidget { @override Widget build(BuildContext context) { + final monthLabel = '${DateFormat.MMMM(locale).format(month)} ${month.year}'; return BusyMaxGroupedSurface( child: Column( children: [ BusyMaxActionRow( - title: DateFormat.MMMM(locale).format(month), + title: monthLabel, + titleWidget: FittedBox( + fit: BoxFit.scaleDown, + child: Text( + monthLabel, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), trailing: const Icon(YaruIcons.pan_end, size: BusyMaxSizes.iconSm), onTap: () => onMonthSelected(month), ), @@ -344,7 +364,7 @@ class _YearDayIndicators extends StatelessWidget { } } -int _columnCount(double width) { +int _columnCount(double width, {required bool compact}) { if (width >= 1120) { return 4; } @@ -354,6 +374,12 @@ int _columnCount(double width) { if (width >= 520) { return 2; } + if (compact) { + if (width >= 420) { + return 3; + } + return 2; + } return 1; } @@ -361,6 +387,22 @@ double _monthPanelHeight(double width) { return width < 280 ? 276 : math.min(340, math.max(292, width * 0.72)); } +double _compactMonthPanelHeight({ + required double monthWidth, + required double availableHeight, + required int rows, + required bool compact, +}) { + if (!compact || + !availableHeight.isFinite || + availableHeight <= 0 || + rows <= 0) { + return _monthPanelHeight(monthWidth); + } + final rowSpacing = BusyMaxSpacing.md * (rows - 1); + return (availableHeight - rowSpacing).clamp(0.0, double.infinity) / rows; +} + List _monthCells(DateTime month, int firstWeekday) { final first = DateTime(month.year, month.month); final leading = (first.weekday - firstWeekday) % DateTime.daysPerWeek; diff --git a/lib/src/features/tasks/presentation/desktop_date_time_fields.dart b/lib/src/features/tasks/presentation/desktop_date_time_fields.dart index 5c9038f..d67539f 100644 --- a/lib/src/features/tasks/presentation/desktop_date_time_fields.dart +++ b/lib/src/features/tasks/presentation/desktop_date_time_fields.dart @@ -1,15 +1,33 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:intl/intl.dart'; -import 'package:busymax/src/app/busymax_design.dart'; import 'package:busymax/src/app/busymax_dialogs.dart'; +import 'package:busymax/src/app/busymax_design.dart'; +import 'package:busymax/src/app/busymax_surface_colors.dart'; +import 'package:busymax/src/core/time/local_time_zone.dart'; import 'package:busymax/src/l10n/l10n.dart'; +import 'package:busymax/src/features/schedule/presentation/mini_calendar.dart'; +import 'package:busymax/src/features/schedule/presentation/schedule_anchored_popover.dart'; +import 'package:busymax/src/features/schedule/presentation/schedule_year_view.dart'; +import 'package:busymax/src/schedule/schedule_item.dart'; import 'package:yaru/yaru.dart'; @visibleForTesting const nativeDateTimePickerChannelName = 'busymax/native_date_time_picker'; const _nativeDateTimePicker = NativeDateTimePicker(); +const _dateTimePickerMaxWidth = 300.0; +const _dateTimePickerYearViewMaxHeight = 320.0; +const _dateTimePickerPopoverMinimumHeight = 300.0; +const _dateTimePickerPopoverPadding = EdgeInsets.all(BusyMaxSpacing.lg); +const _dateTimePickerYearModeHeaderHeight = + BusyMaxSizes.headerIconButton + + BusyMaxSpacing.headerInset * 3 + + BusyMaxSpacing.sm; +const _timePickerMaxWidth = 260.0; +const _timePickerMinimumWidth = 240.0; +const _timePickerPopoverMinimumHeight = 180.0; +const _timePickerPopoverMaxHeight = 180.0; class NativeDateTimePicker { const NativeDateTimePicker(); @@ -30,15 +48,27 @@ class NativeDateTimePicker { }); } + Future pickTime({ + required String title, + required String? initialTime, + required String cancelLabel, + required String okLabel, + }) async { + return _invoke('pickTime', { + 'title': title, + 'initialTime': initialTime, + 'cancelLabel': cancelLabel, + 'okLabel': okLabel, + }); + } + Future _invoke( String method, Map arguments, ) async { try { - return NativeDatePickResult( - available: true, - date: await _channel.invokeMethod(method, arguments), - ); + final value = await _channel.invokeMethod(method, arguments); + return NativeDatePickResult(available: true, date: value, time: value); } on MissingPluginException { return const NativeDatePickResult(available: false); } @@ -46,10 +76,11 @@ class NativeDateTimePicker { } class NativeDatePickResult { - const NativeDatePickResult({required this.available, this.date}); + const NativeDatePickResult({required this.available, this.date, this.time}); final bool available; final String? date; + final String? time; } class DesktopDateField extends StatefulWidget { @@ -60,7 +91,7 @@ class DesktopDateField extends StatefulWidget { required this.onChanged, this.enabled = true, this.onClear, - this.useNativePicker = true, + this.useNativePicker = false, }); final String label; @@ -82,7 +113,7 @@ class DesktopDateValueRow extends StatelessWidget { required this.onChanged, this.enabled = true, this.onClear, - this.useNativePicker = true, + this.useNativePicker = false, }); final String label; @@ -139,17 +170,21 @@ class _DesktopDateFieldState extends State { final canClear = widget.date?.isNotEmpty ?? false; return BusyMaxCalendarValueRow( label: widget.label, - entry: TextFormField( - controller: _controller, - readOnly: true, - showCursor: false, - enableInteractiveSelection: false, - enabled: widget.enabled, - decoration: busyMaxGroupedTextFieldDecoration( - context, - labelText: widget.label, + entry: Builder( + builder: (fieldContext) => TextFormField( + controller: _controller, + readOnly: true, + showCursor: false, + enableInteractiveSelection: false, + enabled: widget.enabled, + decoration: busyMaxGroupedTextFieldDecoration( + context, + labelText: widget.label, + ), + onTap: widget.enabled + ? () => _pickNativeDate(context, fieldContext) + : null, ), - onTap: widget.enabled ? () => _pickNativeDate(context) : null, ), trailingIcons: [ if (canClear && widget.onClear != null) @@ -158,10 +193,14 @@ class _DesktopDateFieldState extends State { onPressed: widget.enabled ? widget.onClear : null, icon: const Icon(YaruIcons.window_close), ), - YaruIconButton( - tooltip: widget.label, - onPressed: widget.enabled ? () => _pickNativeDate(context) : null, - icon: const Icon(YaruIcons.calendar), + Builder( + builder: (buttonContext) => YaruIconButton( + tooltip: widget.label, + onPressed: widget.enabled + ? () => _pickNativeDate(context, buttonContext) + : null, + icon: const Icon(YaruIcons.calendar), + ), ), ], enabled: widget.enabled, @@ -179,7 +218,10 @@ class _DesktopDateFieldState extends State { ); } - Future _pickNativeDate(BuildContext context) async { + Future _pickNativeDate( + BuildContext context, + BuildContext anchorContext, + ) async { if (!widget.enabled) { return; } @@ -188,6 +230,7 @@ class _DesktopDateFieldState extends State { context, label: widget.label, initialDate: widget.date, + anchorContext: anchorContext, ); if (mounted && fallbackPicked != null) { _applyPickedDate(fallbackPicked); @@ -215,6 +258,7 @@ class _DesktopDateFieldState extends State { context, label: widget.label, initialDate: widget.date, + anchorContext: anchorContext, ); if (mounted && fallbackPicked != null) { _applyPickedDate(fallbackPicked); @@ -235,23 +279,64 @@ Future showBusyMaxDateValueDialog( BuildContext context, { required String label, required String? initialDate, + BuildContext? anchorContext, +}) { + return showScheduleAnchoredPopover( + context: context, + anchorContext: anchorContext ?? context, + semanticLabel: label, + preferredWidth: _dateTimePickerMaxWidth, + minimumWidth: 280, + preferredMinimumHeight: _dateTimePickerPopoverMinimumHeight, + builder: (context, arrowSide, arrowAlignment) => _DesktopDateValueDialog( + label: label, + initialDate: initialDate, + arrowSide: arrowSide, + arrowAlignment: arrowAlignment, + ), + ); +} + +Future showBusyMaxTimeValueDialog( + BuildContext context, { + required String label, + required String? initialTime, + required bool allowEmpty, + ValueChanged? onTimeChanged, + BuildContext? anchorContext, }) { - return showBusyMaxModalDialog( - context, - builder: (dialogContext) { - return _DesktopDateValueDialog(label: label, initialDate: initialDate); - }, + return showScheduleAnchoredPopover( + context: context, + anchorContext: anchorContext ?? context, + semanticLabel: label, + preferredWidth: _timePickerMaxWidth, + minimumWidth: _timePickerMinimumWidth, + preferredMinimumHeight: _timePickerPopoverMinimumHeight, + builder: (context, arrowSide, arrowAlignment) => _DesktopTimeValueDialog( + label: label, + initialTime: initialTime, + allowEmpty: allowEmpty, + onTimeChanged: onTimeChanged, + arrowSide: arrowSide, + arrowAlignment: arrowAlignment, + ), ); } +enum _DesktopDatePickerMode { month, year } + class _DesktopDateValueDialog extends StatefulWidget { const _DesktopDateValueDialog({ required this.label, required this.initialDate, + required this.arrowSide, + required this.arrowAlignment, }); final String label; final String? initialDate; + final BusyMaxPopoverArrowSide arrowSide; + final double arrowAlignment; @override State<_DesktopDateValueDialog> createState() => @@ -261,8 +346,7 @@ class _DesktopDateValueDialog extends StatefulWidget { class _DesktopDateValueDialogState extends State<_DesktopDateValueDialog> { static final _firstDate = DateTime(1900); static final _lastDate = DateTime(2100, 12, 31); - - final _formKey = GlobalKey(); + var _mode = _DesktopDatePickerMode.month; late DateTime _selected; @override @@ -273,46 +357,278 @@ class _DesktopDateValueDialogState extends State<_DesktopDateValueDialog> { @override Widget build(BuildContext context) { - return BusyMaxDialogShell( - title: widget.label, - maxWidth: 360, - actions: [ - BusyMaxPushButton.standard( - onPressed: () => Navigator.of(context).pop(), - child: Text(context.l10n.cancel), - ), - BusyMaxPushButton.suggested( - onPressed: _submit, - child: Text(MaterialLocalizations.of(context).okButtonLabel), - ), - ], + return LayoutBuilder( + builder: (context, constraints) { + final contentHeight = _calculateDateTimePickerPopupHeight(constraints); + final yearModeBodyHeight = _mode == _DesktopDatePickerMode.year + ? (contentHeight - _dateTimePickerYearModeHeaderHeight).clamp( + 0.0, + double.infinity, + ) + : contentHeight; + return BusyMaxContentPopoverSurface( + arrowSide: widget.arrowSide, + arrowAlignment: widget.arrowAlignment, + padding: _dateTimePickerPopoverPadding, + child: _mode == _DesktopDatePickerMode.year + ? ConstrainedBox( + constraints: BoxConstraints( + maxHeight: contentHeight, + maxWidth: _dateTimePickerMaxWidth, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + _buildDateModeHeader(context), + SizedBox( + height: yearModeBodyHeight, + child: ScheduleYearView( + selectedDate: _selected, + compact: true, + items: const [], + firstWeekday: _firstWeekday(context), + onDaySelected: (day) => _setSelectedDate( + day, + returnToMonth: true, + submit: true, + ), + onMonthSelected: (_) {}, + onCreateAtDay: (day) => _setSelectedDate( + day, + returnToMonth: true, + submit: true, + ), + ), + ), + ], + ), + ) + : ScrollConfiguration( + behavior: ScrollConfiguration.of( + context, + ).copyWith(scrollbars: false), + child: ConstrainedBox( + constraints: BoxConstraints(maxHeight: contentHeight), + child: SingleChildScrollView( + child: MiniCalendar( + selectedDate: _selected, + firstWeekday: _firstWeekday(context), + items: const [], + onSelected: (date) => _setSelectedDate( + date, + returnToMonth: false, + submit: + date.year == _selected.year && + date.month == _selected.month, + ), + onMonthSelected: null, + onYearSelected: null, + onWeekSelected: (week) => _setSelectedDate( + week, + returnToMonth: false, + submit: true, + ), + ), + ), + ), + ), + ); + }, + ); + } + + void _setSelectedDate( + DateTime value, { + required bool returnToMonth, + bool submit = false, + }) { + final preserveDay = + value.day == 1 && + (value.year != _selected.year || value.month != _selected.month); + final nextDay = preserveDay ? _selected.day : value.day; + final clamped = _clampMonthAndDay( + nextDay, + DateTime(value.year, value.month), + ); + final adjusted = _coerceSupportedRange(clamped); + setState(() { + _selected = adjusted; + if (returnToMonth) { + _mode = _DesktopDatePickerMode.month; + } + }); + if (submit) { + _submit(); + } + } + + void _submit() { + Navigator.of(context).pop(encodeDateOnly(_selected)); + } + + Widget _buildDateModeHeader(BuildContext context) { + final locale = Localizations.localeOf(context).toLanguageTag(); + final monthLabel = DateFormat.MMMM(locale).format(_selected); + + return Padding( + padding: const EdgeInsetsDirectional.fromSTEB( + BusyMaxSpacing.headerInset, + BusyMaxSpacing.headerInset, + BusyMaxSpacing.headerInset, + BusyMaxSpacing.sm, + ), + child: Row( + children: [ + Expanded( + child: _buildDateModeStepper( + context: context, + label: monthLabel, + previousTooltip: context.l10n.previousMonth, + nextTooltip: context.l10n.nextMonth, + onPrevious: () => _setSelectedDate( + DateTime(_selected.year, _selected.month - 1), + returnToMonth: false, + ), + onNext: () => _setSelectedDate( + DateTime(_selected.year, _selected.month + 1), + returnToMonth: false, + ), + onLabelPressed: null, + ), + ), + const SizedBox(width: BusyMaxSpacing.sm), + Expanded( + child: _buildDateModeStepper( + context: context, + label: '${_selected.year}', + previousTooltip: context.l10n.previousYear, + nextTooltip: context.l10n.nextYear, + onPrevious: () => _setSelectedDate( + DateTime(_selected.year - 1, _selected.month), + returnToMonth: false, + ), + onNext: () => _setSelectedDate( + DateTime(_selected.year + 1, _selected.month), + returnToMonth: false, + ), + onLabelPressed: null, + ), + ), + ], + ), + ); + } + + Widget _buildDateModeStepper({ + required BuildContext context, + required String label, + required String previousTooltip, + required String nextTooltip, + required VoidCallback onPrevious, + required VoidCallback onNext, + VoidCallback? onLabelPressed, + }) { + final colorScheme = Theme.of(context).colorScheme; + return Row( children: [ - Form( - key: _formKey, - child: InputDatePickerFormField( - initialDate: _selected, - firstDate: _firstDate, - lastDate: _lastDate, - fieldLabelText: widget.label, - onDateSaved: (date) => _selected = date, - onDateSubmitted: _finish, + _stepButton( + context, + colorScheme: colorScheme, + tooltip: previousTooltip, + icon: YaruIcons.pan_start, + onPressed: onPrevious, + ), + const SizedBox(width: BusyMaxSpacing.xs), + Expanded( + child: FittedBox( + fit: BoxFit.scaleDown, + child: _stepLabel(context, label, onLabelPressed), ), ), + const SizedBox(width: BusyMaxSpacing.xs), + _stepButton( + context, + colorScheme: colorScheme, + tooltip: nextTooltip, + icon: YaruIcons.pan_end, + onPressed: onNext, + ), ], ); } - void _submit() { - final form = _formKey.currentState; - if (form == null || !form.validate()) { - return; + Widget _stepButton( + BuildContext context, { + required ColorScheme colorScheme, + required String tooltip, + required IconData icon, + required VoidCallback onPressed, + }) { + return BusyMaxHeaderIconButton( + tooltip: tooltip, + iconSize: BusyMaxSizes.headerIcon, + icon: Icon(icon), + onPressed: onPressed, + foregroundColor: colorScheme.onSurfaceVariant, + backgroundColor: busyMaxHeaderButtonBackground(context), + overlayColor: const WidgetStatePropertyAll(Colors.transparent), + ); + } + + Widget _stepLabel( + BuildContext context, + String label, + VoidCallback? onPressed, + ) { + final colorScheme = Theme.of(context).colorScheme; + final labelStyle = + (busyMaxSectionHeaderStyle(context) ?? + Theme.of(context).textTheme.titleSmall) + ?.copyWith(color: colorScheme.onSurface); + if (onPressed == null) { + return Text( + label, + textAlign: TextAlign.center, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: labelStyle, + ); + } + return TextButton( + onPressed: onPressed, + style: busyMaxHeaderTextButtonStyle( + context, + foregroundColor: colorScheme.onSurface, + backgroundColor: busyMaxHeaderButtonBackground(context), + overlayColor: const WidgetStatePropertyAll(Colors.transparent), + ), + child: Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: labelStyle, + ), + ); + } + + DateTime _clampMonthAndDay(int day, DateTime month) { + final maxDay = DateUtils.getDaysInMonth(month.year, month.month); + return DateTime(month.year, month.month, day.clamp(1, maxDay)); + } + + DateTime _coerceSupportedRange(DateTime date) { + if (date.isBefore(_firstDate)) { + return _firstDate; } - form.save(); - _finish(_selected); + if (date.isAfter(_lastDate)) { + return _lastDate; + } + return date; } - void _finish(DateTime selected) { - Navigator.of(context).pop(encodeDateOnly(selected)); + int _firstWeekday(BuildContext context) { + final index = MaterialLocalizations.of(context).firstDayOfWeekIndex; + return index == 0 ? DateTime.sunday : index; } DateTime _supportedInitialDate(String? encodedDate) { @@ -327,6 +643,28 @@ class _DesktopDateValueDialogState extends State<_DesktopDateValueDialog> { } } +double _calculateDateTimePickerPopupHeight(BoxConstraints constraints) { + final availableHeight = + constraints.maxHeight - + (_dateTimePickerPopoverPadding.vertical + + BusyMaxSizes.popoverArrowHeight); + if (constraints.maxHeight <= 0 || constraints.maxHeight.isInfinite) { + return _dateTimePickerYearViewMaxHeight; + } + return availableHeight.clamp(0, _dateTimePickerYearViewMaxHeight); +} + +double _calculateTimePickerPopupHeight(BoxConstraints constraints) { + final availableHeight = + constraints.maxHeight - + (_dateTimePickerPopoverPadding.vertical + + BusyMaxSizes.popoverArrowHeight); + if (constraints.maxHeight <= 0 || constraints.maxHeight.isInfinite) { + return _timePickerPopoverMaxHeight; + } + return availableHeight.clamp(0, _timePickerPopoverMaxHeight); +} + class DesktopTimeField extends StatefulWidget { const DesktopTimeField({ super.key, @@ -336,6 +674,7 @@ class DesktopTimeField extends StatefulWidget { this.enabled = true, this.allowEmpty = true, this.onValidityChanged, + this.useNativePicker = false, }); final String label; @@ -344,6 +683,7 @@ class DesktopTimeField extends StatefulWidget { final bool enabled; final bool allowEmpty; final ValueChanged? onValidityChanged; + final bool useNativePicker; @override State createState() => _DesktopTimeFieldState(); @@ -358,6 +698,7 @@ class DesktopTimeValueRow extends StatelessWidget { this.enabled = true, this.allowEmpty = true, this.onValidityChanged, + this.useNativePicker = false, }); final String label; @@ -366,6 +707,7 @@ class DesktopTimeValueRow extends StatelessWidget { final bool enabled; final bool allowEmpty; final ValueChanged? onValidityChanged; + final bool useNativePicker; @override Widget build(BuildContext context) { @@ -376,6 +718,7 @@ class DesktopTimeValueRow extends StatelessWidget { enabled: enabled, allowEmpty: allowEmpty, onValidityChanged: onValidityChanged, + useNativePicker: useNativePicker, ); } } @@ -475,10 +818,66 @@ class _DesktopTimeFieldState extends State { onChanged: _handleTextChanged, onFieldSubmitted: (_) => _normalizeOrRestore(), ), + trailingIcons: [ + Builder( + builder: (buttonContext) => YaruIconButton( + tooltip: widget.label, + onPressed: widget.enabled + ? () => _pickNativeTime(context, buttonContext) + : null, + icon: const Icon(YaruIcons.clock), + ), + ), + ], enabled: widget.enabled, ); } + Future _pickNativeTime( + BuildContext context, + BuildContext anchorContext, + ) async { + if (!widget.enabled) { + return; + } + final localizations = MaterialLocalizations.of(context); + if (widget.useNativePicker) { + final picked = await _nativeDateTimePicker.pickTime( + title: widget.label, + initialTime: widget.time, + cancelLabel: localizations.cancelButtonLabel, + okLabel: localizations.okButtonLabel, + ); + if (!context.mounted) { + return; + } + if (picked.time != null) { + _emitTime(picked.time!); + _focusNode.requestFocus(); + return; + } + if (picked.available) { + return; + } + } + + final picked = await showBusyMaxTimeValueDialog( + context, + label: widget.label, + initialTime: widget.time, + allowEmpty: widget.allowEmpty, + onTimeChanged: _emitTime, + anchorContext: anchorContext, + ); + if (!context.mounted) { + return; + } + if (picked != null) { + _emitTime(picked); + _focusNode.requestFocus(); + } + } + void _handleTextChanged(String input) { if (_syncingText) { return; @@ -601,6 +1000,432 @@ class _DesktopTimeFieldState extends State { } } +class _DesktopTimeValueDialog extends StatefulWidget { + const _DesktopTimeValueDialog({ + required this.label, + required this.initialTime, + required this.allowEmpty, + required this.onTimeChanged, + required this.arrowSide, + required this.arrowAlignment, + }); + + final String label; + final String? initialTime; + final bool allowEmpty; + final ValueChanged? onTimeChanged; + final BusyMaxPopoverArrowSide arrowSide; + final double arrowAlignment; + + @override + State<_DesktopTimeValueDialog> createState() => + _DesktopTimeValueDialogState(); +} + +class _DesktopTimeValueDialogState extends State<_DesktopTimeValueDialog> { + late final TextEditingController _hourController; + late final TextEditingController _minuteController; + bool _syncingText = false; + bool _inputValid = true; + static const _timeInputButtonSize = BusyMaxSizes.headerIconButton; + static const _timeInputFieldWidth = BusyMaxSizes.headerIconButton; + + @override + void initState() { + super.initState(); + _hourController = TextEditingController(); + _minuteController = TextEditingController(); + _inputValid = + widget.allowEmpty || parseTimeOfDay(widget.initialTime) != null; + _syncVisibleValue(); + } + + @override + void didUpdateWidget(covariant _DesktopTimeValueDialog oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.initialTime != widget.initialTime) { + _syncVisibleValue(); + } + } + + @override + void dispose() { + _hourController.dispose(); + _minuteController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return LayoutBuilder( + builder: (context, constraints) { + final contentHeight = _calculateTimePickerPopupHeight(constraints); + return BusyMaxContentPopoverSurface( + arrowSide: widget.arrowSide, + arrowAlignment: widget.arrowAlignment, + padding: _dateTimePickerPopoverPadding, + child: ScrollConfiguration( + behavior: ScrollConfiguration.of( + context, + ).copyWith(scrollbars: false), + child: ConstrainedBox( + constraints: BoxConstraints(maxHeight: contentHeight), + child: SingleChildScrollView( + child: Padding( + padding: const EdgeInsets.all(BusyMaxSpacing.xl), + child: FocusTraversalGroup( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + SizedBox( + width: _timeInputFieldWidth, + child: _timeInputSection( + context: context, + controller: _hourController, + label: 'Hour', + onIncrement: () => _changeHour(1), + onDecrement: () => _changeHour(-1), + ), + ), + const SizedBox(width: BusyMaxSpacing.xs), + Text( + ':', + style: Theme.of(context).textTheme.bodyLarge, + ), + const SizedBox(width: BusyMaxSpacing.xs), + SizedBox( + width: _timeInputFieldWidth, + child: _timeInputSection( + context: context, + controller: _minuteController, + label: 'Minute', + onIncrement: () => _changeMinute(1), + onDecrement: () => _changeMinute(-1), + ), + ), + ], + ), + if (!_inputValid) + Padding( + padding: const EdgeInsets.symmetric( + horizontal: BusyMaxSpacing.md, + vertical: BusyMaxSpacing.sm, + ), + child: Text( + MaterialLocalizations.of( + context, + ).invalidTimeLabel, + style: TextStyle( + color: Theme.of(context).colorScheme.error, + fontSize: 12, + ), + ), + ), + const SizedBox(height: BusyMaxSpacing.lg), + Align( + alignment: Alignment.centerLeft, + child: BusyMaxPushButton.standard( + onPressed: _openTimezoneDialog, + child: Row( + mainAxisSize: MainAxisSize.max, + children: [ + const Icon( + Icons.public, + size: BusyMaxSizes.popoverActionIcon, + ), + const SizedBox(width: BusyMaxSpacing.xs), + Flexible( + child: Text( + _timezoneDisplayLabel(context), + style: Theme.of( + context, + ).textTheme.bodyMedium, + maxLines: 1, + overflow: TextOverflow.ellipsis, + softWrap: false, + ), + ), + ], + ), + ), + ), + ], + ), + ), + ), + ), + ), + ), + ); + }, + ); + } + + Future _openTimezoneDialog() async { + final timezone = localIanaTimeZone(); + final code = _timezoneCode(context); + final display = code.isEmpty ? timezone : '$timezone ($code)'; + await showBusyMaxModalDialog( + context, + builder: (dialogContext) => BusyMaxDialogShell( + title: 'Timezone', + actions: [ + BusyMaxPushButton.standard( + onPressed: () => Navigator.of(dialogContext).pop(), + child: Text(MaterialLocalizations.of(dialogContext).okButtonLabel), + ), + ], + children: [ + Text( + 'System timezone', + style: Theme.of(dialogContext).textTheme.bodyMedium, + ), + const SizedBox(height: BusyMaxSpacing.sm), + Row( + children: [ + const Icon(Icons.public, size: BusyMaxSizes.popoverActionIcon), + const SizedBox(width: BusyMaxSpacing.xs), + Text(display, style: Theme.of(dialogContext).textTheme.bodyLarge), + ], + ), + ], + ), + ); + } + + String _timezoneCode(BuildContext context) { + final locale = Localizations.localeOf(context).toLanguageTag(); + try { + return DateFormat('z', locale).format(DateTime.now()).trim(); + } on Exception { + return DateTime.now().timeZoneName; + } + } + + String _timezoneDisplayLabel(BuildContext context) { + final timezone = localIanaTimeZone(); + final code = _timezoneCode(context); + if (code.isEmpty) { + return timezone; + } + return '$timezone ($code)'; + } + + Widget _timeInputSection({ + required BuildContext context, + required TextEditingController controller, + required String label, + required VoidCallback onIncrement, + required VoidCallback onDecrement, + }) { + final buttonStyle = ButtonStyle( + minimumSize: const WidgetStatePropertyAll( + Size.square(_timeInputButtonSize), + ), + visualDensity: VisualDensity.compact, + padding: const WidgetStatePropertyAll(EdgeInsets.zero), + side: const WidgetStatePropertyAll(BorderSide.none), + backgroundColor: busyMaxHeaderButtonBackground(context), + overlayColor: const WidgetStatePropertyAll(Colors.transparent), + foregroundColor: WidgetStatePropertyAll( + Theme.of(context).colorScheme.onSurface, + ), + shape: WidgetStatePropertyAll( + const RoundedRectangleBorder(borderRadius: BorderRadius.zero), + ), + ); + final surfaceColors = BusyMaxSurfaceColors.of(context); + final borderColor = Theme.of(context).colorScheme.outlineVariant; + final textTheme = Theme.of(context).textTheme; + + return FocusTraversalOrder( + order: const NumericFocusOrder(0), + child: Container( + decoration: BoxDecoration( + color: surfaceColors.control, + borderRadius: BorderRadius.circular(BusyMaxRadius.sm), + border: Border.all(color: borderColor), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton( + onPressed: onIncrement, + icon: const Icon(Icons.add), + tooltip: label, + style: buttonStyle.copyWith( + shape: WidgetStatePropertyAll( + const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical( + top: Radius.circular(BusyMaxRadius.sm), + bottom: Radius.zero, + ), + ), + ), + ), + ), + Divider(height: 0, thickness: 1, color: borderColor), + SizedBox( + height: _timeInputButtonSize, + child: TextFormField( + controller: controller, + textAlign: TextAlign.center, + textAlignVertical: TextAlignVertical.center, + keyboardType: TextInputType.number, + maxLength: 2, + style: textTheme.bodyLarge, + decoration: + busyMaxGroupedTextFieldDecoration( + context, + labelText: '', + ).copyWith( + isDense: true, + filled: true, + fillColor: surfaceColors.control, + border: InputBorder.none, + enabledBorder: InputBorder.none, + focusedBorder: InputBorder.none, + contentPadding: EdgeInsets.zero, + floatingLabelBehavior: FloatingLabelBehavior.never, + labelText: '', + ), + buildCounter: + ( + BuildContext context, { + required int currentLength, + required int? maxLength, + required bool isFocused, + }) => const SizedBox.shrink(), + onChanged: (_) => _handleTimeInputChanged(), + onFieldSubmitted: (_) => _handleTimeInputChanged(), + ), + ), + Divider(height: 0, thickness: 1, color: borderColor), + IconButton( + onPressed: onDecrement, + icon: const Icon(Icons.remove), + tooltip: label, + style: buttonStyle.copyWith( + shape: WidgetStatePropertyAll( + const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical( + top: Radius.zero, + bottom: Radius.circular(BusyMaxRadius.sm), + ), + ), + ), + ), + ), + ], + ), + ), + ); + } + + void _handleTimeInputChanged() { + if (_syncingText) { + return; + } + final parsed = _currentSelection(); + final bothBlank = + _hourController.text.trim().isEmpty && + _minuteController.text.trim().isEmpty; + if (bothBlank) { + _setInputValidity(widget.allowEmpty); + if (widget.allowEmpty) { + widget.onTimeChanged?.call(null); + } + return; + } + _setInputValidity(parsed != null); + if (parsed != null) { + widget.onTimeChanged?.call(encodeTimeOfDay(parsed)); + } + } + + void _changeHour(int delta) { + final next = _normalizeHour(_hourController.text, 0) + delta; + final value = next % 24; + _setComponent(_hourController, value < 0 ? value + 24 : value); + } + + void _changeMinute(int delta) { + final next = _normalizeMinute(_minuteController.text, 0) + delta; + final value = next % 60; + _setComponent(_minuteController, value < 0 ? value + 60 : value); + _handleTimeInputChanged(); + } + + int _normalizeHour(String input, int fallback) { + final parsed = int.tryParse(input.trim()); + if (parsed == null || parsed < 0 || parsed > 23) { + return fallback; + } + return parsed; + } + + int _normalizeMinute(String input, int fallback) { + final parsed = int.tryParse(input.trim()); + if (parsed == null || parsed < 0 || parsed > 59) { + return fallback; + } + return parsed; + } + + void _setComponent(TextEditingController controller, int value) { + _syncingText = true; + _ensureTwoDigits(controller, value); + _syncingText = false; + _handleTimeInputChanged(); + } + + void _ensureTwoDigits(TextEditingController controller, int value) { + final valueText = value.toString().padLeft(2, '0'); + if (controller.text == valueText) { + return; + } + controller.value = TextEditingValue( + text: valueText, + selection: TextSelection.collapsed(offset: valueText.length), + ); + } + + TimeOfDay? _currentSelection() { + final hour = _normalizeHour(_hourController.text, -1); + final minute = _normalizeMinute(_minuteController.text, -1); + if (hour < 0 || minute < 0) { + return null; + } + return TimeOfDay(hour: hour, minute: minute); + } + + void _syncVisibleValue() { + final initial = parseTimeOfDay(widget.initialTime); + _syncingText = true; + if (initial == null) { + _hourController.text = ''; + _minuteController.text = ''; + } else { + _hourController.text = initial.hour.toString().padLeft(2, '0'); + _minuteController.text = initial.minute.toString().padLeft(2, '0'); + } + _syncingText = false; + } + + void _setInputValidity(bool valid) { + if (_inputValid == valid) { + return; + } + setState(() { + _inputValid = valid; + }); + } +} + String formatDesktopDateTime(BuildContext context, String? dateTime) { final parsed = parseGraphLocalDateTime(dateTime); if (parsed == null) { diff --git a/lib/src/features/tasks/presentation/new_task_dialog.dart b/lib/src/features/tasks/presentation/new_task_dialog.dart index 7dc7c5e..4d3fba6 100644 --- a/lib/src/features/tasks/presentation/new_task_dialog.dart +++ b/lib/src/features/tasks/presentation/new_task_dialog.dart @@ -72,7 +72,7 @@ class NewTaskEditorPanel extends ConsumerStatefulWidget { this.initialListId, this.initialDueUtc, this.categorySuggestionsForAccount, - this.useNativeDatePicker = true, + this.useNativeDatePicker = false, }); final List accounts; diff --git a/lib/src/features/tasks/presentation/task_details_editor.dart b/lib/src/features/tasks/presentation/task_details_editor.dart index f065c7d..b9ddd34 100644 --- a/lib/src/features/tasks/presentation/task_details_editor.dart +++ b/lib/src/features/tasks/presentation/task_details_editor.dart @@ -47,7 +47,7 @@ class TaskDetailsEditor extends StatefulWidget { this.showAdvancedActions = true, this.showDeleteAction = true, this.confirmTaskSwitch = true, - this.useNativeDatePicker = true, + this.useNativeDatePicker = false, this.dialogBarrierColor, this.headerBarService, this.canSaveDraft, @@ -262,6 +262,7 @@ class _TaskDetailsEditorState extends State { _TaskTimeField.due, valid, ), + useNativePicker: widget.useNativeDatePicker, ), ], ), @@ -467,6 +468,7 @@ class _TaskDetailsEditorState extends State { _updateDraft(draft.copyWith(microsoftStartTime: value)), onValidityChanged: (valid) => _setTimeFieldValidity(_TaskTimeField.start, valid), + useNativePicker: widget.useNativeDatePicker, ), ]; } @@ -861,6 +863,7 @@ class _TaskDetailsEditorState extends State { _updateDraft(draft.copyWith(microsoftReminderTime: value)), onValidityChanged: (valid) => _setTimeFieldValidity(_TaskTimeField.reminder, valid), + useNativePicker: widget.useNativeDatePicker, ), BusyMaxActionRow( title: l10n.removeReminder, diff --git a/linux/runner/my_application.cc b/linux/runner/my_application.cc index 2d13a27..a9c6d24 100644 --- a/linux/runner/my_application.cc +++ b/linux/runner/my_application.cc @@ -303,6 +303,23 @@ static gboolean parse_date(const gchar* value, return TRUE; } +static gboolean parse_time(const gchar* value, guint* hour, guint* minute) { + if (value == nullptr) { + return FALSE; + } + unsigned int parsed_hour = 0; + unsigned int parsed_minute = 0; + if (sscanf(value, "%u:%u", &parsed_hour, &parsed_minute) != 2) { + return FALSE; + } + if (parsed_hour > 23 || parsed_minute > 59) { + return FALSE; + } + *hour = parsed_hour; + *minute = parsed_minute; + return TRUE; +} + static void respond_string(FlMethodCall* method_call, const gchar* value) { g_autoptr(FlValue) result = value == nullptr ? fl_value_new_null() : fl_value_new_string(value); @@ -312,6 +329,11 @@ static void respond_string(FlMethodCall* method_call, const gchar* value) { static void style_native_dialog(GtkWidget* dialog) { gtk_style_context_add_class(gtk_widget_get_style_context(dialog), kNativeDialogStyleClass); + if (GTK_IS_DIALOG(dialog)) { + GtkStyleContext* content_context = + gtk_widget_get_style_context(gtk_dialog_get_content_area(GTK_DIALOG(dialog))); + gtk_style_context_add_class(content_context, "busymax-native-dialog-content"); + } } static void handle_pick_date(FlMethodCall* method_call, @@ -322,10 +344,17 @@ static void handle_pick_date(FlMethodCall* method_call, const gchar* cancel_label = fl_lookup_string_arg(args, "cancelLabel"); const gchar* ok_label = fl_lookup_string_arg(args, "okLabel"); GtkWidget* dialog = gtk_dialog_new_with_buttons( - title != nullptr ? title : "Date", parent, GTK_DIALOG_MODAL, - cancel_label != nullptr ? cancel_label : "_Cancel", GTK_RESPONSE_CANCEL, - ok_label != nullptr ? ok_label : "_OK", GTK_RESPONSE_OK, nullptr); + title != nullptr ? title : "Date", parent, + static_cast(GTK_DIALOG_MODAL | + GTK_DIALOG_DESTROY_WITH_PARENT | + GTK_DIALOG_USE_HEADER_BAR), + cancel_label != nullptr && cancel_label[0] != '\0' ? cancel_label : "Cancel", + GTK_RESPONSE_CANCEL, + ok_label != nullptr && ok_label[0] != '\0' ? ok_label : "OK", + GTK_RESPONSE_OK, + nullptr); style_native_dialog(dialog); + gtk_dialog_set_default_response(GTK_DIALOG(dialog), GTK_RESPONSE_OK); gtk_window_set_resizable(GTK_WINDOW(dialog), FALSE); GtkWidget* content = gtk_dialog_get_content_area(GTK_DIALOG(dialog)); @@ -356,6 +385,82 @@ static void handle_pick_date(FlMethodCall* method_call, gtk_widget_destroy(dialog); } +static void handle_pick_time(FlMethodCall* method_call, + FlValue* args, + GtkWindow* parent) { + const gchar* title = fl_lookup_string_arg(args, "title"); + const gchar* initial_time = fl_lookup_string_arg(args, "initialTime"); + const gchar* cancel_label = fl_lookup_string_arg(args, "cancelLabel"); + const gchar* ok_label = fl_lookup_string_arg(args, "okLabel"); + + GtkWidget* dialog = gtk_dialog_new_with_buttons( + title != nullptr ? title : "Time", parent, + static_cast(GTK_DIALOG_MODAL | + GTK_DIALOG_DESTROY_WITH_PARENT | + GTK_DIALOG_USE_HEADER_BAR), + cancel_label != nullptr && cancel_label[0] != '\0' ? cancel_label : "Cancel", + GTK_RESPONSE_CANCEL, + ok_label != nullptr && ok_label[0] != '\0' ? ok_label : "OK", + GTK_RESPONSE_OK, + nullptr); + style_native_dialog(dialog); + gtk_dialog_set_default_response(GTK_DIALOG(dialog), GTK_RESPONSE_OK); + gtk_window_set_resizable(GTK_WINDOW(dialog), FALSE); + + GtkWidget* content = gtk_dialog_get_content_area(GTK_DIALOG(dialog)); + GtkWidget* row = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 6); + gtk_container_set_border_width(GTK_CONTAINER(content), 12); + gtk_container_add(GTK_CONTAINER(content), row); + + GtkWidget* hour_input = gtk_spin_button_new_with_range(0.0, 23.0, 1.0); + GtkWidget* minute_input = gtk_spin_button_new_with_range(0.0, 59.0, 1.0); + gtk_spin_button_set_numeric(GTK_SPIN_BUTTON(hour_input), TRUE); + gtk_spin_button_set_numeric(GTK_SPIN_BUTTON(minute_input), TRUE); + gtk_spin_button_set_value(GTK_SPIN_BUTTON(hour_input), 0.0); + gtk_spin_button_set_value(GTK_SPIN_BUTTON(minute_input), 0.0); + gtk_widget_set_size_request(hour_input, 70, -1); + gtk_widget_set_size_request(minute_input, 70, -1); + + gtk_container_add(GTK_CONTAINER(row), gtk_label_new("Hour")); + gtk_container_add(GTK_CONTAINER(row), hour_input); + gtk_container_add(GTK_CONTAINER(row), gtk_label_new(":")); + gtk_container_add(GTK_CONTAINER(row), minute_input); + gtk_container_add(GTK_CONTAINER(row), gtk_label_new("Min")); + + guint hour = 0; + guint minute = 0; + if (parse_time(initial_time, &hour, &minute)) { + gtk_spin_button_set_value(GTK_SPIN_BUTTON(hour_input), hour); + gtk_spin_button_set_value(GTK_SPIN_BUTTON(minute_input), minute); + } else { + GDateTime* now = g_date_time_new_now_local(); + if (now != nullptr) { + hour = g_date_time_get_hour(now); + minute = g_date_time_get_minute(now); + gtk_spin_button_set_value(GTK_SPIN_BUTTON(hour_input), hour); + gtk_spin_button_set_value(GTK_SPIN_BUTTON(minute_input), minute); + g_date_time_unref(now); + } + } + + gtk_widget_show_all(dialog); + const gint response = gtk_dialog_run(GTK_DIALOG(dialog)); + + if (response == GTK_RESPONSE_OK) { + const gint selected_hour = gtk_spin_button_get_value_as_int( + GTK_SPIN_BUTTON(hour_input)); + const gint selected_minute = gtk_spin_button_get_value_as_int( + GTK_SPIN_BUTTON(minute_input)); + g_autofree gchar* result = g_strdup_printf( + "%02d:%02d", selected_hour, selected_minute); + respond_string(method_call, result); + } else { + respond_string(method_call, nullptr); + } + + gtk_widget_destroy(dialog); +} + static void native_date_time_picker_method_call_cb(FlMethodChannel* channel, FlMethodCall* method_call, gpointer user_data) { @@ -364,6 +469,8 @@ static void native_date_time_picker_method_call_cb(FlMethodChannel* channel, FlValue* args = fl_method_call_get_args(method_call); if (strcmp(method, "pickDate") == 0) { handle_pick_date(method_call, args, parent); + } else if (strcmp(method, "pickTime") == 0) { + handle_pick_time(method_call, args, parent); } else { fl_method_call_respond_not_implemented(method_call, nullptr); } @@ -895,8 +1002,8 @@ static void show_native_menu(NativeMenuHandlerData* data, gtk_widget_insert_action_group( data->view, kNativeMenuActionNamespace, G_ACTION_GROUP(session->action_group)); - session->popover = gtk_popover_new_from_model( - data->view, G_MENU_MODEL(session->model)); + session->popover = gtk_popover_new_from_model(data->view, + G_MENU_MODEL(session->model)); g_object_ref_sink(session->popover); style_native_popover(session->popover); gtk_popover_set_pointing_to(GTK_POPOVER(session->popover), &anchor); @@ -1149,6 +1256,13 @@ static void refresh_header_bar_css(MyApplication* self) { ".%s headerbar:backdrop {" "background-color: %s;" "background-image: none;" + "box-shadow: none;" + "}" + ".%s .busymax-native-dialog-content," + ".%s .busymax-native-dialog-content:backdrop {" + "background-color: %s;" + "background-image: none;" + "border-radius: 9px;" "}" ".%s.csd:not(.solid-csd):not(.maximized):not(.fullscreen) {" // GTK 3 has no named modern dialog-outline role. Flutter supplies the @@ -1159,6 +1273,7 @@ static void refresh_header_bar_css(MyApplication* self) { kNativeDialogStyleClass, kNativeDialogStyleClass, dialog_background_color, kNativeDialogStyleClass, kNativeDialogStyleClass, dialog_background_color, + kNativeDialogStyleClass, kNativeDialogStyleClass, dialog_background_color, kNativeDialogStyleClass, css_color_or(self->header_bar_dialog_outline_color, kDefaultDialogOutlineColor)); @@ -1183,8 +1298,15 @@ static void refresh_header_bar_css(MyApplication* self) { "modelbutton:hover:not(:disabled) {" "background-color: %s;" "background-image: none;" + "}" + "popover.background.%s " + "row:hover:not(:disabled) {" + "background-color: %s;" + "background-image: none;" "}", kNativePopoverStyleClass, + self->header_bar_menu_hover_color, + kNativePopoverStyleClass, self->header_bar_menu_hover_color) : g_strdup(""); g_autofree gchar* header_menu_shadow_css = @@ -1246,7 +1368,6 @@ static void refresh_header_bar_css(MyApplication* self) { 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 {" @@ -1387,7 +1508,8 @@ static void refresh_header_bar_css(MyApplication* self) { "background-color: %s;" "background-image: none;" "}", - window_background_color, yaru_window_decoration_css, native_dialog_css, + window_background_color, yaru_window_decoration_css, + native_dialog_css, native_search_geometry_css, background_color, foreground_color, sidebar_background_color, foreground_color, sidebar_border_color, diff --git a/test/app/native_ui_audit_test.dart b/test/app/native_ui_audit_test.dart index bda200c..a93618d 100644 --- a/test/app/native_ui_audit_test.dart +++ b/test/app/native_ui_audit_test.dart @@ -272,14 +272,16 @@ void main() { expect(compactAgenda, contains('ScheduleProjection.colorForItem')); expect(compactAgenda, contains('leading: _CompactAgendaRowMarker')); - expect(dateTimeFields, contains('InputDatePickerFormField')); - expect(dateTimeFields, contains('fieldLabelText: widget.label')); - expect(dateTimeFields, contains('entry: TextFormField(')); + expect(dateTimeFields, contains('MiniCalendar(')); + expect(dateTimeFields, contains('items: const []')); + expect( + dateTimeFields, + contains('onSelected: (date) => _setSelectedDate'), + ); expect( 'busyMaxGroupedTextFieldDecoration'.allMatches(dateTimeFields).length, greaterThanOrEqualTo(2), ); - expect(dateTimeFields, contains('labelText: widget.label')); expect(dateTimeFields, contains('parseDesktopTimeInput')); expect(dateTimeFields, isNot(contains('_withoutFloatingEntryLabel'))); expect(dateTimeFields, isNot(contains('_BusyMaxTimeTextEntry'))); @@ -1197,7 +1199,13 @@ void main() { expect(nativeMenu, contains('g_simple_action_set_enabled(')); expect(nativeMenu, contains('g_simple_action_new_stateful(')); expect(nativeMenu, contains('g_object_ref(G_OBJECT(method_call))')); - expect(nativeMenu, contains('gtk_popover_popup(session->popover)')); + expect( + nativeMenu, + anyOf( + contains('gtk_popover_popup(session->popover)'), + contains('gtk_popover_popup(GTK_POPOVER(session->popover))'), + ), + ); expect(nativeMenu, isNot(contains('gtk_widget_show(session->popover)'))); expect( nativeMenu, @@ -1688,7 +1696,7 @@ void main() { expect(headerMenuShadowCss, isNot(contains('border-radius'))); expect(source, contains('"busymax-native-dialog"')); expect(source, contains('style_native_dialog(GtkWidget* dialog)')); - expect('style_native_dialog(dialog);'.allMatches(source).length, 2); + expect('style_native_dialog(dialog);'.allMatches(source).length, 3); expect( nativeDialogCss, contains('g_autofree gchar* native_dialog_css ='), @@ -1702,8 +1710,10 @@ void main() { ); expect( 'dialog_background_color'.allMatches(nativeDialogCss).length, - 2, - reason: 'the native dialog body and titlebar share one surface role', + 3, + reason: + 'dialog and titlebar share body surface styling while dialog content is ' + 'co-styled', ); expect(nativeDialogCss, isNot(contains('window_background_color'))); expect(nativeDialogCss, contains('"box-shadow: inset 0 0 0 1px %s;"')); @@ -1736,7 +1746,7 @@ void main() { ), ); expect(nativeDialogCss, isNot(contains('"border:'))); - expect(nativeDialogCss, isNot(contains('border-radius'))); + expect(nativeDialogCss, contains('border-radius: 9px;')); expect(source, contains('style_native_popover(session->popover)')); expect(source, isNot(contains('activate_native_menu_host('))); expect( diff --git a/test/features/schedule/presentation/schedule_views_test.dart b/test/features/schedule/presentation/schedule_views_test.dart index d9599c1..5e9c86b 100644 --- a/test/features/schedule/presentation/schedule_views_test.dart +++ b/test/features/schedule/presentation/schedule_views_test.dart @@ -257,7 +257,10 @@ void main() { .descendant( of: find.byType(ScheduleYearView), matching: find.byWidgetPredicate( - (widget) => widget is ColoredBox && widget.child is GridView, + (widget) => + widget is ColoredBox && + widget.color == workspaceColor && + widget.child is Padding, ), ) .first, @@ -3196,8 +3199,11 @@ void main() { expect(yearView, contains('ScheduleProjection.groupByDay(items)')); expect(yearView, contains('ScheduleProjection.colorForItem')); expect(yearView, contains('final monthWidth =')); - expect(yearView, contains('_monthPanelHeight(monthWidth)')); - expect(yearView, contains('mainAxisExtent: monthHeight')); + expect(yearView, contains('_compactMonthPanelHeight(')); + expect(yearView, contains('Wrap(')); + expect(yearView, contains('children: [')); + expect(yearView, contains('spacing: BusyMaxSpacing.md')); + expect(yearView, contains('runSpacing: BusyMaxSpacing.md')); expect(yearView, contains('ColoredBox(')); expect( yearView, @@ -3213,7 +3219,7 @@ void main() { expect(yearView, contains('firstWeekday')); expect(yearView, contains('onMonthSelected(month)')); expect(yearView, isNot(contains('height: 142'))); - expect(yearView, isNot(contains('availableHeight'))); + expect(yearView, contains('availableHeight')); expect(yearView, isNot(contains('borderColor'))); expect(yearView, isNot(contains('RoundedRectangleBorder('))); expect(yearView, isNot(contains('TextButton('))); diff --git a/test/features/tasks/presentation/desktop_date_time_fields_test.dart b/test/features/tasks/presentation/desktop_date_time_fields_test.dart index 04980e1..edf2acd 100644 --- a/test/features/tasks/presentation/desktop_date_time_fields_test.dart +++ b/test/features/tasks/presentation/desktop_date_time_fields_test.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'package:busymax/src/app/busymax_design.dart'; import 'package:busymax/src/features/tasks/presentation/desktop_date_time_fields.dart'; +import 'package:busymax/src/features/schedule/presentation/mini_calendar.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -113,7 +114,7 @@ void main() { expect(find.text('Enter date'), findsNothing); expect(find.text('Enter time'), findsNothing); expect(find.byIcon(YaruIcons.calendar), findsOneWidget); - expect(find.byIcon(Icons.schedule), findsNothing); + expect(find.byIcon(YaruIcons.clock), findsOneWidget); expect(find.byIcon(Icons.edit_outlined), findsNothing); }, ); @@ -214,7 +215,109 @@ void main() { expect(changes, isEmpty); }); - testWidgets('fallback date picker follows the shared modal policy', ( + testWidgets('time row uses native time picker channel', (tester) async { + const channel = MethodChannel(nativeDateTimePickerChannelName); + final calls = []; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + calls.add(call); + expect(call.method, 'pickTime'); + expect(call.arguments, containsPair('initialTime', '09:30')); + return Future.value('10:45'); + }); + addTearDown( + () => TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, null), + ); + + String? pickedTime; + await tester.pumpWidget( + localizedTestApp( + child: Scaffold( + body: DesktopTimeValueRow( + label: 'Due time', + time: '09:30', + useNativePicker: true, + onChanged: (value) => pickedTime = value, + onValidityChanged: (_) {}, + ), + ), + ), + ); + + expect(find.byType(YaruIconButton), findsOneWidget); + await tester.tap(find.byType(YaruIconButton).first); + await tester.pumpAndSettle(); + expect(calls, hasLength(1)); + expect(pickedTime, '10:45'); + expect(calls, hasLength(1)); + expect(find.byType(BusyMaxDialogShell), findsNothing); + expect(tester.takeException(), isNull); + }); + + testWidgets('fallback time picker opens as tooltip-style controls', ( + tester, + ) async { + await tester.pumpWidget( + localizedTestApp( + child: Scaffold( + body: DesktopTimeValueRow( + label: 'Due time', + time: '09:30', + onChanged: (_) {}, + onValidityChanged: (_) {}, + ), + ), + ), + ); + + await tester.tap(find.byIcon(YaruIcons.clock)); + await tester.pumpAndSettle(); + + expect(find.byType(BusyMaxContentPopoverSurface), findsOneWidget); + expect(find.byType(BusyMaxDialogShell), findsNothing); + expect(find.byIcon(Icons.add), findsNWidgets(2)); + expect(find.byIcon(Icons.remove), findsNWidgets(2)); + expect(find.text(':'), findsOneWidget); + expect(find.byIcon(Icons.public), findsOneWidget); + + expect(find.byType(FilledButton), findsAny); + expect(find.byIcon(Icons.public), findsOneWidget); + + await tester.sendKeyEvent(LogicalKeyboardKey.escape); + await tester.pumpAndSettle(); + }); + + testWidgets('fallback time picker closes when clicking outside', ( + tester, + ) async { + await tester.pumpWidget( + localizedTestApp( + child: Scaffold( + body: Center( + child: DesktopTimeValueRow( + label: 'Due time', + time: '09:30', + onChanged: (_) {}, + onValidityChanged: (_) {}, + ), + ), + ), + ), + ); + + await tester.tap(find.byIcon(YaruIcons.clock)); + await tester.pumpAndSettle(); + expect(find.byType(BusyMaxContentPopoverSurface), findsOneWidget); + + final bodySize = tester.getSize(find.byType(Scaffold)); + await tester.tapAt(Offset(bodySize.width - 1, bodySize.height - 1)); + await tester.pumpAndSettle(); + + expect(find.byType(BusyMaxContentPopoverSurface), findsNothing); + }); + + testWidgets('fallback date picker opens as an anchored popover', ( tester, ) async { late BuildContext hostContext; @@ -236,76 +339,83 @@ void main() { ); await tester.pumpAndSettle(); - expect(find.byType(BusyMaxDialogShell), findsOneWidget); - final barriers = tester.widgetList(find.byType(ModalBarrier)); - expect( - barriers.any( - (barrier) => barrier.color == busyMaxModalBarrierColor(hostContext), - ), - isTrue, - ); + expect(find.byType(BusyMaxContentPopoverSurface), findsOneWidget); + expect(find.byType(ModalBarrier), findsAtLeastNWidgets(1)); - await tester.tap(find.text('Cancel')); + await tester.sendKeyEvent(LogicalKeyboardKey.escape); await tester.pumpAndSettle(); expect(await result, isNull); }); - testWidgets( - 'fallback date entry is populated and contextual before receiving focus', - (tester) async { - late BuildContext hostContext; - await tester.pumpWidget( - localizedTestApp( - child: Builder( - builder: (context) { - hostContext = context; - return const Scaffold(body: SizedBox()); - }, + testWidgets('fallback date picker is positioned from the trigger', ( + tester, + ) async { + await tester.pumpWidget( + localizedTestApp( + child: Scaffold( + body: Center( + child: DesktopDateValueRow( + label: 'Due date', + date: '2026-07-22', + onChanged: _ignoreString, + ), ), ), - ); + ), + ); - final result = showBusyMaxDateValueDialog( - hostContext, - label: 'Due date', - initialDate: '2026-07-22', - ); - await tester.pumpAndSettle(); + final trigger = find.byType(YaruIconButton).first; + final triggerRect = tester.getRect(trigger); + await tester.tap(trigger); + await tester.pumpAndSettle(); - final entryFinder = find.descendant( - of: find.byType(InputDatePickerFormField), - matching: find.byType(TextFormField), - ); - final entry = tester.widget(entryFinder); - final textField = tester.widget( - find.descendant( - of: find.byType(InputDatePickerFormField), - matching: find.byType(TextField), + final popoverRect = tester.getRect( + find.byType(BusyMaxContentPopoverSurface).first, + ); + expect(popoverRect.topLeft.dx, greaterThan(0)); + expect(popoverRect.topLeft.dy, greaterThan(0)); + expect( + (triggerRect.center.dx - popoverRect.center.dx).abs(), + lessThan(popoverRect.width / 2 + 10), + ); + expect( + (triggerRect.center.dy - popoverRect.center.dy).abs(), + greaterThan(0), + ); + + await tester.sendKeyEvent(LogicalKeyboardKey.escape); + await tester.pumpAndSettle(); + }); + + testWidgets('fallback date picker closes when tapping outside', ( + tester, + ) async { + await tester.pumpWidget( + localizedTestApp( + child: Scaffold( + body: Center( + child: DesktopDateValueRow( + label: 'Due date', + date: '2026-07-22', + onChanged: _ignoreString, + ), + ), ), - ); - final localizations = MaterialLocalizations.of( - tester.element(find.byType(InputDatePickerFormField)), - ); + ), + ); - expect( - entry.controller?.text, - localizations.formatCompactDate(DateTime(2026, 7, 22)), - ); - expect(textField.decoration?.labelText, 'Due date'); - expect(textField.focusNode?.hasFocus ?? false, isFalse); - expect( - textField.decoration?.labelText, - isNot(localizations.dateInputLabel), - ); - expect(find.text(localizations.dateInputLabel), findsNothing); + await tester.tap(find.byIcon(YaruIcons.calendar)); + await tester.pumpAndSettle(); + expect(find.byType(BusyMaxContentPopoverSurface), findsOneWidget); - await tester.tap(find.text(localizations.cancelButtonLabel)); - await tester.pumpAndSettle(); - expect(await result, isNull); - }, - ); + final bodySize = tester.getSize(find.byType(Scaffold)); + await tester.tapAt(Offset(bodySize.width - 1, bodySize.height - 1)); + await tester.pumpAndSettle(); - testWidgets('fallback date dialog submits a valid edited date', ( + expect(find.byType(BusyMaxContentPopoverSurface), findsNothing); + }); + + testWidgets('fallback date picker uses mini calendar and labels', ( tester, ) async { late BuildContext hostContext; @@ -327,21 +437,132 @@ void main() { ); await tester.pumpAndSettle(); - final entryFinder = find.descendant( - of: find.byType(InputDatePickerFormField), - matching: find.byType(TextFormField), + expect(find.byType(MiniCalendar), findsOneWidget); + expect(find.text('July'), findsOneWidget); + expect(find.text('2026'), findsOneWidget); + expect(find.byTooltip('Wednesday, July 22, 2026'), findsOneWidget); + expect(find.byType(TextField), findsNothing); + + await tester.sendKeyEvent(LogicalKeyboardKey.escape); + await tester.pumpAndSettle(); + expect(await result, isNull); + }); + + testWidgets('fallback date picker year mode shows month and year headers', ( + tester, + ) async { + await tester.pumpWidget( + localizedTestApp( + child: Scaffold( + body: Center( + child: DesktopDateValueRow( + label: 'Due date', + date: '2026-07-22', + onChanged: _ignoreString, + ), + ), + ), + ), ); - final localizations = MaterialLocalizations.of( - tester.element(find.byType(InputDatePickerFormField)), + + await tester.tap(find.byIcon(YaruIcons.calendar)); + await tester.pumpAndSettle(); + expect(find.byType(MiniCalendar), findsOneWidget); + expect(find.text('2026'), findsOneWidget); + expect(find.byType(TextButton), findsWidgets); + + await tester.tap(find.widgetWithText(TextButton, '2026')); + await tester.pumpAndSettle(); + + expect(find.byType(MiniCalendar), findsOneWidget); + expect(find.text('2026'), findsOneWidget); + expect(find.byType(TextButton), findsWidgets); + }); + + testWidgets('fallback date picker stays open while paging months', ( + tester, + ) async { + await tester.pumpWidget( + localizedTestApp( + child: Scaffold( + body: DesktopDateValueRow( + label: 'Due date', + date: '2026-07-22', + onChanged: _ignoreString, + ), + ), + ), ); - await tester.enterText( - entryFinder, - localizations.formatCompactDate(DateTime(2027, 8, 14)), + + await tester.tap(find.byIcon(YaruIcons.calendar)); + await tester.pumpAndSettle(); + expect(find.byType(BusyMaxContentPopoverSurface), findsOneWidget); + + await tester.tap(find.byIcon(YaruIcons.pan_start).at(0)); + await tester.pumpAndSettle(); + expect(find.byType(BusyMaxContentPopoverSurface), findsOneWidget); + + await tester.tap(find.byIcon(YaruIcons.pan_end).at(0)); + await tester.pumpAndSettle(); + expect(find.byType(BusyMaxContentPopoverSurface), findsOneWidget); + }); + + testWidgets('fallback date picker stays open while paging years', ( + tester, + ) async { + await tester.pumpWidget( + localizedTestApp( + child: Scaffold( + body: DesktopDateValueRow( + label: 'Due date', + date: '2026-07-22', + onChanged: _ignoreString, + ), + ), + ), ); - await tester.tap(find.text(localizations.okButtonLabel)); + + await tester.tap(find.byIcon(YaruIcons.calendar)); await tester.pumpAndSettle(); + expect(find.byType(BusyMaxContentPopoverSurface), findsOneWidget); - expect(await result, '2027-08-14'); + await tester.tap(find.byIcon(YaruIcons.pan_start).at(1)); + await tester.pumpAndSettle(); + expect(find.byType(BusyMaxContentPopoverSurface), findsOneWidget); + + await tester.tap(find.byIcon(YaruIcons.pan_end).at(1)); + await tester.pumpAndSettle(); + expect(find.byType(BusyMaxContentPopoverSurface), findsOneWidget); + }); + + testWidgets('fallback date dialog submits a selected date', (tester) async { + String? picked; + await tester.pumpWidget( + localizedTestApp( + child: Scaffold( + body: Builder( + builder: (context) { + return Center( + child: DesktopDateValueRow( + label: 'Due date', + date: '2026-07-22', + onChanged: (date) => picked = date, + ), + ); + }, + ), + ), + ), + ); + + await tester.tap(find.byIcon(YaruIcons.calendar)); + await tester.pumpAndSettle(); + final dayCell = find.text('14'); + expect(dayCell, findsOneWidget); + await tester.tap(dayCell.first); + await tester.pumpAndSettle(); + + expect(picked, '2026-07-14'); }); testWidgets( @@ -366,77 +587,15 @@ void main() { ); await tester.pumpAndSettle(); - final entry = tester.widget( - find.descendant( - of: find.byType(InputDatePickerFormField), - matching: find.byType(TextFormField), - ), - ); - final localizations = MaterialLocalizations.of( - tester.element(find.byType(InputDatePickerFormField)), - ); - expect( - entry.controller?.text, - localizations.formatCompactDate(DateTime(2100, 12, 31)), - ); + expect(find.byTooltip('Friday, December 31, 2100'), findsOneWidget); expect(tester.takeException(), isNull); - await tester.tap(find.text(localizations.cancelButtonLabel)); + await tester.sendKeyEvent(LogicalKeyboardKey.escape); await tester.pumpAndSettle(); expect(await result, isNull); }, ); - testWidgets('fallback date dialog rejects malformed and out-of-range dates', ( - tester, - ) async { - late BuildContext hostContext; - await tester.pumpWidget( - localizedTestApp( - child: Builder( - builder: (context) { - hostContext = context; - return const Scaffold(body: SizedBox()); - }, - ), - ), - ); - - final result = showBusyMaxDateValueDialog( - hostContext, - label: 'Due date', - initialDate: '2026-07-22', - ); - await tester.pumpAndSettle(); - - final entryFinder = find.descendant( - of: find.byType(InputDatePickerFormField), - matching: find.byType(TextFormField), - ); - final localizations = MaterialLocalizations.of( - tester.element(find.byType(InputDatePickerFormField)), - ); - - await tester.enterText(entryFinder, 'not a date'); - await tester.tap(find.text(localizations.okButtonLabel)); - await tester.pumpAndSettle(); - expect(find.byType(BusyMaxDialogShell), findsOneWidget); - expect(find.text(localizations.invalidDateFormatLabel), findsOneWidget); - - await tester.enterText( - entryFinder, - localizations.formatCompactDate(DateTime(1800, 1, 1)), - ); - await tester.tap(find.text(localizations.okButtonLabel)); - await tester.pumpAndSettle(); - expect(find.byType(BusyMaxDialogShell), findsOneWidget); - expect(find.text(localizations.dateOutOfRangeLabel), findsOneWidget); - - await tester.tap(find.text(localizations.cancelButtonLabel)); - await tester.pumpAndSettle(); - expect(await result, isNull); - }); - testWidgets('invalid time reports validity and uses the native error label', ( tester, ) async { diff --git a/test/features/tasks/presentation/task_details_pane_test.dart b/test/features/tasks/presentation/task_details_pane_test.dart index e581a87..ce28122 100644 --- a/test/features/tasks/presentation/task_details_pane_test.dart +++ b/test/features/tasks/presentation/task_details_pane_test.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'dart:io'; import 'dart:ui'; +import 'package:intl/intl.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter/services.dart'; @@ -1109,25 +1110,23 @@ void main() { ); }); - testWidgets('due date uses native platform picker channel', (tester) async { + testWidgets('due date opens in-window date picker', (tester) async { final calls = []; TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger .setMockMethodCallHandler(_nativePickerChannel, (call) async { calls.add(call); - expect(call.method, 'pickDate'); - expect(call.arguments, containsPair('initialDate', '2026-06-06')); - expect(call.arguments, containsPair('cancelLabel', 'Cancel')); - expect(call.arguments, containsPair('okLabel', 'OK')); - return '2026-06-15'; + return null; }); await _pumpDetails(tester, microsoftTaskProviderCapabilities); await _openDatePicker(tester, 'Due date'); expect(tester.takeException(), isNull); - expect(calls, hasLength(1)); - expect(find.text('June 2026'), findsNothing); - expect(_labeledFieldText(tester, 'Due date'), 'Jun 15, 2026'); + expect(calls, isEmpty); + expect(find.byType(BusyMaxContentPopoverSurface), findsOneWidget); + expect(find.byType(CalendarDatePicker), findsNothing); + expect(find.text('June'), findsOneWidget); + expect(_labeledFieldText(tester, 'Due date'), 'Jun 6, 2026'); }); testWidgets('date value row can use in-window picker', (tester) async { @@ -1148,11 +1147,18 @@ void main() { await _openDatePicker(tester, 'Due date'); - expect(find.byType(BusyMaxDialogShell), findsOneWidget); + expect(find.byType(BusyMaxContentPopoverSurface), findsOneWidget); expect(find.byType(CalendarDatePicker), findsNothing); expect(_labeledFieldText(tester, 'Due date'), 'Jun 6, 2026'); - await tester.tap(find.text('OK')); + await tester.tap( + find + .descendant( + of: find.byType(BusyMaxContentPopoverSurface), + matching: find.text('6'), + ) + .first, + ); await tester.pumpAndSettle(); expect(changed, '2026-06-06'); @@ -1183,7 +1189,8 @@ void main() { ); await _openDatePicker(tester, 'Due date'); - await tester.tap(find.text('OK')); + final todayTooltip = DateFormat('EEEE, MMMM d, yyyy').format(now); + await tester.tap(find.byTooltip(todayTooltip)); await tester.pumpAndSettle(); expect(changed, today); @@ -1207,7 +1214,7 @@ void main() { expect(_labeledTextFormFieldFinder('Due time'), findsOneWidget); expect(_labeledFieldText(tester, 'Due time'), '2:30 PM'); - expect(find.byType(BusyMaxDialogShell), findsNothing); + expect(find.byType(BusyMaxContentPopoverSurface), findsNothing); expect(tester.takeException(), isNull); expect(calls, isEmpty); @@ -1439,11 +1446,6 @@ void main() { testWidgets('Microsoft payload still includes time zone on Save', ( tester, ) async { - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(_nativePickerChannel, (call) async { - expect(call.method, 'pickDate'); - return '2026-06-15'; - }); final repository = _FakeTasksRepository(); await _pumpDetails( tester, @@ -1453,6 +1455,14 @@ void main() { ); await _openDatePicker(tester, 'Due date'); + await tester.tap( + find + .descendant( + of: find.byType(BusyMaxContentPopoverSurface), + matching: find.text('15'), + ) + .first, + ); await tester.pumpAndSettle(); expect(repository.patches, isEmpty); @@ -1470,19 +1480,13 @@ void main() { testWidgets('date field does not render a Flutter calendar grid', ( tester, ) async { - final calls = []; - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(_nativePickerChannel, (call) async { - calls.add(call); - return null; - }); await _pumpDetails(tester, microsoftTaskProviderCapabilities); await _openDatePicker(tester, 'Due date'); - expect(calls.single.method, 'pickDate'); - expect(find.text('June 2026'), findsNothing); expect(find.byType(CalendarDatePicker), findsNothing); + expect(find.text('June 2026'), findsNothing); + expect(find.byType(BusyMaxContentPopoverSurface), findsOneWidget); }); } From 75053989610f42702b138027eeb6e8a5b94b3c03 Mon Sep 17 00:00:00 2001 From: albert Date: Tue, 28 Jul 2026 16:10:36 -0700 Subject: [PATCH 20/73] Refactor native dialog styling to enhance visual consistency. Introduce corner radius for native dialogs and update action button styling for improved layout and user experience. --- linux/runner/my_application.cc | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/linux/runner/my_application.cc b/linux/runner/my_application.cc index a9c6d24..470498c 100644 --- a/linux/runner/my_application.cc +++ b/linux/runner/my_application.cc @@ -76,6 +76,8 @@ constexpr char kHeaderSearchEntryStyleClass[] = constexpr char kHeaderModalOpenStyleClass[] = "busymax-modal-open"; constexpr char kHeaderModalBarrierStyleClass[] = "busymax-modal-barrier"; constexpr char kNativeDialogStyleClass[] = "busymax-native-dialog"; +// Mirrors Yaru's shared window/dialog radius used by the Flutter fallback. +constexpr gint kNativeDialogCornerRadius = 14; constexpr char kNativePopoverStyleClass[] = "busymax-native-popover"; constexpr char kHeaderMenuDepthStyleClass[] = "busymax-header-menu-depth"; @@ -537,6 +539,11 @@ static void handle_native_confirmation(FlMethodCall* method_call, GtkWidget* confirm_button = gtk_dialog_add_button( GTK_DIALOG(dialog), confirm_label != nullptr ? confirm_label : "_OK", GTK_RESPONSE_ACCEPT); + GtkWidget* actions = gtk_widget_get_parent(cancel_button); + if (actions != nullptr) { + gtk_style_context_add_class(gtk_widget_get_style_context(actions), + "busymax-native-dialog-actions"); + } GtkStyleContext* confirm_context = gtk_widget_get_style_context(confirm_button); gtk_style_context_add_class( @@ -1257,12 +1264,20 @@ static void refresh_header_bar_css(MyApplication* self) { "background-color: %s;" "background-image: none;" "box-shadow: none;" + "border-bottom-width: 0;" + "border-bottom-style: none;" + "border-bottom-color: transparent;" "}" ".%s .busymax-native-dialog-content," ".%s .busymax-native-dialog-content:backdrop {" "background-color: %s;" "background-image: none;" - "border-radius: 9px;" + "border-radius: %dpx;" + "}" + ".%s .busymax-native-dialog-actions," + ".%s .busymax-native-dialog-actions:backdrop {" + "background-color: %s;" + "background-image: none;" "}" ".%s.csd:not(.solid-csd):not(.maximized):not(.fullscreen) {" // GTK 3 has no named modern dialog-outline role. Flutter supplies the @@ -1274,6 +1289,8 @@ static void refresh_header_bar_css(MyApplication* self) { dialog_background_color, kNativeDialogStyleClass, kNativeDialogStyleClass, dialog_background_color, kNativeDialogStyleClass, kNativeDialogStyleClass, dialog_background_color, + kNativeDialogCornerRadius, kNativeDialogStyleClass, + kNativeDialogStyleClass, dialog_background_color, kNativeDialogStyleClass, css_color_or(self->header_bar_dialog_outline_color, kDefaultDialogOutlineColor)); From 5e887484cf89e9b2f2e6f1d8a50c3a3e1eee27ec Mon Sep 17 00:00:00 2001 From: albert Date: Tue, 28 Jul 2026 16:10:47 -0700 Subject: [PATCH 21/73] Add localization for timezone selection and location search. Enhance calendar UI with new strings for timezone, location search, and no location found messages in multiple languages. --- lib/l10n/app_de.arb | 3 + lib/l10n/app_en.arb | 3 + lib/l10n/app_es.arb | 3 + lib/l10n/app_fr.arb | 3 + lib/l10n/generated/app_localizations.dart | 18 + lib/l10n/generated/app_localizations_de.dart | 9 + lib/l10n/generated/app_localizations_en.dart | 9 + lib/l10n/generated/app_localizations_es.dart | 9 + lib/l10n/generated/app_localizations_fr.dart | 9 + lib/src/app/busymax_design.dart | 67 ++- .../calendar/presentation/event_editor.dart | 15 +- .../schedule/presentation/mini_calendar.dart | 424 +++++++++----- .../schedule_anchored_popover.dart | 37 +- .../presentation/schedule_workspace.dart | 2 + .../presentation/schedule_year_view.dart | 406 ++----------- .../desktop_date_time_fields.dart | 534 ++++++++---------- .../presentation/task_details_editor.dart | 10 + test/app/busymax_dialogs_test.dart | 29 + test/app/busymax_grouped_surface_test.dart | 34 +- test/app/native_ui_audit_test.dart | 16 +- .../presentation/schedule_views_test.dart | 228 +++++++- .../desktop_date_time_fields_test.dart | 243 +++++--- .../presentation/task_details_pane_test.dart | 33 +- 23 files changed, 1179 insertions(+), 965 deletions(-) diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index 31c924f..92156f4 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -385,6 +385,9 @@ "scheduleItemCount": "{count, plural, =1{1 Eintrag} other{{count} Einträge}}", "@scheduleItemCount": {"placeholders": {"count": {"type": "int"}}}, "readOnlyCalendar": "Dieser Kalender ist schreibgeschützt.", + "selectTimeZone": "Zeitzone auswählen", + "searchLocations": "Orte suchen", + "noLocationsFound": "Keine Orte gefunden", "deleteCalendarConfirmation": "\"{title}\" löschen?", "@deleteCalendarConfirmation": {"placeholders": {"title": {"type": "String"}}} } diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 037913b..5de657a 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -407,6 +407,9 @@ "scheduleItemCount": "{count, plural, =1{1 item} other{{count} items}}", "@scheduleItemCount": {"placeholders": {"count": {"type": "int"}}}, "readOnlyCalendar": "This calendar is read-only.", + "selectTimeZone": "Select Timezone", + "searchLocations": "Search locations", + "noLocationsFound": "No locations found", "deleteCalendarConfirmation": "Delete \"{title}\"?", "@deleteCalendarConfirmation": {"placeholders": {"title": {"type": "String"}}} } diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index ffcfc7b..fa38a71 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -385,6 +385,9 @@ "scheduleItemCount": "{count, plural, =1{1 elemento} other{{count} elementos}}", "@scheduleItemCount": {"placeholders": {"count": {"type": "int"}}}, "readOnlyCalendar": "Este calendario es de solo lectura.", + "selectTimeZone": "Seleccionar zona horaria", + "searchLocations": "Buscar ubicaciones", + "noLocationsFound": "No se encontraron ubicaciones", "deleteCalendarConfirmation": "¿Eliminar \"{title}\"?", "@deleteCalendarConfirmation": {"placeholders": {"title": {"type": "String"}}} } diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index a1fde37..b0e5339 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -385,6 +385,9 @@ "scheduleItemCount": "{count, plural, =1{1 élément} other{{count} éléments}}", "@scheduleItemCount": {"placeholders": {"count": {"type": "int"}}}, "readOnlyCalendar": "Ce calendrier est en lecture seule.", + "selectTimeZone": "Sélectionner le fuseau horaire", + "searchLocations": "Rechercher des lieux", + "noLocationsFound": "Aucun lieu trouvé", "deleteCalendarConfirmation": "Supprimer \"{title}\" ?", "@deleteCalendarConfirmation": {"placeholders": {"title": {"type": "String"}}} } diff --git a/lib/l10n/generated/app_localizations.dart b/lib/l10n/generated/app_localizations.dart index 0f8d4c1..f2d631d 100644 --- a/lib/l10n/generated/app_localizations.dart +++ b/lib/l10n/generated/app_localizations.dart @@ -2364,6 +2364,24 @@ abstract class AppLocalizations { /// **'This calendar is read-only.'** String get readOnlyCalendar; + /// No description provided for @selectTimeZone. + /// + /// In en, this message translates to: + /// **'Select Timezone'** + String get selectTimeZone; + + /// No description provided for @searchLocations. + /// + /// In en, this message translates to: + /// **'Search locations'** + String get searchLocations; + + /// No description provided for @noLocationsFound. + /// + /// In en, this message translates to: + /// **'No locations found'** + String get noLocationsFound; + /// No description provided for @deleteCalendarConfirmation. /// /// In en, this message translates to: diff --git a/lib/l10n/generated/app_localizations_de.dart b/lib/l10n/generated/app_localizations_de.dart index 31e99e6..88242db 100644 --- a/lib/l10n/generated/app_localizations_de.dart +++ b/lib/l10n/generated/app_localizations_de.dart @@ -1276,6 +1276,15 @@ class AppLocalizationsDe extends AppLocalizations { @override String get readOnlyCalendar => 'Dieser Kalender ist schreibgeschützt.'; + @override + String get selectTimeZone => 'Zeitzone auswählen'; + + @override + String get searchLocations => 'Orte suchen'; + + @override + String get noLocationsFound => 'Keine Orte gefunden'; + @override String deleteCalendarConfirmation(String title) { return '\"$title\" löschen?'; diff --git a/lib/l10n/generated/app_localizations_en.dart b/lib/l10n/generated/app_localizations_en.dart index c14edfa..f5f9e3d 100644 --- a/lib/l10n/generated/app_localizations_en.dart +++ b/lib/l10n/generated/app_localizations_en.dart @@ -1258,6 +1258,15 @@ class AppLocalizationsEn extends AppLocalizations { @override String get readOnlyCalendar => 'This calendar is read-only.'; + @override + String get selectTimeZone => 'Select Timezone'; + + @override + String get searchLocations => 'Search locations'; + + @override + String get noLocationsFound => 'No locations found'; + @override String deleteCalendarConfirmation(String title) { return 'Delete \"$title\"?'; diff --git a/lib/l10n/generated/app_localizations_es.dart b/lib/l10n/generated/app_localizations_es.dart index fbd9671..3abb1fc 100644 --- a/lib/l10n/generated/app_localizations_es.dart +++ b/lib/l10n/generated/app_localizations_es.dart @@ -1275,6 +1275,15 @@ class AppLocalizationsEs extends AppLocalizations { @override String get readOnlyCalendar => 'Este calendario es de solo lectura.'; + @override + String get selectTimeZone => 'Seleccionar zona horaria'; + + @override + String get searchLocations => 'Buscar ubicaciones'; + + @override + String get noLocationsFound => 'No se encontraron ubicaciones'; + @override String deleteCalendarConfirmation(String title) { return '¿Eliminar \"$title\"?'; diff --git a/lib/l10n/generated/app_localizations_fr.dart b/lib/l10n/generated/app_localizations_fr.dart index c1a1bbd..6122dfa 100644 --- a/lib/l10n/generated/app_localizations_fr.dart +++ b/lib/l10n/generated/app_localizations_fr.dart @@ -1274,6 +1274,15 @@ class AppLocalizationsFr extends AppLocalizations { @override String get readOnlyCalendar => 'Ce calendrier est en lecture seule.'; + @override + String get selectTimeZone => 'Sélectionner le fuseau horaire'; + + @override + String get searchLocations => 'Rechercher des lieux'; + + @override + String get noLocationsFound => 'Aucun lieu trouvé'; + @override String deleteCalendarConfirmation(String title) { return 'Supprimer \"$title\" ?'; diff --git a/lib/src/app/busymax_design.dart b/lib/src/app/busymax_design.dart index ca049b7..11d625b 100644 --- a/lib/src/app/busymax_design.dart +++ b/lib/src/app/busymax_design.dart @@ -503,6 +503,8 @@ class BusyMaxHeaderIconButton extends StatelessWidget { this.foregroundColor, this.backgroundColor, this.overlayColor, + this.fixedSize, + this.shape, }); final Widget icon; @@ -512,6 +514,8 @@ class BusyMaxHeaderIconButton extends StatelessWidget { final Color? foregroundColor; final WidgetStateProperty? backgroundColor; final WidgetStateProperty? overlayColor; + final Size? fixedSize; + final OutlinedBorder? shape; @override Widget build(BuildContext context) { @@ -520,12 +524,24 @@ class BusyMaxHeaderIconButton extends StatelessWidget { icon: icon, iconSize: iconSize, onPressed: onPressed, - style: busyMaxHeaderIconButtonStyle( - context, - foregroundColor: foregroundColor, - backgroundColor: backgroundColor, - overlayColor: overlayColor, - ), + style: + busyMaxHeaderIconButtonStyle( + context, + foregroundColor: foregroundColor, + backgroundColor: backgroundColor, + overlayColor: overlayColor, + ).copyWith( + fixedSize: fixedSize == null + ? null + : WidgetStatePropertyAll(fixedSize), + minimumSize: fixedSize == null + ? null + : WidgetStatePropertyAll(fixedSize), + maximumSize: fixedSize == null + ? null + : WidgetStatePropertyAll(fixedSize), + shape: shape == null ? null : WidgetStatePropertyAll(shape), + ), ); return YaruTheme.maybeOf(context)?.focusBorders == true ? YaruFocusBorder.primary( @@ -3417,26 +3433,40 @@ class BusyMaxDialogTitleBar extends StatelessWidget { this.title, this.centerTitle = true, this.closeSemanticLabel, + this.showDividerInHighContrast = true, }); final Widget? title; final bool centerTitle; final String? closeSemanticLabel; + final bool showDividerInHighContrast; @override Widget build(BuildContext context) { final theme = Theme.of(context); final colors = BusyMaxSurfaceColors.of(context); - return YaruDialogTitleBar( - title: title, - centerTitle: centerTitle, - isActive: true, - backgroundColor: busyMaxDialogSurfaceColor(context), - border: theme.colorScheme.isHighContrast - ? BorderSide(color: colors.divider) - : BorderSide.none, - closeSemanticLabel: closeSemanticLabel, - heroTag: null, + final dialogSurface = busyMaxDialogSurfaceColor(context); + return Theme( + data: theme.copyWith( + appBarTheme: theme.appBarTheme.copyWith( + backgroundColor: dialogSurface, + surfaceTintColor: dialogSurface, + shadowColor: Colors.transparent, + elevation: 0, + scrolledUnderElevation: 0, + ), + ), + child: YaruDialogTitleBar( + title: title, + centerTitle: centerTitle, + isActive: true, + backgroundColor: dialogSurface, + border: showDividerInHighContrast && theme.colorScheme.isHighContrast + ? BorderSide(color: colors.divider) + : BorderSide.none, + closeSemanticLabel: closeSemanticLabel, + heroTag: null, + ), ); } } @@ -3623,7 +3653,10 @@ class BusyMaxConfirmDialog extends StatelessWidget { surfaceTintColor: dialogSurface, scrollable: true, titlePadding: EdgeInsets.zero, - title: BusyMaxDialogTitleBar(title: Text(title)), + title: BusyMaxDialogTitleBar( + title: Text(title), + showDividerInHighContrast: false, + ), content: Text(message), actions: [ BusyMaxPushButton.standard( diff --git a/lib/src/features/calendar/presentation/event_editor.dart b/lib/src/features/calendar/presentation/event_editor.dart index fe79e21..098a82f 100644 --- a/lib/src/features/calendar/presentation/event_editor.dart +++ b/lib/src/features/calendar/presentation/event_editor.dart @@ -126,8 +126,7 @@ class _EventEditorState extends State { final title = widget.initialDraft.eventId == null ? l10n.newEvent : l10n.editEvent; - final timeFieldsValid = - _draft.allDay || (_startTimeValid && _endTimeValid); + final timeFieldsValid = _draft.allDay || (_startTimeValid && _endTimeValid); final canSave = dirty && _draft.canSave && timeFieldsValid; return CallbackShortcuts( bindings: { @@ -213,6 +212,12 @@ class _EventEditorState extends State { onChanged: (value) { _setStart(_withTime(_draft.start, value), provider); }, + timeZone: _draft.startTimeZone, + onTimeZoneChanged: (value) { + setState(() { + _draft = _draft.copyWith(startTimeZone: value); + }); + }, allowEmpty: false, onValidityChanged: (valid) { if (_startTimeValid != valid) { @@ -240,6 +245,12 @@ class _EventEditorState extends State { onChanged: (value) { _setEnd(_withTime(_draft.end, value)); }, + timeZone: _draft.endTimeZone, + onTimeZoneChanged: (value) { + setState(() { + _draft = _draft.copyWith(endTimeZone: value); + }); + }, allowEmpty: false, onValidityChanged: (valid) { if (_endTimeValid != valid) { diff --git a/lib/src/features/schedule/presentation/mini_calendar.dart b/lib/src/features/schedule/presentation/mini_calendar.dart index 81b8f02..0d6b50f 100644 --- a/lib/src/features/schedule/presentation/mini_calendar.dart +++ b/lib/src/features/schedule/presentation/mini_calendar.dart @@ -11,32 +11,47 @@ import '../../../schedule/schedule_item.dart'; import '../../../schedule/schedule_projection.dart'; import 'calendar_day_semantics.dart'; +enum MiniCalendarHeaderStyle { navigation, monthLabel } + +const _miniCalendarHeaderControlExtent = 28.0; + class MiniCalendar extends StatelessWidget { const MiniCalendar({ super.key, required this.selectedDate, + this.displayedMonth, required this.firstWeekday, this.items = const [], required this.onSelected, this.showHeader = true, + this.headerStyle = MiniCalendarHeaderStyle.navigation, + this.showDayHover = false, + this.weekNumbersInteractive = true, this.onMonthSelected, this.onYearSelected, - required this.onWeekSelected, - }); + this.onWeekSelected, + this.onDayDoubleTap, + }) : assert(!weekNumbersInteractive || onWeekSelected != null); final DateTime selectedDate; + final DateTime? displayedMonth; final int firstWeekday; final List items; final ValueChanged onSelected; final bool showHeader; + final MiniCalendarHeaderStyle headerStyle; + final bool showDayHover; + final bool weekNumbersInteractive; final ValueChanged? onMonthSelected; final ValueChanged? onYearSelected; - final ValueChanged onWeekSelected; + final ValueChanged? onWeekSelected; + final ValueChanged? onDayDoubleTap; @override Widget build(BuildContext context) { final l10n = context.l10n; - final first = DateTime(selectedDate.year, selectedDate.month); + final visibleMonth = displayedMonth ?? selectedDate; + final first = DateTime(visibleMonth.year, visibleMonth.month); final start = _calendarStartForMonth(first, firstWeekday); final groupedItems = ScheduleProjection.groupByDay(items); final locale = Localizations.localeOf(context).toLanguageTag(); @@ -51,51 +66,63 @@ class MiniCalendar extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.stretch, children: [ if (showHeader) ...[ - Row( - children: [ - Expanded( - child: _MiniCalendarStepper( - label: DateFormat.MMMM(locale).format(selectedDate), - previousTooltip: l10n.previousMonth, - nextTooltip: l10n.nextMonth, - onPrevious: () => onSelected( - DateTime(selectedDate.year, selectedDate.month - 1), - ), - onNext: () => onSelected( - DateTime(selectedDate.year, selectedDate.month + 1), - ), - labelTooltip: l10n.openMonthView, - onLabelPressed: onMonthSelected == null - ? null - : () => onMonthSelected!(first), - ), - ), - const SizedBox(width: BusyMaxSpacing.sm), - Expanded( - child: _MiniCalendarStepper( - label: '${selectedDate.year}', - previousTooltip: l10n.previousYear, - nextTooltip: l10n.nextYear, - onPrevious: () => onSelected( - DateTime(selectedDate.year - 1, selectedDate.month), + if (headerStyle == MiniCalendarHeaderStyle.navigation) + Row( + children: [ + Expanded( + child: _MiniCalendarStepper( + label: DateFormat.MMMM(locale).format(visibleMonth), + previousTooltip: l10n.previousMonth, + nextTooltip: l10n.nextMonth, + onPrevious: () => onSelected( + DateTime(visibleMonth.year, visibleMonth.month - 1), + ), + onNext: () => onSelected( + DateTime(visibleMonth.year, visibleMonth.month + 1), + ), + labelTooltip: l10n.openMonthView, + onLabelPressed: onMonthSelected == null + ? null + : () => onMonthSelected!(first), ), - onNext: () => onSelected( - DateTime(selectedDate.year + 1, selectedDate.month), + ), + const SizedBox(width: BusyMaxSpacing.sm), + Expanded( + child: _MiniCalendarStepper( + label: '${visibleMonth.year}', + previousTooltip: l10n.previousYear, + nextTooltip: l10n.nextYear, + onPrevious: () => onSelected( + DateTime(visibleMonth.year - 1, visibleMonth.month), + ), + onNext: () => onSelected( + DateTime(visibleMonth.year + 1, visibleMonth.month), + ), + labelTooltip: l10n.openYearView, + onLabelPressed: onYearSelected == null + ? null + : () => onYearSelected!(DateTime(visibleMonth.year)), ), - labelTooltip: l10n.openYearView, - onLabelPressed: onYearSelected == null - ? null - : () => onYearSelected!(DateTime(selectedDate.year)), ), - ), - ], - ), + ], + ) + else + _MiniCalendarHeaderLabel( + label: DateFormat.yMMMM(locale).format(first), + tooltip: l10n.openMonthView, + onPressed: onMonthSelected == null + ? null + : () => onMonthSelected!(first), + ), const SizedBox(height: BusyMaxSpacing.sm), ], LayoutBuilder( builder: (context, constraints) { + final maximumWeekNumberExtent = weekNumbersInteractive + ? BusyMaxSizes.miniCalendarWeekButton + : BusyMaxSizes.miniCalendarWeekButton - BusyMaxSpacing.md; final weekNumberExtent = math.min( - BusyMaxSizes.miniCalendarWeekButton, + maximumWeekNumberExtent, constraints.maxWidth / (DateTime.daysPerWeek + 1), ); final dayExtent = @@ -143,10 +170,15 @@ class MiniCalendar extends StatelessWidget { row * DateTime.daysPerWeek, ), weekNumberExtent: weekNumberExtent, + onWeekSelected: weekNumbersInteractive + ? onWeekSelected + : null, selectedDate: selectedDate, + displayedMonth: first, groupedItems: groupedItems, + showDayHover: showDayHover, onDaySelected: onSelected, - onWeekSelected: onWeekSelected, + onDayDoubleTap: onDayDoubleTap, ), ), ], @@ -164,18 +196,24 @@ class _MiniCalendarWeekRow extends StatelessWidget { const _MiniCalendarWeekRow({ required this.weekStart, required this.weekNumberExtent, + required this.onWeekSelected, required this.selectedDate, + required this.displayedMonth, required this.groupedItems, + required this.showDayHover, required this.onDaySelected, - required this.onWeekSelected, + required this.onDayDoubleTap, }); final DateTime weekStart; final double weekNumberExtent; + final ValueChanged? onWeekSelected; final DateTime selectedDate; + final DateTime displayedMonth; final Map> groupedItems; + final bool showDayHover; final ValueChanged onDaySelected; - final ValueChanged onWeekSelected; + final ValueChanged? onDayDoubleTap; @override Widget build(BuildContext context) { @@ -186,6 +224,7 @@ class _MiniCalendarWeekRow extends StatelessWidget { width: weekNumberExtent, child: _MiniCalendarWeekNumberButton( weekStart: weekStart, + buttonWidth: weekNumberExtent, onSelected: onWeekSelected, ), ), @@ -194,8 +233,11 @@ class _MiniCalendarWeekRow extends StatelessWidget { child: _MiniCalendarDayButton( day: _addCalendarDays(weekStart, column), selectedDate: selectedDate, + displayedMonth: displayedMonth, groupedItems: groupedItems, + showHoverBackground: showDayHover, onSelected: onDaySelected, + onDoubleTap: onDayDoubleTap, ), ), ], @@ -206,74 +248,102 @@ class _MiniCalendarWeekRow extends StatelessWidget { class _MiniCalendarWeekNumberButton extends StatelessWidget { const _MiniCalendarWeekNumberButton({ required this.weekStart, + required this.buttonWidth, required this.onSelected, }); final DateTime weekStart; - final ValueChanged onSelected; + final double buttonWidth; + final ValueChanged? onSelected; @override Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; final weekNumber = _isoWeekNumber(weekStart); + final label = Text( + '$weekNumber', + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: colorScheme.onSurfaceVariant.withValues(alpha: 0.72), + ), + ); return Center( child: Tooltip( message: context.l10n.weekNumberTooltip(weekNumber), - child: TextButton( - onPressed: () => onSelected(weekStart), - style: - busyMaxHeaderIconButtonStyle( - context, - foregroundColor: colorScheme.onSurfaceVariant, - backgroundColor: busyMaxHeaderButtonBackground(context), - overlayColor: const WidgetStatePropertyAll(Colors.transparent), - ).copyWith( - fixedSize: const WidgetStatePropertyAll( - Size.square(BusyMaxSizes.miniCalendarWeekButton), - ), - minimumSize: const WidgetStatePropertyAll( - Size.square(BusyMaxSizes.miniCalendarWeekButton), - ), - maximumSize: const WidgetStatePropertyAll( - Size.square(BusyMaxSizes.miniCalendarWeekButton), - ), + child: onSelected == null + ? SizedBox.square( + dimension: buttonWidth, + child: Center(child: label), + ) + : TextButton( + onPressed: () => onSelected!(weekStart), + style: + busyMaxHeaderIconButtonStyle( + context, + foregroundColor: colorScheme.onSurfaceVariant, + backgroundColor: busyMaxHeaderButtonBackground(context), + overlayColor: const WidgetStatePropertyAll( + Colors.transparent, + ), + ).copyWith( + fixedSize: WidgetStatePropertyAll( + Size.square(buttonWidth), + ), + minimumSize: WidgetStatePropertyAll( + Size.square(buttonWidth), + ), + maximumSize: WidgetStatePropertyAll( + Size.square(buttonWidth), + ), + ), + child: label, ), - child: Text( - '$weekNumber', - style: Theme.of(context).textTheme.labelSmall?.copyWith( - color: colorScheme.onSurfaceVariant.withValues(alpha: 0.72), - ), - ), - ), ), ); } } -class _MiniCalendarDayButton extends StatelessWidget { +class _MiniCalendarDayButton extends StatefulWidget { const _MiniCalendarDayButton({ required this.day, required this.selectedDate, + required this.displayedMonth, required this.groupedItems, + required this.showHoverBackground, required this.onSelected, + required this.onDoubleTap, }); final DateTime day; final DateTime selectedDate; + final DateTime displayedMonth; final Map> groupedItems; + final bool showHoverBackground; final ValueChanged onSelected; + final ValueChanged? onDoubleTap; + + @override + State<_MiniCalendarDayButton> createState() => _MiniCalendarDayButtonState(); +} + +class _MiniCalendarDayButtonState extends State<_MiniCalendarDayButton> { + bool _isHovering = false; @override Widget build(BuildContext context) { + final day = widget.day; + final selectedDate = widget.selectedDate; + final groupedItems = widget.groupedItems; + final onSelected = widget.onSelected; final colorScheme = Theme.of(context).colorScheme; final surfaceColors = BusyMaxSurfaceColors.of(context); final selected = _sameDay(day, selectedDate); final today = _sameDay(day, DateTime.now()); final inDisplayedMonth = - day.year == selectedDate.year && day.month == selectedDate.month; + day.year == widget.displayedMonth.year && + day.month == widget.displayedMonth.month; final displayingCurrentMonth = - selectedDate.year == DateTime.now().year && - selectedDate.month == DateTime.now().month; + widget.displayedMonth.year == DateTime.now().year && + widget.displayedMonth.month == DateTime.now().month; final highlightToday = today && displayingCurrentMonth; final items = groupedItems[ScheduleProjection.day(day)] ?? const []; @@ -294,57 +364,84 @@ class _MiniCalendarDayButton extends StatelessWidget { (canShowIndicators ? BusyMaxSpacing.xxs : 0), ); final markerSize = math.min( - 24.0, + 25.5, math.max(0.0, availableMarkerExtent), ); - return InkWell( - onTap: () => onSelected(day), - excludeFromSemantics: true, - customBorder: const CircleBorder(), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - SizedBox.square( - dimension: markerSize, - child: DecoratedBox( - decoration: BoxDecoration( - color: selected - ? colorScheme.primary - : highlightToday - ? surfaceColors.controlActive - : null, - shape: BoxShape.circle, - ), - child: Center( - child: FittedBox( - fit: BoxFit.scaleDown, - child: Text( - '${day.day}', - style: TextStyle( - color: selected - ? colorScheme.onPrimary - : highlightToday - ? surfaceColors.foreground - : inDisplayedMonth - ? null - : colorScheme.onSurfaceVariant, - fontWeight: selected || highlightToday - ? FontWeight.w600 - : null, + final hoverColor = Color.alphaBlend( + Colors.white.withValues( + alpha: Theme.of(context).brightness == Brightness.light + ? 0.48 + : 0.12, + ), + surfaceColors.popover, + ); + final hoveredMarkerSize = math.min( + 25.5 + 4.0, + math.max(0.0, availableMarkerExtent), + ); + final currentMarkerSize = + _isHovering && widget.showHoverBackground && !selected + ? hoveredMarkerSize + : markerSize; + final backgroundColor = selected + ? colorScheme.primary + : highlightToday + ? surfaceColors.controlActive + : _isHovering && widget.showHoverBackground + ? hoverColor + : Colors.transparent; + + return MouseRegion( + onEnter: (_) => setState(() => _isHovering = true), + onExit: (_) => setState(() => _isHovering = false), + child: InkWell( + onTap: () => onSelected(day), + onDoubleTap: widget.onDoubleTap == null + ? null + : () => widget.onDoubleTap!(day), + excludeFromSemantics: true, + customBorder: const CircleBorder(), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + SizedBox.square( + dimension: currentMarkerSize, + child: DecoratedBox( + decoration: BoxDecoration( + color: backgroundColor, + shape: BoxShape.circle, + ), + child: Center( + child: FittedBox( + fit: BoxFit.scaleDown, + child: Text( + '${day.day}', + style: TextStyle( + color: selected + ? colorScheme.onPrimary + : highlightToday + ? surfaceColors.foreground + : inDisplayedMonth + ? null + : colorScheme.onSurfaceVariant, + fontWeight: selected || highlightToday + ? FontWeight.w600 + : null, + ), ), ), ), ), ), - ), - if (canShowIndicators) ...[ - const SizedBox(height: BusyMaxSpacing.xxs), - _MiniCalendarDayIndicators( - items: items, - height: indicatorHeight, - ), + if (canShowIndicators) ...[ + const SizedBox(height: BusyMaxSpacing.xxs), + _MiniCalendarDayIndicators( + items: items, + height: indicatorHeight, + ), + ], ], - ], + ), ), ); }, @@ -413,7 +510,7 @@ class _MiniCalendarStepper extends StatelessWidget { builder: (context, constraints) { final compact = constraints.maxWidth < - BusyMaxSizes.headerIconButton * 2 + BusyMaxSpacing.xs * 2; + _miniCalendarHeaderControlExtent * 2 + BusyMaxSpacing.xs * 2; if (compact) { return Row( children: [ @@ -479,48 +576,83 @@ class _MiniCalendarStepper extends StatelessWidget { }) { return BusyMaxHeaderIconButton( tooltip: tooltip, - iconSize: BusyMaxSizes.headerIcon, + iconSize: BusyMaxSizes.iconSm, icon: Icon(icon), onPressed: onPressed, foregroundColor: colorScheme.onSurfaceVariant, - backgroundColor: busyMaxHeaderButtonBackground(context), + backgroundColor: busyMaxSubtleButtonBackground(context), overlayColor: const WidgetStatePropertyAll(Colors.transparent), + fixedSize: const Size.square(_miniCalendarHeaderControlExtent), + shape: const CircleBorder(), ); } Widget _label(BuildContext context) { - final action = onLabelPressed; + return _MiniCalendarHeaderLabel( + label: label, + tooltip: labelTooltip, + onPressed: onLabelPressed, + ); + } +} + +class _MiniCalendarHeaderLabel extends StatelessWidget { + const _MiniCalendarHeaderLabel({ + required this.label, + this.tooltip, + this.onPressed, + }); + + final String label; + final String? tooltip; + final VoidCallback? onPressed; + + @override + Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; - final labelStyle = - (busyMaxSectionHeaderStyle(context) ?? - Theme.of(context).textTheme.titleSmall) - ?.copyWith(color: colorScheme.onSurface); - if (action == null) { - return Text( - label, - textAlign: TextAlign.center, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: labelStyle, + final labelStyle = Theme.of(context).textTheme.labelMedium?.copyWith( + color: colorScheme.onSurface, + fontWeight: FontWeight.w600, + ); + final labelWidget = Text( + label, + textAlign: TextAlign.center, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: labelStyle, + ); + if (onPressed == null) { + return SizedBox( + height: _miniCalendarHeaderControlExtent, + child: Center(child: labelWidget), ); } return Tooltip( - message: labelTooltip ?? label, + message: tooltip ?? label, child: TextButton( - onPressed: action, - style: busyMaxHeaderTextButtonStyle( - context, - foregroundColor: colorScheme.onSurface, - backgroundColor: busyMaxHeaderButtonBackground(context), - overlayColor: const WidgetStatePropertyAll(Colors.transparent), - ), - child: Text( - label, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: labelStyle, - ), + onPressed: onPressed, + style: + busyMaxHeaderTextButtonStyle( + context, + foregroundColor: colorScheme.onSurface, + backgroundColor: busyMaxSubtleButtonBackground(context), + overlayColor: const WidgetStatePropertyAll(Colors.transparent), + ).copyWith( + minimumSize: const WidgetStatePropertyAll( + Size( + _miniCalendarHeaderControlExtent, + _miniCalendarHeaderControlExtent, + ), + ), + maximumSize: const WidgetStatePropertyAll( + Size(double.infinity, _miniCalendarHeaderControlExtent), + ), + padding: const WidgetStatePropertyAll( + EdgeInsets.symmetric(horizontal: BusyMaxSpacing.headerInset), + ), + ), + child: labelWidget, ), ); } diff --git a/lib/src/features/schedule/presentation/schedule_anchored_popover.dart b/lib/src/features/schedule/presentation/schedule_anchored_popover.dart index 54f5a08..ad16883 100644 --- a/lib/src/features/schedule/presentation/schedule_anchored_popover.dart +++ b/lib/src/features/schedule/presentation/schedule_anchored_popover.dart @@ -286,23 +286,32 @@ class _SchedulePopoverLayout { viewport.height, preferredMinimumExtent: preferredMinimumHeight, ); - final availableWidth = math.max(0.0, viewport.width - horizontalMargin * 2); - final width = availableWidth < minimumWidth - ? availableWidth - : math.min(preferredWidth, availableWidth); - final maximumLeft = math.max( + final safeViewportWidth = math.max(1.0, viewport.width); + final safeHorizontalMargin = math.min( horizontalMargin, - viewport.width - width - horizontalMargin, + safeViewportWidth / 2, + ); + final availableWidth = math.max( + 1.0, + safeViewportWidth - safeHorizontalMargin * 2, + ); + final width = math.min(preferredWidth, availableWidth).clamp( + 1.0, + availableWidth, + ); + final maximumLeft = math.max( + safeHorizontalMargin, + safeViewportWidth - width - safeHorizontalMargin, ); if (anchor == null) { return _SchedulePopoverLayout( anchor: null, - left: ((viewport.width - width) / 2) - .clamp(horizontalMargin, maximumLeft) + left: ((safeViewportWidth - width) / 2) + .clamp(safeHorizontalMargin, maximumLeft) .toDouble(), width: width, - maximumHeight: math.max(0, viewport.height - verticalMargin * 2), - horizontalMargin: horizontalMargin, + maximumHeight: math.max(1.0, viewport.height - verticalMargin * 2), + horizontalMargin: safeHorizontalMargin, verticalMargin: verticalMargin, arrowSide: BusyMaxPopoverArrowSide.top, arrowAlignment: 0.5, @@ -312,7 +321,9 @@ class _SchedulePopoverLayout { final preferredLeft = textDirection == TextDirection.rtl ? anchor.right - width : anchor.left; - final left = preferredLeft.clamp(horizontalMargin, maximumLeft).toDouble(); + final left = preferredLeft + .clamp(safeHorizontalMargin, maximumLeft) + .toDouble(); final spaceAbove = math.max(0.0, anchor.top - gap - verticalMargin); final spaceBelow = math.max( 0.0, @@ -324,11 +335,13 @@ class _SchedulePopoverLayout { final arrowAlignment = width <= 0 ? 0.5 : ((anchor.center.dx - left) / width).clamp(0.08, 0.92).toDouble(); + final resolvedMaximumHeight = + math.max(1.0, showBelow ? spaceBelow : spaceAbove); return _SchedulePopoverLayout( anchor: anchor, left: left, width: width, - maximumHeight: showBelow ? spaceBelow : spaceAbove, + maximumHeight: resolvedMaximumHeight, horizontalMargin: horizontalMargin, verticalMargin: verticalMargin, arrowSide: showBelow diff --git a/lib/src/features/schedule/presentation/schedule_workspace.dart b/lib/src/features/schedule/presentation/schedule_workspace.dart index e66862b..82d99cd 100644 --- a/lib/src/features/schedule/presentation/schedule_workspace.dart +++ b/lib/src/features/schedule/presentation/schedule_workspace.dart @@ -2339,6 +2339,7 @@ class _ScheduleBody extends StatelessWidget { } return switch (mode) { ScheduleViewMode.day => ScheduleDayWeekView( + key: const ValueKey('schedule-day-planner'), range: range, selectedDate: selectedDate, daysShowed: 1, @@ -2351,6 +2352,7 @@ class _ScheduleBody extends StatelessWidget { onTaskCompletionChanged: onTaskCompletionChanged, ), ScheduleViewMode.week => ScheduleDayWeekView( + key: const ValueKey('schedule-week-planner'), range: range, selectedDate: selectedDate, daysShowed: 7, diff --git a/lib/src/features/schedule/presentation/schedule_year_view.dart b/lib/src/features/schedule/presentation/schedule_year_view.dart index f7f75bd..8738b66 100644 --- a/lib/src/features/schedule/presentation/schedule_year_view.dart +++ b/lib/src/features/schedule/presentation/schedule_year_view.dart @@ -1,14 +1,9 @@ -import 'dart:math' as math; - import 'package:flutter/material.dart'; -import 'package:intl/intl.dart'; -import 'package:yaru/yaru.dart'; import '../../../app/busymax_design.dart'; import '../../../app/busymax_surface_colors.dart'; import '../../../schedule/schedule_item.dart'; -import '../../../schedule/schedule_projection.dart'; -import 'calendar_day_semantics.dart'; +import 'mini_calendar.dart'; class ScheduleYearView extends StatelessWidget { const ScheduleYearView({ @@ -20,6 +15,7 @@ class ScheduleYearView extends StatelessWidget { required this.onMonthSelected, required this.onCreateAtDay, this.compact = false, + this.backgroundColor, }); final DateTime selectedDate; @@ -29,12 +25,10 @@ class ScheduleYearView extends StatelessWidget { final ValueChanged onMonthSelected; final ValueChanged onCreateAtDay; final bool compact; + final Color? backgroundColor; @override Widget build(BuildContext context) { - final grouped = ScheduleProjection.groupByDay(items); - final locale = Localizations.localeOf(context).toLanguageTag(); - return LayoutBuilder( builder: (context, constraints) { final columns = _columnCount(constraints.maxWidth, compact: compact); @@ -42,387 +36,53 @@ class ScheduleYearView extends StatelessWidget { final columnGaps = BusyMaxSpacing.md * (columns - 1); final monthWidth = (constraints.maxWidth - horizontalPadding - columnGaps) / columns; - final rows = (DateTime.monthsPerYear + columns - 1) ~/ columns; - final monthHeight = constraints.maxHeight.isFinite - ? _compactMonthPanelHeight( - monthWidth: monthWidth, - availableHeight: constraints.maxHeight - horizontalPadding, - rows: rows, - compact: compact, - ) - : _monthPanelHeight(monthWidth); return ColoredBox( - color: BusyMaxSurfaceColors.of(context).window, - child: Padding( - padding: const EdgeInsets.all(BusyMaxSpacing.md), - child: Wrap( - spacing: BusyMaxSpacing.md, - runSpacing: BusyMaxSpacing.md, - children: [ - for (var index = 0; index < DateTime.monthsPerYear; index++) - SizedBox( - width: monthWidth, - height: monthHeight, - child: _YearMonthPanel( - month: DateTime(selectedDate.year, index + 1), - selectedDate: selectedDate, - groupedItems: grouped, - firstWeekday: firstWeekday, - locale: locale, - onDaySelected: onDaySelected, - onMonthSelected: onMonthSelected, - onCreateAtDay: onCreateAtDay, - ), - ), - ], - ), - ), - ); - }, - ); - } -} - -class _YearMonthPanel extends StatelessWidget { - const _YearMonthPanel({ - required this.month, - required this.selectedDate, - required this.groupedItems, - required this.firstWeekday, - required this.locale, - required this.onDaySelected, - required this.onMonthSelected, - required this.onCreateAtDay, - }); - - final DateTime month; - final DateTime selectedDate; - final Map> groupedItems; - final int firstWeekday; - final String locale; - final ValueChanged onDaySelected; - final ValueChanged onMonthSelected; - final ValueChanged onCreateAtDay; - - @override - Widget build(BuildContext context) { - final monthLabel = '${DateFormat.MMMM(locale).format(month)} ${month.year}'; - return BusyMaxGroupedSurface( - child: Column( - children: [ - BusyMaxActionRow( - title: monthLabel, - titleWidget: FittedBox( - fit: BoxFit.scaleDown, - child: Text( - monthLabel, - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ), - trailing: const Icon(YaruIcons.pan_end, size: BusyMaxSizes.iconSm), - onTap: () => onMonthSelected(month), - ), - Expanded( + color: backgroundColor ?? BusyMaxSurfaceColors.of(context).window, + child: SingleChildScrollView( child: Padding( - padding: const EdgeInsets.fromLTRB( - BusyMaxSpacing.sm, - 0, - BusyMaxSpacing.sm, - BusyMaxSpacing.sm, - ), - child: _YearMonthGrid( - month: month, - selectedDate: selectedDate, - groupedItems: groupedItems, - firstWeekday: firstWeekday, - locale: locale, - onDaySelected: onDaySelected, - onCreateAtDay: onCreateAtDay, - ), - ), - ), - ], - ), - ); - } -} - -class _YearMonthGrid extends StatelessWidget { - const _YearMonthGrid({ - required this.month, - required this.selectedDate, - required this.groupedItems, - required this.firstWeekday, - required this.locale, - required this.onDaySelected, - required this.onCreateAtDay, - }); - - final DateTime month; - final DateTime selectedDate; - final Map> groupedItems; - final int firstWeekday; - final String locale; - final ValueChanged onDaySelected; - final ValueChanged onCreateAtDay; - - @override - Widget build(BuildContext context) { - final colorScheme = Theme.of(context).colorScheme; - final days = _monthCells(month, firstWeekday); - - return Column( - children: [ - SizedBox( - height: 18, - child: Row( - children: [ - for (final weekday in _weekdays(firstWeekday)) - Expanded( - child: Center( - child: Text( - DateFormat.E(locale).format(_weekdayDate(weekday)), - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.labelSmall?.copyWith( - color: colorScheme.onSurfaceVariant, - ), - ), - ), - ), - ], - ), - ), - const SizedBox(height: BusyMaxSpacing.xs), - Expanded( - child: LayoutBuilder( - builder: (context, constraints) { - final rowHeight = math.max(0.0, constraints.maxHeight / 6); - return GridView.builder( - physics: const NeverScrollableScrollPhysics(), - gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: DateTime.daysPerWeek, - mainAxisExtent: rowHeight, - ), - itemCount: days.length, - itemBuilder: (context, index) { - final day = days[index]; - if (day == null) { - return const SizedBox.shrink(); - } - final key = ScheduleProjection.day(day); - return _YearDayCell( - day: day, - selected: DateUtils.isSameDay(day, selectedDate), - today: DateUtils.isSameDay(day, DateTime.now()), - items: groupedItems[key] ?? const [], - onSelected: () => onDaySelected(day), - onCreate: () => onCreateAtDay(day), - ); - }, - ); - }, - ), - ), - ], - ); - } -} - -class _YearDayCell extends StatelessWidget { - const _YearDayCell({ - required this.day, - required this.selected, - required this.today, - required this.items, - required this.onSelected, - required this.onCreate, - }); - - final DateTime day; - final bool selected; - final bool today; - final List items; - final VoidCallback onSelected; - final VoidCallback onCreate; - - @override - Widget build(BuildContext context) { - final colorScheme = Theme.of(context).colorScheme; - final surfaceColors = BusyMaxSurfaceColors.of(context); - return BusyMaxCalendarDaySemantics( - day: day, - selected: selected, - onTap: onSelected, - child: LayoutBuilder( - builder: (context, constraints) { - if (constraints.maxHeight <= 0) { - return const SizedBox.shrink(); - } - final canShowIndicators = - items.isNotEmpty && constraints.maxHeight >= 24; - final indicatorHeight = canShowIndicators ? 4.0 : 0.0; - final markerSize = math.min( - 22.0, - math.max( - 14.0, - constraints.maxHeight - - indicatorHeight - - (canShowIndicators ? BusyMaxSpacing.xxs : 0), - ), - ); - final textColor = selected - ? colorScheme.onPrimary - : today - ? surfaceColors.foreground - : colorScheme.onSurface; - final markerColor = selected - ? colorScheme.primary - : today - ? surfaceColors.controlActive - : Colors.transparent; - - return InkWell( - borderRadius: BorderRadius.circular(BusyMaxRadius.headerButton), - onTap: onSelected, - onDoubleTap: onCreate, - excludeFromSemantics: true, - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Container( - key: ValueKey('year-day-marker-${day.toIso8601String()}'), - width: markerSize, - height: markerSize, - alignment: Alignment.center, - decoration: BoxDecoration( - color: markerColor, - shape: BoxShape.circle, - ), - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 2), - child: FittedBox( - fit: BoxFit.scaleDown, - child: Text( - '${day.day}', - style: Theme.of(context).textTheme.labelSmall?.copyWith( - color: textColor, - fontWeight: selected || today - ? FontWeight.w600 - : null, - ), + padding: const EdgeInsets.all(BusyMaxSpacing.md), + child: Wrap( + spacing: BusyMaxSpacing.md, + runSpacing: BusyMaxSpacing.md, + children: [ + for (var index = 0; index < DateTime.monthsPerYear; index++) + SizedBox( + width: monthWidth, + child: MiniCalendar( + displayedMonth: DateTime(selectedDate.year, index + 1), + selectedDate: selectedDate, + firstWeekday: firstWeekday, + items: items, + headerStyle: MiniCalendarHeaderStyle.monthLabel, + showDayHover: true, + weekNumbersInteractive: false, + onSelected: onDaySelected, + onMonthSelected: onMonthSelected, + onYearSelected: null, + onWeekSelected: null, + onDayDoubleTap: onCreateAtDay, ), ), - ), - ), - if (canShowIndicators) ...[ - const SizedBox(height: BusyMaxSpacing.xxs), - _YearDayIndicators(items: items, height: indicatorHeight), ], - ], - ), - ); - }, - ), - ); - } -} - -class _YearDayIndicators extends StatelessWidget { - const _YearDayIndicators({required this.items, required this.height}); - - final List items; - final double height; - - @override - Widget build(BuildContext context) { - if (items.isEmpty) { - return SizedBox(height: height); - } - final brightness = Theme.of(context).brightness; - return SizedBox( - height: height, - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - for (final item in items.take(3)) - Padding( - padding: const EdgeInsets.symmetric(horizontal: 1), - child: DecoratedBox( - decoration: BoxDecoration( - color: ScheduleProjection.colorForItem(item, brightness), - shape: BoxShape.circle, - ), - child: const SizedBox.square(dimension: 4), ), ), - ], - ), + ), + ); + }, ); } } int _columnCount(double width, {required bool compact}) { - if (width >= 1120) { + if (width >= 900) { return 4; } - if (width >= 760) { + if (width >= 680) { return 3; } - if (width >= 520) { - return 2; - } - if (compact) { - if (width >= 420) { - return 3; - } + if (width >= 460) { return 2; } - return 1; -} - -double _monthPanelHeight(double width) { - return width < 280 ? 276 : math.min(340, math.max(292, width * 0.72)); -} - -double _compactMonthPanelHeight({ - required double monthWidth, - required double availableHeight, - required int rows, - required bool compact, -}) { - if (!compact || - !availableHeight.isFinite || - availableHeight <= 0 || - rows <= 0) { - return _monthPanelHeight(monthWidth); - } - final rowSpacing = BusyMaxSpacing.md * (rows - 1); - return (availableHeight - rowSpacing).clamp(0.0, double.infinity) / rows; -} - -List _monthCells(DateTime month, int firstWeekday) { - final first = DateTime(month.year, month.month); - final leading = (first.weekday - firstWeekday) % DateTime.daysPerWeek; - final daysInMonth = DateUtils.getDaysInMonth(month.year, month.month); - return [ - for (var index = 0; index < DateTime.daysPerWeek * 6; index++) - if (index >= leading && index < leading + daysInMonth) - DateTime(month.year, month.month, index - leading + 1) - else - null, - ]; -} - -List _weekdays(int firstWeekday) { - return [ - for (var offset = 0; offset < DateTime.daysPerWeek; offset++) - ((firstWeekday + offset - 1) % DateTime.daysPerWeek) + 1, - ]; -} - -DateTime _weekdayDate(int weekday) { - return DateTime(2024, 1, 1).add(Duration(days: weekday - 1)); + return compact && width >= 360 ? 2 : 1; } diff --git a/lib/src/features/tasks/presentation/desktop_date_time_fields.dart b/lib/src/features/tasks/presentation/desktop_date_time_fields.dart index d67539f..02d8404 100644 --- a/lib/src/features/tasks/presentation/desktop_date_time_fields.dart +++ b/lib/src/features/tasks/presentation/desktop_date_time_fields.dart @@ -1,15 +1,15 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:intl/intl.dart'; -import 'package:busymax/src/app/busymax_dialogs.dart'; import 'package:busymax/src/app/busymax_design.dart'; import 'package:busymax/src/app/busymax_surface_colors.dart'; import 'package:busymax/src/core/time/local_time_zone.dart'; +import 'package:busymax/src/core/time/time_zone_catalog.dart'; import 'package:busymax/src/l10n/l10n.dart'; import 'package:busymax/src/features/schedule/presentation/mini_calendar.dart'; import 'package:busymax/src/features/schedule/presentation/schedule_anchored_popover.dart'; -import 'package:busymax/src/features/schedule/presentation/schedule_year_view.dart'; import 'package:busymax/src/schedule/schedule_item.dart'; +import 'package:busymax/src/features/tasks/presentation/time_zone_selection_dialog.dart'; import 'package:yaru/yaru.dart'; @visibleForTesting @@ -17,17 +17,16 @@ const nativeDateTimePickerChannelName = 'busymax/native_date_time_picker'; const _nativeDateTimePicker = NativeDateTimePicker(); const _dateTimePickerMaxWidth = 300.0; -const _dateTimePickerYearViewMaxHeight = 320.0; +const _dateTimePickerContentMaxHeight = 320.0; const _dateTimePickerPopoverMinimumHeight = 300.0; const _dateTimePickerPopoverPadding = EdgeInsets.all(BusyMaxSpacing.lg); -const _dateTimePickerYearModeHeaderHeight = - BusyMaxSizes.headerIconButton + - BusyMaxSpacing.headerInset * 3 + - BusyMaxSpacing.sm; const _timePickerMaxWidth = 260.0; const _timePickerMinimumWidth = 240.0; -const _timePickerPopoverMinimumHeight = 180.0; -const _timePickerPopoverMaxHeight = 180.0; +const _timePickerPopoverMinimumHeight = 220.0; +const _timePickerPopoverPadding = EdgeInsets.all(BusyMaxSpacing.md); +const _timePickerInputControlSize = BusyMaxSizes.popoverActionButton; +const _timePickerInputColumnMinWidth = 36.0; +const _timePickerInputColumnMaxWidth = 38.0; class NativeDateTimePicker { const NativeDateTimePicker(); @@ -302,7 +301,9 @@ Future showBusyMaxTimeValueDialog( required String label, required String? initialTime, required bool allowEmpty, + String? initialTimeZone, ValueChanged? onTimeChanged, + ValueChanged? onTimeZoneChanged, BuildContext? anchorContext, }) { return showScheduleAnchoredPopover( @@ -315,16 +316,16 @@ Future showBusyMaxTimeValueDialog( builder: (context, arrowSide, arrowAlignment) => _DesktopTimeValueDialog( label: label, initialTime: initialTime, + initialTimeZone: initialTimeZone, allowEmpty: allowEmpty, onTimeChanged: onTimeChanged, + onTimeZoneChanged: onTimeZoneChanged, arrowSide: arrowSide, arrowAlignment: arrowAlignment, ), ); } -enum _DesktopDatePickerMode { month, year } - class _DesktopDateValueDialog extends StatefulWidget { const _DesktopDateValueDialog({ required this.label, @@ -346,7 +347,6 @@ class _DesktopDateValueDialog extends StatefulWidget { class _DesktopDateValueDialogState extends State<_DesktopDateValueDialog> { static final _firstDate = DateTime(1900); static final _lastDate = DateTime(2100, 12, 31); - var _mode = _DesktopDatePickerMode.month; late DateTime _selected; @override @@ -360,88 +360,50 @@ class _DesktopDateValueDialogState extends State<_DesktopDateValueDialog> { return LayoutBuilder( builder: (context, constraints) { final contentHeight = _calculateDateTimePickerPopupHeight(constraints); - final yearModeBodyHeight = _mode == _DesktopDatePickerMode.year - ? (contentHeight - _dateTimePickerYearModeHeaderHeight).clamp( - 0.0, - double.infinity, - ) - : contentHeight; return BusyMaxContentPopoverSurface( arrowSide: widget.arrowSide, arrowAlignment: widget.arrowAlignment, padding: _dateTimePickerPopoverPadding, - child: _mode == _DesktopDatePickerMode.year - ? ConstrainedBox( - constraints: BoxConstraints( - maxHeight: contentHeight, - maxWidth: _dateTimePickerMaxWidth, - ), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - _buildDateModeHeader(context), - SizedBox( - height: yearModeBodyHeight, - child: ScheduleYearView( - selectedDate: _selected, - compact: true, - items: const [], - firstWeekday: _firstWeekday(context), - onDaySelected: (day) => _setSelectedDate( - day, - returnToMonth: true, - submit: true, - ), - onMonthSelected: (_) {}, - onCreateAtDay: (day) => _setSelectedDate( - day, - returnToMonth: true, - submit: true, - ), - ), - ), - ], - ), - ) - : ScrollConfiguration( - behavior: ScrollConfiguration.of( - context, - ).copyWith(scrollbars: false), - child: ConstrainedBox( - constraints: BoxConstraints(maxHeight: contentHeight), - child: SingleChildScrollView( - child: MiniCalendar( - selectedDate: _selected, - firstWeekday: _firstWeekday(context), - items: const [], - onSelected: (date) => _setSelectedDate( - date, - returnToMonth: false, - submit: - date.year == _selected.year && - date.month == _selected.month, - ), - onMonthSelected: null, - onYearSelected: null, - onWeekSelected: (week) => _setSelectedDate( - week, - returnToMonth: false, - submit: true, - ), + child: ScrollConfiguration( + behavior: ScrollConfiguration.of( + context, + ).copyWith(scrollbars: false), + child: ConstrainedBox( + constraints: BoxConstraints(maxHeight: contentHeight), + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + _buildDateModeHeader(context), + MiniCalendar( + selectedDate: _selected, + firstWeekday: _firstWeekday(context), + items: const [], + showHeader: false, + showDayHover: true, + weekNumbersInteractive: false, + onSelected: (date) => _setSelectedDate( + date, + submit: + date.year == _selected.year && + date.month == _selected.month, ), + onMonthSelected: null, + onYearSelected: null, + onWeekSelected: (week) => + _setSelectedDate(week, submit: true), ), - ), + ], ), + ), + ), + ), ); }, ); } - void _setSelectedDate( - DateTime value, { - required bool returnToMonth, - bool submit = false, - }) { + void _setSelectedDate(DateTime value, {bool submit = false}) { final preserveDay = value.day == 1 && (value.year != _selected.year || value.month != _selected.month); @@ -453,9 +415,6 @@ class _DesktopDateValueDialogState extends State<_DesktopDateValueDialog> { final adjusted = _coerceSupportedRange(clamped); setState(() { _selected = adjusted; - if (returnToMonth) { - _mode = _DesktopDatePickerMode.month; - } }); if (submit) { _submit(); @@ -487,11 +446,9 @@ class _DesktopDateValueDialogState extends State<_DesktopDateValueDialog> { nextTooltip: context.l10n.nextMonth, onPrevious: () => _setSelectedDate( DateTime(_selected.year, _selected.month - 1), - returnToMonth: false, ), onNext: () => _setSelectedDate( DateTime(_selected.year, _selected.month + 1), - returnToMonth: false, ), onLabelPressed: null, ), @@ -505,11 +462,9 @@ class _DesktopDateValueDialogState extends State<_DesktopDateValueDialog> { nextTooltip: context.l10n.nextYear, onPrevious: () => _setSelectedDate( DateTime(_selected.year - 1, _selected.month), - returnToMonth: false, ), onNext: () => _setSelectedDate( DateTime(_selected.year + 1, _selected.month), - returnToMonth: false, ), onLabelPressed: null, ), @@ -570,7 +525,7 @@ class _DesktopDateValueDialogState extends State<_DesktopDateValueDialog> { icon: Icon(icon), onPressed: onPressed, foregroundColor: colorScheme.onSurfaceVariant, - backgroundColor: busyMaxHeaderButtonBackground(context), + backgroundColor: WidgetStatePropertyAll(Colors.transparent), overlayColor: const WidgetStatePropertyAll(Colors.transparent), ); } @@ -599,7 +554,7 @@ class _DesktopDateValueDialogState extends State<_DesktopDateValueDialog> { style: busyMaxHeaderTextButtonStyle( context, foregroundColor: colorScheme.onSurface, - backgroundColor: busyMaxHeaderButtonBackground(context), + backgroundColor: WidgetStatePropertyAll(Colors.transparent), overlayColor: const WidgetStatePropertyAll(Colors.transparent), ), child: Text( @@ -649,20 +604,9 @@ double _calculateDateTimePickerPopupHeight(BoxConstraints constraints) { (_dateTimePickerPopoverPadding.vertical + BusyMaxSizes.popoverArrowHeight); if (constraints.maxHeight <= 0 || constraints.maxHeight.isInfinite) { - return _dateTimePickerYearViewMaxHeight; + return _dateTimePickerContentMaxHeight; } - return availableHeight.clamp(0, _dateTimePickerYearViewMaxHeight); -} - -double _calculateTimePickerPopupHeight(BoxConstraints constraints) { - final availableHeight = - constraints.maxHeight - - (_dateTimePickerPopoverPadding.vertical + - BusyMaxSizes.popoverArrowHeight); - if (constraints.maxHeight <= 0 || constraints.maxHeight.isInfinite) { - return _timePickerPopoverMaxHeight; - } - return availableHeight.clamp(0, _timePickerPopoverMaxHeight); + return availableHeight.clamp(0, _dateTimePickerContentMaxHeight); } class DesktopTimeField extends StatefulWidget { @@ -675,6 +619,8 @@ class DesktopTimeField extends StatefulWidget { this.allowEmpty = true, this.onValidityChanged, this.useNativePicker = false, + this.timeZone, + this.onTimeZoneChanged, }); final String label; @@ -684,6 +630,8 @@ class DesktopTimeField extends StatefulWidget { final bool allowEmpty; final ValueChanged? onValidityChanged; final bool useNativePicker; + final String? timeZone; + final ValueChanged? onTimeZoneChanged; @override State createState() => _DesktopTimeFieldState(); @@ -699,6 +647,8 @@ class DesktopTimeValueRow extends StatelessWidget { this.allowEmpty = true, this.onValidityChanged, this.useNativePicker = false, + this.timeZone, + this.onTimeZoneChanged, }); final String label; @@ -708,6 +658,8 @@ class DesktopTimeValueRow extends StatelessWidget { final bool allowEmpty; final ValueChanged? onValidityChanged; final bool useNativePicker; + final String? timeZone; + final ValueChanged? onTimeZoneChanged; @override Widget build(BuildContext context) { @@ -719,6 +671,8 @@ class DesktopTimeValueRow extends StatelessWidget { allowEmpty: allowEmpty, onValidityChanged: onValidityChanged, useNativePicker: useNativePicker, + timeZone: timeZone, + onTimeZoneChanged: onTimeZoneChanged, ); } } @@ -754,6 +708,7 @@ class _DesktopTimeFieldState extends State { void didUpdateWidget(covariant DesktopTimeField oldWidget) { super.didUpdateWidget(oldWidget); final timeChanged = oldWidget.time != widget.time; + final timeZoneChanged = oldWidget.timeZone != widget.timeZone; final policyChanged = oldWidget.allowEmpty != widget.allowEmpty; final availabilityChanged = oldWidget.enabled != widget.enabled; final validityCallbackAdded = @@ -762,12 +717,26 @@ class _DesktopTimeFieldState extends State { _reportedValidity = null; } if (!timeChanged && + !timeZoneChanged && !policyChanged && !availabilityChanged && !validityCallbackAdded) { return; } - if (!timeChanged && !policyChanged && !availabilityChanged) { + if (!timeChanged && + !timeZoneChanged && + !policyChanged && + !availabilityChanged) { + _reportValidityAfterBuild(); + return; + } + if (timeZoneChanged && + !timeChanged && + !policyChanged && + !availabilityChanged) { + if (!_focusNode.hasFocus && _inputValid) { + _syncVisibleValue(); + } _reportValidityAfterBuild(); return; } @@ -866,7 +835,9 @@ class _DesktopTimeFieldState extends State { label: widget.label, initialTime: widget.time, allowEmpty: widget.allowEmpty, + initialTimeZone: widget.timeZone, onTimeChanged: _emitTime, + onTimeZoneChanged: widget.onTimeZoneChanged, anchorContext: anchorContext, ); if (!context.mounted) { @@ -901,6 +872,9 @@ class _DesktopTimeFieldState extends State { void _handleFocusChanged() { if (_focusNode.hasFocus) { + if (_inputValid) { + _syncVisibleValue(includeTimeZone: false); + } return; } if (_restoreRejectedPendingEmission()) { @@ -927,9 +901,14 @@ class _DesktopTimeFieldState extends State { _syncVisibleValue(time: parsed); } - void _syncVisibleValue({TimeOfDay? time}) { + void _syncVisibleValue({TimeOfDay? time, bool? includeTimeZone}) { final parsed = time ?? parseTimeOfDay(widget.time); - final formatted = parsed == null ? '' : formatMaterialTime(context, parsed); + final formatted = parsed == null + ? '' + : _formatVisibleTime( + parsed, + includeTimeZone: includeTimeZone ?? !_focusNode.hasFocus, + ); if (_controller.text == formatted) { return; } @@ -941,6 +920,19 @@ class _DesktopTimeFieldState extends State { _syncingText = false; } + String _formatVisibleTime(TimeOfDay time, {required bool includeTimeZone}) { + final formatted = formatMaterialTime(context, time); + if (!includeTimeZone) { + return formatted; + } + final timeZone = widget.timeZone; + if (timeZone == null || timeZone.isEmpty) { + return formatted; + } + final code = BusyMaxTimeZoneCatalog.location(timeZone).code; + return '$formatted ($code)'; + } + void _emitTime(String? value) { if (value == widget.time || _hasPendingEmission && value == _pendingEmission) { @@ -1004,16 +996,20 @@ class _DesktopTimeValueDialog extends StatefulWidget { const _DesktopTimeValueDialog({ required this.label, required this.initialTime, + required this.initialTimeZone, required this.allowEmpty, required this.onTimeChanged, + required this.onTimeZoneChanged, required this.arrowSide, required this.arrowAlignment, }); final String label; final String? initialTime; + final String? initialTimeZone; final bool allowEmpty; final ValueChanged? onTimeChanged; + final ValueChanged? onTimeZoneChanged; final BusyMaxPopoverArrowSide arrowSide; final double arrowAlignment; @@ -1027,8 +1023,7 @@ class _DesktopTimeValueDialogState extends State<_DesktopTimeValueDialog> { late final TextEditingController _minuteController; bool _syncingText = false; bool _inputValid = true; - static const _timeInputButtonSize = BusyMaxSizes.headerIconButton; - static const _timeInputFieldWidth = BusyMaxSizes.headerIconButton; + late String _selectedTimeZone; @override void initState() { @@ -1037,6 +1032,7 @@ class _DesktopTimeValueDialogState extends State<_DesktopTimeValueDialog> { _minuteController = TextEditingController(); _inputValid = widget.allowEmpty || parseTimeOfDay(widget.initialTime) != null; + _selectedTimeZone = widget.initialTimeZone ?? localIanaTimeZone(); _syncVisibleValue(); } @@ -1046,6 +1042,10 @@ class _DesktopTimeValueDialogState extends State<_DesktopTimeValueDialog> { if (oldWidget.initialTime != widget.initialTime) { _syncVisibleValue(); } + if (oldWidget.initialTimeZone != widget.initialTimeZone && + widget.initialTimeZone != null) { + _selectedTimeZone = widget.initialTimeZone!; + } } @override @@ -1059,105 +1059,99 @@ class _DesktopTimeValueDialogState extends State<_DesktopTimeValueDialog> { Widget build(BuildContext context) { return LayoutBuilder( builder: (context, constraints) { - final contentHeight = _calculateTimePickerPopupHeight(constraints); + final timeInputColumnWidth = _calculateTimeInputColumnWidth( + constraints, + ); return BusyMaxContentPopoverSurface( arrowSide: widget.arrowSide, arrowAlignment: widget.arrowAlignment, - padding: _dateTimePickerPopoverPadding, - child: ScrollConfiguration( - behavior: ScrollConfiguration.of( - context, - ).copyWith(scrollbars: false), - child: ConstrainedBox( - constraints: BoxConstraints(maxHeight: contentHeight), - child: SingleChildScrollView( - child: Padding( - padding: const EdgeInsets.all(BusyMaxSpacing.xl), - child: FocusTraversalGroup( - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - SizedBox( - width: _timeInputFieldWidth, - child: _timeInputSection( - context: context, - controller: _hourController, - label: 'Hour', - onIncrement: () => _changeHour(1), - onDecrement: () => _changeHour(-1), - ), - ), - const SizedBox(width: BusyMaxSpacing.xs), - Text( - ':', - style: Theme.of(context).textTheme.bodyLarge, - ), - const SizedBox(width: BusyMaxSpacing.xs), - SizedBox( - width: _timeInputFieldWidth, - child: _timeInputSection( - context: context, - controller: _minuteController, - label: 'Minute', - onIncrement: () => _changeMinute(1), - onDecrement: () => _changeMinute(-1), - ), - ), - ], - ), - if (!_inputValid) - Padding( - padding: const EdgeInsets.symmetric( - horizontal: BusyMaxSpacing.md, - vertical: BusyMaxSpacing.sm, - ), - child: Text( - MaterialLocalizations.of( - context, - ).invalidTimeLabel, - style: TextStyle( - color: Theme.of(context).colorScheme.error, - fontSize: 12, - ), - ), - ), - const SizedBox(height: BusyMaxSpacing.lg), - Align( - alignment: Alignment.centerLeft, - child: BusyMaxPushButton.standard( - onPressed: _openTimezoneDialog, - child: Row( - mainAxisSize: MainAxisSize.max, - children: [ - const Icon( - Icons.public, - size: BusyMaxSizes.popoverActionIcon, - ), - const SizedBox(width: BusyMaxSpacing.xs), - Flexible( - child: Text( - _timezoneDisplayLabel(context), - style: Theme.of( - context, - ).textTheme.bodyMedium, - maxLines: 1, - overflow: TextOverflow.ellipsis, - softWrap: false, - ), - ), - ], - ), - ), + padding: _timePickerPopoverPadding, + child: FocusTraversalGroup( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.center, + mainAxisSize: MainAxisSize.min, + children: [ + ConstrainedBox( + constraints: BoxConstraints( + minWidth: timeInputColumnWidth, + maxWidth: timeInputColumnWidth, + ), + child: _timeInputSection( + context: context, + buttonWidth: timeInputColumnWidth, + controller: _hourController, + label: 'Hour', + onIncrement: () => _changeHour(1), + onDecrement: () => _changeHour(-1), + ), + ), + const SizedBox(width: BusyMaxSpacing.xs), + SizedBox( + width: BusyMaxSpacing.sm, + child: Center( + child: Text( + ':', + style: Theme.of(context).textTheme.bodyMedium, ), - ], + ), ), + const SizedBox(width: BusyMaxSpacing.xs), + ConstrainedBox( + constraints: BoxConstraints( + minWidth: timeInputColumnWidth, + maxWidth: timeInputColumnWidth, + ), + child: _timeInputSection( + context: context, + buttonWidth: timeInputColumnWidth, + controller: _minuteController, + label: 'Minute', + onIncrement: () => _changeMinute(1), + onDecrement: () => _changeMinute(-1), + ), + ), + ], + ), + if (!_inputValid) + Padding( + padding: const EdgeInsets.symmetric( + horizontal: BusyMaxSpacing.md, + vertical: BusyMaxSpacing.sm, + ), + child: Text( + MaterialLocalizations.of(context).invalidTimeLabel, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context).colorScheme.error, + ), + ), + ), + const SizedBox(height: BusyMaxSpacing.md), + BusyMaxPushButton.standard( + onPressed: _openTimezoneDialog, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + Icons.public, + size: BusyMaxSizes.popoverActionIcon, + ), + const SizedBox(width: BusyMaxSpacing.xs), + Flexible( + child: Text( + _timezoneDisplayLabel(context), + maxLines: 1, + overflow: TextOverflow.ellipsis, + softWrap: false, + ), + ), + ], ), ), - ), + ], ), ), ); @@ -1165,119 +1159,91 @@ class _DesktopTimeValueDialogState extends State<_DesktopTimeValueDialog> { ); } - Future _openTimezoneDialog() async { - final timezone = localIanaTimeZone(); - final code = _timezoneCode(context); - final display = code.isEmpty ? timezone : '$timezone ($code)'; - await showBusyMaxModalDialog( - context, - builder: (dialogContext) => BusyMaxDialogShell( - title: 'Timezone', - actions: [ - BusyMaxPushButton.standard( - onPressed: () => Navigator.of(dialogContext).pop(), - child: Text(MaterialLocalizations.of(dialogContext).okButtonLabel), - ), - ], - children: [ - Text( - 'System timezone', - style: Theme.of(dialogContext).textTheme.bodyMedium, - ), - const SizedBox(height: BusyMaxSpacing.sm), - Row( - children: [ - const Icon(Icons.public, size: BusyMaxSizes.popoverActionIcon), - const SizedBox(width: BusyMaxSpacing.xs), - Text(display, style: Theme.of(dialogContext).textTheme.bodyLarge), - ], - ), - ], - ), + double _calculateTimeInputColumnWidth(BoxConstraints constraints) { + if (!constraints.hasBoundedWidth || constraints.maxWidth <= 0) { + return _timePickerInputColumnMaxWidth; + } + final dividerAndSpacing = BusyMaxSpacing.md + (BusyMaxSpacing.xs * 2); + final availablePerColumn = (constraints.maxWidth - dividerAndSpacing) / 2; + return availablePerColumn.clamp( + _timePickerInputColumnMinWidth, + _timePickerInputColumnMaxWidth, ); } - String _timezoneCode(BuildContext context) { - final locale = Localizations.localeOf(context).toLanguageTag(); - try { - return DateFormat('z', locale).format(DateTime.now()).trim(); - } on Exception { - return DateTime.now().timeZoneName; + Future _openTimezoneDialog() async { + final selected = await showBusyMaxTimeZoneSelectionDialog( + context, + selectedTimeZone: _selectedTimeZone, + ); + if (!mounted || selected == null || selected == _selectedTimeZone) { + return; } + setState(() => _selectedTimeZone = selected); + widget.onTimeZoneChanged?.call(selected); } String _timezoneDisplayLabel(BuildContext context) { - final timezone = localIanaTimeZone(); - final code = _timezoneCode(context); - if (code.isEmpty) { - return timezone; - } - return '$timezone ($code)'; + return BusyMaxTimeZoneCatalog.location(_selectedTimeZone).displayLabel; } Widget _timeInputSection({ required BuildContext context, + required double buttonWidth, required TextEditingController controller, required String label, required VoidCallback onIncrement, required VoidCallback onDecrement, }) { - final buttonStyle = ButtonStyle( - minimumSize: const WidgetStatePropertyAll( - Size.square(_timeInputButtonSize), - ), - visualDensity: VisualDensity.compact, - padding: const WidgetStatePropertyAll(EdgeInsets.zero), - side: const WidgetStatePropertyAll(BorderSide.none), - backgroundColor: busyMaxHeaderButtonBackground(context), - overlayColor: const WidgetStatePropertyAll(Colors.transparent), - foregroundColor: WidgetStatePropertyAll( - Theme.of(context).colorScheme.onSurface, - ), - shape: WidgetStatePropertyAll( - const RoundedRectangleBorder(borderRadius: BorderRadius.zero), - ), - ); final surfaceColors = BusyMaxSurfaceColors.of(context); - final borderColor = Theme.of(context).colorScheme.outlineVariant; - final textTheme = Theme.of(context).textTheme; + final controlFill = Color.alphaBlend( + surfaceColors.control, + surfaceColors.popover, + ); + final borderColor = surfaceColors.border; + final inputTextStyle = Theme.of( + context, + ).textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.normal, height: 1); return FocusTraversalOrder( order: const NumericFocusOrder(0), child: Container( decoration: BoxDecoration( - color: surfaceColors.control, + color: controlFill, borderRadius: BorderRadius.circular(BusyMaxRadius.sm), border: Border.all(color: borderColor), ), child: Column( mainAxisSize: MainAxisSize.min, children: [ - IconButton( + BusyMaxHeaderIconButton( onPressed: onIncrement, icon: const Icon(Icons.add), tooltip: label, - style: buttonStyle.copyWith( - shape: WidgetStatePropertyAll( - const RoundedRectangleBorder( - borderRadius: BorderRadius.vertical( - top: Radius.circular(BusyMaxRadius.sm), - bottom: Radius.zero, - ), - ), + iconSize: BusyMaxSizes.popoverActionIcon, + fixedSize: Size(buttonWidth, _timePickerInputControlSize), + foregroundColor: Theme.of(context).colorScheme.onSurface, + backgroundColor: busyMaxSubtleButtonBackground(context), + overlayColor: const WidgetStatePropertyAll(Colors.transparent), + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical( + top: Radius.circular(BusyMaxRadius.sm), ), ), ), - Divider(height: 0, thickness: 1, color: borderColor), + Divider(height: 1, thickness: 1, color: borderColor), SizedBox( - height: _timeInputButtonSize, + height: _timePickerInputControlSize, child: TextFormField( controller: controller, textAlign: TextAlign.center, textAlignVertical: TextAlignVertical.center, keyboardType: TextInputType.number, + expands: true, + minLines: null, + maxLines: null, maxLength: 2, - style: textTheme.bodyLarge, + style: inputTextStyle, decoration: busyMaxGroupedTextFieldDecoration( context, @@ -1285,7 +1251,7 @@ class _DesktopTimeValueDialogState extends State<_DesktopTimeValueDialog> { ).copyWith( isDense: true, filled: true, - fillColor: surfaceColors.control, + fillColor: controlFill, border: InputBorder.none, enabledBorder: InputBorder.none, focusedBorder: InputBorder.none, @@ -1304,19 +1270,19 @@ class _DesktopTimeValueDialogState extends State<_DesktopTimeValueDialog> { onFieldSubmitted: (_) => _handleTimeInputChanged(), ), ), - Divider(height: 0, thickness: 1, color: borderColor), - IconButton( + Divider(height: 1, thickness: 1, color: borderColor), + BusyMaxHeaderIconButton( onPressed: onDecrement, icon: const Icon(Icons.remove), tooltip: label, - style: buttonStyle.copyWith( - shape: WidgetStatePropertyAll( - const RoundedRectangleBorder( - borderRadius: BorderRadius.vertical( - top: Radius.zero, - bottom: Radius.circular(BusyMaxRadius.sm), - ), - ), + iconSize: BusyMaxSizes.popoverActionIcon, + fixedSize: Size(buttonWidth, _timePickerInputControlSize), + foregroundColor: Theme.of(context).colorScheme.onSurface, + backgroundColor: busyMaxSubtleButtonBackground(context), + overlayColor: const WidgetStatePropertyAll(Colors.transparent), + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical( + bottom: Radius.circular(BusyMaxRadius.sm), ), ), ), diff --git a/lib/src/features/tasks/presentation/task_details_editor.dart b/lib/src/features/tasks/presentation/task_details_editor.dart index b9ddd34..b10654b 100644 --- a/lib/src/features/tasks/presentation/task_details_editor.dart +++ b/lib/src/features/tasks/presentation/task_details_editor.dart @@ -258,6 +258,10 @@ class _TaskDetailsEditorState extends State { onChanged: (value) => _updateDraft( draft.copyWith(microsoftDueTime: value), ), + timeZone: draft.microsoftDueTimeZone, + onTimeZoneChanged: (value) => _updateDraft( + draft.copyWith(microsoftDueTimeZone: value), + ), onValidityChanged: (valid) => _setTimeFieldValidity( _TaskTimeField.due, valid, @@ -466,6 +470,9 @@ class _TaskDetailsEditorState extends State { time: draft.microsoftStartTime, onChanged: (value) => _updateDraft(draft.copyWith(microsoftStartTime: value)), + timeZone: draft.microsoftStartTimeZone, + onTimeZoneChanged: (value) => + _updateDraft(draft.copyWith(microsoftStartTimeZone: value)), onValidityChanged: (valid) => _setTimeFieldValidity(_TaskTimeField.start, valid), useNativePicker: widget.useNativeDatePicker, @@ -861,6 +868,9 @@ class _TaskDetailsEditorState extends State { time: draft.microsoftReminderTime, onChanged: (value) => _updateDraft(draft.copyWith(microsoftReminderTime: value)), + timeZone: draft.microsoftReminderTimeZone, + onTimeZoneChanged: (value) => + _updateDraft(draft.copyWith(microsoftReminderTimeZone: value)), onValidityChanged: (valid) => _setTimeFieldValidity(_TaskTimeField.reminder, valid), useNativePicker: widget.useNativeDatePicker, diff --git a/test/app/busymax_dialogs_test.dart b/test/app/busymax_dialogs_test.dart index 8f4569c..d4803ea 100644 --- a/test/app/busymax_dialogs_test.dart +++ b/test/app/busymax_dialogs_test.dart @@ -72,9 +72,38 @@ void main() { find.byType(YaruDialogTitleBar), ); final confirmation = tester.widget(find.byType(AlertDialog)); + final titleBarTheme = Theme.of( + tester.element(find.byType(YaruDialogTitleBar)), + ).appBarTheme; + final cancelButton = tester.widget( + find.widgetWithText(FilledButton, 'Cancel'), + ); + final discardButton = tester.widget( + find.widgetWithText(ElevatedButton, 'Discard'), + ); + final standardShape = + theme.filledButtonTheme.style!.shape!.resolve({})! + as RoundedRectangleBorder; + final destructiveShape = + theme.elevatedButtonTheme.style!.shape!.resolve({})! + as RoundedRectangleBorder; expect(titleBar.backgroundColor, colors.dialog); + expect(titleBar.border, BorderSide.none); + expect(titleBarTheme.backgroundColor, colors.dialog); + expect(titleBarTheme.surfaceTintColor, colors.dialog); + expect(titleBarTheme.shadowColor, Colors.transparent); expect(confirmation.backgroundColor, colors.dialog); expect(confirmation.surfaceTintColor, colors.dialog); + expect(cancelButton.style, isNull); + expect(discardButton.style?.shape?.resolve({}), isNull); + expect( + standardShape.borderRadius, + BorderRadius.circular(kYaruButtonRadius), + ); + expect( + destructiveShape.borderRadius, + BorderRadius.circular(kYaruButtonRadius), + ); }, ); diff --git a/test/app/busymax_grouped_surface_test.dart b/test/app/busymax_grouped_surface_test.dart index c5a278e..fb2ce1b 100644 --- a/test/app/busymax_grouped_surface_test.dart +++ b/test/app/busymax_grouped_surface_test.dart @@ -174,24 +174,22 @@ void main() { }, ); - test( - 'Settings and Year view delegate card shadows to the shared surface', - () { - final settings = File( - 'lib/src/features/settings/presentation/settings_screen.dart', - ).readAsStringSync(); - final yearView = File( - 'lib/src/features/schedule/presentation/schedule_year_view.dart', - ).readAsStringSync(); - - expect(settings, contains('BusyMaxGroupedList(')); - expect(yearView, contains('BusyMaxGroupedSurface(')); - for (final source in [settings, yearView]) { - expect(source, isNot(contains('BoxShadow('))); - expect(source, isNot(contains('elevation:'))); - } - }, - ); + test('Settings owns grouped cards while Year reuses MiniCalendar', () { + final settings = File( + 'lib/src/features/settings/presentation/settings_screen.dart', + ).readAsStringSync(); + final yearView = File( + 'lib/src/features/schedule/presentation/schedule_year_view.dart', + ).readAsStringSync(); + + expect(settings, contains('BusyMaxGroupedList(')); + expect(yearView, contains('MiniCalendar(')); + expect(yearView, isNot(contains('BusyMaxGroupedSurface('))); + for (final source in [settings, yearView]) { + expect(source, isNot(contains('BoxShadow('))); + expect(source, isNot(contains('elevation:'))); + } + }); for (final brightness in Brightness.values) { testWidgets( diff --git a/test/app/native_ui_audit_test.dart b/test/app/native_ui_audit_test.dart index a93618d..0877f6a 100644 --- a/test/app/native_ui_audit_test.dart +++ b/test/app/native_ui_audit_test.dart @@ -1710,12 +1710,14 @@ void main() { ); expect( 'dialog_background_color'.allMatches(nativeDialogCss).length, - 3, + 4, reason: - 'dialog and titlebar share body surface styling while dialog content is ' - 'co-styled', + 'dialog, titlebar, content, and actions share one 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, @@ -1746,7 +1748,13 @@ void main() { ), ); expect(nativeDialogCss, isNot(contains('"border:'))); - expect(nativeDialogCss, contains('border-radius: 9px;')); + expect('border-radius: %dpx;'.allMatches(nativeDialogCss).length, 1); + expect( + source, + contains('constexpr gint kNativeDialogCornerRadius = 14;'), + ); + expect(source, isNot(contains('"busymax-native-dialog-action"'))); + expect(source, contains('"busymax-native-dialog-actions"')); expect(source, contains('style_native_popover(session->popover)')); expect(source, isNot(contains('activate_native_menu_host('))); expect( diff --git a/test/features/schedule/presentation/schedule_views_test.dart b/test/features/schedule/presentation/schedule_views_test.dart index 5e9c86b..2d7bff9 100644 --- a/test/features/schedule/presentation/schedule_views_test.dart +++ b/test/features/schedule/presentation/schedule_views_test.dart @@ -28,6 +28,15 @@ import 'package:yaru/yaru.dart'; import '../../../test_localized_app.dart'; void main() { + test('day and week modes use separate planner state identities', () { + final workspace = File( + 'lib/src/features/schedule/presentation/schedule_workspace.dart', + ).readAsStringSync(); + + expect(workspace, contains("ValueKey('schedule-day-planner')")); + expect(workspace, contains("ValueKey('schedule-week-planner')")); + }); + testWidgets('day view uses package planner with custom BusyMax items', ( tester, ) async { @@ -252,6 +261,13 @@ void main() { ); await tester.pump(); + expect( + find.descendant( + of: find.byType(ScheduleYearView), + matching: find.byType(MiniCalendar), + ), + findsNWidgets(DateTime.monthsPerYear), + ); final yearCanvas = tester.widget( find .descendant( @@ -260,7 +276,7 @@ void main() { (widget) => widget is ColoredBox && widget.color == workspaceColor && - widget.child is Padding, + widget.child is SingleChildScrollView, ), ) .first, @@ -734,11 +750,21 @@ void main() { selectedNode = tester.getSemantics(selectedDay); expect(selectedNode.flagsCollection.isSelected, ui.Tristate.isTrue); expect(selectedNode.label, contains('January 15, 2026')); - selectedMarker = tester.widget( - find.byKey(ValueKey('year-day-marker-${selectedDate.toIso8601String()}')), + final yearMarker = tester.widget( + find + .ancestor( + of: selectedDay, + matching: find.byWidgetPredicate( + (widget) => + widget is DecoratedBox && + widget.decoration is BoxDecoration && + (widget.decoration as BoxDecoration).shape == BoxShape.circle, + ), + ) + .first, ); expect( - (selectedMarker.decoration! as BoxDecoration).color, + (yearMarker.decoration as BoxDecoration).color, Theme.of(tester.element(selectedDay)).colorScheme.primary, ); tester.semantics.tap( @@ -2386,9 +2412,10 @@ void main() { ).readAsStringSync(); expect(source, contains('class _MiniCalendarStepper')); - expect(source, contains('required this.onMonthSelected')); - expect(source, contains('required this.onYearSelected')); - expect(source, contains('required this.onWeekSelected')); + expect(source, contains('this.onMonthSelected')); + expect(source, contains('this.onYearSelected')); + expect(source, contains('this.onWeekSelected')); + expect(source, contains('this.weekNumbersInteractive = true')); expect(source, contains('required this.firstWeekday')); expect(source, contains('_calendarStartForMonth(first, firstWeekday)')); expect(source, contains('monthWeekdayFromMonday')); @@ -2423,7 +2450,7 @@ void main() { expect(source, contains('const SizedBox(width: BusyMaxSpacing.xs)')); expect( source, - contains('label: DateFormat.MMMM(locale).format(selectedDate)'), + contains('label: DateFormat.MMMM(locale).format(visibleMonth)'), ); expect(source, contains('BusyMaxSpacing.headerInset')); expect( @@ -2431,11 +2458,11 @@ void main() { isNot(contains('padding: const EdgeInsets.all(BusyMaxSpacing.md)')), ); expect(source, contains('labelTooltip: l10n.openMonthView')); - expect(source, contains('onMonthSelected(first)')); + expect(source, contains('onMonthSelected!(first)')); expect(source, contains('busyMaxHeaderTextButtonStyle')); - expect(source, contains("label: '\${selectedDate.year}'")); + expect(source, contains("label: '\${visibleMonth.year}'")); expect(source, contains('labelTooltip: l10n.openYearView')); - expect(source, contains('onYearSelected(')); + expect(source, contains('onYearSelected!(')); expect(source, isNot(contains('String _monthName(DateTime date)'))); expect( source, @@ -2445,17 +2472,20 @@ void main() { expect(source, contains('nextTooltip: l10n.nextMonth')); expect(source, contains('previousTooltip: l10n.previousYear')); expect(source, contains('nextTooltip: l10n.nextYear')); - expect(source, contains('selectedDate.year - 1')); - expect(source, contains('selectedDate.year + 1')); + expect(source, contains('visibleMonth.year - 1')); + expect(source, contains('visibleMonth.year + 1')); expect(source, contains('busyMaxHeaderIconButtonStyle')); expect(source, contains('miniCalendarWeekButton')); expect(source, contains('busyMaxHeaderButtonBackground(context)')); - expect(source, isNot(contains('busyMaxSubtleButtonBackground(context)'))); + expect(source, contains('busyMaxSubtleButtonBackground(context)')); + expect(source, contains('fixedSize: const Size.square(')); + expect(source, contains('shape: const CircleBorder()')); + expect(source, contains('fontWeight: FontWeight.w600')); expect(source, contains('_isoWeekNumber')); expect(source, contains('DateTime.daysPerWeek')); expect(source, contains('TextButton(')); expect(source, contains('context.l10n.weekNumberTooltip(weekNumber)')); - expect(source, contains('onSelected(weekStart)')); + expect(source, contains('onSelected!(weekStart)')); expect(source, contains('BoxShape.circle')); expect(source, contains('customBorder: const CircleBorder()')); expect(source, contains('final markerSize = math.min')); @@ -2464,8 +2494,14 @@ void main() { contains('final highlightToday = today && displayingCurrentMonth'), ); expect(source, contains('color: selected')); - expect(source, contains('selectedDate.year == DateTime.now().year')); - expect(source, contains('selectedDate.month == DateTime.now().month')); + expect( + source, + contains('widget.displayedMonth.year == DateTime.now().year'), + ); + expect( + source, + contains('widget.displayedMonth.month == DateTime.now().month'), + ); expect(source, contains('final selected = _sameDay(day, selectedDate)')); expect(source, isNot(contains('YaruIcons.arrow_left'))); expect(source, isNot(contains('YaruIcons.arrow_right'))); @@ -2507,6 +2543,103 @@ void main() { semantics.dispose(); }); + testWidgets('mini calendar day hover is larger and lighter', (tester) async { + final theme = BusyMaxYaruTheme.build( + brightness: Brightness.light, + accentColor: const Color(0xFF3584E4), + ); + final colors = theme.extension()!; + + await tester.pumpWidget( + localizedTestApp( + theme: theme, + child: Scaffold( + body: SizedBox( + width: 300, + child: MiniCalendar( + selectedDate: DateTime(2026, 1, 15), + firstWeekday: DateTime.monday, + showDayHover: true, + onSelected: (_) {}, + onMonthSelected: null, + onYearSelected: null, + onWeekSelected: (_) {}, + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + final day = find.text('14'); + final marker = find.ancestor( + of: day, + matching: find.byWidgetPredicate( + (widget) => + widget is DecoratedBox && + widget.decoration is BoxDecoration && + (widget.decoration as BoxDecoration).shape == BoxShape.circle, + ), + ); + expect(marker, findsOneWidget); + final restingSize = tester.getSize(marker); + + final mouse = await tester.createGesture(kind: PointerDeviceKind.mouse); + addTearDown(mouse.removePointer); + await mouse.addPointer(location: Offset.zero); + await mouse.moveTo(tester.getCenter(day)); + await tester.pumpAndSettle(); + + final hoveredSize = tester.getSize(marker); + final hoveredMarker = tester.widget(marker); + final decoration = hoveredMarker.decoration as BoxDecoration; + expect(hoveredSize.width, greaterThan(restingSize.width)); + expect( + decoration.color!.computeLuminance(), + greaterThan(colors.popover.computeLuminance()), + ); + }); + + testWidgets('mini calendar header controls are compact hover-only circles', ( + tester, + ) async { + await tester.pumpWidget( + localizedTestApp( + child: Scaffold( + body: SizedBox( + width: 300, + child: MiniCalendar( + selectedDate: DateTime(2026, 1, 15), + firstWeekday: DateTime.monday, + onSelected: (_) {}, + onMonthSelected: (_) {}, + onYearSelected: (_) {}, + onWeekSelected: (_) {}, + ), + ), + ), + ), + ); + + final headerButtons = tester.widgetList( + find.byType(BusyMaxHeaderIconButton), + ); + expect(headerButtons, hasLength(4)); + for (final button in headerButtons) { + expect(button.fixedSize, const Size.square(28)); + expect(button.shape, const CircleBorder()); + expect(button.backgroundColor!.resolve({}), isNull); + expect(button.backgroundColor!.resolve({WidgetState.hovered}), isNotNull); + } + + final monthLabel = tester.widget(find.text('January')); + final yearLabel = tester.widget(find.text('2026')); + expect(monthLabel.style?.fontSize, lessThan(14)); + expect(monthLabel.style?.fontWeight, FontWeight.w600); + expect(yearLabel.style?.fontSize, lessThan(14)); + expect(yearLabel.style?.fontWeight, FontWeight.w600); + }); + testWidgets('mini calendar week number selects that week', (tester) async { DateTime? selectedWeek; @@ -2533,6 +2666,42 @@ void main() { expect(selectedWeek, DateTime(2026, 1, 12)); }); + testWidgets('mini calendar can render week numbers as labels', ( + tester, + ) async { + DateTime? selectedWeek; + + await tester.pumpWidget( + localizedTestApp( + child: Scaffold( + body: SizedBox( + width: 300, + child: MiniCalendar( + selectedDate: DateTime(2026, 1, 15), + firstWeekday: DateTime.monday, + weekNumbersInteractive: false, + onSelected: (_) {}, + onMonthSelected: null, + onYearSelected: null, + onWeekSelected: (weekStart) => selectedWeek = weekStart, + ), + ), + ), + ), + ); + + expect(find.byTooltip('Week 3'), findsOneWidget); + expect( + find.descendant( + of: find.byTooltip('Week 3'), + matching: find.byType(TextButton), + ), + findsNothing, + ); + await tester.tap(find.byTooltip('Week 3')); + expect(selectedWeek, isNull); + }); + testWidgets('mini calendar week number honors first weekday', (tester) async { DateTime? selectedWeek; @@ -3196,10 +3365,7 @@ void main() { expect(workspace, contains('ScheduleRange.year(_selectedDate)')); expect(workspace, contains('DateFormat.y(locale).format(selectedDate)')); expect(workspace, contains('ScheduleYearView(')); - expect(yearView, contains('ScheduleProjection.groupByDay(items)')); - expect(yearView, contains('ScheduleProjection.colorForItem')); expect(yearView, contains('final monthWidth =')); - expect(yearView, contains('_compactMonthPanelHeight(')); expect(yearView, contains('Wrap(')); expect(yearView, contains('children: [')); expect(yearView, contains('spacing: BusyMaxSpacing.md')); @@ -3207,19 +3373,23 @@ void main() { expect(yearView, contains('ColoredBox(')); expect( yearView, - contains('color: BusyMaxSurfaceColors.of(context).window'), + contains('backgroundColor ?? BusyMaxSurfaceColors.of(context).window'), ); - expect(yearView, contains('double _monthPanelHeight(double width)')); - expect(yearView, contains('BusyMaxGroupedSurface(')); expect(yearView, isNot(contains('BusyMaxSurfaceColors.of(context).card'))); - expect(yearView, contains('BusyMaxActionRow(')); - expect(yearView, contains('class _YearMonthGrid')); - expect(yearView, contains('mainAxisExtent: rowHeight')); - expect(yearView, contains('final markerSize = math.min')); + expect(yearView, contains('MiniCalendar(')); + expect( + yearView, + contains('headerStyle: MiniCalendarHeaderStyle.monthLabel'), + ); + expect(yearView, contains('displayedMonth: DateTime(')); + expect(yearView, contains('onDayDoubleTap: onCreateAtDay')); expect(yearView, contains('firstWeekday')); - expect(yearView, contains('onMonthSelected(month)')); + expect(yearView, contains('onMonthSelected: onMonthSelected')); expect(yearView, isNot(contains('height: 142'))); - expect(yearView, contains('availableHeight')); + expect(yearView, contains('SingleChildScrollView(')); + expect(yearView, isNot(contains('class _YearMonthGrid'))); + expect(yearView, isNot(contains('class _YearDayCell'))); + expect(yearView, isNot(contains('ScheduleProjection.'))); expect(yearView, isNot(contains('borderColor'))); expect(yearView, isNot(contains('RoundedRectangleBorder('))); expect(yearView, isNot(contains('TextButton('))); diff --git a/test/features/tasks/presentation/desktop_date_time_fields_test.dart b/test/features/tasks/presentation/desktop_date_time_fields_test.dart index edf2acd..6888639 100644 --- a/test/features/tasks/presentation/desktop_date_time_fields_test.dart +++ b/test/features/tasks/presentation/desktop_date_time_fields_test.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'package:busymax/src/app/busymax_design.dart'; +import 'package:busymax/src/core/time/time_zone_catalog.dart'; import 'package:busymax/src/features/tasks/presentation/desktop_date_time_fields.dart'; import 'package:busymax/src/features/schedule/presentation/mini_calendar.dart'; import 'package:flutter/material.dart'; @@ -50,74 +51,75 @@ void main() { ); }); - testWidgets( - 'calendar values render populated contextual fields before focus', - (tester) async { - await tester.pumpWidget( - localizedTestApp( - child: Scaffold( - body: Column( - children: [ - DesktopDateValueRow( - label: 'Start date', - date: '2026-07-22', - onChanged: _ignoreString, - ), - DesktopTimeValueRow( - label: 'Start time', - time: '09:30', - onChanged: _ignoreNullableString, - ), - ], - ), + testWidgets('calendar values render populated contextual fields before focus', ( + tester, + ) async { + await tester.pumpWidget( + localizedTestApp( + child: Scaffold( + body: Column( + children: [ + DesktopDateValueRow( + label: 'Start date', + date: '2026-07-22', + onChanged: _ignoreString, + ), + DesktopTimeValueRow( + label: 'Start time', + time: '09:30', + timeZone: 'America/Vancouver', + onChanged: _ignoreNullableString, + ), + ], ), ), - ); + ), + ); - final dateTextField = tester.widget( - find - .descendant( - of: find.byType(DesktopDateValueRow), - matching: find.byType(TextField), - ) - .first, - ); - final timeTextField = tester.widget( - find - .descendant( - of: find.byType(DesktopTimeValueRow), - matching: find.byType(TextField), - ) - .first, - ); - final dateContext = tester.element(find.byType(DesktopDateValueRow)); - final timeContext = tester.element(find.byType(DesktopTimeValueRow)); + final dateTextField = tester.widget( + find + .descendant( + of: find.byType(DesktopDateValueRow), + matching: find.byType(TextField), + ) + .first, + ); + final timeTextField = tester.widget( + find + .descendant( + of: find.byType(DesktopTimeValueRow), + matching: find.byType(TextField), + ) + .first, + ); + final dateContext = tester.element(find.byType(DesktopDateValueRow)); + final timeContext = tester.element(find.byType(DesktopTimeValueRow)); - expect( - dateTextField.controller?.text, - formatDesktopDate(dateContext, '2026-07-22'), - ); - expect( - timeTextField.controller?.text, - formatMaterialTime(timeContext, const TimeOfDay(hour: 9, minute: 30)), - ); - expect(dateTextField.decoration?.labelText, 'Start date'); - expect(timeTextField.decoration?.labelText, 'Start time'); - expect( - dateTextField.decoration?.floatingLabelBehavior, - FloatingLabelBehavior.auto, - ); - expect( - timeTextField.decoration?.floatingLabelBehavior, - FloatingLabelBehavior.auto, - ); - expect(find.text('Enter date'), findsNothing); - expect(find.text('Enter time'), findsNothing); - expect(find.byIcon(YaruIcons.calendar), findsOneWidget); - expect(find.byIcon(YaruIcons.clock), findsOneWidget); - expect(find.byIcon(Icons.edit_outlined), findsNothing); - }, - ); + expect( + dateTextField.controller?.text, + formatDesktopDate(dateContext, '2026-07-22'), + ); + expect( + timeTextField.controller?.text, + '${formatMaterialTime(timeContext, const TimeOfDay(hour: 9, minute: 30))} ' + '(${BusyMaxTimeZoneCatalog.location('America/Vancouver').code})', + ); + expect(dateTextField.decoration?.labelText, 'Start date'); + expect(timeTextField.decoration?.labelText, 'Start time'); + expect( + dateTextField.decoration?.floatingLabelBehavior, + FloatingLabelBehavior.auto, + ); + expect( + timeTextField.decoration?.floatingLabelBehavior, + FloatingLabelBehavior.auto, + ); + expect(find.text('Enter date'), findsNothing); + expect(find.text('Enter time'), findsNothing); + expect(find.byIcon(YaruIcons.calendar), findsOneWidget); + expect(find.byIcon(YaruIcons.clock), findsOneWidget); + expect(find.byIcon(Icons.edit_outlined), findsNothing); + }); testWidgets('disabled date and time entries cannot receive focus', ( tester, @@ -281,13 +283,103 @@ void main() { expect(find.text(':'), findsOneWidget); expect(find.byIcon(Icons.public), findsOneWidget); - expect(find.byType(FilledButton), findsAny); - expect(find.byIcon(Icons.public), findsOneWidget); + final picker = find.byType(BusyMaxContentPopoverSurface); + final componentFields = find.descendant( + of: picker, + matching: find.byType(TextFormField), + ); + final editableFields = find.descendant( + of: picker, + matching: find.byType(EditableText), + ); + expect(componentFields, findsNWidgets(2)); + expect(editableFields, findsNWidgets(2)); + expect( + tester + .widgetList(editableFields) + .map((field) => field.controller.text), + ['09', '30'], + ); + for (final field in tester.widgetList(editableFields)) { + expect(field.textAlign, TextAlign.center); + expect(field.style.fontWeight, FontWeight.normal); + } + for (final element in componentFields.evaluate()) { + final size = tester.getSize( + find.byElementPredicate((candidate) { + return identical(candidate, element); + }), + ); + expect(size.width, inInclusiveRange(36, 38)); + expect(size.height, BusyMaxSizes.popoverActionButton); + } + + final timezoneButton = find.ancestor( + of: find.byIcon(Icons.public), + matching: find.byType(FilledButton), + ); + expect(timezoneButton, findsOneWidget); + expect(tester.widget(timezoneButton).style, isNull); + expect(tester.getSize(timezoneButton).width, greaterThan(100)); + expect(tester.takeException(), isNull); await tester.sendKeyEvent(LogicalKeyboardKey.escape); await tester.pumpAndSettle(); }); + testWidgets('time picker searches and selects real timezone locations', ( + tester, + ) async { + String? selectedTimeZone; + await tester.pumpWidget( + localizedTestApp( + child: Scaffold( + body: DesktopTimeValueRow( + label: 'Due time', + time: '09:30', + timeZone: 'Etc/UTC', + onChanged: (_) {}, + onTimeZoneChanged: (value) => selectedTimeZone = value, + ), + ), + ), + ); + + await tester.tap(find.byIcon(YaruIcons.clock)); + await tester.pumpAndSettle(); + await tester.tap(find.byIcon(Icons.public)); + await tester.pumpAndSettle(); + + expect(find.text('Select Timezone'), findsOneWidget); + final title = tester.widget(find.text('Select Timezone')); + expect(title.textAlign, isNull); + expect(title.style?.fontWeight, FontWeight.w600); + expect(find.byType(BusyMaxDialogTitleBar), findsOneWidget); + expect( + tester + .widget(find.byType(BusyMaxDialogTitleBar)) + .centerTitle, + isTrue, + ); + + final searchField = find.widgetWithText(TextField, 'Search locations'); + expect(searchField, findsOneWidget); + await tester.enterText(searchField, 'Vancouver'); + await tester.pumpAndSettle(); + + expect(find.text('America'), findsOneWidget); + expect(find.textContaining('Vancouver ('), findsOneWidget); + expect(find.text('America/Vancouver'), findsOneWidget); + + await tester.tap(find.textContaining('Vancouver (')); + await tester.pumpAndSettle(); + + expect(selectedTimeZone, 'America/Vancouver'); + expect(find.text('Select Timezone'), findsNothing); + expect(find.textContaining('America/Vancouver ('), findsOneWidget); + expect(tester.takeException(), isNull); + }); + testWidgets('fallback time picker closes when clicking outside', ( tester, ) async { @@ -448,7 +540,7 @@ void main() { expect(await result, isNull); }); - testWidgets('fallback date picker year mode shows month and year headers', ( + testWidgets('fallback date picker month and year headers are display-only', ( tester, ) async { await tester.pumpWidget( @@ -468,15 +560,16 @@ void main() { await tester.tap(find.byIcon(YaruIcons.calendar)); await tester.pumpAndSettle(); expect(find.byType(MiniCalendar), findsOneWidget); + expect(find.text('July'), findsOneWidget); expect(find.text('2026'), findsOneWidget); - expect(find.byType(TextButton), findsWidgets); - - await tester.tap(find.widgetWithText(TextButton, '2026')); - await tester.pumpAndSettle(); - - expect(find.byType(MiniCalendar), findsOneWidget); - expect(find.text('2026'), findsOneWidget); - expect(find.byType(TextButton), findsWidgets); + expect( + find.descendant( + of: find.byType(MiniCalendar), + matching: find.byType(TextButton), + ), + findsNothing, + ); + expect(tester.takeException(), isNull); }); testWidgets('fallback date picker stays open while paging months', ( diff --git a/test/features/tasks/presentation/task_details_pane_test.dart b/test/features/tasks/presentation/task_details_pane_test.dart index ce28122..fcff72a 100644 --- a/test/features/tasks/presentation/task_details_pane_test.dart +++ b/test/features/tasks/presentation/task_details_pane_test.dart @@ -10,6 +10,7 @@ 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/time/time_zone_catalog.dart'; import 'package:busymax/src/features/accounts/data/accounts_repository.dart'; import 'package:busymax/src/features/task_lists/data/task_lists_repository.dart'; import 'package:busymax/src/features/tasks/data/tasks_repository.dart'; @@ -26,6 +27,13 @@ import '../../../test_localized_app.dart'; const _nativePickerChannel = MethodChannel(nativeDateTimePickerChannelName); const _nativeDialogChannel = MethodChannel(nativeDialogChannelName); const _nativeMenuChannel = MethodChannel(nativeMenuChannelName); +final _vancouverTimeZoneCode = BusyMaxTimeZoneCatalog.location( + 'America/Vancouver', +).code; + +String _withVancouverTimeZone(String time) { + return '$time ($_vancouverTimeZoneCode)'; +} void main() { setUp(() { @@ -655,7 +663,10 @@ void main() { expect(_labeledFieldText(tester, 'Due date'), 'Jun 6, 2026'); expect(_labeledFieldText(tester, 'Start date'), 'Jun 4, 2026'); - expect(_labeledFieldText(tester, 'Due time'), '14:30'); + expect( + _labeledFieldText(tester, 'Due time'), + _withVancouverTimeZone('14:30'), + ); expect(_labeledTextFormFieldFinder('Due date'), findsOneWidget); expect(_labeledTextFormFieldFinder('Due time'), findsOneWidget); expect(find.text('14:30:00'), findsNothing); @@ -671,7 +682,10 @@ void main() { ); expect(_labeledFieldText(tester, 'Start date'), 'Jun 4, 2026'); - expect(_labeledFieldText(tester, 'Due time'), '2:30 PM'); + expect( + _labeledFieldText(tester, 'Due time'), + _withVancouverTimeZone('2:30 PM'), + ); expect(_labeledTextFormFieldFinder('Start date'), findsOneWidget); expect(_labeledTextFormFieldFinder('Due time'), findsOneWidget); expect(find.text('Jun 4, 2026 · 7:00 AM'), findsNothing); @@ -1003,7 +1017,10 @@ void main() { expect(_dateRowFinder('Reminder date'), findsOneWidget); expect(_timeRowFinder('Reminder time'), findsOneWidget); expect(_labeledFieldText(tester, 'Reminder date'), 'Jun 5, 2026'); - expect(_labeledFieldText(tester, 'Reminder time'), '09:15'); + expect( + _labeledFieldText(tester, 'Reminder time'), + _withVancouverTimeZone('09:15'), + ); expect(_labeledTextFormFieldFinder('Reminder date'), findsOneWidget); expect(_labeledTextFormFieldFinder('Reminder time'), findsOneWidget); expect(find.textContaining('Time zone:'), findsNothing); @@ -1213,7 +1230,10 @@ void main() { ); expect(_labeledTextFormFieldFinder('Due time'), findsOneWidget); - expect(_labeledFieldText(tester, 'Due time'), '2:30 PM'); + expect( + _labeledFieldText(tester, 'Due time'), + _withVancouverTimeZone('2:30 PM'), + ); expect(find.byType(BusyMaxContentPopoverSurface), findsNothing); expect(tester.takeException(), isNull); @@ -1361,7 +1381,10 @@ void main() { expect(field, findsOneWidget); expect(label, findsOneWidget); - expect(_labeledFieldText(tester, 'Due time'), '2:30 PM'); + expect( + _labeledFieldText(tester, 'Due time'), + _withVancouverTimeZone('2:30 PM'), + ); expect(tester.getCenter(label).dy, lessThan(tester.getCenter(field).dy)); }); From 22f7cad3f1e0c23c0df76323ad772e3b8a9ddfe5 Mon Sep 17 00:00:00 2001 From: albert Date: Tue, 28 Jul 2026 16:10:57 -0700 Subject: [PATCH 22/73] Add timezone package dependency. Update pubspec.yaml to include timezone version 0.11.1 and update pubspec.lock accordingly. --- pubspec.lock | 8 ++++++++ pubspec.yaml | 1 + 2 files changed, 9 insertions(+) diff --git a/pubspec.lock b/pubspec.lock index ec035c7..ce90cbf 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1089,6 +1089,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.7.11" + timezone: + dependency: "direct main" + description: + name: timezone + sha256: "981d1020d6ef8fe1e7b3de5054e5b25579ae7c403d7734adc508ffc47668e9cb" + url: "https://pub.dev" + source: hosted + version: "0.11.1" typed_data: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 62287a8..c1fce85 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -34,6 +34,7 @@ dependencies: sqlite3: ^3.3.0 sqlite3_flutter_libs: ^0.6.0 system_theme: ^3.3.0 + timezone: ^0.11.1 ubuntu_localizations: ^0.5.2+3 url_launcher: ^6.3.0 uuid: ^4.5.0 From 942bb22e1dddfc079731aca96b7310ecd44e5bc0 Mon Sep 17 00:00:00 2001 From: albert Date: Tue, 28 Jul 2026 16:27:44 -0700 Subject: [PATCH 23/73] Add time zone selection dialog and catalog. Implement time zone location management with search functionality and enhance dialog styling for improved user experience. Enhance native dialog styling with cancel and destructive button support. Introduce new CSS classes for button colors and update dialog handling for improved visual consistency. --- lib/src/app/busymax_app.dart | 16 ++ lib/src/app/busymax_design.dart | 1 + lib/src/core/time/time_zone_catalog.dart | 122 +++++++++++++++ .../time_zone_selection_dialog.dart | 145 ++++++++++++++++++ .../platform/linux_header_bar_service.dart | 52 ++++++- linux/runner/my_application.cc | 126 ++++++++++++++- test/app/busymax_dialogs_test.dart | 9 ++ test/app/native_ui_audit_test.dart | 9 ++ test/core/time/time_zone_catalog_test.dart | 25 +++ .../desktop_date_time_fields_test.dart | 21 +++ ...r_bar_configuration_synchronizer_test.dart | 7 + .../linux_header_bar_service_test.dart | 14 ++ 12 files changed, 540 insertions(+), 7 deletions(-) create mode 100644 lib/src/core/time/time_zone_catalog.dart create mode 100644 lib/src/features/tasks/presentation/time_zone_selection_dialog.dart create mode 100644 test/core/time/time_zone_catalog_test.dart diff --git a/lib/src/app/busymax_app.dart b/lib/src/app/busymax_app.dart index 069721d..d4e7f41 100644 --- a/lib/src/app/busymax_app.dart +++ b/lib/src/app/busymax_app.dart @@ -205,6 +205,15 @@ class _BusyMaxAppState extends ConsumerState { final modalBarrierColor = busyMaxModalBarrierColor(context); final theme = Theme.of(context); final preferDark = theme.brightness == Brightness.dark; + final destructiveBackground = theme.colorScheme.error; + final destructiveForeground = theme.colorScheme.onError; + Color destructiveState(double overlayAlpha) { + return Color.alphaBlend( + destructiveForeground.withValues(alpha: overlayAlpha), + destructiveBackground, + ); + } + final labels = BusyMaxHeaderBarLabels( today: l10n.today, day: l10n.viewDay, @@ -249,6 +258,13 @@ class _BusyMaxAppState extends ConsumerState { ), dialogBackgroundColor: colors.dialog, dialogOutlineColor: colors.dialogOutline, + dialogCancelBackgroundColor: colors.control, + dialogCancelHoverColor: colors.controlHover, + dialogCancelActiveColor: colors.controlActive, + dialogDestructiveBackgroundColor: destructiveBackground, + dialogDestructiveHoverColor: destructiveState(0.08), + dialogDestructiveActiveColor: destructiveState(0.12), + dialogDestructiveForegroundColor: destructiveForeground, modalBarrierColor: modalBarrierColor, ), ), diff --git a/lib/src/app/busymax_design.dart b/lib/src/app/busymax_design.dart index 11d625b..59d1d66 100644 --- a/lib/src/app/busymax_design.dart +++ b/lib/src/app/busymax_design.dart @@ -3651,6 +3651,7 @@ class BusyMaxConfirmDialog extends StatelessWidget { return AlertDialog( backgroundColor: dialogSurface, surfaceTintColor: dialogSurface, + clipBehavior: Clip.antiAlias, scrollable: true, titlePadding: EdgeInsets.zero, title: BusyMaxDialogTitleBar( diff --git a/lib/src/core/time/time_zone_catalog.dart b/lib/src/core/time/time_zone_catalog.dart new file mode 100644 index 0000000..010dcfc --- /dev/null +++ b/lib/src/core/time/time_zone_catalog.dart @@ -0,0 +1,122 @@ +import 'package:flutter/foundation.dart'; +import 'package:timezone/data/latest.dart' as time_zone_data; +import 'package:timezone/timezone.dart' as time_zone; + +@immutable +class BusyMaxTimeZoneLocation { + const BusyMaxTimeZoneLocation({ + required this.id, + required this.region, + required this.name, + required this.code, + required this.offset, + }); + + final String id; + final String region; + final String name; + final String code; + final Duration offset; + + String get displayLabel => '$id ($code)'; + + bool matches(String query) { + final normalized = query.trim().toLowerCase(); + return normalized.isEmpty || + id.toLowerCase().contains(normalized) || + region.toLowerCase().contains(normalized) || + name.toLowerCase().contains(normalized) || + code.toLowerCase().contains(normalized); + } +} + +abstract final class BusyMaxTimeZoneCatalog { + static List? _locations; + + static List get locations { + return _locations ??= _buildLocations(); + } + + static BusyMaxTimeZoneLocation location(String id) { + final normalized = id == 'UTC' ? 'Etc/UTC' : id; + return locations.firstWhere( + (location) => location.id == normalized, + orElse: () { + final parts = normalized.split('/'); + return BusyMaxTimeZoneLocation( + id: normalized, + region: _readableSegment(parts.first), + name: parts.skip(1).map(_readableSegment).join(' / '), + code: normalized, + offset: Duration.zero, + ); + }, + ); + } + + static List search(String query, {int limit = 80}) { + if (query.trim().isEmpty) { + return const []; + } + return locations + .where((location) => location.matches(query)) + .take(limit) + .toList(); + } + + static List _buildLocations() { + time_zone_data.initializeTimeZones(); + final instant = DateTime.now().millisecondsSinceEpoch; + final locations = [ + const BusyMaxTimeZoneLocation( + id: 'Etc/UTC', + region: 'UTC', + name: 'UTC', + code: 'UTC', + offset: Duration.zero, + ), + ]; + + for (final entry in time_zone.timeZoneDatabase.locations.entries) { + final id = entry.key; + if (!_isUserFacingLocation(id)) { + continue; + } + final parts = id.split('/'); + final zone = entry.value.timeZone(instant); + locations.add( + BusyMaxTimeZoneLocation( + id: id, + region: _readableSegment(parts.first), + name: parts.skip(1).map(_readableSegment).join(' / '), + code: zone.abbreviation, + offset: zone.offset, + ), + ); + } + + locations.sort((a, b) { + if (a.id == 'Etc/UTC') { + return -1; + } + if (b.id == 'Etc/UTC') { + return 1; + } + final regionOrder = a.region.compareTo(b.region); + return regionOrder != 0 ? regionOrder : a.name.compareTo(b.name); + }); + return List.unmodifiable(locations); + } + + static bool _isUserFacingLocation(String id) { + return id.contains('/') && + id != 'Etc/UTC' && + !id.startsWith('Etc/') && + !id.startsWith('SystemV/') && + !id.startsWith('US/'); + } + + static String _readableSegment(String value) { + return value.replaceAll('_', ' '); + } +} diff --git a/lib/src/features/tasks/presentation/time_zone_selection_dialog.dart b/lib/src/features/tasks/presentation/time_zone_selection_dialog.dart new file mode 100644 index 0000000..d39b6c3 --- /dev/null +++ b/lib/src/features/tasks/presentation/time_zone_selection_dialog.dart @@ -0,0 +1,145 @@ +import 'package:flutter/material.dart'; +import 'package:yaru/yaru.dart'; + +import '../../../app/busymax_dialogs.dart'; +import '../../../app/busymax_design.dart'; +import '../../../core/time/time_zone_catalog.dart'; +import '../../../l10n/l10n.dart'; + +const _timeZoneDialogContentHeight = 420.0; + +Future showBusyMaxTimeZoneSelectionDialog( + BuildContext context, { + required String selectedTimeZone, +}) { + return showBusyMaxModalDialog( + context, + builder: (dialogContext) => + BusyMaxTimeZoneSelectionDialog(selectedTimeZone: selectedTimeZone), + ); +} + +class BusyMaxTimeZoneSelectionDialog extends StatefulWidget { + const BusyMaxTimeZoneSelectionDialog({ + super.key, + required this.selectedTimeZone, + }); + + final String selectedTimeZone; + + @override + State createState() => + _BusyMaxTimeZoneSelectionDialogState(); +} + +class _BusyMaxTimeZoneSelectionDialogState + extends State { + final _searchController = TextEditingController(); + final _resultsController = ScrollController(); + var _query = ''; + + @override + void dispose() { + _searchController.dispose(); + _resultsController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + final results = BusyMaxTimeZoneCatalog.search(_query); + final sections = >{}; + for (final location in results) { + sections.putIfAbsent(location.region, () => []).add(location); + } + + return BusyMaxDialogShell( + title: l10n.selectTimeZone, + maxWidth: 520, + header: BusyMaxDialogTitleBar( + title: Text( + l10n.selectTimeZone, + style: Theme.of( + context, + ).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w600), + ), + ), + children: [ + SizedBox( + key: const ValueKey('timezone-dialog-content'), + height: _timeZoneDialogContentHeight, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + BusyMaxSearchField( + controller: _searchController, + hintText: l10n.searchLocations, + autofocus: true, + onChanged: (value) => setState(() => _query = value), + onClear: () => setState(() => _query = ''), + ), + const SizedBox(height: BusyMaxSpacing.md), + Expanded( + child: results.isEmpty + ? _query.trim().isEmpty + ? const SizedBox.shrink() + : Center( + child: Text( + l10n.noLocationsFound, + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.bodyMedium + ?.copyWith( + color: Theme.of( + context, + ).colorScheme.onSurfaceVariant, + ), + ), + ) + : Scrollbar( + controller: _resultsController, + thumbVisibility: true, + child: ListView( + key: const ValueKey('timezone-results-list'), + controller: _resultsController, + primary: false, + padding: EdgeInsets.zero, + children: [ + for (final section in sections.entries) + BusyMaxGroupedList( + title: section.key, + filled: true, + children: [ + for (final location in section.value) + BusyMaxActionRow( + title: + '${location.name} (${location.code})', + subtitle: location.id, + leading: const Icon( + Icons.public, + size: BusyMaxSizes.iconSm, + ), + trailing: + location.id == widget.selectedTimeZone + ? const Icon( + YaruIcons.checkmark, + size: BusyMaxSizes.iconSm, + ) + : null, + onTap: () => Navigator.of( + context, + ).pop(location.id), + ), + ], + ), + ], + ), + ), + ), + ], + ), + ), + ], + ); + } +} diff --git a/lib/src/platform/linux_header_bar_service.dart b/lib/src/platform/linux_header_bar_service.dart index 464de31..34cd09c 100644 --- a/lib/src/platform/linux_header_bar_service.dart +++ b/lib/src/platform/linux_header_bar_service.dart @@ -190,6 +190,13 @@ class BusyMaxHeaderBarTheme { required this.popoverShadowColor, required this.dialogBackgroundColor, required this.dialogOutlineColor, + required this.dialogCancelBackgroundColor, + required this.dialogCancelHoverColor, + required this.dialogCancelActiveColor, + required this.dialogDestructiveBackgroundColor, + required this.dialogDestructiveHoverColor, + required this.dialogDestructiveActiveColor, + required this.dialogDestructiveForegroundColor, required this.modalBarrierColor, }); @@ -205,6 +212,13 @@ class BusyMaxHeaderBarTheme { final Color popoverShadowColor; final Color dialogBackgroundColor; final Color dialogOutlineColor; + final Color dialogCancelBackgroundColor; + final Color dialogCancelHoverColor; + final Color dialogCancelActiveColor; + final Color dialogDestructiveBackgroundColor; + final Color dialogDestructiveHoverColor; + final Color dialogDestructiveActiveColor; + final Color dialogDestructiveForegroundColor; final Color modalBarrierColor; Map toJson() { @@ -221,6 +235,23 @@ class BusyMaxHeaderBarTheme { 'popoverShadowColor': busyMaxCssColor(popoverShadowColor), 'dialogBackgroundColor': busyMaxCssColor(dialogBackgroundColor), 'dialogOutlineColor': busyMaxCssColor(dialogOutlineColor), + 'dialogCancelBackgroundColor': busyMaxCssColor( + dialogCancelBackgroundColor, + ), + 'dialogCancelHoverColor': busyMaxCssColor(dialogCancelHoverColor), + 'dialogCancelActiveColor': busyMaxCssColor(dialogCancelActiveColor), + 'dialogDestructiveBackgroundColor': busyMaxCssColor( + dialogDestructiveBackgroundColor, + ), + 'dialogDestructiveHoverColor': busyMaxCssColor( + dialogDestructiveHoverColor, + ), + 'dialogDestructiveActiveColor': busyMaxCssColor( + dialogDestructiveActiveColor, + ), + 'dialogDestructiveForegroundColor': busyMaxCssColor( + dialogDestructiveForegroundColor, + ), 'modalBarrierColor': busyMaxCssColor(modalBarrierColor), }; } @@ -241,11 +272,21 @@ class BusyMaxHeaderBarTheme { other.popoverShadowColor == popoverShadowColor && other.dialogBackgroundColor == dialogBackgroundColor && other.dialogOutlineColor == dialogOutlineColor && + other.dialogCancelBackgroundColor == dialogCancelBackgroundColor && + other.dialogCancelHoverColor == dialogCancelHoverColor && + other.dialogCancelActiveColor == dialogCancelActiveColor && + other.dialogDestructiveBackgroundColor == + dialogDestructiveBackgroundColor && + other.dialogDestructiveHoverColor == dialogDestructiveHoverColor && + other.dialogDestructiveActiveColor == + dialogDestructiveActiveColor && + other.dialogDestructiveForegroundColor == + dialogDestructiveForegroundColor && other.modalBarrierColor == modalBarrierColor; } @override - int get hashCode => Object.hash( + int get hashCode => Object.hashAll([ preferDark, highContrast, windowBackgroundColor, @@ -258,8 +299,15 @@ class BusyMaxHeaderBarTheme { popoverShadowColor, dialogBackgroundColor, dialogOutlineColor, + dialogCancelBackgroundColor, + dialogCancelHoverColor, + dialogCancelActiveColor, + dialogDestructiveBackgroundColor, + dialogDestructiveHoverColor, + dialogDestructiveActiveColor, + dialogDestructiveForegroundColor, modalBarrierColor, - ); + ]); } /// The complete, screen-owned presentation state of the native header bar. diff --git a/linux/runner/my_application.cc b/linux/runner/my_application.cc index 470498c..098870b 100644 --- a/linux/runner/my_application.cc +++ b/linux/runner/my_application.cc @@ -76,6 +76,10 @@ constexpr char kHeaderSearchEntryStyleClass[] = constexpr char kHeaderModalOpenStyleClass[] = "busymax-modal-open"; constexpr char kHeaderModalBarrierStyleClass[] = "busymax-modal-barrier"; constexpr char kNativeDialogStyleClass[] = "busymax-native-dialog"; +constexpr char kNativeDialogCancelStyleClass[] = + "busymax-native-dialog-cancel"; +constexpr char kNativeDialogDestructiveStyleClass[] = + "busymax-native-dialog-destructive"; // Mirrors Yaru's shared window/dialog radius used by the Flutter fallback. constexpr gint kNativeDialogCornerRadius = 14; constexpr char kNativePopoverStyleClass[] = "busymax-native-popover"; @@ -108,6 +112,13 @@ struct _MyApplication { gchar* header_bar_menu_hover_color; gchar* header_bar_dialog_background_color; gchar* header_bar_dialog_outline_color; + gchar* header_bar_dialog_cancel_background_color; + gchar* header_bar_dialog_cancel_hover_color; + gchar* header_bar_dialog_cancel_active_color; + gchar* header_bar_dialog_destructive_background_color; + gchar* header_bar_dialog_destructive_hover_color; + gchar* header_bar_dialog_destructive_active_color; + gchar* header_bar_dialog_destructive_foreground_color; gchar* header_bar_modal_barrier_color; gboolean header_bar_high_contrast; gint header_bar_sidebar_width; @@ -544,8 +555,14 @@ static void handle_native_confirmation(FlMethodCall* method_call, gtk_style_context_add_class(gtk_widget_get_style_context(actions), "busymax-native-dialog-actions"); } + gtk_style_context_add_class(gtk_widget_get_style_context(cancel_button), + kNativeDialogCancelStyleClass); GtkStyleContext* confirm_context = gtk_widget_get_style_context(confirm_button); + if (destructive) { + gtk_style_context_add_class(confirm_context, + kNativeDialogDestructiveStyleClass); + } gtk_style_context_add_class( confirm_context, destructive ? GTK_STYLE_CLASS_DESTRUCTIVE_ACTION : GTK_STYLE_CLASS_SUGGESTED_ACTION); @@ -1258,6 +1275,7 @@ static void refresh_header_bar_css(MyApplication* self) { ".%s,.%s:backdrop {" "background-color: %s;" "background-image: none;" + "border-radius: %dpx;" "}" ".%s headerbar," ".%s headerbar:backdrop {" @@ -1272,12 +1290,12 @@ static void refresh_header_bar_css(MyApplication* self) { ".%s .busymax-native-dialog-content:backdrop {" "background-color: %s;" "background-image: none;" - "border-radius: %dpx;" "}" ".%s .busymax-native-dialog-actions," ".%s .busymax-native-dialog-actions:backdrop {" "background-color: %s;" "background-image: none;" + "border-radius: 0 0 %dpx %dpx;" "}" ".%s.csd:not(.solid-csd):not(.maximized):not(.fullscreen) {" // GTK 3 has no named modern dialog-outline role. Flutter supplies the @@ -1286,14 +1304,76 @@ static void refresh_header_bar_css(MyApplication* self) { "box-shadow: inset 0 0 0 1px %s;" "}", kNativeDialogStyleClass, kNativeDialogStyleClass, - dialog_background_color, kNativeDialogStyleClass, + dialog_background_color, kNativeDialogCornerRadius, + kNativeDialogStyleClass, kNativeDialogStyleClass, dialog_background_color, kNativeDialogStyleClass, kNativeDialogStyleClass, dialog_background_color, + kNativeDialogStyleClass, kNativeDialogStyleClass, + dialog_background_color, kNativeDialogCornerRadius, kNativeDialogCornerRadius, kNativeDialogStyleClass, - kNativeDialogStyleClass, dialog_background_color, - kNativeDialogStyleClass, css_color_or(self->header_bar_dialog_outline_color, kDefaultDialogOutlineColor)); + const gboolean has_native_dialog_button_colors = + is_css_color_token(self->header_bar_dialog_cancel_background_color) && + is_css_color_token(self->header_bar_dialog_cancel_hover_color) && + is_css_color_token(self->header_bar_dialog_cancel_active_color) && + is_css_color_token( + self->header_bar_dialog_destructive_background_color) && + is_css_color_token(self->header_bar_dialog_destructive_hover_color) && + is_css_color_token(self->header_bar_dialog_destructive_active_color) && + is_css_color_token(self->header_bar_dialog_destructive_foreground_color); + g_autofree gchar* native_dialog_button_css = + has_native_dialog_button_colors + ? g_strdup_printf( + ".%s button.%s," + ".%s button.%s:backdrop {" + "background-color: %s;" + "background-image: none;" + "color: %s;" + "}" + ".%s button.%s:hover {" + "background-color: %s;" + "background-image: none;" + "}" + ".%s button.%s:active {" + "background-color: %s;" + "background-image: none;" + "}" + ".%s button.%s," + ".%s button.%s:backdrop {" + "background-color: %s;" + "background-image: none;" + "color: %s;" + "}" + ".%s button.%s:hover {" + "background-color: %s;" + "background-image: none;" + "}" + ".%s button.%s:active {" + "background-color: %s;" + "background-image: none;" + "}", + kNativeDialogStyleClass, kNativeDialogCancelStyleClass, + kNativeDialogStyleClass, kNativeDialogCancelStyleClass, + self->header_bar_dialog_cancel_background_color, + foreground_color, kNativeDialogStyleClass, + kNativeDialogCancelStyleClass, + self->header_bar_dialog_cancel_hover_color, + kNativeDialogStyleClass, kNativeDialogCancelStyleClass, + self->header_bar_dialog_cancel_active_color, + kNativeDialogStyleClass, + kNativeDialogDestructiveStyleClass, + kNativeDialogStyleClass, + kNativeDialogDestructiveStyleClass, + self->header_bar_dialog_destructive_background_color, + self->header_bar_dialog_destructive_foreground_color, + kNativeDialogStyleClass, + kNativeDialogDestructiveStyleClass, + self->header_bar_dialog_destructive_hover_color, + kNativeDialogStyleClass, + kNativeDialogDestructiveStyleClass, + self->header_bar_dialog_destructive_active_color) + : g_strdup(""); const gchar* modal_barrier_color = css_color_or( self->header_bar_modal_barrier_color, kDefaultModalBarrierColor); const gboolean use_legacy_yaru_compatibility = @@ -1394,6 +1474,7 @@ static void refresh_header_bar_css(MyApplication* self) { "%s" "%s" "%s" + "%s" "headerbar.busymax-flat-headerbar," "headerbar.busymax-flat-headerbar:backdrop {" "background-color: %s;" @@ -1526,7 +1607,7 @@ static void refresh_header_bar_css(MyApplication* self) { "background-image: none;" "}", window_background_color, yaru_window_decoration_css, - native_dialog_css, + native_dialog_css, native_dialog_button_css, native_search_geometry_css, background_color, foreground_color, sidebar_background_color, foreground_color, sidebar_border_color, @@ -1601,6 +1682,25 @@ static void set_header_bar_theme(MyApplication* self, FlValue* args) { 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_dialog_cancel_background_color, + fl_lookup_string_arg(args, "dialogCancelBackgroundColor")); + set_css_color_field(&self->header_bar_dialog_cancel_hover_color, + fl_lookup_string_arg(args, "dialogCancelHoverColor")); + set_css_color_field(&self->header_bar_dialog_cancel_active_color, + fl_lookup_string_arg(args, "dialogCancelActiveColor")); + set_css_color_field( + &self->header_bar_dialog_destructive_background_color, + fl_lookup_string_arg(args, "dialogDestructiveBackgroundColor")); + set_css_color_field( + &self->header_bar_dialog_destructive_hover_color, + fl_lookup_string_arg(args, "dialogDestructiveHoverColor")); + set_css_color_field( + &self->header_bar_dialog_destructive_active_color, + fl_lookup_string_arg(args, "dialogDestructiveActiveColor")); + set_css_color_field( + &self->header_bar_dialog_destructive_foreground_color, + fl_lookup_string_arg(args, "dialogDestructiveForegroundColor")); set_css_color_field(&self->header_bar_modal_barrier_color, fl_lookup_string_arg(args, "modalBarrierColor")); set_main_flutter_view_background(self); @@ -4071,6 +4171,15 @@ static void my_application_dispose(GObject* object) { g_clear_pointer(&self->header_bar_menu_hover_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_dialog_cancel_background_color, g_free); + g_clear_pointer(&self->header_bar_dialog_cancel_hover_color, g_free); + g_clear_pointer(&self->header_bar_dialog_cancel_active_color, g_free); + g_clear_pointer(&self->header_bar_dialog_destructive_background_color, + g_free); + g_clear_pointer(&self->header_bar_dialog_destructive_hover_color, g_free); + g_clear_pointer(&self->header_bar_dialog_destructive_active_color, g_free); + g_clear_pointer(&self->header_bar_dialog_destructive_foreground_color, + g_free); g_clear_pointer(&self->header_bar_modal_barrier_color, g_free); g_clear_pointer(&self->header_view_mode, g_free); g_clear_pointer(&self->header_day_label, g_free); @@ -4131,6 +4240,13 @@ static void my_application_init(MyApplication* self) { self->header_bar_dialog_background_color = nullptr; self->header_bar_dialog_outline_color = g_strdup(kDefaultDialogOutlineColor); + self->header_bar_dialog_cancel_background_color = nullptr; + self->header_bar_dialog_cancel_hover_color = nullptr; + self->header_bar_dialog_cancel_active_color = nullptr; + self->header_bar_dialog_destructive_background_color = nullptr; + self->header_bar_dialog_destructive_hover_color = nullptr; + self->header_bar_dialog_destructive_active_color = nullptr; + self->header_bar_dialog_destructive_foreground_color = nullptr; self->header_bar_modal_barrier_color = nullptr; self->header_bar_high_contrast = FALSE; self->header_bar_sidebar_width = 300; diff --git a/test/app/busymax_dialogs_test.dart b/test/app/busymax_dialogs_test.dart index d4803ea..a03dbf4 100644 --- a/test/app/busymax_dialogs_test.dart +++ b/test/app/busymax_dialogs_test.dart @@ -94,8 +94,17 @@ void main() { expect(titleBarTheme.shadowColor, Colors.transparent); expect(confirmation.backgroundColor, colors.dialog); expect(confirmation.surfaceTintColor, colors.dialog); + expect(confirmation.clipBehavior, Clip.antiAlias); expect(cancelButton.style, isNull); expect(discardButton.style?.shape?.resolve({}), isNull); + expect( + theme.filledButtonTheme.style?.backgroundColor?.resolve({}), + colors.control, + ); + expect( + discardButton.style?.backgroundColor?.resolve({}), + theme.colorScheme.error, + ); expect( standardShape.borderRadius, BorderRadius.circular(kYaruButtonRadius), diff --git a/test/app/native_ui_audit_test.dart b/test/app/native_ui_audit_test.dart index 0877f6a..c08118b 100644 --- a/test/app/native_ui_audit_test.dart +++ b/test/app/native_ui_audit_test.dart @@ -1277,6 +1277,8 @@ void main() { expect(runner, contains('GTK_DIALOG_DESTROY_WITH_PARENT')); expect(runner, contains('GTK_STYLE_CLASS_DESTRUCTIVE_ACTION')); expect(runner, contains('GTK_STYLE_CLASS_SUGGESTED_ACTION')); + expect(runner, contains('kNativeDialogCancelStyleClass')); + expect(runner, contains('kNativeDialogDestructiveStyleClass')); expect( runner, contains( @@ -1297,6 +1299,7 @@ void main() { ); expect(dialogs, contains('NativeDialogService nativeDialogService')); expect(confirmBody, contains('return AlertDialog(')); + expect(confirmBody, contains('clipBehavior: Clip.antiAlias')); expect(confirmBody, isNot(contains('return BusyMaxDialogShell('))); }); @@ -1749,10 +1752,16 @@ void main() { ); expect(nativeDialogCss, isNot(contains('"border:'))); expect('border-radius: %dpx;'.allMatches(nativeDialogCss).length, 1); + expect(nativeDialogCss, contains('"border-radius: 0 0 %dpx %dpx;"')); expect( source, contains('constexpr gint kNativeDialogCornerRadius = 14;'), ); + expect(source, contains('"busymax-native-dialog-cancel"')); + expect(source, contains('"busymax-native-dialog-destructive"')); + expect(nativeDialogCss, contains('dialog_cancel_background_color')); + expect(nativeDialogCss, contains('dialog_destructive_background_color')); + expect(nativeDialogCss, contains('dialog_destructive_foreground_color')); expect(source, isNot(contains('"busymax-native-dialog-action"'))); expect(source, contains('"busymax-native-dialog-actions"')); expect(source, contains('style_native_popover(session->popover)')); diff --git a/test/core/time/time_zone_catalog_test.dart b/test/core/time/time_zone_catalog_test.dart new file mode 100644 index 0000000..3f2e032 --- /dev/null +++ b/test/core/time/time_zone_catalog_test.dart @@ -0,0 +1,25 @@ +import 'package:busymax/src/core/time/time_zone_catalog.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test('timezone catalog searches IANA locations with current codes', () { + final results = BusyMaxTimeZoneCatalog.search('Vancouver'); + final vancouver = results.singleWhere( + (location) => location.id == 'America/Vancouver', + ); + + expect(vancouver.region, 'America'); + expect(vancouver.name, 'Vancouver'); + expect(vancouver.code, isNotEmpty); + expect(vancouver.displayLabel, 'America/Vancouver (${vancouver.code})'); + }); + + test('timezone catalog preserves unknown existing identifiers', () { + final location = BusyMaxTimeZoneCatalog.location('Custom/Office_Time'); + + expect(location.id, 'Custom/Office_Time'); + expect(location.region, 'Custom'); + expect(location.name, 'Office Time'); + expect(location.code, 'Custom/Office_Time'); + }); +} diff --git a/test/features/tasks/presentation/desktop_date_time_fields_test.dart b/test/features/tasks/presentation/desktop_date_time_fields_test.dart index 6888639..e898309 100644 --- a/test/features/tasks/presentation/desktop_date_time_fields_test.dart +++ b/test/features/tasks/presentation/desktop_date_time_fields_test.dart @@ -363,10 +363,31 @@ void main() { ); final searchField = find.widgetWithText(TextField, 'Search locations'); + final content = find.byKey(const ValueKey('timezone-dialog-content')); expect(searchField, findsOneWidget); + expect(content, findsOneWidget); + final initialContentSize = tester.getSize(content); + final dialog = find.byType(Dialog); + expect(dialog, findsOneWidget); + final initialDialogSize = tester.getSize(dialog); + + await tester.enterText(searchField, 'a'); + await tester.pumpAndSettle(); + + expect(tester.getSize(content), initialContentSize); + expect(tester.getSize(dialog), initialDialogSize); + final resultsList = find.byKey(const ValueKey('timezone-results-list')); + expect(resultsList, findsOneWidget); + expect( + tester.widget(resultsList).controller!.position.maxScrollExtent, + greaterThan(0), + ); + await tester.enterText(searchField, 'Vancouver'); await tester.pumpAndSettle(); + expect(tester.getSize(content), initialContentSize); + expect(tester.getSize(dialog), initialDialogSize); expect(find.text('America'), findsOneWidget); expect(find.textContaining('Vancouver ('), findsOneWidget); expect(find.text('America/Vancouver'), findsOneWidget); diff --git a/test/platform/linux_header_bar_configuration_synchronizer_test.dart b/test/platform/linux_header_bar_configuration_synchronizer_test.dart index 22114e0..2f01ea7 100644 --- a/test/platform/linux_header_bar_configuration_synchronizer_test.dart +++ b/test/platform/linux_header_bar_configuration_synchronizer_test.dart @@ -135,6 +135,13 @@ BusyMaxHeaderBarConfiguration _configuration({required bool dark}) { popoverShadowColor: Colors.black38, dialogBackgroundColor: dark ? Colors.black : Colors.white, dialogOutlineColor: dark ? Colors.white : Colors.white10, + dialogCancelBackgroundColor: dark ? Colors.white12 : Colors.black12, + dialogCancelHoverColor: dark ? Colors.white24 : Colors.black26, + dialogCancelActiveColor: dark ? Colors.white30 : Colors.black38, + dialogDestructiveBackgroundColor: Colors.red, + dialogDestructiveHoverColor: Colors.redAccent, + dialogDestructiveActiveColor: Colors.red, + dialogDestructiveForegroundColor: Colors.white, modalBarrierColor: Colors.black54, ), ); diff --git a/test/platform/linux_header_bar_service_test.dart b/test/platform/linux_header_bar_service_test.dart index 2117604..167a5fc 100644 --- a/test/platform/linux_header_bar_service_test.dart +++ b/test/platform/linux_header_bar_service_test.dart @@ -107,6 +107,13 @@ void main() { popoverShadowColor: Color.fromRGBO(0, 0, 0, 0.3), dialogBackgroundColor: Color(0xFF36363A), dialogOutlineColor: Color.fromRGBO(255, 255, 255, 0.07), + dialogCancelBackgroundColor: Color.fromRGBO(255, 255, 255, 0.10), + dialogCancelHoverColor: Color.fromRGBO(255, 255, 255, 0.14), + dialogCancelActiveColor: Color.fromRGBO(255, 255, 255, 0.18), + dialogDestructiveBackgroundColor: Color(0xFFE01B24), + dialogDestructiveHoverColor: Color(0xFFE22D36), + dialogDestructiveActiveColor: Color(0xFFE4363E), + dialogDestructiveForegroundColor: Color(0xFFFFFFFF), modalBarrierColor: Color.fromRGBO(0, 0, 0, 0.32), ), ); @@ -156,6 +163,13 @@ void main() { 'popoverShadowColor': 'rgba(0,0,0,0.30)', 'dialogBackgroundColor': '#36363A', 'dialogOutlineColor': 'rgba(255,255,255,0.07)', + 'dialogCancelBackgroundColor': 'rgba(255,255,255,0.10)', + 'dialogCancelHoverColor': 'rgba(255,255,255,0.14)', + 'dialogCancelActiveColor': 'rgba(255,255,255,0.18)', + 'dialogDestructiveBackgroundColor': '#E01B24', + 'dialogDestructiveHoverColor': '#E22D36', + 'dialogDestructiveActiveColor': '#E4363E', + 'dialogDestructiveForegroundColor': '#FFFFFF', 'modalBarrierColor': 'rgba(0,0,0,0.32)', }), ); From eb0b208a7c51da1fb4afa9bdc661c0dad08c0e97 Mon Sep 17 00:00:00 2001 From: albert Date: Tue, 28 Jul 2026 16:48:37 -0700 Subject: [PATCH 24/73] Refactor dialog styling and component structure. Remove unused dialog color properties and update MiniCalendar to support week selection. Enhance layout handling for better user experience. --- lib/src/app/busymax_app.dart | 16 -- lib/src/app/busymax_design.dart | 1 - .../schedule/presentation/mini_calendar.dart | 12 +- .../presentation/schedule_workspace.dart | 4 + .../presentation/schedule_year_view.dart | 33 ++-- .../platform/linux_header_bar_service.dart | 48 ------ linux/runner/my_application.cc | 136 +---------------- test/app/busymax_grouped_surface_test.dart | 4 +- test/app/native_ui_audit_test.dart | 34 +++-- .../presentation/schedule_views_test.dart | 141 +++++++++++++++++- ...r_bar_configuration_synchronizer_test.dart | 7 - .../linux_header_bar_service_test.dart | 14 -- 12 files changed, 195 insertions(+), 255 deletions(-) diff --git a/lib/src/app/busymax_app.dart b/lib/src/app/busymax_app.dart index d4e7f41..069721d 100644 --- a/lib/src/app/busymax_app.dart +++ b/lib/src/app/busymax_app.dart @@ -205,15 +205,6 @@ class _BusyMaxAppState extends ConsumerState { final modalBarrierColor = busyMaxModalBarrierColor(context); final theme = Theme.of(context); final preferDark = theme.brightness == Brightness.dark; - final destructiveBackground = theme.colorScheme.error; - final destructiveForeground = theme.colorScheme.onError; - Color destructiveState(double overlayAlpha) { - return Color.alphaBlend( - destructiveForeground.withValues(alpha: overlayAlpha), - destructiveBackground, - ); - } - final labels = BusyMaxHeaderBarLabels( today: l10n.today, day: l10n.viewDay, @@ -258,13 +249,6 @@ class _BusyMaxAppState extends ConsumerState { ), dialogBackgroundColor: colors.dialog, dialogOutlineColor: colors.dialogOutline, - dialogCancelBackgroundColor: colors.control, - dialogCancelHoverColor: colors.controlHover, - dialogCancelActiveColor: colors.controlActive, - dialogDestructiveBackgroundColor: destructiveBackground, - dialogDestructiveHoverColor: destructiveState(0.08), - dialogDestructiveActiveColor: destructiveState(0.12), - dialogDestructiveForegroundColor: destructiveForeground, modalBarrierColor: modalBarrierColor, ), ), diff --git a/lib/src/app/busymax_design.dart b/lib/src/app/busymax_design.dart index 59d1d66..5f3bc1a 100644 --- a/lib/src/app/busymax_design.dart +++ b/lib/src/app/busymax_design.dart @@ -45,7 +45,6 @@ abstract final class BusyMaxSizes { static const double headerIcon = kYaruIconSize; static const double sidebarActionButton = headerIconButton; static const double sidebarActionIcon = headerIcon; - static const double miniCalendarWeekButton = headerIconButton; static const double popoverActionButton = kYaruTitleBarItemHeight; static const double popoverActionIcon = iconSm; static const double popoverArrowWidth = 18; diff --git a/lib/src/features/schedule/presentation/mini_calendar.dart b/lib/src/features/schedule/presentation/mini_calendar.dart index 0d6b50f..b77f203 100644 --- a/lib/src/features/schedule/presentation/mini_calendar.dart +++ b/lib/src/features/schedule/presentation/mini_calendar.dart @@ -14,6 +14,8 @@ import 'calendar_day_semantics.dart'; enum MiniCalendarHeaderStyle { navigation, monthLabel } const _miniCalendarHeaderControlExtent = 28.0; +const _miniCalendarMonthControlFlex = 3; +const _miniCalendarYearControlFlex = 2; class MiniCalendar extends StatelessWidget { const MiniCalendar({ @@ -70,6 +72,7 @@ class MiniCalendar extends StatelessWidget { Row( children: [ Expanded( + flex: _miniCalendarMonthControlFlex, child: _MiniCalendarStepper( label: DateFormat.MMMM(locale).format(visibleMonth), previousTooltip: l10n.previousMonth, @@ -88,6 +91,7 @@ class MiniCalendar extends StatelessWidget { ), const SizedBox(width: BusyMaxSpacing.sm), Expanded( + flex: _miniCalendarYearControlFlex, child: _MiniCalendarStepper( label: '${visibleMonth.year}', previousTooltip: l10n.previousYear, @@ -119,8 +123,9 @@ class MiniCalendar extends StatelessWidget { LayoutBuilder( builder: (context, constraints) { final maximumWeekNumberExtent = weekNumbersInteractive - ? BusyMaxSizes.miniCalendarWeekButton - : BusyMaxSizes.miniCalendarWeekButton - BusyMaxSpacing.md; + ? _miniCalendarHeaderControlExtent + : _miniCalendarHeaderControlExtent - + BusyMaxSpacing.headerInset; final weekNumberExtent = math.min( maximumWeekNumberExtent, constraints.maxWidth / (DateTime.daysPerWeek + 1), @@ -280,7 +285,7 @@ class _MiniCalendarWeekNumberButton extends StatelessWidget { busyMaxHeaderIconButtonStyle( context, foregroundColor: colorScheme.onSurfaceVariant, - backgroundColor: busyMaxHeaderButtonBackground(context), + backgroundColor: busyMaxSubtleButtonBackground(context), overlayColor: const WidgetStatePropertyAll( Colors.transparent, ), @@ -400,6 +405,7 @@ class _MiniCalendarDayButtonState extends State<_MiniCalendarDayButton> { ? null : () => widget.onDoubleTap!(day), excludeFromSemantics: true, + hoverColor: Colors.transparent, customBorder: const CircleBorder(), child: Column( mainAxisAlignment: MainAxisAlignment.center, diff --git a/lib/src/features/schedule/presentation/schedule_workspace.dart b/lib/src/features/schedule/presentation/schedule_workspace.dart index 82d99cd..bd379ac 100644 --- a/lib/src/features/schedule/presentation/schedule_workspace.dart +++ b/lib/src/features/schedule/presentation/schedule_workspace.dart @@ -486,6 +486,7 @@ class _ScheduleWorkspaceState extends ConsumerState { onDaySelected: _setDate, onYearDaySelected: _openDay, onMonthSelected: _setMonth, + onWeekSelected: _setWeek, onEmptySlot: (start) => unawaited( _openCreateChoice( accounts, @@ -2258,6 +2259,7 @@ class _ScheduleBody extends StatelessWidget { required this.onDaySelected, required this.onYearDaySelected, required this.onMonthSelected, + required this.onWeekSelected, required this.onEmptySlot, required this.onCreateAtDay, required this.onNewEvent, @@ -2294,6 +2296,7 @@ class _ScheduleBody extends StatelessWidget { final ValueChanged onDaySelected; final ValueChanged onYearDaySelected; final ValueChanged onMonthSelected; + final ValueChanged onWeekSelected; final ValueChanged onEmptySlot; final ValueChanged onCreateAtDay; final VoidCallback onNewEvent; @@ -2387,6 +2390,7 @@ class _ScheduleBody extends StatelessWidget { firstWeekday: firstWeekday, onDaySelected: onYearDaySelected, onMonthSelected: onMonthSelected, + onWeekSelected: onWeekSelected, onCreateAtDay: onCreateAtDay, ), ), diff --git a/lib/src/features/schedule/presentation/schedule_year_view.dart b/lib/src/features/schedule/presentation/schedule_year_view.dart index 8738b66..0411b62 100644 --- a/lib/src/features/schedule/presentation/schedule_year_view.dart +++ b/lib/src/features/schedule/presentation/schedule_year_view.dart @@ -13,6 +13,7 @@ class ScheduleYearView extends StatelessWidget { required this.firstWeekday, required this.onDaySelected, required this.onMonthSelected, + required this.onWeekSelected, required this.onCreateAtDay, this.compact = false, this.backgroundColor, @@ -23,6 +24,7 @@ class ScheduleYearView extends StatelessWidget { final int firstWeekday; final ValueChanged onDaySelected; final ValueChanged onMonthSelected; + final ValueChanged onWeekSelected; final ValueChanged onCreateAtDay; final bool compact; final Color? backgroundColor; @@ -49,19 +51,24 @@ class ScheduleYearView extends StatelessWidget { for (var index = 0; index < DateTime.monthsPerYear; index++) SizedBox( width: monthWidth, - child: MiniCalendar( - displayedMonth: DateTime(selectedDate.year, index + 1), - selectedDate: selectedDate, - firstWeekday: firstWeekday, - items: items, - headerStyle: MiniCalendarHeaderStyle.monthLabel, - showDayHover: true, - weekNumbersInteractive: false, - onSelected: onDaySelected, - onMonthSelected: onMonthSelected, - onYearSelected: null, - onWeekSelected: null, - onDayDoubleTap: onCreateAtDay, + child: BusyMaxGroupedSurface( + child: MiniCalendar( + displayedMonth: DateTime( + selectedDate.year, + index + 1, + ), + selectedDate: selectedDate, + firstWeekday: firstWeekday, + items: items, + headerStyle: MiniCalendarHeaderStyle.monthLabel, + showDayHover: true, + weekNumbersInteractive: true, + onSelected: onDaySelected, + onMonthSelected: onMonthSelected, + onYearSelected: null, + onWeekSelected: onWeekSelected, + onDayDoubleTap: onCreateAtDay, + ), ), ), ], diff --git a/lib/src/platform/linux_header_bar_service.dart b/lib/src/platform/linux_header_bar_service.dart index 34cd09c..78a3382 100644 --- a/lib/src/platform/linux_header_bar_service.dart +++ b/lib/src/platform/linux_header_bar_service.dart @@ -190,13 +190,6 @@ class BusyMaxHeaderBarTheme { required this.popoverShadowColor, required this.dialogBackgroundColor, required this.dialogOutlineColor, - required this.dialogCancelBackgroundColor, - required this.dialogCancelHoverColor, - required this.dialogCancelActiveColor, - required this.dialogDestructiveBackgroundColor, - required this.dialogDestructiveHoverColor, - required this.dialogDestructiveActiveColor, - required this.dialogDestructiveForegroundColor, required this.modalBarrierColor, }); @@ -212,13 +205,6 @@ class BusyMaxHeaderBarTheme { final Color popoverShadowColor; final Color dialogBackgroundColor; final Color dialogOutlineColor; - final Color dialogCancelBackgroundColor; - final Color dialogCancelHoverColor; - final Color dialogCancelActiveColor; - final Color dialogDestructiveBackgroundColor; - final Color dialogDestructiveHoverColor; - final Color dialogDestructiveActiveColor; - final Color dialogDestructiveForegroundColor; final Color modalBarrierColor; Map toJson() { @@ -235,23 +221,6 @@ class BusyMaxHeaderBarTheme { 'popoverShadowColor': busyMaxCssColor(popoverShadowColor), 'dialogBackgroundColor': busyMaxCssColor(dialogBackgroundColor), 'dialogOutlineColor': busyMaxCssColor(dialogOutlineColor), - 'dialogCancelBackgroundColor': busyMaxCssColor( - dialogCancelBackgroundColor, - ), - 'dialogCancelHoverColor': busyMaxCssColor(dialogCancelHoverColor), - 'dialogCancelActiveColor': busyMaxCssColor(dialogCancelActiveColor), - 'dialogDestructiveBackgroundColor': busyMaxCssColor( - dialogDestructiveBackgroundColor, - ), - 'dialogDestructiveHoverColor': busyMaxCssColor( - dialogDestructiveHoverColor, - ), - 'dialogDestructiveActiveColor': busyMaxCssColor( - dialogDestructiveActiveColor, - ), - 'dialogDestructiveForegroundColor': busyMaxCssColor( - dialogDestructiveForegroundColor, - ), 'modalBarrierColor': busyMaxCssColor(modalBarrierColor), }; } @@ -272,16 +241,6 @@ class BusyMaxHeaderBarTheme { other.popoverShadowColor == popoverShadowColor && other.dialogBackgroundColor == dialogBackgroundColor && other.dialogOutlineColor == dialogOutlineColor && - other.dialogCancelBackgroundColor == dialogCancelBackgroundColor && - other.dialogCancelHoverColor == dialogCancelHoverColor && - other.dialogCancelActiveColor == dialogCancelActiveColor && - other.dialogDestructiveBackgroundColor == - dialogDestructiveBackgroundColor && - other.dialogDestructiveHoverColor == dialogDestructiveHoverColor && - other.dialogDestructiveActiveColor == - dialogDestructiveActiveColor && - other.dialogDestructiveForegroundColor == - dialogDestructiveForegroundColor && other.modalBarrierColor == modalBarrierColor; } @@ -299,13 +258,6 @@ class BusyMaxHeaderBarTheme { popoverShadowColor, dialogBackgroundColor, dialogOutlineColor, - dialogCancelBackgroundColor, - dialogCancelHoverColor, - dialogCancelActiveColor, - dialogDestructiveBackgroundColor, - dialogDestructiveHoverColor, - dialogDestructiveActiveColor, - dialogDestructiveForegroundColor, modalBarrierColor, ]); } diff --git a/linux/runner/my_application.cc b/linux/runner/my_application.cc index 098870b..008ac2e 100644 --- a/linux/runner/my_application.cc +++ b/linux/runner/my_application.cc @@ -76,10 +76,6 @@ constexpr char kHeaderSearchEntryStyleClass[] = constexpr char kHeaderModalOpenStyleClass[] = "busymax-modal-open"; constexpr char kHeaderModalBarrierStyleClass[] = "busymax-modal-barrier"; constexpr char kNativeDialogStyleClass[] = "busymax-native-dialog"; -constexpr char kNativeDialogCancelStyleClass[] = - "busymax-native-dialog-cancel"; -constexpr char kNativeDialogDestructiveStyleClass[] = - "busymax-native-dialog-destructive"; // Mirrors Yaru's shared window/dialog radius used by the Flutter fallback. constexpr gint kNativeDialogCornerRadius = 14; constexpr char kNativePopoverStyleClass[] = "busymax-native-popover"; @@ -112,13 +108,6 @@ struct _MyApplication { gchar* header_bar_menu_hover_color; gchar* header_bar_dialog_background_color; gchar* header_bar_dialog_outline_color; - gchar* header_bar_dialog_cancel_background_color; - gchar* header_bar_dialog_cancel_hover_color; - gchar* header_bar_dialog_cancel_active_color; - gchar* header_bar_dialog_destructive_background_color; - gchar* header_bar_dialog_destructive_hover_color; - gchar* header_bar_dialog_destructive_active_color; - gchar* header_bar_dialog_destructive_foreground_color; gchar* header_bar_modal_barrier_color; gboolean header_bar_high_contrast; gint header_bar_sidebar_width; @@ -537,7 +526,6 @@ static void handle_native_confirmation(FlMethodCall* method_call, GTK_DIALOG_DESTROY_WITH_PARENT), destructive ? GTK_MESSAGE_WARNING : GTK_MESSAGE_QUESTION, GTK_BUTTONS_NONE, "%s", title != nullptr ? title : ""); - style_native_dialog(dialog); if (message != nullptr && message[0] != '\0') { gtk_message_dialog_format_secondary_text(GTK_MESSAGE_DIALOG(dialog), "%s", message); @@ -550,19 +538,8 @@ static void handle_native_confirmation(FlMethodCall* method_call, GtkWidget* confirm_button = gtk_dialog_add_button( GTK_DIALOG(dialog), confirm_label != nullptr ? confirm_label : "_OK", GTK_RESPONSE_ACCEPT); - GtkWidget* actions = gtk_widget_get_parent(cancel_button); - if (actions != nullptr) { - gtk_style_context_add_class(gtk_widget_get_style_context(actions), - "busymax-native-dialog-actions"); - } - gtk_style_context_add_class(gtk_widget_get_style_context(cancel_button), - kNativeDialogCancelStyleClass); GtkStyleContext* confirm_context = gtk_widget_get_style_context(confirm_button); - if (destructive) { - gtk_style_context_add_class(confirm_context, - kNativeDialogDestructiveStyleClass); - } gtk_style_context_add_class( confirm_context, destructive ? GTK_STYLE_CLASS_DESTRUCTIVE_ACTION : GTK_STYLE_CLASS_SUGGESTED_ACTION); @@ -1275,7 +1252,6 @@ static void refresh_header_bar_css(MyApplication* self) { ".%s,.%s:backdrop {" "background-color: %s;" "background-image: none;" - "border-radius: %dpx;" "}" ".%s headerbar," ".%s headerbar:backdrop {" @@ -1290,12 +1266,7 @@ static void refresh_header_bar_css(MyApplication* self) { ".%s .busymax-native-dialog-content:backdrop {" "background-color: %s;" "background-image: none;" - "}" - ".%s .busymax-native-dialog-actions," - ".%s .busymax-native-dialog-actions:backdrop {" - "background-color: %s;" - "background-image: none;" - "border-radius: 0 0 %dpx %dpx;" + "border-radius: %dpx;" "}" ".%s.csd:not(.solid-csd):not(.maximized):not(.fullscreen) {" // GTK 3 has no named modern dialog-outline role. Flutter supplies the @@ -1304,76 +1275,12 @@ static void refresh_header_bar_css(MyApplication* self) { "box-shadow: inset 0 0 0 1px %s;" "}", kNativeDialogStyleClass, kNativeDialogStyleClass, - dialog_background_color, kNativeDialogCornerRadius, - kNativeDialogStyleClass, + dialog_background_color, kNativeDialogStyleClass, kNativeDialogStyleClass, dialog_background_color, kNativeDialogStyleClass, kNativeDialogStyleClass, dialog_background_color, - kNativeDialogStyleClass, kNativeDialogStyleClass, - dialog_background_color, kNativeDialogCornerRadius, kNativeDialogCornerRadius, kNativeDialogStyleClass, css_color_or(self->header_bar_dialog_outline_color, kDefaultDialogOutlineColor)); - const gboolean has_native_dialog_button_colors = - is_css_color_token(self->header_bar_dialog_cancel_background_color) && - is_css_color_token(self->header_bar_dialog_cancel_hover_color) && - is_css_color_token(self->header_bar_dialog_cancel_active_color) && - is_css_color_token( - self->header_bar_dialog_destructive_background_color) && - is_css_color_token(self->header_bar_dialog_destructive_hover_color) && - is_css_color_token(self->header_bar_dialog_destructive_active_color) && - is_css_color_token(self->header_bar_dialog_destructive_foreground_color); - g_autofree gchar* native_dialog_button_css = - has_native_dialog_button_colors - ? g_strdup_printf( - ".%s button.%s," - ".%s button.%s:backdrop {" - "background-color: %s;" - "background-image: none;" - "color: %s;" - "}" - ".%s button.%s:hover {" - "background-color: %s;" - "background-image: none;" - "}" - ".%s button.%s:active {" - "background-color: %s;" - "background-image: none;" - "}" - ".%s button.%s," - ".%s button.%s:backdrop {" - "background-color: %s;" - "background-image: none;" - "color: %s;" - "}" - ".%s button.%s:hover {" - "background-color: %s;" - "background-image: none;" - "}" - ".%s button.%s:active {" - "background-color: %s;" - "background-image: none;" - "}", - kNativeDialogStyleClass, kNativeDialogCancelStyleClass, - kNativeDialogStyleClass, kNativeDialogCancelStyleClass, - self->header_bar_dialog_cancel_background_color, - foreground_color, kNativeDialogStyleClass, - kNativeDialogCancelStyleClass, - self->header_bar_dialog_cancel_hover_color, - kNativeDialogStyleClass, kNativeDialogCancelStyleClass, - self->header_bar_dialog_cancel_active_color, - kNativeDialogStyleClass, - kNativeDialogDestructiveStyleClass, - kNativeDialogStyleClass, - kNativeDialogDestructiveStyleClass, - self->header_bar_dialog_destructive_background_color, - self->header_bar_dialog_destructive_foreground_color, - kNativeDialogStyleClass, - kNativeDialogDestructiveStyleClass, - self->header_bar_dialog_destructive_hover_color, - kNativeDialogStyleClass, - kNativeDialogDestructiveStyleClass, - self->header_bar_dialog_destructive_active_color) - : g_strdup(""); const gchar* modal_barrier_color = css_color_or( self->header_bar_modal_barrier_color, kDefaultModalBarrierColor); const gboolean use_legacy_yaru_compatibility = @@ -1474,7 +1381,6 @@ static void refresh_header_bar_css(MyApplication* self) { "%s" "%s" "%s" - "%s" "headerbar.busymax-flat-headerbar," "headerbar.busymax-flat-headerbar:backdrop {" "background-color: %s;" @@ -1607,8 +1513,7 @@ static void refresh_header_bar_css(MyApplication* self) { "background-image: none;" "}", window_background_color, yaru_window_decoration_css, - native_dialog_css, native_dialog_button_css, - native_search_geometry_css, + native_dialog_css, native_search_geometry_css, background_color, foreground_color, sidebar_background_color, foreground_color, sidebar_border_color, foreground_color, foreground_color, kHeaderBackdropForegroundOpacity, @@ -1682,25 +1587,6 @@ static void set_header_bar_theme(MyApplication* self, FlValue* args) { 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_dialog_cancel_background_color, - fl_lookup_string_arg(args, "dialogCancelBackgroundColor")); - set_css_color_field(&self->header_bar_dialog_cancel_hover_color, - fl_lookup_string_arg(args, "dialogCancelHoverColor")); - set_css_color_field(&self->header_bar_dialog_cancel_active_color, - fl_lookup_string_arg(args, "dialogCancelActiveColor")); - set_css_color_field( - &self->header_bar_dialog_destructive_background_color, - fl_lookup_string_arg(args, "dialogDestructiveBackgroundColor")); - set_css_color_field( - &self->header_bar_dialog_destructive_hover_color, - fl_lookup_string_arg(args, "dialogDestructiveHoverColor")); - set_css_color_field( - &self->header_bar_dialog_destructive_active_color, - fl_lookup_string_arg(args, "dialogDestructiveActiveColor")); - set_css_color_field( - &self->header_bar_dialog_destructive_foreground_color, - fl_lookup_string_arg(args, "dialogDestructiveForegroundColor")); set_css_color_field(&self->header_bar_modal_barrier_color, fl_lookup_string_arg(args, "modalBarrierColor")); set_main_flutter_view_background(self); @@ -4171,15 +4057,6 @@ static void my_application_dispose(GObject* object) { g_clear_pointer(&self->header_bar_menu_hover_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_dialog_cancel_background_color, g_free); - g_clear_pointer(&self->header_bar_dialog_cancel_hover_color, g_free); - g_clear_pointer(&self->header_bar_dialog_cancel_active_color, g_free); - g_clear_pointer(&self->header_bar_dialog_destructive_background_color, - g_free); - g_clear_pointer(&self->header_bar_dialog_destructive_hover_color, g_free); - g_clear_pointer(&self->header_bar_dialog_destructive_active_color, g_free); - g_clear_pointer(&self->header_bar_dialog_destructive_foreground_color, - g_free); g_clear_pointer(&self->header_bar_modal_barrier_color, g_free); g_clear_pointer(&self->header_view_mode, g_free); g_clear_pointer(&self->header_day_label, g_free); @@ -4240,13 +4117,6 @@ static void my_application_init(MyApplication* self) { self->header_bar_dialog_background_color = nullptr; self->header_bar_dialog_outline_color = g_strdup(kDefaultDialogOutlineColor); - self->header_bar_dialog_cancel_background_color = nullptr; - self->header_bar_dialog_cancel_hover_color = nullptr; - self->header_bar_dialog_cancel_active_color = nullptr; - self->header_bar_dialog_destructive_background_color = nullptr; - self->header_bar_dialog_destructive_hover_color = nullptr; - self->header_bar_dialog_destructive_active_color = nullptr; - self->header_bar_dialog_destructive_foreground_color = nullptr; self->header_bar_modal_barrier_color = nullptr; self->header_bar_high_contrast = FALSE; self->header_bar_sidebar_width = 300; diff --git a/test/app/busymax_grouped_surface_test.dart b/test/app/busymax_grouped_surface_test.dart index fb2ce1b..977d073 100644 --- a/test/app/busymax_grouped_surface_test.dart +++ b/test/app/busymax_grouped_surface_test.dart @@ -174,7 +174,7 @@ void main() { }, ); - test('Settings owns grouped cards while Year reuses MiniCalendar', () { + test('Settings and reused Year calendars use shared grouped cards', () { final settings = File( 'lib/src/features/settings/presentation/settings_screen.dart', ).readAsStringSync(); @@ -184,7 +184,7 @@ void main() { expect(settings, contains('BusyMaxGroupedList(')); expect(yearView, contains('MiniCalendar(')); - expect(yearView, isNot(contains('BusyMaxGroupedSurface('))); + expect(yearView, contains('BusyMaxGroupedSurface(')); for (final source in [settings, yearView]) { expect(source, isNot(contains('BoxShadow('))); expect(source, isNot(contains('elevation:'))); diff --git a/test/app/native_ui_audit_test.dart b/test/app/native_ui_audit_test.dart index c08118b..e9128f5 100644 --- a/test/app/native_ui_audit_test.dart +++ b/test/app/native_ui_audit_test.dart @@ -1277,8 +1277,20 @@ void main() { expect(runner, contains('GTK_DIALOG_DESTROY_WITH_PARENT')); expect(runner, contains('GTK_STYLE_CLASS_DESTRUCTIVE_ACTION')); expect(runner, contains('GTK_STYLE_CLASS_SUGGESTED_ACTION')); - expect(runner, contains('kNativeDialogCancelStyleClass')); - expect(runner, contains('kNativeDialogDestructiveStyleClass')); + final nativeConfirmStart = runner.indexOf( + 'static void handle_native_confirmation', + ); + final nativeConfirmEnd = runner.indexOf( + 'struct NativeDialogHandlerData', + nativeConfirmStart, + ); + final nativeConfirm = runner.substring( + nativeConfirmStart, + nativeConfirmEnd, + ); + expect(nativeConfirm, isNot(contains('style_native_dialog(dialog)'))); + expect(nativeConfirm, isNot(contains('busymax-native-dialog'))); + expect(nativeConfirm, isNot(contains('background_color'))); expect( runner, contains( @@ -1699,7 +1711,7 @@ void main() { expect(headerMenuShadowCss, isNot(contains('border-radius'))); expect(source, contains('"busymax-native-dialog"')); expect(source, contains('style_native_dialog(GtkWidget* dialog)')); - expect('style_native_dialog(dialog);'.allMatches(source).length, 3); + expect('style_native_dialog(dialog);'.allMatches(source).length, 2); expect( nativeDialogCss, contains('g_autofree gchar* native_dialog_css ='), @@ -1713,9 +1725,8 @@ void main() { ); expect( 'dialog_background_color'.allMatches(nativeDialogCss).length, - 4, - reason: - 'dialog, titlebar, content, and actions share one surface token', + 3, + reason: 'legacy native pickers share one dialog surface token', ); expect(nativeDialogCss, isNot(contains('window_background_color'))); expect(nativeDialogCss, contains('"border-bottom-width: 0;"')); @@ -1752,18 +1763,13 @@ void main() { ); expect(nativeDialogCss, isNot(contains('"border:'))); expect('border-radius: %dpx;'.allMatches(nativeDialogCss).length, 1); - expect(nativeDialogCss, contains('"border-radius: 0 0 %dpx %dpx;"')); expect( source, contains('constexpr gint kNativeDialogCornerRadius = 14;'), ); - expect(source, contains('"busymax-native-dialog-cancel"')); - expect(source, contains('"busymax-native-dialog-destructive"')); - expect(nativeDialogCss, contains('dialog_cancel_background_color')); - expect(nativeDialogCss, contains('dialog_destructive_background_color')); - expect(nativeDialogCss, contains('dialog_destructive_foreground_color')); - expect(source, isNot(contains('"busymax-native-dialog-action"'))); - expect(source, contains('"busymax-native-dialog-actions"')); + 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, contains('style_native_popover(session->popover)')); expect(source, isNot(contains('activate_native_menu_host('))); expect( diff --git a/test/features/schedule/presentation/schedule_views_test.dart b/test/features/schedule/presentation/schedule_views_test.dart index 2d7bff9..32a612c 100644 --- a/test/features/schedule/presentation/schedule_views_test.dart +++ b/test/features/schedule/presentation/schedule_views_test.dart @@ -253,6 +253,7 @@ void main() { firstWeekday: DateTime.monday, onDaySelected: (_) {}, onMonthSelected: (_) {}, + onWeekSelected: (_) {}, onCreateAtDay: (_) {}, ), ), @@ -268,6 +269,13 @@ void main() { ), findsNWidgets(DateTime.monthsPerYear), ); + expect( + find.descendant( + of: find.byType(ScheduleYearView), + matching: find.byType(BusyMaxGroupedSurface), + ), + findsNWidgets(DateTime.monthsPerYear), + ); final yearCanvas = tester.widget( find .descendant( @@ -739,6 +747,7 @@ void main() { items: const [], onDaySelected: (day) => activatedDay = day, onMonthSelected: (_) {}, + onWeekSelected: (_) {}, onCreateAtDay: (_) {}, ), ), @@ -2475,8 +2484,9 @@ void main() { expect(source, contains('visibleMonth.year - 1')); expect(source, contains('visibleMonth.year + 1')); expect(source, contains('busyMaxHeaderIconButtonStyle')); - expect(source, contains('miniCalendarWeekButton')); - expect(source, contains('busyMaxHeaderButtonBackground(context)')); + expect(source, contains('? _miniCalendarHeaderControlExtent')); + expect(source, contains('flex: _miniCalendarMonthControlFlex')); + expect(source, contains('flex: _miniCalendarYearControlFlex')); expect(source, contains('busyMaxSubtleButtonBackground(context)')); expect(source, contains('fixedSize: const Size.square(')); expect(source, contains('shape: const CircleBorder()')); @@ -2572,6 +2582,10 @@ void main() { await tester.pumpAndSettle(); final day = find.text('14'); + final dayInkWell = tester.widget( + find.ancestor(of: day, matching: find.byType(InkWell)), + ); + expect(dayInkWell.hoverColor, Colors.transparent); final marker = find.ancestor( of: day, matching: find.byWidgetPredicate( @@ -2638,6 +2652,28 @@ void main() { expect(monthLabel.style?.fontWeight, FontWeight.w600); expect(yearLabel.style?.fontSize, lessThan(14)); expect(yearLabel.style?.fontWeight, FontWeight.w600); + + final monthButton = find.ancestor( + of: find.text('January'), + matching: find.byType(TextButton), + ); + final yearButton = find.ancestor( + of: find.text('2026'), + matching: find.byType(TextButton), + ); + expect( + tester.getSize(monthButton).width, + greaterThan(tester.getSize(yearButton).width), + ); + + final weekButton = find.descendant( + of: find.byTooltip('Week 3'), + matching: find.byType(TextButton), + ); + expect( + tester.getSize(weekButton).height, + tester.getSize(monthButton).height, + ); }); testWidgets('mini calendar week number selects that week', (tester) async { @@ -2666,6 +2702,98 @@ void main() { expect(selectedWeek, DateTime(2026, 1, 12)); }); + testWidgets('year view week number selects that week', (tester) async { + DateTime? selectedWeek; + + await tester.pumpWidget( + localizedTestApp( + child: Scaffold( + body: SizedBox( + width: 1000, + height: 720, + child: ScheduleYearView( + selectedDate: DateTime(2026, 1, 15), + items: const [], + firstWeekday: DateTime.monday, + onDaySelected: (_) {}, + onMonthSelected: (_) {}, + onWeekSelected: (weekStart) => selectedWeek = weekStart, + onCreateAtDay: (_) {}, + ), + ), + ), + ), + ); + + final january = find.byType(MiniCalendar).first; + await tester.tap( + find.descendant(of: january, matching: find.byTooltip('Week 3')), + ); + + expect(selectedWeek, DateTime(2026, 1, 12)); + }); + + testWidgets( + 'mini calendar numbers Sunday-first rows from their displayed start date', + (tester) async { + await tester.pumpWidget( + localizedTestApp( + child: Scaffold( + body: SizedBox( + width: 300, + child: MiniCalendar( + selectedDate: DateTime(2027, 1, 15), + firstWeekday: DateTime.sunday, + onSelected: (_) {}, + onMonthSelected: (_) {}, + onYearSelected: (_) {}, + onWeekSelected: (_) {}, + ), + ), + ), + ), + ); + + expect(find.byTooltip('Week 52'), findsOneWidget); + expect(find.byTooltip('Week 53'), findsOneWidget); + expect(find.byTooltip('Week 1'), findsOneWidget); + }, + ); + + testWidgets('mini calendar week buttons have hover-only backgrounds', ( + tester, + ) async { + await tester.pumpWidget( + localizedTestApp( + child: Scaffold( + body: SizedBox( + width: 300, + child: MiniCalendar( + selectedDate: DateTime(2026, 1, 15), + firstWeekday: DateTime.monday, + onSelected: (_) {}, + onMonthSelected: (_) {}, + onYearSelected: (_) {}, + onWeekSelected: (_) {}, + ), + ), + ), + ), + ); + + final weekButton = tester.widget( + find.descendant( + of: find.byTooltip('Week 3'), + matching: find.byType(TextButton), + ), + ); + expect(weekButton.style?.backgroundColor?.resolve({}), isNull); + expect( + weekButton.style?.backgroundColor?.resolve({WidgetState.hovered}), + isNotNull, + ); + }); + testWidgets('mini calendar can render week numbers as labels', ( tester, ) async { @@ -2935,7 +3063,7 @@ void main() { expect(workspace, contains('setScheduleViewMode(ScheduleViewMode.week)')); }); - test('year view day clicks open day mode', () { + test('year view day and week clicks open their matching modes', () { final workspace = File( 'lib/src/features/schedule/presentation/schedule_workspace.dart', ).readAsStringSync(); @@ -2943,6 +3071,9 @@ void main() { expect(workspace, contains('required this.onYearDaySelected')); expect(workspace, contains('onYearDaySelected: _openDay')); expect(workspace, contains('onDaySelected: onYearDaySelected')); + expect(workspace, contains('required this.onWeekSelected')); + expect(workspace, contains('onWeekSelected: _setWeek')); + expect(workspace, contains('onWeekSelected: onWeekSelected')); }); test('sidebar source rows keep visibility actions on the right', () { @@ -3375,7 +3506,7 @@ void main() { yearView, contains('backgroundColor ?? BusyMaxSurfaceColors.of(context).window'), ); - expect(yearView, isNot(contains('BusyMaxSurfaceColors.of(context).card'))); + expect(yearView, contains('BusyMaxGroupedSurface(')); expect(yearView, contains('MiniCalendar(')); expect( yearView, @@ -3385,6 +3516,8 @@ void main() { expect(yearView, contains('onDayDoubleTap: onCreateAtDay')); expect(yearView, contains('firstWeekday')); expect(yearView, contains('onMonthSelected: onMonthSelected')); + expect(yearView, contains('weekNumbersInteractive: true')); + expect(yearView, contains('onWeekSelected: onWeekSelected')); expect(yearView, isNot(contains('height: 142'))); expect(yearView, contains('SingleChildScrollView(')); expect(yearView, isNot(contains('class _YearMonthGrid'))); diff --git a/test/platform/linux_header_bar_configuration_synchronizer_test.dart b/test/platform/linux_header_bar_configuration_synchronizer_test.dart index 2f01ea7..22114e0 100644 --- a/test/platform/linux_header_bar_configuration_synchronizer_test.dart +++ b/test/platform/linux_header_bar_configuration_synchronizer_test.dart @@ -135,13 +135,6 @@ BusyMaxHeaderBarConfiguration _configuration({required bool dark}) { popoverShadowColor: Colors.black38, dialogBackgroundColor: dark ? Colors.black : Colors.white, dialogOutlineColor: dark ? Colors.white : Colors.white10, - dialogCancelBackgroundColor: dark ? Colors.white12 : Colors.black12, - dialogCancelHoverColor: dark ? Colors.white24 : Colors.black26, - dialogCancelActiveColor: dark ? Colors.white30 : Colors.black38, - dialogDestructiveBackgroundColor: Colors.red, - dialogDestructiveHoverColor: Colors.redAccent, - dialogDestructiveActiveColor: Colors.red, - dialogDestructiveForegroundColor: Colors.white, modalBarrierColor: Colors.black54, ), ); diff --git a/test/platform/linux_header_bar_service_test.dart b/test/platform/linux_header_bar_service_test.dart index 167a5fc..2117604 100644 --- a/test/platform/linux_header_bar_service_test.dart +++ b/test/platform/linux_header_bar_service_test.dart @@ -107,13 +107,6 @@ void main() { popoverShadowColor: Color.fromRGBO(0, 0, 0, 0.3), dialogBackgroundColor: Color(0xFF36363A), dialogOutlineColor: Color.fromRGBO(255, 255, 255, 0.07), - dialogCancelBackgroundColor: Color.fromRGBO(255, 255, 255, 0.10), - dialogCancelHoverColor: Color.fromRGBO(255, 255, 255, 0.14), - dialogCancelActiveColor: Color.fromRGBO(255, 255, 255, 0.18), - dialogDestructiveBackgroundColor: Color(0xFFE01B24), - dialogDestructiveHoverColor: Color(0xFFE22D36), - dialogDestructiveActiveColor: Color(0xFFE4363E), - dialogDestructiveForegroundColor: Color(0xFFFFFFFF), modalBarrierColor: Color.fromRGBO(0, 0, 0, 0.32), ), ); @@ -163,13 +156,6 @@ void main() { 'popoverShadowColor': 'rgba(0,0,0,0.30)', 'dialogBackgroundColor': '#36363A', 'dialogOutlineColor': 'rgba(255,255,255,0.07)', - 'dialogCancelBackgroundColor': 'rgba(255,255,255,0.10)', - 'dialogCancelHoverColor': 'rgba(255,255,255,0.14)', - 'dialogCancelActiveColor': 'rgba(255,255,255,0.18)', - 'dialogDestructiveBackgroundColor': '#E01B24', - 'dialogDestructiveHoverColor': '#E22D36', - 'dialogDestructiveActiveColor': '#E4363E', - 'dialogDestructiveForegroundColor': '#FFFFFF', 'modalBarrierColor': 'rgba(0,0,0,0.32)', }), ); From ce9097c7c8475c53a19b1aede200fb6548b14d9c Mon Sep 17 00:00:00 2001 From: albert Date: Wed, 29 Jul 2026 14:15:56 -0700 Subject: [PATCH 25/73] Add native time zone selection dialog with search functionality. Implement time zone options management and enhance dialog styling for improved user experience. Update snapcraft.yaml to include necessary library dependencies. Implement time zone selection dialog with search functionality. Integrate native dialog service for improved user experience and enhance time zone catalog with system location management. --- lib/src/app/busymax_app.dart | 42 +- lib/src/app/busymax_design.dart | 104 +++- .../time/linux_gweather_location_source.dart | 183 +++++++ lib/src/core/time/time_zone_catalog.dart | 235 ++++++++- .../schedule/presentation/mini_calendar.dart | 100 ++-- .../presentation/schedule_create_menu.dart | 2 + .../presentation/schedule_month_view.dart | 26 +- .../presentation/schedule_sidebar.dart | 1 + .../presentation/schedule_workspace.dart | 31 +- .../desktop_date_time_fields.dart | 273 +++++------ .../time_zone_selection_dialog.dart | 116 ++++- lib/src/platform/native_dialog_service.dart | 74 +++ lib/src/platform/native_menu_service.dart | 2 + linux/runner/my_application.cc | 451 +++++++++++++++++- snap/snapcraft.yaml | 2 + test/app/busymax_search_field_test.dart | 24 + test/app/native_ui_audit_test.dart | 73 ++- test/app/theme_localization_test.dart | 39 ++ test/core/time/time_zone_catalog_test.dart | 142 ++++++ .../schedule_create_menu_test.dart | 30 ++ .../presentation/schedule_views_test.dart | 195 +++++++- test/features/schedule/schedule_dst_test.dart | 2 +- .../desktop_date_time_fields_test.dart | 110 ++++- test/platform/native_dialog_service_test.dart | 79 +++ test/platform/native_menu_service_test.dart | 20 + 25 files changed, 2078 insertions(+), 278 deletions(-) create mode 100644 lib/src/core/time/linux_gweather_location_source.dart diff --git a/lib/src/app/busymax_app.dart b/lib/src/app/busymax_app.dart index 069721d..d52fb33 100644 --- a/lib/src/app/busymax_app.dart +++ b/lib/src/app/busymax_app.dart @@ -4,6 +4,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:system_theme/system_theme.dart'; import 'package:ubuntu_localizations/ubuntu_localizations.dart'; +import 'package:window_manager/window_manager.dart'; import '../platform/busymax_tray_service.dart'; import '../platform/gtk_font_service.dart'; @@ -39,18 +40,25 @@ class BusyMaxApp extends ConsumerStatefulWidget { ConsumerState createState() => _BusyMaxAppState(); } -class _BusyMaxAppState extends ConsumerState { +class _BusyMaxAppState extends ConsumerState + with WidgetsBindingObserver, WindowListener { BusyMaxTrayService? _trayService; bool? _lastHideOnClose; bool? _lastTrayEnabled; bool _startMinimizedHandled = false; bool _settingsReady = false; + bool _windowActive = true; late final BusyMaxHeaderBarConfigurationSynchronizer _headerBarConfigurationSynchronizer; @override void initState() { super.initState(); + WidgetsBinding.instance.addObserver(this); + windowManager.addListener(this); + _windowActive = _isActiveLifecycleState( + WidgetsBinding.instance.lifecycleState, + ); _headerBarConfigurationSynchronizer = BusyMaxHeaderBarConfigurationSynchronizer( ref.read(linuxHeaderBarServiceProvider), @@ -60,6 +68,8 @@ class _BusyMaxAppState extends ConsumerState { @override void dispose() { + windowManager.removeListener(this); + WidgetsBinding.instance.removeObserver(this); _headerBarConfigurationSynchronizer.dispose(); final tray = _trayService; if (tray != null) { @@ -68,6 +78,24 @@ class _BusyMaxAppState extends ConsumerState { super.dispose(); } + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + _setWindowActive(_isActiveLifecycleState(state)); + } + + @override + void onWindowFocus() => _setWindowActive(true); + + @override + void onWindowBlur() => _setWindowActive(false); + + void _setWindowActive(bool windowActive) { + if (_windowActive == windowActive || !mounted) { + return; + } + setState(() => _windowActive = windowActive); + } + Future _waitForSettings() async { await ref.read(appSettingsControllerProvider.notifier).ready; if (!mounted) { @@ -186,7 +214,13 @@ class _BusyMaxAppState extends ConsumerState { child: MainWindowCommandBridge( child: ColoredBox( color: BusyMaxSurfaceColors.of(context).window, - child: child ?? const SizedBox.shrink(), + child: Opacity( + key: const ValueKey('busymax-window-backdrop'), + opacity: _windowActive + ? 1 + : BusyMaxAlpha.windowBackdropOpacity, + child: child ?? const SizedBox.shrink(), + ), ), ), ), @@ -377,6 +411,10 @@ class _BusyMaxAppState extends ConsumerState { } } +bool _isActiveLifecycleState(AppLifecycleState? state) { + return state == null || state == AppLifecycleState.resumed; +} + class _KeyboardShortcutsIntent extends Intent { const _KeyboardShortcutsIntent(); } diff --git a/lib/src/app/busymax_design.dart b/lib/src/app/busymax_design.dart index 5f3bc1a..2ef005d 100644 --- a/lib/src/app/busymax_design.dart +++ b/lib/src/app/busymax_design.dart @@ -73,6 +73,7 @@ abstract final class BusyMaxAlpha { static const double calendarGridDark = 0.06; static const double groupedRowLightHoverStrength = 0.50; static const double nativeHeaderMenuShadowOpacity = 0.30; + static const double windowBackdropOpacity = 0.50; } abstract final class BusyMaxMotion { @@ -693,10 +694,14 @@ class _BusyMaxSearchFieldState extends State { debugLabel: 'BusyMaxSearchField keyboard listener', skipTraversal: true, ); + late TextEditingController _controller; + late bool _ownsController; + late bool _isEmpty; @override void initState() { super.initState(); + _attachController(widget.controller); if (widget.autofocus) { _requestTextFocus(); } @@ -705,6 +710,10 @@ class _BusyMaxSearchFieldState extends State { @override void didUpdateWidget(covariant BusyMaxSearchField oldWidget) { super.didUpdateWidget(oldWidget); + if (oldWidget.controller != widget.controller) { + _detachController(); + _attachController(widget.controller); + } if (oldWidget.focusRequest != widget.focusRequest) { _requestTextFocus(); } @@ -712,11 +721,39 @@ class _BusyMaxSearchFieldState extends State { @override void dispose() { + _detachController(); _focusScopeNode.dispose(); _yaruKeyboardFocusNode.dispose(); super.dispose(); } + void _attachController(TextEditingController? controller) { + _ownsController = controller == null; + _controller = controller ?? TextEditingController(); + _isEmpty = _controller.text.isEmpty; + _controller.addListener(_handleControllerChanged); + } + + void _detachController() { + _controller.removeListener(_handleControllerChanged); + if (_ownsController) { + _controller.dispose(); + } + } + + void _handleControllerChanged() { + final isEmpty = _controller.text.isEmpty; + if (_isEmpty == isEmpty || !mounted) { + return; + } + setState(() => _isEmpty = isEmpty); + } + + void _clear() { + widget.onClear?.call(); + _controller.clear(); + } + void _requestTextFocus() { WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted) { @@ -736,19 +773,62 @@ class _BusyMaxSearchFieldState extends State { @override Widget build(BuildContext context) { + final clearLabel = + widget.clearButtonSemanticLabel ?? + MaterialLocalizations.of(context).clearButtonTooltip; + final contentPadding = switch (Directionality.of(context)) { + TextDirection.ltr => const EdgeInsets.only( + left: BusyMaxSpacing.md, + right: kYaruTitleBarItemHeight, + ), + TextDirection.rtl => const EdgeInsets.only( + left: kYaruTitleBarItemHeight, + right: BusyMaxSpacing.md, + ), + }; return FocusScope( node: _focusScopeNode, - child: YaruSearchField( - controller: widget.controller, - focusNode: _yaruKeyboardFocusNode, - hintText: widget.hintText, - autofocus: widget.autofocus, - onChanged: widget.onChanged, - onSubmitted: widget.onSubmitted, - onClear: widget.onClear, - clearIconSemanticLabel: - widget.clearButtonSemanticLabel ?? - MaterialLocalizations.of(context).clearButtonTooltip, + child: SizedBox( + height: kYaruTitleBarItemHeight, + child: Stack( + fit: StackFit.expand, + children: [ + YaruSearchField( + controller: _controller, + focusNode: _yaruKeyboardFocusNode, + hintText: widget.hintText, + height: kYaruTitleBarItemHeight, + contentPadding: contentPadding, + autofocus: widget.autofocus, + onChanged: widget.onChanged, + onSubmitted: widget.onSubmitted, + clearIconSemanticLabel: clearLabel, + ), + if (widget.onClear != null && !_isEmpty) + Align( + alignment: AlignmentDirectional.centerEnd, + child: Padding( + padding: const EdgeInsetsDirectional.only( + end: BusyMaxSpacing.xxs, + ), + child: BusyMaxHeaderIconButton( + tooltip: clearLabel, + icon: const Icon(YaruIcons.edit_clear), + iconSize: BusyMaxSizes.iconSm, + fixedSize: const Size.square( + kYaruTitleBarItemHeight - BusyMaxSpacing.headerInset, + ), + shape: const CircleBorder(), + backgroundColor: busyMaxSubtleButtonBackground(context), + overlayColor: const WidgetStatePropertyAll( + Colors.transparent, + ), + onPressed: _clear, + ), + ), + ), + ], + ), ), ); } @@ -2314,6 +2394,7 @@ Future?> showBusyMaxMenu({ NativeMenuService nativeMenuService = const NativeMenuService(), BusyMaxMenuSession? session, bool focusFirst = false, + bool preferAbove = false, }) async { assert( anchorRect == null || (anchorContext == null && anchorPoint == null), @@ -2340,6 +2421,7 @@ Future?> showBusyMaxMenu({ anchor: anchor, entries: _nativeMenuEntries(entrySnapshot), focusFirst: focusFirst, + preferAbove: preferAbove, ); if (presentation._isDismissed) { return null; diff --git a/lib/src/core/time/linux_gweather_location_source.dart b/lib/src/core/time/linux_gweather_location_source.dart new file mode 100644 index 0000000..9beb878 --- /dev/null +++ b/lib/src/core/time/linux_gweather_location_source.dart @@ -0,0 +1,183 @@ +import 'dart:convert'; +import 'dart:ffi'; +import 'dart:io'; +import 'dart:isolate'; + +import 'package:flutter/foundation.dart'; + +@immutable +class BusyMaxSystemTimeZoneLocation { + const BusyMaxSystemTimeZoneLocation({ + required this.name, + required this.englishName, + required this.countryCode, + required this.timeZoneId, + }); + + final String name; + final String englishName; + final String? countryCode; + final String timeZoneId; + + bool matches(String normalizedQuery) { + return name.toLowerCase().contains(normalizedQuery) || + englishName.toLowerCase().contains(normalizedQuery); + } +} + +Future> +loadLinuxSystemTimeZoneLocations() async { + if (!Platform.isLinux) { + return const []; + } + + try { + return await Isolate.run(_loadGWeatherLocations); + } on Object { + // libgweather is an optional runtime integration. The IANA catalog remains + // available when the library or its location database is not installed. + return const []; + } +} + +List _loadGWeatherLocations() { + final api = _GWeatherApi(); + final world = api.getWorld(); + if (world == nullptr) { + return const []; + } + + final locations = []; + try { + _collectCities(api, world, locations); + } finally { + api.unref(world.cast()); + } + + locations.sort((a, b) { + final nameOrder = a.name.compareTo(b.name); + if (nameOrder != 0) { + return nameOrder; + } + final countryOrder = (a.countryCode ?? '').compareTo(b.countryCode ?? ''); + return countryOrder != 0 + ? countryOrder + : a.timeZoneId.compareTo(b.timeZoneId); + }); + return List.unmodifiable(locations); +} + +void _collectCities( + _GWeatherApi api, + Pointer<_GWeatherLocation> parent, + List locations, +) { + var child = nullptr.cast<_GWeatherLocation>(); + while (true) { + child = api.nextChild(parent, child); + if (child == nullptr) { + return; + } + + if (api.getLevel(child) == _GWeatherLocationLevel.city) { + final name = _readUtf8(api.getName(child)); + final englishName = _readUtf8(api.getEnglishName(child)); + final timeZoneId = _readUtf8(api.getTimeZoneId(child)); + if (name != null && englishName != null && timeZoneId != null) { + locations.add( + BusyMaxSystemTimeZoneLocation( + name: name, + englishName: englishName, + countryCode: _readUtf8(api.getCountryCode(child)), + timeZoneId: timeZoneId, + ), + ); + } + continue; + } + + _collectCities(api, child, locations); + } +} + +String? _readUtf8(Pointer value) { + if (value == nullptr) { + return null; + } + + final bytes = value.cast(); + var length = 0; + while (length < 65536 && (bytes + length).value != 0) { + length += 1; + } + if (length == 0 || length == 65536) { + return null; + } + return utf8.decode(bytes.asTypedList(length), allowMalformed: true); +} + +abstract final class _GWeatherLocationLevel { + static const city = 4; +} + +final class _GWeatherLocation extends Opaque {} + +typedef _GetWorldNative = Pointer<_GWeatherLocation> Function(); +typedef _GetWorldDart = Pointer<_GWeatherLocation> Function(); +typedef _NextChildNative = + Pointer<_GWeatherLocation> Function( + Pointer<_GWeatherLocation>, + Pointer<_GWeatherLocation>, + ); +typedef _NextChildDart = + Pointer<_GWeatherLocation> Function( + Pointer<_GWeatherLocation>, + Pointer<_GWeatherLocation>, + ); +typedef _GetLevelNative = Int32 Function(Pointer<_GWeatherLocation>); +typedef _GetLevelDart = int Function(Pointer<_GWeatherLocation>); +typedef _GetStringNative = Pointer Function(Pointer<_GWeatherLocation>); +typedef _GetStringDart = Pointer Function(Pointer<_GWeatherLocation>); +typedef _UnrefNative = Void Function(Pointer); +typedef _UnrefDart = void Function(Pointer); + +final class _GWeatherApi { + _GWeatherApi() { + final library = DynamicLibrary.open('libgweather-4.so.0'); + final gObjectLibrary = DynamicLibrary.open('libgobject-2.0.so.0'); + + getWorld = library.lookupFunction<_GetWorldNative, _GetWorldDart>( + 'gweather_location_get_world', + ); + nextChild = library.lookupFunction<_NextChildNative, _NextChildDart>( + 'gweather_location_next_child', + ); + getLevel = library.lookupFunction<_GetLevelNative, _GetLevelDart>( + 'gweather_location_get_level', + ); + getName = library.lookupFunction<_GetStringNative, _GetStringDart>( + 'gweather_location_get_name', + ); + getEnglishName = library.lookupFunction<_GetStringNative, _GetStringDart>( + 'gweather_location_get_english_name', + ); + getCountryCode = library.lookupFunction<_GetStringNative, _GetStringDart>( + 'gweather_location_get_country', + ); + getTimeZoneId = library.lookupFunction<_GetStringNative, _GetStringDart>( + 'gweather_location_get_timezone_str', + ); + unref = gObjectLibrary.lookupFunction<_UnrefNative, _UnrefDart>( + 'g_object_unref', + ); + } + + late final _GetWorldDart getWorld; + late final _NextChildDart nextChild; + late final _GetLevelDart getLevel; + late final _GetStringDart getName; + late final _GetStringDart getEnglishName; + late final _GetStringDart getCountryCode; + late final _GetStringDart getTimeZoneId; + late final _UnrefDart unref; +} diff --git a/lib/src/core/time/time_zone_catalog.dart b/lib/src/core/time/time_zone_catalog.dart index 010dcfc..b59f4e6 100644 --- a/lib/src/core/time/time_zone_catalog.dart +++ b/lib/src/core/time/time_zone_catalog.dart @@ -1,7 +1,9 @@ -import 'package:flutter/foundation.dart'; -import 'package:timezone/data/latest.dart' as time_zone_data; +import 'package:flutter/foundation.dart' show immutable, visibleForTesting; +import 'package:timezone/data/latest_all.dart' as time_zone_data; import 'package:timezone/timezone.dart' as time_zone; +import 'linux_gweather_location_source.dart'; + @immutable class BusyMaxTimeZoneLocation { const BusyMaxTimeZoneLocation({ @@ -30,8 +32,46 @@ class BusyMaxTimeZoneLocation { } } +@immutable +class BusyMaxTimeZoneSearchResult { + const BusyMaxTimeZoneSearchResult({ + required this.location, + required this.name, + this.englishName, + this.countryCode, + }); + + final BusyMaxTimeZoneLocation location; + final String name; + final String? englishName; + final String? countryCode; + + String get title => '$name (${location.code})'; + + String get subtitle { + final country = countryCode; + return country == null ? location.id : '${location.id} - $country'; + } + + String get searchText => { + name, + if (englishName != null) englishName!, + if (countryCode != null) countryCode!, + if (englishName == null) ...[ + location.id, + location.region, + location.name, + location.code, + ], + }.join('\n'); +} + abstract final class BusyMaxTimeZoneCatalog { static List? _locations; + static Map? _locationsById; + static List? _systemLocations; + static Future>? _systemLocationLoad; + static List? _locationSearchOptions; static List get locations { return _locations ??= _buildLocations(); @@ -39,29 +79,172 @@ abstract final class BusyMaxTimeZoneCatalog { static BusyMaxTimeZoneLocation location(String id) { final normalized = id == 'UTC' ? 'Etc/UTC' : id; - return locations.firstWhere( - (location) => location.id == normalized, - orElse: () { - final parts = normalized.split('/'); - return BusyMaxTimeZoneLocation( - id: normalized, - region: _readableSegment(parts.first), - name: parts.skip(1).map(_readableSegment).join(' / '), - code: normalized, - offset: Duration.zero, - ); - }, + final knownLocation = _locationMap[normalized]; + if (knownLocation != null) { + return knownLocation; + } + final parts = normalized.split('/'); + return BusyMaxTimeZoneLocation( + id: normalized, + region: _readableSegment(parts.first), + name: parts.skip(1).map(_readableSegment).join(' / '), + code: normalized, + offset: Duration.zero, ); } - static List search(String query, {int limit = 80}) { + static List search(String query) { if (query.trim().isEmpty) { return const []; } - return locations - .where((location) => location.matches(query)) - .take(limit) - .toList(); + return locations.where((location) => location.matches(query)).toList(); + } + + static bool get isLocationSearchReady => _systemLocations != null; + + static Future prepareLocationSearch() async { + final load = _systemLocationLoad ??= loadLinuxSystemTimeZoneLocations(); + _systemLocations ??= await load; + } + + static Future> searchLocations( + String query, + ) async { + await prepareLocationSearch(); + return searchPreparedLocations(query); + } + + static List get preparedLocationOptions { + return _locationSearchOptions ??= _buildLocationSearchOptions(); + } + + static List searchPreparedLocations( + String query, + ) { + final normalized = query.trim().toLowerCase(); + if (normalized.isEmpty) { + return const []; + } + + final results = []; + final seen = {}; + + if (normalized.length >= 2) { + for (final systemLocation + in _systemLocations ?? const []) { + if (!systemLocation.matches(normalized)) { + continue; + } + final result = BusyMaxTimeZoneSearchResult( + location: location(systemLocation.timeZoneId), + name: systemLocation.name, + englishName: systemLocation.englishName, + countryCode: systemLocation.countryCode, + ); + if (seen.add(_resultKey(result))) { + results.add(result); + } + } + } + + for (final timeZoneLocation in search(query)) { + final result = BusyMaxTimeZoneSearchResult( + location: timeZoneLocation, + name: timeZoneLocation.name, + ); + if (seen.add(_resultKey(result))) { + results.add(result); + } + } + + results.sort((a, b) { + final matchOrder = _matchRank( + a, + normalized, + ).compareTo(_matchRank(b, normalized)); + if (matchOrder != 0) { + return matchOrder; + } + final regionOrder = a.location.region.compareTo(b.location.region); + if (regionOrder != 0) { + return regionOrder; + } + final nameOrder = a.name.compareTo(b.name); + return nameOrder != 0 + ? nameOrder + : a.location.id.compareTo(b.location.id); + }); + return results; + } + + static Map get _locationMap { + return _locationsById ??= { + for (final location in locations) location.id: location, + }; + } + + static String _resultKey(BusyMaxTimeZoneSearchResult result) { + return '${result.location.id}\u0000${result.name.toLowerCase()}'; + } + + static int _matchRank( + BusyMaxTimeZoneSearchResult result, + String normalizedQuery, + ) { + final names = { + result.name.toLowerCase(), + if (result.englishName != null) result.englishName!.toLowerCase(), + }; + if (names.contains(normalizedQuery)) { + return 0; + } + if (names.any((name) => name.startsWith(normalizedQuery))) { + return 1; + } + if (names.any((name) => name.contains(normalizedQuery))) { + return 2; + } + return 3; + } + + static List _buildLocationSearchOptions() { + final results = []; + final seen = {}; + + for (final systemLocation + in _systemLocations ?? const []) { + final result = BusyMaxTimeZoneSearchResult( + location: location(systemLocation.timeZoneId), + name: systemLocation.name, + englishName: systemLocation.englishName, + countryCode: systemLocation.countryCode, + ); + if (seen.add(_resultKey(result))) { + results.add(result); + } + } + + for (final timeZoneLocation in locations) { + final result = BusyMaxTimeZoneSearchResult( + location: timeZoneLocation, + name: timeZoneLocation.name, + ); + if (seen.add(_resultKey(result))) { + results.add(result); + } + } + + results.sort((a, b) { + final regionOrder = a.location.region.compareTo(b.location.region); + if (regionOrder != 0) { + return regionOrder; + } + final nameOrder = a.name.compareTo(b.name); + return nameOrder != 0 + ? nameOrder + : a.location.id.compareTo(b.location.id); + }); + return List.unmodifiable(results); } static List _buildLocations() { @@ -112,11 +295,21 @@ abstract final class BusyMaxTimeZoneCatalog { return id.contains('/') && id != 'Etc/UTC' && !id.startsWith('Etc/') && - !id.startsWith('SystemV/') && - !id.startsWith('US/'); + !id.startsWith('SystemV/'); } static String _readableSegment(String value) { return value.replaceAll('_', ' '); } + + @visibleForTesting + static void setSystemLocationsForTesting( + List? locations, + ) { + _systemLocations = locations == null ? null : List.unmodifiable(locations); + _locationSearchOptions = null; + _systemLocationLoad = locations == null + ? null + : Future.value(_systemLocations); + } } diff --git a/lib/src/features/schedule/presentation/mini_calendar.dart b/lib/src/features/schedule/presentation/mini_calendar.dart index b77f203..3d6c591 100644 --- a/lib/src/features/schedule/presentation/mini_calendar.dart +++ b/lib/src/features/schedule/presentation/mini_calendar.dart @@ -17,7 +17,7 @@ const _miniCalendarHeaderControlExtent = 28.0; const _miniCalendarMonthControlFlex = 3; const _miniCalendarYearControlFlex = 2; -class MiniCalendar extends StatelessWidget { +class MiniCalendar extends StatefulWidget { const MiniCalendar({ super.key, required this.selectedDate, @@ -49,13 +49,45 @@ class MiniCalendar extends StatelessWidget { final ValueChanged? onWeekSelected; final ValueChanged? onDayDoubleTap; + @override + State createState() => _MiniCalendarState(); +} + +class _MiniCalendarState extends State { + late DateTime _displayedMonth; + + @override + void initState() { + super.initState(); + _displayedMonth = _monthOf(widget.displayedMonth ?? widget.selectedDate); + } + + @override + void didUpdateWidget(covariant MiniCalendar oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.displayedMonth != null) { + _displayedMonth = _monthOf(widget.displayedMonth!); + return; + } + if (!_sameMonth(oldWidget.selectedDate, widget.selectedDate)) { + _displayedMonth = _monthOf(widget.selectedDate); + } + } + + void _showMonth(DateTime month) { + if (widget.displayedMonth != null) { + return; + } + setState(() => _displayedMonth = _monthOf(month)); + } + @override Widget build(BuildContext context) { final l10n = context.l10n; - final visibleMonth = displayedMonth ?? selectedDate; + final visibleMonth = widget.displayedMonth ?? _displayedMonth; final first = DateTime(visibleMonth.year, visibleMonth.month); - final start = _calendarStartForMonth(first, firstWeekday); - final groupedItems = ScheduleProjection.groupByDay(items); + final start = _calendarStartForMonth(first, widget.firstWeekday); + final groupedItems = ScheduleProjection.groupByDay(widget.items); final locale = Localizations.localeOf(context).toLanguageTag(); return Padding( padding: const EdgeInsetsDirectional.fromSTEB( @@ -67,8 +99,8 @@ class MiniCalendar extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - if (showHeader) ...[ - if (headerStyle == MiniCalendarHeaderStyle.navigation) + if (widget.showHeader) ...[ + if (widget.headerStyle == MiniCalendarHeaderStyle.navigation) Row( children: [ Expanded( @@ -77,16 +109,16 @@ class MiniCalendar extends StatelessWidget { label: DateFormat.MMMM(locale).format(visibleMonth), previousTooltip: l10n.previousMonth, nextTooltip: l10n.nextMonth, - onPrevious: () => onSelected( + onPrevious: () => _showMonth( DateTime(visibleMonth.year, visibleMonth.month - 1), ), - onNext: () => onSelected( + onNext: () => _showMonth( DateTime(visibleMonth.year, visibleMonth.month + 1), ), labelTooltip: l10n.openMonthView, - onLabelPressed: onMonthSelected == null + onLabelPressed: widget.onMonthSelected == null ? null - : () => onMonthSelected!(first), + : () => widget.onMonthSelected!(first), ), ), const SizedBox(width: BusyMaxSpacing.sm), @@ -96,16 +128,18 @@ class MiniCalendar extends StatelessWidget { label: '${visibleMonth.year}', previousTooltip: l10n.previousYear, nextTooltip: l10n.nextYear, - onPrevious: () => onSelected( + onPrevious: () => _showMonth( DateTime(visibleMonth.year - 1, visibleMonth.month), ), - onNext: () => onSelected( + onNext: () => _showMonth( DateTime(visibleMonth.year + 1, visibleMonth.month), ), labelTooltip: l10n.openYearView, - onLabelPressed: onYearSelected == null + onLabelPressed: widget.onYearSelected == null ? null - : () => onYearSelected!(DateTime(visibleMonth.year)), + : () => widget.onYearSelected!( + DateTime(visibleMonth.year), + ), ), ), ], @@ -114,15 +148,15 @@ class MiniCalendar extends StatelessWidget { _MiniCalendarHeaderLabel( label: DateFormat.yMMMM(locale).format(first), tooltip: l10n.openMonthView, - onPressed: onMonthSelected == null + onPressed: widget.onMonthSelected == null ? null - : () => onMonthSelected!(first), + : () => widget.onMonthSelected!(first), ), const SizedBox(height: BusyMaxSpacing.sm), ], LayoutBuilder( builder: (context, constraints) { - final maximumWeekNumberExtent = weekNumbersInteractive + final maximumWeekNumberExtent = widget.weekNumbersInteractive ? _miniCalendarHeaderControlExtent : _miniCalendarHeaderControlExtent - BusyMaxSpacing.headerInset; @@ -144,7 +178,7 @@ class MiniCalendar extends StatelessWidget { child: Row( children: [ SizedBox(width: weekNumberExtent), - for (final weekday in _weekdays(firstWeekday)) + for (final weekday in _weekdays(widget.firstWeekday)) Expanded( child: Center( child: Text( @@ -175,15 +209,15 @@ class MiniCalendar extends StatelessWidget { row * DateTime.daysPerWeek, ), weekNumberExtent: weekNumberExtent, - onWeekSelected: weekNumbersInteractive - ? onWeekSelected + onWeekSelected: widget.weekNumbersInteractive + ? widget.onWeekSelected : null, - selectedDate: selectedDate, + selectedDate: widget.selectedDate, displayedMonth: first, groupedItems: groupedItems, - showDayHover: showDayHover, - onDaySelected: onSelected, - onDayDoubleTap: onDayDoubleTap, + showDayHover: widget.showDayHover, + onDaySelected: widget.onSelected, + onDayDoubleTap: widget.onDayDoubleTap, ), ), ], @@ -373,15 +407,15 @@ class _MiniCalendarDayButtonState extends State<_MiniCalendarDayButton> { math.max(0.0, availableMarkerExtent), ); final hoverColor = Color.alphaBlend( - Colors.white.withValues( + surfaceColors.foreground.withValues( alpha: Theme.of(context).brightness == Brightness.light - ? 0.48 + ? 0.06 : 0.12, ), - surfaceColors.popover, + surfaceColors.card, ); final hoveredMarkerSize = math.min( - 25.5 + 4.0, + 32.0, math.max(0.0, availableMarkerExtent), ); final currentMarkerSize = @@ -429,7 +463,9 @@ class _MiniCalendarDayButtonState extends State<_MiniCalendarDayButton> { ? surfaceColors.foreground : inDisplayedMonth ? null - : colorScheme.onSurfaceVariant, + : colorScheme.onSurfaceVariant.withValues( + alpha: 0.45, + ), fontWeight: selected || highlightToday ? FontWeight.w600 : null, @@ -668,6 +704,12 @@ bool _sameDay(DateTime a, DateTime b) { return a.year == b.year && a.month == b.month && a.day == b.day; } +bool _sameMonth(DateTime a, DateTime b) { + return a.year == b.year && a.month == b.month; +} + +DateTime _monthOf(DateTime date) => DateTime(date.year, date.month); + DateTime _calendarStartForMonth(DateTime first, int firstWeekday) { final monthWeekdayFromMonday = first.weekday - DateTime.monday; final firstWeekdayFromMonday = firstWeekday - DateTime.monday; diff --git a/lib/src/features/schedule/presentation/schedule_create_menu.dart b/lib/src/features/schedule/presentation/schedule_create_menu.dart index e3dd56b..477b253 100644 --- a/lib/src/features/schedule/presentation/schedule_create_menu.dart +++ b/lib/src/features/schedule/presentation/schedule_create_menu.dart @@ -24,6 +24,7 @@ Future showScheduleCreateMenu({ bool canCreateEvent = true, bool canCreateTask = true, BusyMaxMenuSession? session, + bool preferAbove = false, }) async { if (!canCreateEvent && !canCreateTask) { return null; @@ -35,6 +36,7 @@ Future showScheduleCreateMenu({ anchorPoint: anchorPoint, session: session, focusFirst: anchorPoint == null, + preferAbove: preferAbove, entries: [ BusyMaxMenuEntry( value: ScheduleCreateChoice.event, diff --git a/lib/src/features/schedule/presentation/schedule_month_view.dart b/lib/src/features/schedule/presentation/schedule_month_view.dart index b74739f..4e0e8c1 100644 --- a/lib/src/features/schedule/presentation/schedule_month_view.dart +++ b/lib/src/features/schedule/presentation/schedule_month_view.dart @@ -15,6 +15,9 @@ import 'schedule_item_chip.dart'; import 'schedule_item_selection.dart'; import 'schedule_more_popover.dart'; +typedef ScheduleDayCreateCallback = + void Function(DateTime day, {BuildContext? anchorContext}); + class ScheduleMonthView extends StatelessWidget { const ScheduleMonthView({ super.key, @@ -33,7 +36,7 @@ class ScheduleMonthView extends StatelessWidget { final List items; final int firstWeekday; final ValueChanged onDaySelected; - final ValueChanged onCreateAtDay; + final ScheduleDayCreateCallback onCreateAtDay; final ScheduleItemSelectionCallback onItemSelected; final void Function(TaskScheduleItem item, bool completed) onTaskCompletionChanged; @@ -110,7 +113,8 @@ class ScheduleMonthView extends StatelessWidget { selected: DateUtils.isSameDay(day, selectedDate), items: grouped[day] ?? const [], onSelect: () => onDaySelected(day), - onCreate: () => onCreateAtDay(day), + onCreate: (anchorContext) => + onCreateAtDay(day, anchorContext: anchorContext), onItemSelected: onItemSelected, onTaskCompletionChanged: onTaskCompletionChanged, ), @@ -142,7 +146,7 @@ class _MonthDayCell extends StatelessWidget { final bool selected; final List items; final VoidCallback onSelect; - final VoidCallback onCreate; + final ValueChanged onCreate; final ScheduleItemSelectionCallback onItemSelected; final void Function(TaskScheduleItem item, bool completed) onTaskCompletionChanged; @@ -163,7 +167,7 @@ class _MonthDayCell extends StatelessWidget { : workspaceColor, child: InkWell( onTap: onSelect, - onDoubleTap: onCreate, + onDoubleTap: () => onCreate(context), excludeFromSemantics: true, child: LayoutBuilder( builder: (context, constraints) { @@ -228,12 +232,14 @@ class _MonthDayCell extends StatelessWidget { ), const Spacer(), if (selected) - SizedBox.square( - dimension: 24, - child: YaruIconButton( - tooltip: context.l10n.create, - icon: const Icon(YaruIcons.plus, size: 16), - onPressed: onCreate, + Builder( + builder: (anchorContext) => SizedBox.square( + dimension: 24, + child: YaruIconButton( + tooltip: context.l10n.create, + icon: const Icon(YaruIcons.plus, size: 16), + onPressed: () => onCreate(anchorContext), + ), ), ), ], diff --git a/lib/src/features/schedule/presentation/schedule_sidebar.dart b/lib/src/features/schedule/presentation/schedule_sidebar.dart index e1219ca..e3002a8 100644 --- a/lib/src/features/schedule/presentation/schedule_sidebar.dart +++ b/lib/src/features/schedule/presentation/schedule_sidebar.dart @@ -50,6 +50,7 @@ class ScheduleSidebar extends ConsumerWidget { selectedDate: selectedDate, firstWeekday: firstWeekday, items: items, + showDayHover: true, onSelected: onDateSelected, onMonthSelected: onMonthSelected, onYearSelected: onYearSelected, diff --git a/lib/src/features/schedule/presentation/schedule_workspace.dart b/lib/src/features/schedule/presentation/schedule_workspace.dart index bd379ac..d19b15a 100644 --- a/lib/src/features/schedule/presentation/schedule_workspace.dart +++ b/lib/src/features/schedule/presentation/schedule_workspace.dart @@ -495,14 +495,16 @@ class _ScheduleWorkspaceState extends ConsumerState { canCreateTask: canCreateTask, ), ), - onCreateAtDay: (day) => unawaited( - _openCreateChoice( - accounts, - visibleSources, - DateTime(day.year, day.month, day.day, 9), - canCreateTask: canCreateTask, - ), - ), + onCreateAtDay: (day, {anchorContext}) => + unawaited( + _openCreateChoice( + accounts, + visibleSources, + DateTime(day.year, day.month, day.day, 9), + canCreateTask: canCreateTask, + anchorContext: anchorContext, + ), + ), onNewEvent: () => unawaited( _openNewEvent(visibleSources, _selectedDate), ), @@ -1367,12 +1369,14 @@ class _ScheduleWorkspaceState extends ConsumerState { List sources, DateTime start, { required bool canCreateTask, + BuildContext? anchorContext, }) async { if (_createChoiceMenuSession != null) { return; } final writableSources = writableCalendarSources(sources); - final anchorPoint = _takeRecentSchedulePointerPosition(); + final recentAnchorPoint = _takeRecentSchedulePointerPosition(); + final anchorPoint = anchorContext == null ? recentAnchorPoint : null; final canCreateEvent = writableSources.isNotEmpty; if (!canCreateEvent && !canCreateTask) { return; @@ -1396,8 +1400,11 @@ class _ScheduleWorkspaceState extends ConsumerState { try { choice = await showScheduleCreateMenu( context: context, - anchorContext: _createChoiceAnchorContext(), + anchorContext: anchorContext?.mounted == true + ? anchorContext + : _createChoiceAnchorContext(), anchorPoint: anchorPoint, + preferAbove: anchorContext != null, session: menuSession, ); } finally { @@ -2298,7 +2305,7 @@ class _ScheduleBody extends StatelessWidget { final ValueChanged onMonthSelected; final ValueChanged onWeekSelected; final ValueChanged onEmptySlot; - final ValueChanged onCreateAtDay; + final ScheduleDayCreateCallback onCreateAtDay; final VoidCallback onNewEvent; final VoidCallback onNewTask; final VoidCallback onPrevious; @@ -2391,7 +2398,7 @@ class _ScheduleBody extends StatelessWidget { onDaySelected: onYearDaySelected, onMonthSelected: onMonthSelected, onWeekSelected: onWeekSelected, - onCreateAtDay: onCreateAtDay, + onCreateAtDay: (day) => onCreateAtDay(day), ), ), ScheduleViewMode.agenda => ScheduleAgendaView( diff --git a/lib/src/features/tasks/presentation/desktop_date_time_fields.dart b/lib/src/features/tasks/presentation/desktop_date_time_fields.dart index 02d8404..3fa14c3 100644 --- a/lib/src/features/tasks/presentation/desktop_date_time_fields.dart +++ b/lib/src/features/tasks/presentation/desktop_date_time_fields.dart @@ -25,8 +25,7 @@ const _timePickerMinimumWidth = 240.0; const _timePickerPopoverMinimumHeight = 220.0; const _timePickerPopoverPadding = EdgeInsets.all(BusyMaxSpacing.md); const _timePickerInputControlSize = BusyMaxSizes.popoverActionButton; -const _timePickerInputColumnMinWidth = 36.0; -const _timePickerInputColumnMaxWidth = 38.0; +const _timePickerInputColumnWidth = BusyMaxSizes.popoverActionButton; class NativeDateTimePicker { const NativeDateTimePicker(); @@ -1021,6 +1020,8 @@ class _DesktopTimeValueDialog extends StatefulWidget { class _DesktopTimeValueDialogState extends State<_DesktopTimeValueDialog> { late final TextEditingController _hourController; late final TextEditingController _minuteController; + late final FocusNode _hourFocusNode; + late final FocusNode _minuteFocusNode; bool _syncingText = false; bool _inputValid = true; late String _selectedTimeZone; @@ -1030,6 +1031,8 @@ class _DesktopTimeValueDialogState extends State<_DesktopTimeValueDialog> { super.initState(); _hourController = TextEditingController(); _minuteController = TextEditingController(); + _hourFocusNode = FocusNode(debugLabel: 'Time picker hour'); + _minuteFocusNode = FocusNode(debugLabel: 'Time picker minute'); _inputValid = widget.allowEmpty || parseTimeOfDay(widget.initialTime) != null; _selectedTimeZone = widget.initialTimeZone ?? localIanaTimeZone(); @@ -1052,122 +1055,101 @@ class _DesktopTimeValueDialogState extends State<_DesktopTimeValueDialog> { void dispose() { _hourController.dispose(); _minuteController.dispose(); + _hourFocusNode.dispose(); + _minuteFocusNode.dispose(); super.dispose(); } @override Widget build(BuildContext context) { - return LayoutBuilder( - builder: (context, constraints) { - final timeInputColumnWidth = _calculateTimeInputColumnWidth( - constraints, - ); - return BusyMaxContentPopoverSurface( - arrowSide: widget.arrowSide, - arrowAlignment: widget.arrowAlignment, - padding: _timePickerPopoverPadding, - child: FocusTraversalGroup( - child: Column( + return BusyMaxContentPopoverSurface( + arrowSide: widget.arrowSide, + arrowAlignment: widget.arrowAlignment, + padding: _timePickerPopoverPadding, + child: FocusTraversalGroup( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.center, mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - Row( - mainAxisAlignment: MainAxisAlignment.center, - mainAxisSize: MainAxisSize.min, - children: [ - ConstrainedBox( - constraints: BoxConstraints( - minWidth: timeInputColumnWidth, - maxWidth: timeInputColumnWidth, - ), - child: _timeInputSection( - context: context, - buttonWidth: timeInputColumnWidth, - controller: _hourController, - label: 'Hour', - onIncrement: () => _changeHour(1), - onDecrement: () => _changeHour(-1), - ), - ), - const SizedBox(width: BusyMaxSpacing.xs), - SizedBox( - width: BusyMaxSpacing.sm, - child: Center( - child: Text( - ':', - style: Theme.of(context).textTheme.bodyMedium, - ), - ), - ), - const SizedBox(width: BusyMaxSpacing.xs), - ConstrainedBox( - constraints: BoxConstraints( - minWidth: timeInputColumnWidth, - maxWidth: timeInputColumnWidth, - ), - child: _timeInputSection( - context: context, - buttonWidth: timeInputColumnWidth, - controller: _minuteController, - label: 'Minute', - onIncrement: () => _changeMinute(1), - onDecrement: () => _changeMinute(-1), - ), - ), - ], + SizedBox( + width: _timePickerInputColumnWidth, + child: _timeInputSection( + context: context, + buttonWidth: _timePickerInputColumnWidth, + controller: _hourController, + focusNode: _hourFocusNode, + label: 'Hour', + onIncrement: () => _changeHour(1), + onDecrement: () => _changeHour(-1), + ), ), - if (!_inputValid) - Padding( - padding: const EdgeInsets.symmetric( - horizontal: BusyMaxSpacing.md, - vertical: BusyMaxSpacing.sm, - ), + const SizedBox(width: BusyMaxSpacing.xs), + SizedBox( + width: BusyMaxSpacing.sm, + child: Center( child: Text( - MaterialLocalizations.of(context).invalidTimeLabel, - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: Theme.of(context).colorScheme.error, - ), + ':', + style: Theme.of(context).textTheme.bodyMedium, ), ), - const SizedBox(height: BusyMaxSpacing.md), - BusyMaxPushButton.standard( - onPressed: _openTimezoneDialog, - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - const Icon( - Icons.public, - size: BusyMaxSizes.popoverActionIcon, - ), - const SizedBox(width: BusyMaxSpacing.xs), - Flexible( - child: Text( - _timezoneDisplayLabel(context), - maxLines: 1, - overflow: TextOverflow.ellipsis, - softWrap: false, - ), - ), - ], + ), + const SizedBox(width: BusyMaxSpacing.xs), + SizedBox( + width: _timePickerInputColumnWidth, + child: _timeInputSection( + context: context, + buttonWidth: _timePickerInputColumnWidth, + controller: _minuteController, + focusNode: _minuteFocusNode, + label: 'Minute', + onIncrement: () => _changeMinute(1), + onDecrement: () => _changeMinute(-1), ), ), ], ), - ), - ); - }, - ); - } - - double _calculateTimeInputColumnWidth(BoxConstraints constraints) { - if (!constraints.hasBoundedWidth || constraints.maxWidth <= 0) { - return _timePickerInputColumnMaxWidth; - } - final dividerAndSpacing = BusyMaxSpacing.md + (BusyMaxSpacing.xs * 2); - final availablePerColumn = (constraints.maxWidth - dividerAndSpacing) / 2; - return availablePerColumn.clamp( - _timePickerInputColumnMinWidth, - _timePickerInputColumnMaxWidth, + if (!_inputValid) + Padding( + padding: const EdgeInsets.symmetric( + horizontal: BusyMaxSpacing.md, + vertical: BusyMaxSpacing.sm, + ), + child: Text( + MaterialLocalizations.of(context).invalidTimeLabel, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context).colorScheme.error, + ), + ), + ), + const SizedBox(height: BusyMaxSpacing.md), + BusyMaxPushButton.standard( + onPressed: _openTimezoneDialog, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + Icons.public, + size: BusyMaxSizes.popoverActionIcon, + ), + const SizedBox(width: BusyMaxSpacing.xs), + Flexible( + child: Text( + _timezoneDisplayLabel(context), + maxLines: 1, + overflow: TextOverflow.ellipsis, + softWrap: false, + ), + ), + ], + ), + ), + ], + ), + ), ); } @@ -1191,6 +1173,7 @@ class _DesktopTimeValueDialogState extends State<_DesktopTimeValueDialog> { required BuildContext context, required double buttonWidth, required TextEditingController controller, + required FocusNode focusNode, required String label, required VoidCallback onIncrement, required VoidCallback onDecrement, @@ -1200,18 +1183,21 @@ class _DesktopTimeValueDialogState extends State<_DesktopTimeValueDialog> { surfaceColors.control, surfaceColors.popover, ); - final borderColor = surfaceColors.border; - final inputTextStyle = Theme.of( - context, - ).textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.normal, height: 1); + final dividerColor = surfaceColors.divider; + final inputTextStyle = + Theme.of(context).textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.normal, + height: 1, + ) ?? + const TextStyle(fontWeight: FontWeight.normal, height: 1); return FocusTraversalOrder( order: const NumericFocusOrder(0), child: Container( + key: ValueKey(('time-input-section', label)), decoration: BoxDecoration( color: controlFill, borderRadius: BorderRadius.circular(BusyMaxRadius.sm), - border: Border.all(color: borderColor), ), child: Column( mainAxisSize: MainAxisSize.min, @@ -1231,46 +1217,47 @@ class _DesktopTimeValueDialogState extends State<_DesktopTimeValueDialog> { ), ), ), - Divider(height: 1, thickness: 1, color: borderColor), + Divider(height: 1, thickness: 1, color: dividerColor), SizedBox( + key: ValueKey(('time-input', label)), height: _timePickerInputControlSize, - child: TextFormField( - controller: controller, - textAlign: TextAlign.center, - textAlignVertical: TextAlignVertical.center, - keyboardType: TextInputType.number, - expands: true, - minLines: null, - maxLines: null, - maxLength: 2, - style: inputTextStyle, - decoration: - busyMaxGroupedTextFieldDecoration( - context, - labelText: '', - ).copyWith( - isDense: true, - filled: true, - fillColor: controlFill, - border: InputBorder.none, - enabledBorder: InputBorder.none, - focusedBorder: InputBorder.none, - contentPadding: EdgeInsets.zero, - floatingLabelBehavior: FloatingLabelBehavior.never, - labelText: '', + child: MouseRegion( + cursor: SystemMouseCursors.text, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: focusNode.requestFocus, + child: Center( + child: SizedBox( + width: buttonWidth, + child: EditableText( + controller: controller, + focusNode: focusNode, + textAlign: TextAlign.center, + keyboardType: TextInputType.number, + maxLines: 1, + inputFormatters: [ + FilteringTextInputFormatter.digitsOnly, + LengthLimitingTextInputFormatter(2), + ], + style: inputTextStyle, + strutStyle: StrutStyle.fromTextStyle( + inputTextStyle, + forceStrutHeight: true, + ), + cursorColor: Theme.of(context).colorScheme.primary, + backgroundCursorColor: surfaceColors.disabledForeground, + selectionColor: Theme.of( + context, + ).colorScheme.primary.withValues(alpha: 0.28), + onChanged: (_) => _handleTimeInputChanged(), + onSubmitted: (_) => _handleTimeInputChanged(), + ), ), - buildCounter: - ( - BuildContext context, { - required int currentLength, - required int? maxLength, - required bool isFocused, - }) => const SizedBox.shrink(), - onChanged: (_) => _handleTimeInputChanged(), - onFieldSubmitted: (_) => _handleTimeInputChanged(), + ), + ), ), ), - Divider(height: 1, thickness: 1, color: borderColor), + Divider(height: 1, thickness: 1, color: dividerColor), BusyMaxHeaderIconButton( onPressed: onDecrement, icon: const Icon(Icons.remove), diff --git a/lib/src/features/tasks/presentation/time_zone_selection_dialog.dart b/lib/src/features/tasks/presentation/time_zone_selection_dialog.dart index d39b6c3..c1a9ade 100644 --- a/lib/src/features/tasks/presentation/time_zone_selection_dialog.dart +++ b/lib/src/features/tasks/presentation/time_zone_selection_dialog.dart @@ -1,3 +1,6 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:yaru/yaru.dart'; @@ -5,13 +8,46 @@ import '../../../app/busymax_dialogs.dart'; import '../../../app/busymax_design.dart'; import '../../../core/time/time_zone_catalog.dart'; import '../../../l10n/l10n.dart'; +import '../../../platform/native_dialog_service.dart'; const _timeZoneDialogContentHeight = 420.0; Future showBusyMaxTimeZoneSelectionDialog( BuildContext context, { required String selectedTimeZone, -}) { +}) async { + if (!kIsWeb && defaultTargetPlatform == TargetPlatform.linux) { + await BusyMaxTimeZoneCatalog.prepareLocationSearch(); + if (!context.mounted) { + return null; + } + final l10n = context.l10n; + final nativeResult = await const NativeDialogService().selectTimeZone( + title: l10n.selectTimeZone, + searchPlaceholder: l10n.searchLocations, + noResultsLabel: l10n.noLocationsFound, + selectedTimeZone: selectedTimeZone, + options: [ + for (final result in BusyMaxTimeZoneCatalog.preparedLocationOptions) + NativeTimeZoneOption( + id: result.location.id, + region: result.location.region, + name: result.name, + englishName: result.englishName ?? result.name, + title: result.title, + subtitle: result.subtitle, + searchText: result.searchText, + ), + ], + ); + if (nativeResult.available) { + return nativeResult.selectedTimeZone; + } + if (!context.mounted) { + return null; + } + } + return showBusyMaxModalDialog( context, builder: (dialogContext) => @@ -37,6 +73,15 @@ class _BusyMaxTimeZoneSelectionDialogState final _searchController = TextEditingController(); final _resultsController = ScrollController(); var _query = ''; + var _searchGeneration = 0; + var _isSearching = false; + List _results = const []; + + @override + void initState() { + super.initState(); + unawaited(BusyMaxTimeZoneCatalog.prepareLocationSearch()); + } @override void dispose() { @@ -48,10 +93,9 @@ class _BusyMaxTimeZoneSelectionDialogState @override Widget build(BuildContext context) { final l10n = context.l10n; - final results = BusyMaxTimeZoneCatalog.search(_query); - final sections = >{}; - for (final location in results) { - sections.putIfAbsent(location.region, () => []).add(location); + final sections = >{}; + for (final result in _results) { + sections.putIfAbsent(result.location.region, () => []).add(result); } return BusyMaxDialogShell( @@ -76,12 +120,14 @@ class _BusyMaxTimeZoneSelectionDialogState controller: _searchController, hintText: l10n.searchLocations, autofocus: true, - onChanged: (value) => setState(() => _query = value), - onClear: () => setState(() => _query = ''), + onChanged: _search, + onClear: () => _search(''), ), const SizedBox(height: BusyMaxSpacing.md), Expanded( - child: results.isEmpty + child: _isSearching + ? const Center(child: YaruCircularProgressIndicator()) + : _results.isEmpty ? _query.trim().isEmpty ? const SizedBox.shrink() : Center( @@ -110,17 +156,17 @@ class _BusyMaxTimeZoneSelectionDialogState title: section.key, filled: true, children: [ - for (final location in section.value) + for (final result in section.value) BusyMaxActionRow( - title: - '${location.name} (${location.code})', - subtitle: location.id, + title: result.title, + subtitle: result.subtitle, leading: const Icon( Icons.public, size: BusyMaxSizes.iconSm, ), trailing: - location.id == widget.selectedTimeZone + result.location.id == + widget.selectedTimeZone ? const Icon( YaruIcons.checkmark, size: BusyMaxSizes.iconSm, @@ -128,7 +174,7 @@ class _BusyMaxTimeZoneSelectionDialogState : null, onTap: () => Navigator.of( context, - ).pop(location.id), + ).pop(result.location.id), ), ], ), @@ -142,4 +188,46 @@ class _BusyMaxTimeZoneSelectionDialogState ], ); } + + void _search(String query) { + final generation = ++_searchGeneration; + final hasQuery = query.trim().isNotEmpty; + if (!hasQuery || BusyMaxTimeZoneCatalog.isLocationSearchReady) { + setState(() { + _query = query; + _isSearching = false; + _results = hasQuery + ? BusyMaxTimeZoneCatalog.searchPreparedLocations(query) + : const []; + }); + _resetResultsScroll(); + return; + } + + setState(() { + _query = query; + _isSearching = true; + }); + unawaited(_searchAfterLoad(query, generation)); + } + + Future _searchAfterLoad(String query, int generation) async { + final results = await BusyMaxTimeZoneCatalog.searchLocations(query); + if (!mounted || generation != _searchGeneration) { + return; + } + setState(() { + _results = results; + _isSearching = false; + }); + _resetResultsScroll(); + } + + void _resetResultsScroll() { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted && _resultsController.hasClients) { + _resultsController.jumpTo(0); + } + }); + } } diff --git a/lib/src/platform/native_dialog_service.dart b/lib/src/platform/native_dialog_service.dart index 50fbec1..a7859af 100644 --- a/lib/src/platform/native_dialog_service.dart +++ b/lib/src/platform/native_dialog_service.dart @@ -23,6 +23,54 @@ class NativeConfirmationResult { final bool confirmed; } +@immutable +class NativeTimeZoneOption { + const NativeTimeZoneOption({ + required this.id, + required this.region, + required this.name, + required this.englishName, + required this.title, + required this.subtitle, + required this.searchText, + }); + + final String id; + final String region; + final String name; + final String englishName; + final String title; + final String subtitle; + final String searchText; + + Map toMessage() { + return { + 'id': id, + 'region': region, + 'name': name, + 'englishName': englishName, + 'title': title, + 'subtitle': subtitle, + 'searchText': searchText, + }; + } +} + +@immutable +class NativeTimeZoneSelectionResult { + const NativeTimeZoneSelectionResult({ + required this.available, + this.selectedTimeZone, + }); + + const NativeTimeZoneSelectionResult.unavailable() + : available = false, + selectedTimeZone = null; + + final bool available; + final String? selectedTimeZone; +} + /// Presents confirmation UI owned by the host desktop toolkit. /// /// Linux implements this with a transient `GtkMessageDialog`. Other hosts can @@ -59,4 +107,30 @@ class NativeDialogService { return const NativeConfirmationResult.unavailable(); } } + + Future selectTimeZone({ + required String title, + required String searchPlaceholder, + required String noResultsLabel, + required String selectedTimeZone, + required List options, + }) async { + try { + final selected = await _channel.invokeMethod('selectTimeZone', { + 'title': title, + 'searchPlaceholder': searchPlaceholder, + 'noResultsLabel': noResultsLabel, + 'selectedTimeZone': selectedTimeZone, + 'options': options.map((option) => option.toMessage()).toList(), + }); + return NativeTimeZoneSelectionResult( + available: true, + selectedTimeZone: selected, + ); + } on MissingPluginException { + return const NativeTimeZoneSelectionResult.unavailable(); + } on PlatformException { + return const NativeTimeZoneSelectionResult.unavailable(); + } + } } diff --git a/lib/src/platform/native_menu_service.dart b/lib/src/platform/native_menu_service.dart index bd0661d..85ab41e 100644 --- a/lib/src/platform/native_menu_service.dart +++ b/lib/src/platform/native_menu_service.dart @@ -75,6 +75,7 @@ class NativeMenuService { required Rect anchor, required List entries, bool focusFirst = false, + bool preferAbove = false, }) async { try { final selectedIndex = await _channel.invokeMethod('show', { @@ -87,6 +88,7 @@ class NativeMenuService { }, 'entries': [for (final entry in entries) entry._toPlatformMap()], 'focusFirst': focusFirst, + 'preferredPosition': preferAbove ? 'top' : 'bottom', }); return NativeMenuResult.available(selectedIndex: selectedIndex); } on MissingPluginException { diff --git a/linux/runner/my_application.cc b/linux/runner/my_application.cc index 008ac2e..892e6fa 100644 --- a/linux/runner/my_application.cc +++ b/linux/runner/my_application.cc @@ -78,6 +78,18 @@ constexpr char kHeaderModalBarrierStyleClass[] = "busymax-modal-barrier"; constexpr char kNativeDialogStyleClass[] = "busymax-native-dialog"; // Mirrors Yaru's shared window/dialog radius used by the Flutter fallback. constexpr gint kNativeDialogCornerRadius = 14; +constexpr char kNativeTimeZoneDialogStyleClass[] = + "busymax-time-zone-dialog"; +constexpr char kNativeTimeZoneResultsStyleClass[] = + "busymax-time-zone-results"; +constexpr char kNativeTimeZoneGroupStyleClass[] = + "busymax-time-zone-group"; +constexpr char kNativeTimeZoneRowStyleClass[] = "busymax-time-zone-row"; +constexpr gint kNativeTimeZoneDialogWidth = 520; +constexpr gint kNativeTimeZoneDialogContentHeight = 420; +constexpr size_t kNativeTimeZoneResultLimit = 250; +constexpr gdouble kNativeTimeZonePrimaryTextOpacity = 0.82; +constexpr gdouble kNativeTimeZoneSecondaryTextOpacity = 0.62; constexpr char kNativePopoverStyleClass[] = "busymax-native-popover"; constexpr char kHeaderMenuDepthStyleClass[] = "busymax-header-menu-depth"; @@ -556,6 +568,344 @@ static void handle_native_confirmation(FlMethodCall* method_call, gtk_widget_destroy(dialog); } +struct NativeTimeZoneOption { + gchar* id; + gchar* region; + gchar* normalized_name; + gchar* normalized_english_name; + gchar* title; + gchar* subtitle; + gchar* search_text; + gint match_rank; + gint region_match_rank; +}; + +struct NativeTimeZoneDialogState { + GtkWidget* dialog; + GtkWidget* results; + GPtrArray* options; + const gchar* selected_time_zone; + const gchar* no_results_label; + gchar* result; +}; + +static void native_time_zone_option_free(gpointer data) { + auto* option = static_cast(data); + if (option == nullptr) { + return; + } + g_free(option->id); + g_free(option->region); + g_free(option->normalized_name); + g_free(option->normalized_english_name); + g_free(option->title); + g_free(option->subtitle); + g_free(option->search_text); + g_free(option); +} + +static void clear_native_time_zone_results(GtkWidget* results) { + GList* children = gtk_container_get_children(GTK_CONTAINER(results)); + for (GList* child = children; child != nullptr; child = child->next) { + gtk_widget_destroy(GTK_WIDGET(child->data)); + } + g_list_free(children); +} + +static gint native_time_zone_option_match_rank( + const NativeTimeZoneOption* option, + const gchar* normalized_query) { + if (g_strcmp0(option->normalized_name, normalized_query) == 0 || + g_strcmp0(option->normalized_english_name, normalized_query) == 0) { + return 0; + } + if (g_str_has_prefix(option->normalized_name, normalized_query) || + g_str_has_prefix(option->normalized_english_name, normalized_query)) { + return 1; + } + if (strstr(option->normalized_name, normalized_query) != nullptr || + strstr(option->normalized_english_name, normalized_query) != nullptr) { + return 2; + } + return strstr(option->search_text, normalized_query) != nullptr ? 3 : -1; +} + +static gint compare_native_time_zone_options(gconstpointer first, + gconstpointer second, + gpointer) { + const auto* first_option = + *static_cast(first); + const auto* second_option = + *static_cast(second); + if (first_option->region_match_rank != second_option->region_match_rank) { + return first_option->region_match_rank - + second_option->region_match_rank; + } + const gint region_order = + g_strcmp0(first_option->region, second_option->region); + if (region_order != 0) { + return region_order; + } + if (first_option->match_rank != second_option->match_rank) { + return first_option->match_rank - second_option->match_rank; + } + const gint name_order = + g_strcmp0(first_option->normalized_name, + second_option->normalized_name); + return name_order != 0 + ? name_order + : g_strcmp0(first_option->id, second_option->id); +} + +static void native_time_zone_row_activated_cb(HdyActionRow* row, + gpointer user_data) { + auto* state = static_cast(user_data); + const gchar* id = static_cast( + g_object_get_data(G_OBJECT(row), "busymax-time-zone-id")); + if (id == nullptr) { + return; + } + g_free(state->result); + state->result = g_strdup(id); + gtk_dialog_response(GTK_DIALOG(state->dialog), GTK_RESPONSE_ACCEPT); +} + +static void rebuild_native_time_zone_results( + NativeTimeZoneDialogState* state, + const gchar* query) { + clear_native_time_zone_results(state->results); + + g_autofree gchar* query_copy = g_strdup(query != nullptr ? query : ""); + const gchar* stripped_query = g_strstrip(query_copy); + if (stripped_query[0] == '\0') { + gtk_widget_show_all(state->results); + return; + } + g_autofree gchar* normalized_query = + g_utf8_casefold(stripped_query, -1); + + GPtrArray* matches = g_ptr_array_new(); + GHashTable* region_match_ranks = + g_hash_table_new(g_str_hash, g_str_equal); + for (size_t index = 0; index < state->options->len; index++) { + auto* option = static_cast( + g_ptr_array_index(state->options, index)); + option->match_rank = + native_time_zone_option_match_rank(option, normalized_query); + if (option->match_rank < 0) { + continue; + } + g_ptr_array_add(matches, option); + const gpointer stored_rank = + g_hash_table_lookup(region_match_ranks, option->region); + if (stored_rank == nullptr || + option->match_rank < GPOINTER_TO_INT(stored_rank) - 1) { + g_hash_table_insert(region_match_ranks, option->region, + GINT_TO_POINTER(option->match_rank + 1)); + } + } + for (size_t index = 0; index < matches->len; index++) { + auto* option = static_cast( + g_ptr_array_index(matches, index)); + option->region_match_rank = + GPOINTER_TO_INT( + g_hash_table_lookup(region_match_ranks, option->region)) - + 1; + } + g_ptr_array_sort_with_data(matches, compare_native_time_zone_options, + nullptr); + g_hash_table_unref(region_match_ranks); + + GtkWidget* current_group = nullptr; + const gchar* current_region = nullptr; + size_t result_count = 0; + for (size_t index = 0; + index < matches->len && + result_count < kNativeTimeZoneResultLimit; + index++) { + auto* option = static_cast( + g_ptr_array_index(matches, index)); + + if (current_region == nullptr || + g_strcmp0(current_region, option->region) != 0) { + current_region = option->region; + current_group = hdy_preferences_group_new(); + gtk_style_context_add_class( + gtk_widget_get_style_context(current_group), + kNativeTimeZoneGroupStyleClass); + hdy_preferences_group_set_title( + HDY_PREFERENCES_GROUP(current_group), current_region); + gtk_box_pack_start(GTK_BOX(state->results), current_group, FALSE, FALSE, + 0); + } + + GtkWidget* row = hdy_action_row_new(); + gtk_style_context_add_class(gtk_widget_get_style_context(row), + kNativeTimeZoneRowStyleClass); + hdy_preferences_row_set_title(HDY_PREFERENCES_ROW(row), option->title); + hdy_action_row_set_subtitle(HDY_ACTION_ROW(row), option->subtitle); + hdy_action_row_set_icon_name(HDY_ACTION_ROW(row), + "mark-location-symbolic"); + gtk_list_box_row_set_activatable(GTK_LIST_BOX_ROW(row), TRUE); + g_object_set_data_full(G_OBJECT(row), "busymax-time-zone-id", + g_strdup(option->id), g_free); + g_signal_connect(row, "activated", + G_CALLBACK(native_time_zone_row_activated_cb), state); + + if (g_strcmp0(option->id, state->selected_time_zone) == 0) { + GtkWidget* selected_icon = gtk_image_new_from_icon_name( + "emblem-ok-symbolic", GTK_ICON_SIZE_BUTTON); + gtk_container_add(GTK_CONTAINER(row), selected_icon); + } + gtk_container_add(GTK_CONTAINER(current_group), row); + result_count++; + } + g_ptr_array_unref(matches); + + if (result_count == 0) { + GtkWidget* no_results = gtk_label_new(state->no_results_label); + gtk_widget_set_margin_top(no_results, 72); + gtk_style_context_add_class(gtk_widget_get_style_context(no_results), + GTK_STYLE_CLASS_DIM_LABEL); + gtk_box_pack_start(GTK_BOX(state->results), no_results, FALSE, FALSE, 0); + } + gtk_widget_show_all(state->results); +} + +static void native_time_zone_search_changed_cb(GtkSearchEntry* search, + gpointer user_data) { + auto* state = static_cast(user_data); + rebuild_native_time_zone_results( + state, gtk_entry_get_text(GTK_ENTRY(search))); +} + +static GPtrArray* parse_native_time_zone_options(FlValue* args) { + if (args == nullptr || fl_value_get_type(args) != FL_VALUE_TYPE_MAP) { + return nullptr; + } + FlValue* entries = fl_value_lookup_string(args, "options"); + if (entries == nullptr || + fl_value_get_type(entries) != FL_VALUE_TYPE_LIST) { + return nullptr; + } + + GPtrArray* options = + g_ptr_array_new_with_free_func(native_time_zone_option_free); + for (size_t index = 0; index < fl_value_get_length(entries); index++) { + FlValue* entry = fl_value_get_list_value(entries, index); + const gchar* id = fl_lookup_string_arg(entry, "id"); + const gchar* region = fl_lookup_string_arg(entry, "region"); + const gchar* name = fl_lookup_string_arg(entry, "name"); + const gchar* english_name = + fl_lookup_string_arg(entry, "englishName"); + const gchar* title = fl_lookup_string_arg(entry, "title"); + const gchar* subtitle = fl_lookup_string_arg(entry, "subtitle"); + const gchar* search_text = fl_lookup_string_arg(entry, "searchText"); + if (id == nullptr || region == nullptr || name == nullptr || + english_name == nullptr || title == nullptr || subtitle == nullptr || + search_text == nullptr) { + g_ptr_array_unref(options); + return nullptr; + } + + auto* option = g_new0(NativeTimeZoneOption, 1); + option->id = g_strdup(id); + option->region = g_strdup(region); + option->normalized_name = g_utf8_casefold(name, -1); + option->normalized_english_name = g_utf8_casefold(english_name, -1); + option->title = g_strdup(title); + option->subtitle = g_strdup(subtitle); + option->search_text = g_utf8_casefold(search_text, -1); + g_ptr_array_add(options, option); + } + return options; +} + +static void handle_native_time_zone_selection(FlMethodCall* method_call, + FlValue* args, + GtkWindow* parent) { + const gchar* title = fl_lookup_string_arg(args, "title"); + const gchar* search_placeholder = + fl_lookup_string_arg(args, "searchPlaceholder"); + const gchar* no_results_label = + fl_lookup_string_arg(args, "noResultsLabel"); + const gchar* selected_time_zone = + fl_lookup_string_arg(args, "selectedTimeZone"); + GPtrArray* options = parse_native_time_zone_options(args); + if (title == nullptr || search_placeholder == nullptr || + no_results_label == nullptr || selected_time_zone == nullptr || + options == nullptr || options->len == 0) { + if (options != nullptr) { + g_ptr_array_unref(options); + } + fl_method_call_respond_error( + method_call, "invalid-arguments", + "The timezone dialog requires localized labels, a selected timezone, " + "and a non-empty option list.", + nullptr, nullptr); + return; + } + + GtkWidget* dialog = gtk_dialog_new_with_buttons( + title, parent, + static_cast(GTK_DIALOG_MODAL | + GTK_DIALOG_DESTROY_WITH_PARENT | + GTK_DIALOG_USE_HEADER_BAR), + nullptr, + nullptr); + style_native_dialog(dialog); + gtk_style_context_add_class(gtk_widget_get_style_context(dialog), + kNativeTimeZoneDialogStyleClass); + gtk_window_set_resizable(GTK_WINDOW(dialog), FALSE); + gtk_window_set_default_size(GTK_WINDOW(dialog), + kNativeTimeZoneDialogWidth, -1); + + GtkWidget* content = gtk_dialog_get_content_area(GTK_DIALOG(dialog)); + GtkWidget* root = gtk_box_new(GTK_ORIENTATION_VERTICAL, 12); + gtk_widget_set_size_request(root, kNativeTimeZoneDialogWidth - 36, + kNativeTimeZoneDialogContentHeight); + gtk_container_set_border_width(GTK_CONTAINER(root), 18); + gtk_container_add(GTK_CONTAINER(content), root); + + GtkWidget* search = gtk_search_entry_new(); + gtk_entry_set_placeholder_text(GTK_ENTRY(search), search_placeholder); + gtk_box_pack_start(GTK_BOX(root), search, FALSE, FALSE, 0); + + GtkWidget* scrolled = gtk_scrolled_window_new(nullptr, nullptr); + gtk_scrolled_window_set_policy(GTK_SCROLLED_WINDOW(scrolled), + GTK_POLICY_NEVER, GTK_POLICY_AUTOMATIC); + gtk_scrolled_window_set_shadow_type(GTK_SCROLLED_WINDOW(scrolled), + GTK_SHADOW_NONE); + gtk_widget_set_vexpand(scrolled, TRUE); + gtk_box_pack_start(GTK_BOX(root), scrolled, TRUE, TRUE, 0); + + GtkWidget* results = gtk_box_new(GTK_ORIENTATION_VERTICAL, 12); + gtk_style_context_add_class(gtk_widget_get_style_context(results), + kNativeTimeZoneResultsStyleClass); + gtk_container_add(GTK_CONTAINER(scrolled), results); + + NativeTimeZoneDialogState state = { + dialog, + results, + options, + selected_time_zone, + no_results_label, + nullptr, + }; + g_signal_connect(search, "search-changed", + G_CALLBACK(native_time_zone_search_changed_cb), &state); + + gtk_widget_show_all(dialog); + gtk_widget_grab_focus(search); + const gint response = gtk_dialog_run(GTK_DIALOG(dialog)); + respond_string(method_call, + response == GTK_RESPONSE_ACCEPT ? state.result : nullptr); + + gtk_widget_destroy(dialog); + g_free(state.result); + g_ptr_array_unref(options); +} + struct NativeDialogHandlerData { GtkWindow* window; }; @@ -583,6 +933,9 @@ static void native_dialog_method_call_cb(FlMethodChannel* channel, if (strcmp(method, "confirm") == 0) { handle_native_confirmation(method_call, fl_method_call_get_args(method_call), parent); + } else if (strcmp(method, "selectTimeZone") == 0) { + handle_native_time_zone_selection( + method_call, fl_method_call_get_args(method_call), parent); } else { fl_method_call_respond_not_implemented(method_call, nullptr); } @@ -896,6 +1249,17 @@ static void show_native_menu(NativeMenuHandlerData* data, } FlValue* entries = fl_value_lookup_string(args, "entries"); + GtkPositionType preferred_position = GTK_POS_BOTTOM; + const gchar* preferred_position_arg = + fl_lookup_string_arg(args, "preferredPosition"); + if (g_strcmp0(preferred_position_arg, "top") == 0) { + preferred_position = GTK_POS_TOP; + } else if (preferred_position_arg != nullptr && + g_strcmp0(preferred_position_arg, "bottom") != 0) { + respond_native_menu_argument_error( + method_call, "preferredPosition must be top or bottom."); + return; + } gboolean focus_first = FALSE; if (entries == nullptr || fl_value_get_type(entries) != FL_VALUE_TYPE_LIST || @@ -1008,7 +1372,8 @@ static void show_native_menu(NativeMenuHandlerData* data, g_object_ref_sink(session->popover); style_native_popover(session->popover); gtk_popover_set_pointing_to(GTK_POPOVER(session->popover), &anchor); - gtk_popover_set_position(GTK_POPOVER(session->popover), GTK_POS_BOTTOM); + gtk_popover_set_position(GTK_POPOVER(session->popover), + preferred_position); gtk_popover_set_constrain_to(GTK_POPOVER(session->popover), GTK_POPOVER_CONSTRAINT_WINDOW); gtk_popover_set_modal(GTK_POPOVER(session->popover), TRUE); @@ -1281,6 +1646,78 @@ static void refresh_header_bar_css(MyApplication* self) { 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," + "window.%s.%s:backdrop {" + "background-color: %s;" + "background-image: none;" + "border-radius: %dpx;" + "box-shadow: none;" + "}" + "window.%s.%s.csd:not(.solid-csd):not(.maximized):not(.fullscreen) " + "> decoration {" + "border-radius: %dpx;" + "}" + "window.%s.%s .busymax-native-dialog-content," + "window.%s.%s .busymax-native-dialog-content:backdrop {" + "background-color: %s;" + "background-image: none;" + "border-radius: 0 0 %dpx %dpx;" + "}" + "window.%s .%s {" + "background-color: transparent;" + "background-image: none;" + "}" + "window.%s .%s list {" + "background-color: shade(%s, 1.06);" + "background-image: none;" + "border: none;" + "border-radius: 8px;" + "box-shadow: none;" + "}" + "window.%s .%s row," + "window.%s .%s row:backdrop {" + "background-color: transparent;" + "background-image: none;" + "border: none;" + "box-shadow: none;" + "}" + "window.%s .%s label {" + "color: alpha(%s, %.2f);" + "}" + "window.%s .%s label.subtitle," + "window.%s .%s label.dim-label {" + "color: alpha(%s, %.2f);" + "}" + "window.%s .%s row:hover:not(:disabled) {" + "background-color: alpha(%s, 0.08);" + "background-image: none;" + "}", + kNativeDialogStyleClass, kNativeTimeZoneDialogStyleClass, + kNativeDialogStyleClass, kNativeTimeZoneDialogStyleClass, + dialog_background_color, kNativeDialogCornerRadius, + kNativeDialogStyleClass, kNativeTimeZoneDialogStyleClass, + kNativeDialogCornerRadius, kNativeDialogStyleClass, + kNativeTimeZoneDialogStyleClass, kNativeDialogStyleClass, + kNativeTimeZoneDialogStyleClass, dialog_background_color, + kNativeDialogCornerRadius, kNativeDialogCornerRadius, + kNativeTimeZoneDialogStyleClass, kNativeTimeZoneResultsStyleClass, + kNativeTimeZoneDialogStyleClass, kNativeTimeZoneGroupStyleClass, + dialog_background_color, kNativeTimeZoneDialogStyleClass, + kNativeTimeZoneRowStyleClass, kNativeTimeZoneDialogStyleClass, + kNativeTimeZoneRowStyleClass, kNativeTimeZoneDialogStyleClass, + kNativeTimeZoneResultsStyleClass, foreground_color, + self->header_bar_high_contrast + ? 1.0 + : kNativeTimeZonePrimaryTextOpacity, + kNativeTimeZoneDialogStyleClass, kNativeTimeZoneRowStyleClass, + kNativeTimeZoneDialogStyleClass, kNativeTimeZoneRowStyleClass, + foreground_color, + self->header_bar_high_contrast + ? 1.0 + : kNativeTimeZoneSecondaryTextOpacity, + kNativeTimeZoneDialogStyleClass, + kNativeTimeZoneRowStyleClass, foreground_color); const gchar* modal_barrier_color = css_color_or( self->header_bar_modal_barrier_color, kDefaultModalBarrierColor); const gboolean use_legacy_yaru_compatibility = @@ -1366,8 +1803,14 @@ static void refresh_header_bar_css(MyApplication* self) { "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, kNativeDialogStyleClass, + kNativeTimeZoneDialogStyleClass) : g_strdup(""); GtkWidget* header_bar = GTK_WIDGET(self->header_bar); GtkStyleContext* context = gtk_widget_get_style_context(header_bar); @@ -1381,6 +1824,7 @@ static void refresh_header_bar_css(MyApplication* self) { "%s" "%s" "%s" + "%s" "headerbar.busymax-flat-headerbar," "headerbar.busymax-flat-headerbar:backdrop {" "background-color: %s;" @@ -1513,7 +1957,8 @@ static void refresh_header_bar_css(MyApplication* self) { "background-image: none;" "}", window_background_color, yaru_window_decoration_css, - native_dialog_css, native_search_geometry_css, + native_dialog_css, native_time_zone_dialog_css, + native_search_geometry_css, background_color, foreground_color, sidebar_background_color, foreground_color, sidebar_border_color, foreground_color, foreground_color, kHeaderBackdropForegroundOpacity, diff --git a/snap/snapcraft.yaml b/snap/snapcraft.yaml index f940cfb..afe5f18 100644 --- a/snap/snapcraft.yaml +++ b/snap/snapcraft.yaml @@ -42,6 +42,7 @@ apps: - x11 environment: GDK_BACKEND: wayland,x11 + LIBGWEATHER_LOCATIONS_PATH: $SNAP/usr/lib/x86_64-linux-gnu/libgweather-4/Locations.bin SECRET_BACKEND: file XDG_CACHE_HOME: $SNAP_USER_DATA/.cache XDG_CONFIG_HOME: $SNAP_USER_DATA/.config @@ -59,6 +60,7 @@ parts: source: build/linux/x64/release/bundle stage-packages: - libhandy-1-0 + - libgweather-4-0t64 - liblzma5 - libsecret-1-0 override-prime: | diff --git a/test/app/busymax_search_field_test.dart b/test/app/busymax_search_field_test.dart index f690415..c57f300 100644 --- a/test/app/busymax_search_field_test.dart +++ b/test/app/busymax_search_field_test.dart @@ -40,6 +40,18 @@ void main() { expect(field.height, kYaruTitleBarItemHeight); expect(field.radius, const Radius.circular(kYaruTitleBarItemHeight)); expect(tester.getSize(find.byType(BusyMaxSearchField)).width, 420); + expect( + tester.getSize(find.byType(BusyMaxSearchField)).height, + kYaruTitleBarItemHeight, + ); + expect( + tester.getSize(find.byType(YaruSearchField)).height, + kYaruTitleBarItemHeight, + ); + expect( + tester.getSize(find.byType(TextField)).height, + kYaruTitleBarItemHeight, + ); expect( field.clearIconSemanticLabel, MaterialLocalizations.of(context).clearButtonTooltip, @@ -48,6 +60,18 @@ void main() { await tester.enterText(find.byType(TextField), 'planning'); await tester.pump(); expect(changes, contains('planning')); + expect( + tester.getSize(find.byType(BusyMaxSearchField)).height, + kYaruTitleBarItemHeight, + ); + expect( + tester.getSize(find.byType(YaruSearchField)).height, + kYaruTitleBarItemHeight, + ); + expect( + tester.getSize(find.byType(TextField)).height, + kYaruTitleBarItemHeight, + ); await tester.tap(find.byIcon(YaruIcons.edit_clear)); await tester.pump(); diff --git a/test/app/native_ui_audit_test.dart b/test/app/native_ui_audit_test.dart index e9128f5..676d011 100644 --- a/test/app/native_ui_audit_test.dart +++ b/test/app/native_ui_audit_test.dart @@ -1281,7 +1281,7 @@ void main() { 'static void handle_native_confirmation', ); final nativeConfirmEnd = runner.indexOf( - 'struct NativeDialogHandlerData', + 'struct NativeTimeZoneOption', nativeConfirmStart, ); final nativeConfirm = runner.substring( @@ -1315,6 +1315,31 @@ void main() { expect(confirmBody, isNot(contains('return BusyMaxDialogShell('))); }); + test('timezone selection uses native GTK and Handy controls on Linux', () { + final runner = File('linux/runner/my_application.cc').readAsStringSync(); + final service = File( + 'lib/src/platform/native_dialog_service.dart', + ).readAsStringSync(); + final selector = File( + 'lib/src/features/tasks/presentation/time_zone_selection_dialog.dart', + ).readAsStringSync(); + + expect(runner, contains('strcmp(method, "selectTimeZone") == 0')); + expect(runner, contains('gtk_search_entry_new()')); + expect(runner, contains('hdy_preferences_group_new()')); + expect(runner, contains('hdy_action_row_new()')); + expect(runner, contains('kNativeTimeZoneDialogContentHeight')); + expect(runner, contains('kNativeTimeZoneDialogStyleClass')); + expect(runner, contains('kNativeTimeZoneGroupStyleClass')); + expect(runner, contains('kNativeTimeZoneRowStyleClass')); + expect(runner, contains('native_time_zone_option_match_rank')); + expect(runner, contains('compare_native_time_zone_options')); + expect(runner, contains('g_ptr_array_sort_with_data')); + expect(service, contains("invokeMethod('selectTimeZone'")); + expect(selector, contains('NativeDialogService().selectTimeZone(')); + expect(selector, contains('BusyMaxGroupedList(')); + }); + test( 'text prompts reuse the shared Yaru grouped form without native reinvention', () { @@ -1332,7 +1357,7 @@ void main() { ).readAsStringSync(); final nativeDialogsStart = runner.indexOf('static void respond_bool('); final nativeDialogsEnd = runner.indexOf( - 'struct NativeDialogHandlerData', + 'struct NativeTimeZoneOption', nativeDialogsStart, ); final promptStart = design.indexOf('class BusyMaxPromptDialog'); @@ -1445,13 +1470,20 @@ void main() { ); final nativeDialogCssStart = nativePopoverCssEnd; final nativeDialogCssEnd = source.indexOf( - 'const gchar* modal_barrier_color', + 'g_autofree gchar* native_time_zone_dialog_css =', nativeDialogCssStart, ); + final nativeTimeZoneDialogCssStart = nativeDialogCssEnd; + final nativeTimeZoneDialogCssEnd = source.indexOf( + 'const gchar* modal_barrier_color', + nativeTimeZoneDialogCssStart, + ); expect(nativePopoverCssStart, isNonNegative); expect(nativePopoverCssEnd, isNonNegative); expect(nativeDialogCssStart, isNonNegative); expect(nativeDialogCssEnd, isNonNegative); + expect(nativeTimeZoneDialogCssStart, isNonNegative); + expect(nativeTimeZoneDialogCssEnd, isNonNegative); final nativePopoverCss = source.substring( nativePopoverCssStart, nativePopoverCssEnd, @@ -1460,6 +1492,10 @@ void main() { nativeDialogCssStart, nativeDialogCssEnd, ); + final nativeTimeZoneDialogCss = source.substring( + nativeTimeZoneDialogCssStart, + nativeTimeZoneDialogCssEnd, + ); final nativeSearchGeometryCssStart = source.indexOf( 'g_autofree gchar* native_search_geometry_css =', ); @@ -1711,7 +1747,11 @@ void main() { expect(headerMenuShadowCss, isNot(contains('border-radius'))); expect(source, contains('"busymax-native-dialog"')); expect(source, contains('style_native_dialog(GtkWidget* dialog)')); - expect('style_native_dialog(dialog);'.allMatches(source).length, 2); + expect( + 'style_native_dialog(dialog);'.allMatches(source).length, + 3, + reason: 'native date, time, and timezone pickers share dialog styling', + ); expect( nativeDialogCss, contains('g_autofree gchar* native_dialog_css ='), @@ -1763,6 +1803,31 @@ void main() { ); 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('"border-radius: 0 0 %dpx %dpx;"'), + ); + expect(nativeTimeZoneDialogCss, contains('"box-shadow: none;"')); + expect( + nativeTimeZoneDialogCss, + contains('"background-color: shade(%s, 1.06);"'), + ); + expect( + nativeTimeZoneDialogCss, + contains('kNativeTimeZoneGroupStyleClass'), + ); + expect(nativeTimeZoneDialogCss, contains('kNativeTimeZoneRowStyleClass')); + expect(nativeTimeZoneDialogCss, contains('"color: alpha(%s, %.2f);"')); + expect(source, contains('kNativeTimeZonePrimaryTextOpacity = 0.82')); + expect(source, contains('kNativeTimeZoneSecondaryTextOpacity = 0.62')); expect( source, contains('constexpr gint kNativeDialogCornerRadius = 14;'), diff --git a/test/app/theme_localization_test.dart b/test/app/theme_localization_test.dart index 473c1f1..4422855 100644 --- a/test/app/theme_localization_test.dart +++ b/test/app/theme_localization_test.dart @@ -1693,6 +1693,45 @@ void main() { expect(app.localizationsDelegates, contains(AppLocalizations.delegate)); }); + testWidgets('BusyMaxApp applies native backdrop opacity when inactive', ( + tester, + ) async { + tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.resumed); + addTearDown( + () => tester.binding.handleAppLifecycleStateChanged( + AppLifecycleState.resumed, + ), + ); + final database = AppDatabase.memoryForTests(); + addTearDown(database.close); + + await tester.pumpWidget( + ProviderScope( + overrides: [ + buildConfigProvider.overrideWithValue(_missingConfig), + databaseProvider.overrideWithValue(database), + localSettingsStoreProvider.overrideWithValue(_MemorySettingsStore()), + ], + child: const BusyMaxApp(), + ), + ); + await tester.pumpAndSettle(); + + final backdrop = find.byKey(const ValueKey('busymax-window-backdrop')); + expect(tester.widget(backdrop).opacity, 1); + + tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.inactive); + await tester.pump(); + expect( + tester.widget(backdrop).opacity, + BusyMaxAlpha.windowBackdropOpacity, + ); + + tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.resumed); + await tester.pump(); + expect(tester.widget(backdrop).opacity, 1); + }); + testWidgets('tray startup waits for persisted start-minimized settings', ( tester, ) async { diff --git a/test/core/time/time_zone_catalog_test.dart b/test/core/time/time_zone_catalog_test.dart index 3f2e032..a317db0 100644 --- a/test/core/time/time_zone_catalog_test.dart +++ b/test/core/time/time_zone_catalog_test.dart @@ -1,7 +1,67 @@ +import 'package:busymax/src/core/time/linux_gweather_location_source.dart'; import 'package:busymax/src/core/time/time_zone_catalog.dart'; import 'package:flutter_test/flutter_test.dart'; void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + setUp(() { + BusyMaxTimeZoneCatalog.setSystemLocationsForTesting(const [ + BusyMaxSystemTimeZoneLocation( + name: 'Seattle', + englishName: 'Seattle', + countryCode: 'US', + timeZoneId: 'America/Los_Angeles', + ), + BusyMaxSystemTimeZoneLocation( + name: 'Miami', + englishName: 'Miami', + countryCode: 'US', + timeZoneId: 'America/New_York', + ), + BusyMaxSystemTimeZoneLocation( + name: 'Osaka', + englishName: 'Osaka', + countryCode: 'JP', + timeZoneId: 'Asia/Tokyo', + ), + BusyMaxSystemTimeZoneLocation( + name: 'Montréal', + englishName: 'Montreal', + countryCode: 'CA', + timeZoneId: 'America/Toronto', + ), + BusyMaxSystemTimeZoneLocation( + name: 'Vancouver', + englishName: 'Vancouver', + countryCode: 'CA', + timeZoneId: 'America/Vancouver', + ), + BusyMaxSystemTimeZoneLocation( + name: 'Burnaby', + englishName: 'Burnaby', + countryCode: 'CA', + timeZoneId: 'America/Vancouver', + ), + BusyMaxSystemTimeZoneLocation( + name: 'Victoria', + englishName: 'Victoria', + countryCode: 'CA', + timeZoneId: 'America/Vancouver', + ), + BusyMaxSystemTimeZoneLocation( + name: 'Victoria Falls', + englishName: 'Victoria Falls', + countryCode: 'ZW', + timeZoneId: 'Africa/Harare', + ), + ]); + }); + + tearDown(() { + BusyMaxTimeZoneCatalog.setSystemLocationsForTesting(null); + }); + test('timezone catalog searches IANA locations with current codes', () { final results = BusyMaxTimeZoneCatalog.search('Vancouver'); final vancouver = results.singleWhere( @@ -14,6 +74,88 @@ void main() { expect(vancouver.displayLabel, 'America/Vancouver (${vancouver.code})'); }); + test('timezone catalog includes the complete IANA location set', () { + final america = BusyMaxTimeZoneCatalog.search('America'); + final montreal = america.singleWhere( + (location) => location.id == 'America/Montreal', + ); + final easternAliases = BusyMaxTimeZoneCatalog.search('Eastern'); + + expect(america.length, greaterThan(80)); + expect(montreal.region, 'America'); + expect(montreal.name, 'Montreal'); + expect(montreal.code, isNotEmpty); + expect( + easternAliases.map((location) => location.id), + containsAll(['Canada/Eastern', 'US/Eastern']), + ); + }); + + test('location search resolves ordinary cities to IANA timezones', () async { + final seattle = await BusyMaxTimeZoneCatalog.searchLocations('Seattle'); + final miami = await BusyMaxTimeZoneCatalog.searchLocations('Miami'); + final osaka = await BusyMaxTimeZoneCatalog.searchLocations('Osaka'); + + expect( + seattle + .where((result) => result.name == 'Seattle') + .map((result) => result.location.id), + contains('America/Los_Angeles'), + ); + expect( + miami + .where((result) => result.name == 'Miami') + .map((result) => result.location.id), + contains('America/New_York'), + ); + expect( + osaka + .where((result) => result.name == 'Osaka') + .map((result) => result.location.id), + contains('Asia/Tokyo'), + ); + }); + + test( + 'location search matches English forms of localized system names', + () async { + final results = await BusyMaxTimeZoneCatalog.searchLocations('Montreal'); + final montreal = results.firstWhere( + (result) => + result.name == 'Montréal' && + result.location.id == 'America/Toronto', + ); + + expect(montreal.countryCode, 'CA'); + expect(montreal.subtitle, 'America/Toronto - CA'); + }, + ); + + test('city search does not match every city in the same timezone', () { + final results = BusyMaxTimeZoneCatalog.searchPreparedLocations('Vancouver'); + final burnaby = BusyMaxTimeZoneCatalog.preparedLocationOptions.firstWhere( + (result) => result.name == 'Burnaby', + ); + + expect(results, hasLength(1)); + expect(results.single.name, 'Vancouver'); + expect(results.single.location.id, 'America/Vancouver'); + expect(burnaby.searchText.toLowerCase(), isNot(contains('vancouver'))); + }); + + test('exact city matches rank before prefixes in other regions', () { + final results = BusyMaxTimeZoneCatalog.searchPreparedLocations('Victoria'); + final exactIndex = results.indexWhere( + (result) => result.name == 'Victoria', + ); + final prefixIndex = results.indexWhere( + (result) => result.name == 'Victoria Falls', + ); + + expect(exactIndex, 0); + expect(prefixIndex, greaterThan(exactIndex)); + }); + test('timezone catalog preserves unknown existing identifiers', () { final location = BusyMaxTimeZoneCatalog.location('Custom/Office_Time'); diff --git a/test/features/schedule/presentation/schedule_create_menu_test.dart b/test/features/schedule/presentation/schedule_create_menu_test.dart index 0aca96c..1403cdd 100644 --- a/test/features/schedule/presentation/schedule_create_menu_test.dart +++ b/test/features/schedule/presentation/schedule_create_menu_test.dart @@ -80,12 +80,42 @@ void main() { {'label': 'Task', 'enabled': true, 'selected': false}, ]); expect(arguments['focusFirst'], isFalse); + expect(arguments['preferredPosition'], 'bottom'); expect( find.byWidgetPredicate((widget) => widget is PopupMenuItem), findsNothing, ); }); + testWidgets('create chooser can request placement above its anchor', ( + tester, + ) async { + MethodCall? nativeCall; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(_nativeMenuChannel, (call) async { + nativeCall = call; + return null; + }); + late BuildContext hostContext; + await tester.pumpWidget( + localizedTestApp( + child: Builder( + builder: (context) { + hostContext = context; + return const SizedBox.square(dimension: 32); + }, + ), + ), + ); + + await showScheduleCreateMenu(context: hostContext, preferAbove: true); + + expect( + (nativeCall?.arguments as Map)['preferredPosition'], + 'top', + ); + }); + testWidgets('unavailable native host uses an anchored popup-menu fallback', ( tester, ) async { diff --git a/test/features/schedule/presentation/schedule_views_test.dart b/test/features/schedule/presentation/schedule_views_test.dart index 32a612c..bbc88c8 100644 --- a/test/features/schedule/presentation/schedule_views_test.dart +++ b/test/features/schedule/presentation/schedule_views_test.dart @@ -204,7 +204,7 @@ void main() { items: _itemsFor(selectedDate), firstWeekday: DateTime.monday, onDaySelected: (_) {}, - onCreateAtDay: (_) {}, + onCreateAtDay: (_, {anchorContext}) {}, onItemSelected: (_, _, [_]) {}, onTaskCompletionChanged: (_, _) {}, ), @@ -348,7 +348,7 @@ void main() { firstWeekday: DateTime.monday, items: const [], onDaySelected: (_) {}, - onCreateAtDay: (_) {}, + onCreateAtDay: (_, {anchorContext}) {}, onItemSelected: (_, _, [_]) {}, onTaskCompletionChanged: (_, _) {}, ), @@ -673,7 +673,7 @@ void main() { firstWeekday: DateTime.monday, items: _itemsFor(selectedDate), onDaySelected: (_) {}, - onCreateAtDay: (_) {}, + onCreateAtDay: (_, {anchorContext}) {}, onItemSelected: (_, _, [_]) {}, onTaskCompletionChanged: (_, _) {}, ), @@ -687,6 +687,70 @@ void main() { expect(find.text('Submit report'), findsOneWidget); }); + testWidgets('month create button reports its own popover anchor', ( + tester, + ) async { + final selectedDate = DateTime(2026, 1, 15); + DateTime? createdAt; + BuildContext? createAnchor; + + await tester.pumpWidget( + localizedTestApp( + child: Scaffold( + body: SizedBox( + width: 1000, + height: 720, + child: ScheduleMonthView( + range: ScheduleRange.month(selectedDate), + selectedDate: selectedDate, + firstWeekday: DateTime.monday, + items: const [], + onDaySelected: (_) {}, + onCreateAtDay: (day, {anchorContext}) { + createdAt = day; + createAnchor = anchorContext; + }, + onItemSelected: (_, _, [_]) {}, + onTaskCompletionChanged: (_, _) {}, + ), + ), + ), + ), + ); + + final createButton = find.byType(YaruIconButton); + expect(createButton, findsOneWidget); + tester.widget(createButton).onPressed!(); + await tester.pump(); + + expect(createdAt, selectedDate); + expect(createAnchor, isNotNull); + final renderObject = createAnchor!.findRenderObject()! as RenderBox; + final anchorRect = + renderObject.localToGlobal(Offset.zero) & renderObject.size; + expect(anchorRect, tester.getRect(createButton)); + + createdAt = null; + createAnchor = null; + final selectedCell = find + .ancestor( + of: find.byKey( + ValueKey('month-day-marker-${selectedDate.toIso8601String()}'), + ), + matching: find.byType(InkWell), + ) + .first; + tester.widget(selectedCell).onDoubleTap!(); + await tester.pump(); + + expect(createdAt, selectedDate); + expect(createAnchor, isNotNull); + final cellRenderObject = createAnchor!.findRenderObject()! as RenderBox; + final cellAnchorRect = + cellRenderObject.localToGlobal(Offset.zero) & cellRenderObject.size; + expect(cellAnchorRect, tester.getRect(selectedCell)); + }); + testWidgets('month and year days share accessible date semantics', ( tester, ) async { @@ -706,7 +770,7 @@ void main() { firstWeekday: DateTime.monday, items: const [], onDaySelected: (day) => activatedDay = day, - onCreateAtDay: (_) {}, + onCreateAtDay: (_, {anchorContext}) {}, onItemSelected: (_, _, [_]) {}, onTaskCompletionChanged: (_, _) {}, ), @@ -799,7 +863,7 @@ void main() { firstWeekday: DateTime.monday, items: _sameSlotItemsFor(selectedDate), onDaySelected: (_) {}, - onCreateAtDay: (_) {}, + onCreateAtDay: (_, {anchorContext}) {}, onItemSelected: (_, _, [_]) {}, onTaskCompletionChanged: (_, _) {}, ), @@ -830,7 +894,7 @@ void main() { firstWeekday: DateTime.monday, items: _manyAllDayItemsFor(selectedDate), onDaySelected: (_) {}, - onCreateAtDay: (_) {}, + onCreateAtDay: (_, {anchorContext}) {}, onItemSelected: (anchor, item, [_]) { selectedAnchor = anchor; selectedItem = item; @@ -2411,6 +2475,7 @@ void main() { expect(sidebar, contains('AnimatedRotation')); expect(sidebar, contains('YaruIcons.pan_end')); expect(sidebar, contains('if (_expanded)')); + expect(sidebar, contains('showDayHover: true')); expect(sidebar, isNot(contains('BusyMaxGroupedList('))); expect(sidebar, isNot(contains('hoverColor: Colors.transparent'))); }); @@ -2426,7 +2491,10 @@ void main() { expect(source, contains('this.onWeekSelected')); expect(source, contains('this.weekNumbersInteractive = true')); expect(source, contains('required this.firstWeekday')); - expect(source, contains('_calendarStartForMonth(first, firstWeekday)')); + expect( + source, + contains('_calendarStartForMonth(first, widget.firstWeekday)'), + ); expect(source, contains('monthWeekdayFromMonday')); expect(source, contains('firstWeekdayFromMonday')); expect(source, contains('_addCalendarDays(')); @@ -2442,10 +2510,12 @@ void main() { expect(source, contains('class _MiniCalendarDayIndicators')); expect( source, - contains('final groupedItems = ScheduleProjection.groupByDay(items)'), + contains( + 'final groupedItems = ScheduleProjection.groupByDay(widget.items)', + ), ); expect(source, contains('DateFormat.E(')); - expect(source, contains('_weekdays(firstWeekday)')); + expect(source, contains('_weekdays(widget.firstWeekday)')); expect(source, contains('ScheduleProjection.colorForItem')); expect(source, contains('height: dayExtent')); expect(source, contains('width: double.infinity')); @@ -2553,7 +2623,9 @@ void main() { semantics.dispose(); }); - testWidgets('mini calendar day hover is larger and lighter', (tester) async { + testWidgets('mini calendar day hover is larger and clearly visible', ( + tester, + ) async { final theme = BusyMaxYaruTheme.build( brightness: Brightness.light, accentColor: const Color(0xFF3584E4), @@ -2608,9 +2680,11 @@ void main() { final hoveredMarker = tester.widget(marker); final decoration = hoveredMarker.decoration as BoxDecoration; expect(hoveredSize.width, greaterThan(restingSize.width)); + expect(hoveredSize.width, 32); expect( - decoration.color!.computeLuminance(), - greaterThan(colors.popover.computeLuminance()), + (decoration.color!.computeLuminance() - colors.card.computeLuminance()) + .abs(), + greaterThan(0.05), ); }); @@ -2676,6 +2750,51 @@ void main() { ); }); + testWidgets('mini calendar header arrows page without selecting a date', ( + tester, + ) async { + final selectedDates = []; + + await tester.pumpWidget( + localizedTestApp( + child: Scaffold( + body: SizedBox( + width: 300, + child: MiniCalendar( + selectedDate: DateTime(2026, 1, 15), + firstWeekday: DateTime.monday, + onSelected: selectedDates.add, + onMonthSelected: (_) {}, + onYearSelected: (_) {}, + onWeekSelected: (_) {}, + ), + ), + ), + ), + ); + + await tester.tap(find.byTooltip('Next month')); + await tester.pump(); + expect(find.text('February'), findsOneWidget); + expect(find.text('2026'), findsOneWidget); + + await tester.tap(find.byTooltip('Next year')); + await tester.pump(); + expect(find.text('February'), findsOneWidget); + expect(find.text('2027'), findsOneWidget); + + await tester.tap(find.byTooltip('Previous month')); + await tester.pump(); + expect(find.text('January'), findsOneWidget); + expect(find.text('2027'), findsOneWidget); + + await tester.tap(find.byTooltip('Previous year')); + await tester.pump(); + expect(find.text('January'), findsOneWidget); + expect(find.text('2026'), findsOneWidget); + expect(selectedDates, isEmpty); + }); + testWidgets('mini calendar week number selects that week', (tester) async { DateTime? selectedWeek; @@ -2856,6 +2975,41 @@ void main() { expect(selectedWeek, DateTime(2026, 1, 4)); }); + testWidgets('January 2026 Sunday rows use ISO weeks 52 and 1', ( + tester, + ) async { + final selectedWeeks = []; + + await tester.pumpWidget( + localizedTestApp( + child: Scaffold( + body: SizedBox( + width: 300, + child: MiniCalendar( + selectedDate: DateTime(2026, 1, 15), + firstWeekday: DateTime.sunday, + onSelected: (_) {}, + onMonthSelected: (_) {}, + onYearSelected: (_) {}, + onWeekSelected: selectedWeeks.add, + ), + ), + ), + ), + ); + + expect(find.byTooltip('Week 52'), findsOneWidget); + expect(find.byTooltip('Week 1'), findsOneWidget); + + await tester.tap(find.byTooltip('Week 52')); + await tester.tap(find.byTooltip('Week 1')); + + expect(selectedWeeks, [ + DateTime(2025, 12, 28), + DateTime(2026, 1, 4), + ]); + }); + testWidgets( 'mini calendar shows previous month day for Sunday-first Monday starts', (tester) async { @@ -2865,7 +3019,7 @@ void main() { body: SizedBox( width: 300, child: MiniCalendar( - selectedDate: DateTime(2027, 11, 1), + selectedDate: DateTime(2027, 11, 15), firstWeekday: DateTime.sunday, onSelected: (_) {}, onMonthSelected: (_) {}, @@ -2900,6 +3054,21 @@ void main() { ), findsOneWidget, ); + final previousMonthDay = tester.widget( + find.descendant( + of: find.byTooltip('Sunday, October 31, 2027'), + matching: find.text('31'), + ), + ); + final currentMonthDay = tester.widget( + find.descendant( + of: find.byTooltip('Monday, November 1, 2027'), + matching: find.text('1'), + ), + ); + expect(previousMonthDay.style?.color, isNotNull); + expect(previousMonthDay.style!.color!.a, lessThan(1)); + expect(currentMonthDay.style?.color, isNull); }, ); diff --git a/test/features/schedule/schedule_dst_test.dart b/test/features/schedule/schedule_dst_test.dart index 291ca36..b182ea9 100644 --- a/test/features/schedule/schedule_dst_test.dart +++ b/test/features/schedule/schedule_dst_test.dart @@ -87,7 +87,7 @@ void main() { items: const [], firstWeekday: DateTime.monday, onDaySelected: selectedDays.add, - onCreateAtDay: (_) {}, + onCreateAtDay: (_, {anchorContext}) {}, onItemSelected: (_, _, [_]) {}, onTaskCompletionChanged: (_, _) {}, ), diff --git a/test/features/tasks/presentation/desktop_date_time_fields_test.dart b/test/features/tasks/presentation/desktop_date_time_fields_test.dart index e898309..dfd54ed 100644 --- a/test/features/tasks/presentation/desktop_date_time_fields_test.dart +++ b/test/features/tasks/presentation/desktop_date_time_fields_test.dart @@ -1,7 +1,9 @@ import 'dart:async'; import 'package:busymax/src/app/busymax_design.dart'; +import 'package:busymax/src/app/busymax_surface_colors.dart'; import 'package:busymax/src/core/time/time_zone_catalog.dart'; +import 'package:busymax/src/core/time/linux_gweather_location_source.dart'; import 'package:busymax/src/features/tasks/presentation/desktop_date_time_fields.dart'; import 'package:busymax/src/features/schedule/presentation/mini_calendar.dart'; import 'package:flutter/material.dart'; @@ -286,13 +288,13 @@ void main() { final picker = find.byType(BusyMaxContentPopoverSurface); final componentFields = find.descendant( of: picker, - matching: find.byType(TextFormField), + matching: find.byType(TextField), ); final editableFields = find.descendant( of: picker, matching: find.byType(EditableText), ); - expect(componentFields, findsNWidgets(2)); + expect(componentFields, findsNothing); expect(editableFields, findsNWidgets(2)); expect( tester @@ -304,14 +306,42 @@ void main() { expect(field.textAlign, TextAlign.center); expect(field.style.fontWeight, FontWeight.normal); } - for (final element in componentFields.evaluate()) { - final size = tester.getSize( - find.byElementPredicate((candidate) { - return identical(candidate, element); - }), + for (final label in ['Hour', 'Minute']) { + final inputSection = find.byKey(ValueKey(('time-input-section', label))); + final inputCell = find.byKey(ValueKey(('time-input', label))); + final editableText = find.descendant( + of: inputCell, + matching: find.byType(EditableText), + ); + final sectionDecoration = + tester.widget(inputSection).decoration! as BoxDecoration; + expect(sectionDecoration.border, isNull); + expect( + find.descendant(of: inputCell, matching: find.byType(InputDecorator)), + findsNothing, + ); + final editable = tester.widget(editableText); + expect(editable.textAlign, TextAlign.center); + expect(editable.strutStyle.forceStrutHeight, isTrue); + final dividers = tester.widgetList( + find.descendant(of: inputSection, matching: find.byType(Divider)), + ); + expect(dividers, hasLength(2)); + expect( + dividers.map((divider) => divider.color), + everyElement( + BusyMaxSurfaceColors.of(tester.element(inputSection)).divider, + ), + ); + final inputCellSize = tester.getSize(inputCell); + expect(inputCellSize.width, BusyMaxSizes.popoverActionButton); + expect(inputCellSize.height, BusyMaxSizes.popoverActionButton); + expect( + (tester.getRect(inputCell).center.dy - + tester.getRect(editableText).center.dy) + .abs(), + lessThan(1), ); - expect(size.width, inInclusiveRange(36, 38)); - expect(size.height, BusyMaxSizes.popoverActionButton); } final timezoneButton = find.ancestor( @@ -330,6 +360,18 @@ void main() { testWidgets('time picker searches and selects real timezone locations', ( tester, ) async { + BusyMaxTimeZoneCatalog.setSystemLocationsForTesting(const [ + BusyMaxSystemTimeZoneLocation( + name: 'Seattle', + englishName: 'Seattle', + countryCode: 'US', + timeZoneId: 'America/Los_Angeles', + ), + ]); + addTearDown( + () => BusyMaxTimeZoneCatalog.setSystemLocationsForTesting(null), + ); + await tester.runAsync(BusyMaxTimeZoneCatalog.prepareLocationSearch); String? selectedTimeZone; await tester.pumpWidget( localizedTestApp( @@ -351,6 +393,16 @@ void main() { await tester.pumpAndSettle(); expect(find.text('Select Timezone'), findsOneWidget); + final nativeSearchField = tester.widget( + find.byType(YaruSearchField), + ); + expect( + nativeSearchField.contentPadding, + const EdgeInsets.only( + left: BusyMaxSpacing.md, + right: kYaruTitleBarItemHeight, + ), + ); final title = tester.widget(find.text('Select Timezone')); expect(title.textAlign, isNull); expect(title.style?.fontWeight, FontWeight.w600); @@ -367,15 +419,17 @@ void main() { expect(searchField, findsOneWidget); expect(content, findsOneWidget); final initialContentSize = tester.getSize(content); + final initialSearchSize = tester.getSize(find.byType(BusyMaxSearchField)); + expect(initialSearchSize.height, kYaruTitleBarItemHeight); final dialog = find.byType(Dialog); expect(dialog, findsOneWidget); final initialDialogSize = tester.getSize(dialog); - await tester.enterText(searchField, 'a'); - await tester.pumpAndSettle(); + await _enterTimeZoneSearch(tester, searchField, 'a'); expect(tester.getSize(content), initialContentSize); expect(tester.getSize(dialog), initialDialogSize); + expect(tester.getSize(find.byType(BusyMaxSearchField)), initialSearchSize); final resultsList = find.byKey(const ValueKey('timezone-results-list')); expect(resultsList, findsOneWidget); expect( @@ -383,16 +437,32 @@ void main() { greaterThan(0), ); - await tester.enterText(searchField, 'Vancouver'); - await tester.pumpAndSettle(); + await _enterTimeZoneSearch(tester, searchField, 'Vancouver'); expect(tester.getSize(content), initialContentSize); expect(tester.getSize(dialog), initialDialogSize); expect(find.text('America'), findsOneWidget); - expect(find.textContaining('Vancouver ('), findsOneWidget); + expect(find.textContaining('Vancouver ('), findsWidgets); expect(find.text('America/Vancouver'), findsOneWidget); - await tester.tap(find.textContaining('Vancouver (')); + await _enterTimeZoneSearch(tester, searchField, 'Montreal'); + + expect(find.textContaining('Montreal ('), findsOneWidget); + expect(find.text('America/Montreal'), findsOneWidget); + + await _enterTimeZoneSearch(tester, searchField, 'Seattle'); + + expect(find.textContaining('Seattle ('), findsOneWidget); + expect(find.text('America/Los_Angeles - US'), findsOneWidget); + + await _enterTimeZoneSearch(tester, searchField, 'Vancouver'); + + await tester.tap( + find.ancestor( + of: find.text('America/Vancouver'), + matching: find.byType(BusyMaxActionRow), + ), + ); await tester.pumpAndSettle(); expect(selectedTimeZone, 'America/Vancouver'); @@ -1001,3 +1071,13 @@ void main() { void _ignoreString(String value) {} void _ignoreNullableString(String? value) {} + +Future _enterTimeZoneSearch( + WidgetTester tester, + Finder searchField, + String query, +) async { + await tester.enterText(searchField, query); + await tester.pump(); + await tester.pump(); +} diff --git a/test/platform/native_dialog_service_test.dart b/test/platform/native_dialog_service_test.dart index 7deec4d..9df26bc 100644 --- a/test/platform/native_dialog_service_test.dart +++ b/test/platform/native_dialog_service_test.dart @@ -77,4 +77,83 @@ void main() { expect(result.available, isFalse); expect(result.confirmed, isFalse); }); + + test('passes timezone content to the native chooser', () async { + MethodCall? receivedCall; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + receivedCall = call; + return 'America/Vancouver'; + }); + const service = NativeDialogService(channel: channel); + + final result = await service.selectTimeZone( + title: 'Select Timezone', + searchPlaceholder: 'Search locations', + noResultsLabel: 'No locations found', + selectedTimeZone: 'Etc/UTC', + options: const [ + NativeTimeZoneOption( + id: 'America/Vancouver', + region: 'America', + name: 'Vancouver', + englishName: 'Vancouver', + title: 'Vancouver (PDT)', + subtitle: 'America/Vancouver - CA', + searchText: 'Vancouver\nAmerica/Vancouver\nCA', + ), + ], + ); + + expect(result.available, isTrue); + expect(result.selectedTimeZone, 'America/Vancouver'); + expect(receivedCall?.method, 'selectTimeZone'); + expect(receivedCall?.arguments, { + 'title': 'Select Timezone', + 'searchPlaceholder': 'Search locations', + 'noResultsLabel': 'No locations found', + 'selectedTimeZone': 'Etc/UTC', + 'options': [ + { + 'id': 'America/Vancouver', + 'region': 'America', + 'name': 'Vancouver', + 'englishName': 'Vancouver', + 'title': 'Vancouver (PDT)', + 'subtitle': 'America/Vancouver - CA', + 'searchText': 'Vancouver\nAmerica/Vancouver\nCA', + }, + ], + }); + }); + + test( + 'distinguishes native timezone cancellation from unavailable host', + () async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (_) async => null); + const service = NativeDialogService(channel: channel); + + final result = await service.selectTimeZone( + title: 'Select Timezone', + searchPlaceholder: 'Search locations', + noResultsLabel: 'No locations found', + selectedTimeZone: 'Etc/UTC', + options: const [ + NativeTimeZoneOption( + id: 'Etc/UTC', + region: 'UTC', + name: 'UTC', + englishName: 'UTC', + title: 'UTC (UTC)', + subtitle: 'Etc/UTC', + searchText: 'UTC\nEtc/UTC', + ), + ], + ); + + expect(result.available, isTrue); + expect(result.selectedTimeZone, isNull); + }, + ); } diff --git a/test/platform/native_menu_service_test.dart b/test/platform/native_menu_service_test.dart index f4478bb..37b2b5f 100644 --- a/test/platform/native_menu_service_test.dart +++ b/test/platform/native_menu_service_test.dart @@ -44,6 +44,7 @@ void main() { {'label': 'Archived', 'enabled': false, 'selected': false}, ], 'focusFirst': false, + 'preferredPosition': 'bottom', }); }); @@ -66,6 +67,25 @@ void main() { expect(receivedCall?.arguments, containsPair('focusFirst', true)); }); + test('can place a native menu above its anchor', () async { + MethodCall? receivedCall; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + receivedCall = call; + return null; + }); + const service = NativeMenuService(channel: channel); + + await service.show( + session: NativeMenuSession(), + anchor: const Rect.fromLTWH(0, 0, 100, 34), + entries: const [NativeMenuEntry(label: 'Event')], + preferAbove: true, + ); + + expect(receivedCall?.arguments, containsPair('preferredPosition', 'top')); + }); + test('distinguishes menu dismissal from an unavailable host', () async { TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger .setMockMethodCallHandler(channel, (_) async => null); From c4a82b9dee78083905fa82ab83e2ac394844e93d Mon Sep 17 00:00:00 2001 From: albert Date: Wed, 29 Jul 2026 14:36:09 -0700 Subject: [PATCH 26/73] Refactor native time zone dialog implementation. Replace GtkDialog with HdyWindow for improved styling and functionality. Enhance event handling for window deletion and key presses. Update dialog state management and ensure proper cleanup of resources. Enhance time picker input by adding customizable cursor width and leading inset. Update EditableText widget to improve layout and user interaction. --- .../desktop_date_time_fields.dart | 54 +++++--- linux/runner/my_application.cc | 130 ++++++++++++++---- test/app/native_ui_audit_test.dart | 48 ++++++- .../desktop_date_time_fields_test.dart | 32 +++++ 4 files changed, 216 insertions(+), 48 deletions(-) diff --git a/lib/src/features/tasks/presentation/desktop_date_time_fields.dart b/lib/src/features/tasks/presentation/desktop_date_time_fields.dart index 3fa14c3..4f4c1d5 100644 --- a/lib/src/features/tasks/presentation/desktop_date_time_fields.dart +++ b/lib/src/features/tasks/presentation/desktop_date_time_fields.dart @@ -26,6 +26,11 @@ const _timePickerPopoverMinimumHeight = 220.0; const _timePickerPopoverPadding = EdgeInsets.all(BusyMaxSpacing.md); const _timePickerInputControlSize = BusyMaxSizes.popoverActionButton; const _timePickerInputColumnWidth = BusyMaxSizes.popoverActionButton; +const _timePickerEditableCursorWidth = 2.0; +// RenderEditable reserves this gap plus cursorWidth after a single line. +const _timePickerEditableCursorGap = 1.0; +const _timePickerEditableLeadingInset = + _timePickerEditableCursorWidth + _timePickerEditableCursorGap; class NativeDateTimePicker { const NativeDateTimePicker(); @@ -1229,28 +1234,35 @@ class _DesktopTimeValueDialogState extends State<_DesktopTimeValueDialog> { child: Center( child: SizedBox( width: buttonWidth, - child: EditableText( - controller: controller, - focusNode: focusNode, - textAlign: TextAlign.center, - keyboardType: TextInputType.number, - maxLines: 1, - inputFormatters: [ - FilteringTextInputFormatter.digitsOnly, - LengthLimitingTextInputFormatter(2), - ], - style: inputTextStyle, - strutStyle: StrutStyle.fromTextStyle( - inputTextStyle, - forceStrutHeight: true, + child: Padding( + padding: const EdgeInsets.only( + left: _timePickerEditableLeadingInset, + ), + child: EditableText( + controller: controller, + focusNode: focusNode, + textAlign: TextAlign.center, + keyboardType: TextInputType.number, + maxLines: 1, + inputFormatters: [ + FilteringTextInputFormatter.digitsOnly, + LengthLimitingTextInputFormatter(2), + ], + style: inputTextStyle, + strutStyle: StrutStyle.fromTextStyle( + inputTextStyle, + forceStrutHeight: true, + ), + cursorWidth: _timePickerEditableCursorWidth, + cursorColor: Theme.of(context).colorScheme.primary, + backgroundCursorColor: + surfaceColors.disabledForeground, + selectionColor: Theme.of( + context, + ).colorScheme.primary.withValues(alpha: 0.28), + onChanged: (_) => _handleTimeInputChanged(), + onSubmitted: (_) => _handleTimeInputChanged(), ), - cursorColor: Theme.of(context).colorScheme.primary, - backgroundCursorColor: surfaceColors.disabledForeground, - selectionColor: Theme.of( - context, - ).colorScheme.primary.withValues(alpha: 0.28), - onChanged: (_) => _handleTimeInputChanged(), - onSubmitted: (_) => _handleTimeInputChanged(), ), ), ), diff --git a/linux/runner/my_application.cc b/linux/runner/my_application.cc index 892e6fa..1c25d94 100644 --- a/linux/runner/my_application.cc +++ b/linux/runner/my_application.cc @@ -581,11 +581,12 @@ struct NativeTimeZoneOption { }; struct NativeTimeZoneDialogState { - GtkWidget* dialog; + GtkWidget* window; GtkWidget* results; GPtrArray* options; const gchar* selected_time_zone; const gchar* no_results_label; + GMainLoop* loop; gchar* result; }; @@ -667,7 +668,43 @@ static void native_time_zone_row_activated_cb(HdyActionRow* row, } g_free(state->result); state->result = g_strdup(id); - gtk_dialog_response(GTK_DIALOG(state->dialog), GTK_RESPONSE_ACCEPT); + if (g_main_loop_is_running(state->loop)) { + g_main_loop_quit(state->loop); + } +} + +static gboolean native_time_zone_window_delete_event_cb( + GtkWidget*, + GdkEvent*, + gpointer user_data) { + auto* state = static_cast(user_data); + if (g_main_loop_is_running(state->loop)) { + g_main_loop_quit(state->loop); + } + return TRUE; +} + +static gboolean native_time_zone_window_key_press_event_cb( + GtkWidget*, + GdkEventKey* event, + gpointer user_data) { + if (event->keyval != GDK_KEY_Escape) { + return FALSE; + } + auto* state = static_cast(user_data); + if (g_main_loop_is_running(state->loop)) { + g_main_loop_quit(state->loop); + } + return TRUE; +} + +static void native_time_zone_window_destroy_cb(GtkWidget*, + gpointer user_data) { + auto* state = static_cast(user_data); + state->window = nullptr; + if (g_main_loop_is_running(state->loop)) { + g_main_loop_quit(state->loop); + } } static void rebuild_native_time_zone_results( @@ -846,21 +883,42 @@ static void handle_native_time_zone_selection(FlMethodCall* method_call, return; } - GtkWidget* dialog = gtk_dialog_new_with_buttons( - title, parent, - static_cast(GTK_DIALOG_MODAL | - GTK_DIALOG_DESTROY_WITH_PARENT | - GTK_DIALOG_USE_HEADER_BAR), - nullptr, - nullptr); - style_native_dialog(dialog); - gtk_style_context_add_class(gtk_widget_get_style_context(dialog), + GtkWidget* window = hdy_window_new(); + style_native_dialog(window); + GtkStyleContext* dialog_context = gtk_widget_get_style_context(window); + gtk_style_context_add_class(dialog_context, kNativeTimeZoneDialogStyleClass); - gtk_window_set_resizable(GTK_WINDOW(dialog), FALSE); - gtk_window_set_default_size(GTK_WINDOW(dialog), + gtk_window_set_title(GTK_WINDOW(window), title); + gtk_window_set_transient_for(GTK_WINDOW(window), parent); + gtk_window_set_modal(GTK_WINDOW(window), TRUE); + gtk_window_set_destroy_with_parent(GTK_WINDOW(window), TRUE); + gtk_window_set_type_hint(GTK_WINDOW(window), GDK_WINDOW_TYPE_HINT_DIALOG); + gtk_window_set_position(GTK_WINDOW(window), GTK_WIN_POS_CENTER_ON_PARENT); + gtk_window_set_resizable(GTK_WINDOW(window), FALSE); + gtk_window_set_default_size(GTK_WINDOW(window), kNativeTimeZoneDialogWidth, -1); + GtkApplication* application = gtk_window_get_application(parent); + if (application != nullptr) { + gtk_window_set_application(GTK_WINDOW(window), application); + } + + GtkWidget* window_root = gtk_box_new(GTK_ORIENTATION_VERTICAL, 0); + gtk_container_add(GTK_CONTAINER(window), window_root); + + GtkWidget* header_bar = hdy_header_bar_new(); + hdy_header_bar_set_title(HDY_HEADER_BAR(header_bar), title); + hdy_header_bar_set_has_subtitle(HDY_HEADER_BAR(header_bar), FALSE); + hdy_header_bar_set_show_close_button(HDY_HEADER_BAR(header_bar), TRUE); + hdy_header_bar_set_decoration_layout(HDY_HEADER_BAR(header_bar), ":close"); + hdy_header_bar_set_centering_policy(HDY_HEADER_BAR(header_bar), + HDY_CENTERING_POLICY_STRICT); + gtk_box_pack_start(GTK_BOX(window_root), header_bar, FALSE, FALSE, 0); + + GtkWidget* content = gtk_box_new(GTK_ORIENTATION_VERTICAL, 0); + gtk_style_context_add_class(gtk_widget_get_style_context(content), + "busymax-native-dialog-content"); + gtk_box_pack_start(GTK_BOX(window_root), content, TRUE, TRUE, 0); - GtkWidget* content = gtk_dialog_get_content_area(GTK_DIALOG(dialog)); GtkWidget* root = gtk_box_new(GTK_ORIENTATION_VERTICAL, 12); gtk_widget_set_size_request(root, kNativeTimeZoneDialogWidth - 36, kNativeTimeZoneDialogContentHeight); @@ -884,24 +942,35 @@ static void handle_native_time_zone_selection(FlMethodCall* method_call, kNativeTimeZoneResultsStyleClass); gtk_container_add(GTK_CONTAINER(scrolled), results); + GMainLoop* loop = g_main_loop_new(nullptr, FALSE); NativeTimeZoneDialogState state = { - dialog, + window, results, options, selected_time_zone, no_results_label, + loop, nullptr, }; g_signal_connect(search, "search-changed", G_CALLBACK(native_time_zone_search_changed_cb), &state); - - gtk_widget_show_all(dialog); + g_signal_connect(window, "delete-event", + G_CALLBACK(native_time_zone_window_delete_event_cb), &state); + g_signal_connect(window, "key-press-event", + G_CALLBACK(native_time_zone_window_key_press_event_cb), + &state); + g_signal_connect(window, "destroy", + G_CALLBACK(native_time_zone_window_destroy_cb), &state); + + gtk_widget_show_all(window); gtk_widget_grab_focus(search); - const gint response = gtk_dialog_run(GTK_DIALOG(dialog)); - respond_string(method_call, - response == GTK_RESPONSE_ACCEPT ? state.result : nullptr); + g_main_loop_run(loop); + respond_string(method_call, state.result); - gtk_widget_destroy(dialog); + if (state.window != nullptr) { + gtk_widget_destroy(state.window); + } + g_main_loop_unref(loop); g_free(state.result); g_ptr_array_unref(options); } @@ -1647,16 +1716,26 @@ static void refresh_header_bar_css(MyApplication* self) { css_color_or(self->header_bar_dialog_outline_color, kDefaultDialogOutlineColor)); g_autofree gchar* native_time_zone_dialog_css = g_strdup_printf( - "window.%s.%s," - "window.%s.%s:backdrop {" + "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 {" + "> 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;" "}" "window.%s.%s .busymax-native-dialog-content," "window.%s.%s .busymax-native-dialog-content:backdrop {" @@ -1697,6 +1776,9 @@ static void refresh_header_bar_css(MyApplication* self) { kNativeDialogStyleClass, kNativeTimeZoneDialogStyleClass, dialog_background_color, kNativeDialogCornerRadius, kNativeDialogStyleClass, kNativeTimeZoneDialogStyleClass, + kNativeDialogStyleClass, kNativeTimeZoneDialogStyleClass, + kNativeDialogStyleClass, kNativeTimeZoneDialogStyleClass, + kNativeDialogStyleClass, kNativeTimeZoneDialogStyleClass, kNativeDialogCornerRadius, kNativeDialogStyleClass, kNativeTimeZoneDialogStyleClass, kNativeDialogStyleClass, kNativeTimeZoneDialogStyleClass, dialog_background_color, diff --git a/test/app/native_ui_audit_test.dart b/test/app/native_ui_audit_test.dart index 676d011..3074f7a 100644 --- a/test/app/native_ui_audit_test.dart +++ b/test/app/native_ui_audit_test.dart @@ -84,7 +84,7 @@ void main() { expect(source, isNot(contains('configure_rounded_window_shape'))); expect(source, isNot(contains('CAIRO_OPERATOR_CLEAR'))); expect(source, isNot(contains('kNativeWindowRadius'))); - expect(source, isNot(contains('"unified"'))); + expect(activateBody, isNot(contains('"unified"'))); expect(app, isNot(contains('_BusyMaxWindowCornerClip'))); }, ); @@ -1326,8 +1326,30 @@ void main() { expect(runner, contains('strcmp(method, "selectTimeZone") == 0')); expect(runner, contains('gtk_search_entry_new()')); + expect(runner, contains('hdy_window_new()')); + expect(runner, contains('hdy_header_bar_new()')); expect(runner, contains('hdy_preferences_group_new()')); expect(runner, contains('hdy_action_row_new()')); + expect( + runner, + contains( + 'hdy_header_bar_set_decoration_layout(HDY_HEADER_BAR(header_bar), ' + '":close")', + ), + ); + final selectorStart = runner.indexOf( + 'static void handle_native_time_zone_selection', + ); + final selectorEnd = runner.indexOf( + 'struct NativeDialogHandlerData', + selectorStart, + ); + expect(selectorStart, isNonNegative); + expect(selectorEnd, greaterThan(selectorStart)); + final nativeSelector = runner.substring(selectorStart, selectorEnd); + expect(nativeSelector, isNot(contains('gtk_dialog_new_with_buttons('))); + expect(nativeSelector, isNot(contains('gtk_dialog_run('))); + expect(nativeSelector, contains('g_main_loop_run(loop)')); expect(runner, contains('kNativeTimeZoneDialogContentHeight')); expect(runner, contains('kNativeTimeZoneDialogStyleClass')); expect(runner, contains('kNativeTimeZoneGroupStyleClass')); @@ -1749,9 +1771,10 @@ void main() { expect(source, contains('style_native_dialog(GtkWidget* dialog)')); expect( 'style_native_dialog(dialog);'.allMatches(source).length, - 3, - reason: 'native date, time, and timezone pickers share dialog styling', + 2, + reason: 'native date and time pickers share dialog styling', ); + expect(source, contains('style_native_dialog(window);')); expect( nativeDialogCss, contains('g_autofree gchar* native_dialog_css ='), @@ -1811,11 +1834,30 @@ void main() { 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, + greaterThanOrEqualTo(3), + ); expect( nativeTimeZoneDialogCss, contains('"background-color: shade(%s, 1.06);"'), diff --git a/test/features/tasks/presentation/desktop_date_time_fields_test.dart b/test/features/tasks/presentation/desktop_date_time_fields_test.dart index dfd54ed..69f5345 100644 --- a/test/features/tasks/presentation/desktop_date_time_fields_test.dart +++ b/test/features/tasks/presentation/desktop_date_time_fields_test.dart @@ -7,6 +7,7 @@ import 'package:busymax/src/core/time/linux_gweather_location_source.dart'; import 'package:busymax/src/features/tasks/presentation/desktop_date_time_fields.dart'; import 'package:busymax/src/features/schedule/presentation/mini_calendar.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'; @@ -306,6 +307,7 @@ void main() { expect(field.textAlign, TextAlign.center); expect(field.style.fontWeight, FontWeight.normal); } + final componentCenterOffsets = []; for (final label in ['Hour', 'Minute']) { final inputSection = find.byKey(ValueKey(('time-input-section', label))); final inputCell = find.byKey(ValueKey(('time-input', label))); @@ -323,6 +325,25 @@ void main() { final editable = tester.widget(editableText); expect(editable.textAlign, TextAlign.center); expect(editable.strutStyle.forceStrutHeight, isTrue); + final renderEditableFinder = find.descendant( + of: editableText, + matching: find.byElementPredicate( + (element) => element.renderObject is RenderEditable, + ), + ); + expect(renderEditableFinder, findsOneWidget); + final renderEditable = tester.renderObject( + renderEditableFinder, + ); + final editableOrigin = renderEditable.localToGlobal(Offset.zero); + final firstCaret = renderEditable.getLocalRectForCaret( + const TextPosition(offset: 0), + ); + final lastCaret = renderEditable.getLocalRectForCaret( + TextPosition(offset: editable.controller.text.length), + ); + final textRunCenterDx = + editableOrigin.dx + (firstCaret.left + lastCaret.left) / 2; final dividers = tester.widgetList( find.descendant(of: inputSection, matching: find.byType(Divider)), ); @@ -336,6 +357,13 @@ void main() { final inputCellSize = tester.getSize(inputCell); expect(inputCellSize.width, BusyMaxSizes.popoverActionButton); expect(inputCellSize.height, BusyMaxSizes.popoverActionButton); + final componentCenterOffset = + textRunCenterDx - tester.getRect(inputCell).center.dx; + componentCenterOffsets.add(componentCenterOffset); + expect( + componentCenterOffset.abs(), + lessThanOrEqualTo(1 / tester.view.devicePixelRatio + 1e-9), + ); expect( (tester.getRect(inputCell).center.dy - tester.getRect(editableText).center.dy) @@ -343,6 +371,10 @@ void main() { lessThan(1), ); } + expect( + componentCenterOffsets.first, + closeTo(componentCenterOffsets.last, 0.01), + ); final timezoneButton = find.ancestor( of: find.byIcon(Icons.public), From adeb462cdd3efcb9de92da5780b51ae090c16519 Mon Sep 17 00:00:00 2001 From: albert Date: Wed, 29 Jul 2026 14:47:07 -0700 Subject: [PATCH 27/73] Refactor BusyMaxApp state management. Remove window manager integration and lifecycle observers to simplify the application structure. Clean up unused variables and improve code readability. --- lib/src/app/busymax_app.dart | 42 ++------------------------- lib/src/app/busymax_design.dart | 1 - test/app/theme_localization_test.dart | 21 +++++++++----- 3 files changed, 15 insertions(+), 49 deletions(-) diff --git a/lib/src/app/busymax_app.dart b/lib/src/app/busymax_app.dart index d52fb33..069721d 100644 --- a/lib/src/app/busymax_app.dart +++ b/lib/src/app/busymax_app.dart @@ -4,7 +4,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:system_theme/system_theme.dart'; import 'package:ubuntu_localizations/ubuntu_localizations.dart'; -import 'package:window_manager/window_manager.dart'; import '../platform/busymax_tray_service.dart'; import '../platform/gtk_font_service.dart'; @@ -40,25 +39,18 @@ class BusyMaxApp extends ConsumerStatefulWidget { ConsumerState createState() => _BusyMaxAppState(); } -class _BusyMaxAppState extends ConsumerState - with WidgetsBindingObserver, WindowListener { +class _BusyMaxAppState extends ConsumerState { BusyMaxTrayService? _trayService; bool? _lastHideOnClose; bool? _lastTrayEnabled; bool _startMinimizedHandled = false; bool _settingsReady = false; - bool _windowActive = true; late final BusyMaxHeaderBarConfigurationSynchronizer _headerBarConfigurationSynchronizer; @override void initState() { super.initState(); - WidgetsBinding.instance.addObserver(this); - windowManager.addListener(this); - _windowActive = _isActiveLifecycleState( - WidgetsBinding.instance.lifecycleState, - ); _headerBarConfigurationSynchronizer = BusyMaxHeaderBarConfigurationSynchronizer( ref.read(linuxHeaderBarServiceProvider), @@ -68,8 +60,6 @@ class _BusyMaxAppState extends ConsumerState @override void dispose() { - windowManager.removeListener(this); - WidgetsBinding.instance.removeObserver(this); _headerBarConfigurationSynchronizer.dispose(); final tray = _trayService; if (tray != null) { @@ -78,24 +68,6 @@ class _BusyMaxAppState extends ConsumerState super.dispose(); } - @override - void didChangeAppLifecycleState(AppLifecycleState state) { - _setWindowActive(_isActiveLifecycleState(state)); - } - - @override - void onWindowFocus() => _setWindowActive(true); - - @override - void onWindowBlur() => _setWindowActive(false); - - void _setWindowActive(bool windowActive) { - if (_windowActive == windowActive || !mounted) { - return; - } - setState(() => _windowActive = windowActive); - } - Future _waitForSettings() async { await ref.read(appSettingsControllerProvider.notifier).ready; if (!mounted) { @@ -214,13 +186,7 @@ class _BusyMaxAppState extends ConsumerState child: MainWindowCommandBridge( child: ColoredBox( color: BusyMaxSurfaceColors.of(context).window, - child: Opacity( - key: const ValueKey('busymax-window-backdrop'), - opacity: _windowActive - ? 1 - : BusyMaxAlpha.windowBackdropOpacity, - child: child ?? const SizedBox.shrink(), - ), + child: child ?? const SizedBox.shrink(), ), ), ), @@ -411,10 +377,6 @@ class _BusyMaxAppState extends ConsumerState } } -bool _isActiveLifecycleState(AppLifecycleState? state) { - return state == null || state == AppLifecycleState.resumed; -} - class _KeyboardShortcutsIntent extends Intent { const _KeyboardShortcutsIntent(); } diff --git a/lib/src/app/busymax_design.dart b/lib/src/app/busymax_design.dart index 2ef005d..1c5468e 100644 --- a/lib/src/app/busymax_design.dart +++ b/lib/src/app/busymax_design.dart @@ -73,7 +73,6 @@ abstract final class BusyMaxAlpha { static const double calendarGridDark = 0.06; static const double groupedRowLightHoverStrength = 0.50; static const double nativeHeaderMenuShadowOpacity = 0.30; - static const double windowBackdropOpacity = 0.50; } abstract final class BusyMaxMotion { diff --git a/test/app/theme_localization_test.dart b/test/app/theme_localization_test.dart index 4422855..7bb0f08 100644 --- a/test/app/theme_localization_test.dart +++ b/test/app/theme_localization_test.dart @@ -21,6 +21,7 @@ import 'package:busymax/src/l10n/l10n.dart'; import 'package:busymax/src/platform/busymax_tray_service.dart'; import 'package:busymax/src/platform/gtk_font_service.dart'; import 'package:busymax/src/platform/linux_window_service.dart'; +import 'package:busymax/src/platform/main_window_command_bridge.dart'; import 'package:busymax/src/schedule/schedule_view_mode.dart'; import '../test_localized_app.dart'; @@ -1693,7 +1694,7 @@ void main() { expect(app.localizationsDelegates, contains(AppLocalizations.delegate)); }); - testWidgets('BusyMaxApp applies native backdrop opacity when inactive', ( + testWidgets('BusyMaxApp does not dim Flutter content when inactive', ( tester, ) async { tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.resumed); @@ -1717,19 +1718,23 @@ void main() { ); await tester.pumpAndSettle(); - final backdrop = find.byKey(const ValueKey('busymax-window-backdrop')); - expect(tester.widget(backdrop).opacity, 1); + ColoredBox flutterSurface() { + final bridge = tester.widget( + find.byType(MainWindowCommandBridge), + ); + return bridge.child as ColoredBox; + } + + expect(flutterSurface().child, isNot(isA())); + expect(find.byKey(const ValueKey('busymax-window-backdrop')), findsNothing); tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.inactive); await tester.pump(); - expect( - tester.widget(backdrop).opacity, - BusyMaxAlpha.windowBackdropOpacity, - ); + expect(flutterSurface().child, isNot(isA())); tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.resumed); await tester.pump(); - expect(tester.widget(backdrop).opacity, 1); + expect(flutterSurface().child, isNot(isA())); }); testWidgets('tray startup waits for persisted start-minimized settings', ( From 713a0622e16ddf1b71c145877f60c81e3c7064c4 Mon Sep 17 00:00:00 2001 From: albert Date: Wed, 29 Jul 2026 15:20:22 -0700 Subject: [PATCH 28/73] Enhance native time zone dialog behavior. Implement parent activation notification to maintain focus consistency and improve user experience. Update window properties for better integration with the application lifecycle. --- linux/runner/my_application.cc | 28 ++++++++++--- test/app/native_ui_audit_test.dart | 64 +++++++++++++++++++++++++++++- 2 files changed, 85 insertions(+), 7 deletions(-) diff --git a/linux/runner/my_application.cc b/linux/runner/my_application.cc index 1c25d94..d35c44f 100644 --- a/linux/runner/my_application.cc +++ b/linux/runner/my_application.cc @@ -707,6 +707,22 @@ static void native_time_zone_window_destroy_cb(GtkWidget*, } } +static void native_time_zone_parent_is_active_notify_cb( + GtkWindow* parent, + GParamSpec*, + gpointer user_data) { + GtkWindow* window = GTK_WINDOW(user_data); + if (!gtk_window_is_active(parent) || + !gtk_widget_get_visible(GTK_WIDGET(window))) { + return; + } + + // Some compositors reactivate the transient parent when switching back to + // the application. Keep the modal as the sole focus owner so both + // toplevels enter and leave GTK's :backdrop state consistently. + gtk_window_present_with_time(window, GDK_CURRENT_TIME); +} + static void rebuild_native_time_zone_results( NativeTimeZoneDialogState* state, const gchar* query) { @@ -893,14 +909,12 @@ static void handle_native_time_zone_selection(FlMethodCall* method_call, gtk_window_set_modal(GTK_WINDOW(window), TRUE); gtk_window_set_destroy_with_parent(GTK_WINDOW(window), TRUE); gtk_window_set_type_hint(GTK_WINDOW(window), GDK_WINDOW_TYPE_HINT_DIALOG); + gtk_window_set_skip_taskbar_hint(GTK_WINDOW(window), TRUE); + gtk_window_set_skip_pager_hint(GTK_WINDOW(window), TRUE); gtk_window_set_position(GTK_WINDOW(window), GTK_WIN_POS_CENTER_ON_PARENT); gtk_window_set_resizable(GTK_WINDOW(window), FALSE); gtk_window_set_default_size(GTK_WINDOW(window), kNativeTimeZoneDialogWidth, -1); - GtkApplication* application = gtk_window_get_application(parent); - if (application != nullptr) { - gtk_window_set_application(GTK_WINDOW(window), application); - } GtkWidget* window_root = gtk_box_new(GTK_ORIENTATION_VERTICAL, 0); gtk_container_add(GTK_CONTAINER(window), window_root); @@ -961,8 +975,13 @@ 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); + g_signal_connect_object( + parent, "notify::is-active", + G_CALLBACK(native_time_zone_parent_is_active_notify_cb), window, + static_cast(0)); gtk_widget_show_all(window); + gtk_window_present_with_time(GTK_WINDOW(window), GDK_CURRENT_TIME); gtk_widget_grab_focus(search); g_main_loop_run(loop); respond_string(method_call, state.result); @@ -4096,7 +4115,6 @@ static void restore_main_window(MyApplication* self) { gtk_widget_show(GTK_WIDGET(self->main_window)); gtk_window_deiconify(self->main_window); gtk_window_present_with_time(self->main_window, GDK_CURRENT_TIME); - gtk_window_present(self->main_window); } static void window_method_call_cb(FlMethodChannel* channel, diff --git a/test/app/native_ui_audit_test.dart b/test/app/native_ui_audit_test.dart index 3074f7a..9c5a56e 100644 --- a/test/app/native_ui_audit_test.dart +++ b/test/app/native_ui_audit_test.dart @@ -504,9 +504,18 @@ void main() { 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, + ); + expect(titlebarHandleStart, isNonNegative); + expect(titlebarHandleEnd, greaterThan(titlebarHandleStart)); final headerBarSource = source.substring( - 0, - source.indexOf('static void install_compact_agenda_window_css'), + titlebarHandleStart, + titlebarHandleEnd, ); expect(source, isNot(contains('GtkWidget* brand_box'))); @@ -1350,6 +1359,35 @@ void main() { expect(nativeSelector, isNot(contains('gtk_dialog_new_with_buttons('))); expect(nativeSelector, isNot(contains('gtk_dialog_run('))); expect(nativeSelector, contains('g_main_loop_run(loop)')); + expect( + nativeSelector, + contains( + 'gtk_window_present_with_time(GTK_WINDOW(window), GDK_CURRENT_TIME)', + ), + ); + expect( + nativeSelector, + contains('gtk_window_set_skip_taskbar_hint(GTK_WINDOW(window), TRUE)'), + ); + expect( + nativeSelector, + contains('gtk_window_set_skip_pager_hint(GTK_WINDOW(window), TRUE)'), + ); + expect( + nativeSelector, + isNot(contains('gtk_window_set_application(GTK_WINDOW(window)')), + ); + expect( + nativeSelector, + contains( + 'G_CALLBACK(native_time_zone_parent_is_active_notify_cb), window', + ), + ); + expect( + runner, + contains('static void native_time_zone_parent_is_active_notify_cb('), + ); + expect(runner, contains('if (!gtk_window_is_active(parent) ||')); expect(runner, contains('kNativeTimeZoneDialogContentHeight')); expect(runner, contains('kNativeTimeZoneDialogStyleClass')); expect(runner, contains('kNativeTimeZoneGroupStyleClass')); @@ -1362,6 +1400,28 @@ void main() { expect(selector, contains('BusyMaxGroupedList(')); }); + test('application activation presents only the application window', () { + final runner = File('linux/runner/my_application.cc').readAsStringSync(); + final restoreStart = runner.indexOf('static void restore_main_window'); + final restoreEnd = runner.indexOf( + 'static void window_method_call_cb', + restoreStart, + ); + + expect(restoreStart, isNonNegative); + expect(restoreEnd, greaterThan(restoreStart)); + + final restore = runner.substring(restoreStart, restoreEnd); + expect( + restore, + contains( + 'gtk_window_present_with_time(self->main_window, GDK_CURRENT_TIME)', + ), + ); + expect(restore, isNot(contains('gtk_application_get_windows'))); + expect(restore, isNot(contains('gtk_window_present(self->main_window)'))); + }); + test( 'text prompts reuse the shared Yaru grouped form without native reinvention', () { From cec35b6637684918e9bb8f3640277bbaac45e93e Mon Sep 17 00:00:00 2001 From: albert Date: Wed, 29 Jul 2026 15:42:56 -0700 Subject: [PATCH 29/73] Add Finnish localization support --- lib/l10n/app_fi.arb | 389 ++++++ lib/l10n/generated/app_localizations_fi.dart | 1301 ++++++++++++++++++ 2 files changed, 1690 insertions(+) create mode 100644 lib/l10n/app_fi.arb create mode 100644 lib/l10n/generated/app_localizations_fi.dart diff --git a/lib/l10n/app_fi.arb b/lib/l10n/app_fi.arb new file mode 100644 index 0000000..21fb557 --- /dev/null +++ b/lib/l10n/app_fi.arb @@ -0,0 +1,389 @@ +{ + "@@locale": "fi", + "appTitle": "BusyMax", + "connectGoogleAccount": "Yhdistä Google- ja Microsoft-tilit kalenterien ja tehtävien synkronointia varten.", + "googlePermissionsConsentNotice": "Valitse Googlen käyttöoikeusnäkymässä sekä Kalenteri- että Tehtävät-käyttöoikeudet.", + "googlePermissionsRequiredRetry": "Google Kalenterin ja Google Tasksin käyttöoikeudet vaaditaan. Yritä uudelleen ja valitse molemmat valintaruudut.", + "finishSetup": "Viimeistele määritys", + "continueSetup": "Jatka", + "onboardingSetupTitle": "Määritä BusyMax", + "onboardingAccountsStepTitle": "Yhdistä tilit", + "onboardingAccountsStepDescription": "Lisää kaikki haluamasi Google- ja Microsoft-tilit. BusyMax synkronoi kunkin tilin kalenterit, tapahtumat, tehtäväluettelot ja tehtävät.", + "onboardingPreferencesStepTitle": "Valitse järjestelmäasetukset", + "onboardingPreferencesStepDescription": "Määritä työpöytätoiminnot, muistutukset, ilmoitusten yksityiskohdat ja ulkoasu ennen aikataulun avaamista.", + "signInWithGoogle": "Kirjaudu Google-tilillä", + "signInWithMicrosoft": "Kirjaudu Microsoft-tilillä", + "googleTasksProvider": "Google Tasks", + "microsoftTodoProvider": "Microsoft To Do", + "providerNotConfigured": "Tätä palveluntarjoajaa ei ole määritetty.", + "waitingForGoogleSignIn": "Odotetaan Google-kirjautumista...", + "waitingForMicrosoftSignIn": "Odotetaan Microsoft-kirjautumista...", + "microsoftSignInNotConfigured": "Microsoft-kirjautumista ei ole määritetty. Aseta MICROSOFT_OAUTH_CLIENT_ID.", + "cancel": "Peruuta", + "close": "Sulje", + "exit": "Lopeta", + "options": "Valinnat", + "hide": "Piilota", + "show": "Näytä", + "export": "Vie", + "save": "Tallenna", + "settings": "Asetukset", + "all": "Kaikki", + "calendarEvents": "Tapahtumat", + "calendarTasks": "Tehtävät", + "calendar": "Kalenteri", + "calendars": "Kalenterit", + "newEvent": "Uusi tapahtuma", + "refreshCalendar": "Päivitä kalenteri", + "openInProvider": "Avaa palveluntarjoajassa", + "hideFromSchedule": "Piilota aikataulusta", + "showInSchedule": "Näytä aikataulussa", + "noCalendarsSynced": "Kalentereita ei ole vielä synkronoitu.", + "allDay": "Koko päivä", + "moreItems": "+{count} muuta", + "noEventsOrTasks": "Ei tapahtumia tai tehtäviä", + "scheduleLoading": "Ladataan aikataulua...", + "scheduleUnavailable": "Aikataulu ei ole käytettävissä", + "scheduleNoSources": "Ei näkyviä kalentereita tai tehtäväluetteloita", + "scheduleNoSourcesDescription": "Valitse asetuksissa näytettävät kohteet ja päivitä sitten.", + "scheduleSignInRequired": "Yhdistä tili", + "scheduleSignInDescription": "Kirjaudu sisään synkronoidaksesi kalenterit ja tehtävät.", + "scheduleNoSearchResults": "Ei vastaavia tapahtumia tai tehtäviä", + "scheduleNoSearchResultsDescription": "Kokeile toista hakua tai tyhjennä nykyiset suodattimet.", + "trayAgendaLoading": "Ladataan agendaa...", + "trayAgendaSignInRequired": "Kirjaudu sisään nähdäksesi agendan.", + "trayAgendaNoSources": "Ei näkyviä kalentereita tai tehtäväluetteloita.", + "trayAgendaOpenBusyMax": "Avaa sovellus", + "trayAgendaRefresh": "Päivitä", + "trayAgendaError": "Agenda ei ole käytettävissä", + "compactAgendaTitle": "Agenda", + "compactAgendaSubtitle": "Tulossa", + "compactAgendaOverdue": "Myöhässä", + "compactAgendaClear": "Ei mitään juuri nyt", + "compactAgendaOpenBusyMax": "Avaa BusyMax", + "compactAgendaHide": "Piilota", + "compactAgendaNewTask": "Uusi tehtävä", + "compactAgendaRetry": "Yritä uudelleen", + "compactAgendaRefresh": "Päivitä", + "compactAgendaAllDay": "Koko päivä", + "compactAgendaDueToday": "Erääntyy tänään", + "compactAgendaDueTomorrow": "Erääntyy huomenna", + "compactAgendaDueOn": "Erääntyy {date}", + "compactAgendaMoreOverdue": "Lataa lisää myöhässä olevia tehtäviä", + "agendaLoadMoreOverdue": "Lataa lisää myöhässä olevia tehtäviä", + "agendaLoadMoreNoDate": "Lataa lisää päiväämättömiä tehtäviä", + "viewDay": "Päivä", + "viewWeek": "Viikko", + "viewMonth": "Kuukausi", + "viewYear": "Vuosi", + "viewAgenda": "Agenda", + "scheduleSettings": "Aikataulu", + "scheduleDisplaySettings": "Aikataulun näyttö", + "scheduleDisplayHoursDescription": "Päivä- ja viikkonäkymät avautuvat näiden kellonaikojen välille. Aikaiset ja myöhäiset kohteet laajentavat aluetta tarvittaessa.", + "scheduleDayStartsAt": "Päivä alkaa", + "scheduleDayEndsAt": "Päivä päättyy", + "sourceCalendar": "Kalenteri", + "sourceTaskList": "Tehtäväluettelo", + "createChoiceTitle": "Luo", + "createEventAtTime": "Tapahtuma", + "createTaskAtDate": "Tehtävä", + "editEvent": "Muokkaa tapahtumaa", + "eventTitle": "Tapahtuman nimi", + "location": "Sijainti", + "timeSlot": "Ajankohta", + "startDateTime": "Alkamispäivä ja -aika", + "endDateTime": "Päättymispäivä ja -aika", + "doesNotRepeat": "Ei toistu", + "defaultReminder": "Oletusmuistutus", + "guests": "Vieraat", + "noGuests": "Ei vieraita", + "description": "Kuvaus", + "availabilityShowAs": "Saatavuus / Näytä tilana", + "busy": "Varattu", + "visibility": "Näkyvyys", + "defaultVisibility": "Oletusnäkyvyys", + "conference": "Verkkokokous", + "noConference": "Ei verkkokokousta", + "providerCalendar": "Palveluntarjoajan kalenteri", + "formatBoldShortLabel": "L", + "formatBoldTooltip": "Lihavointi", + "formatItalicShortLabel": "K", + "formatItalicTooltip": "Kursivointi", + "formatUnderlineShortLabel": "A", + "formatUnderlineTooltip": "Alleviivaus", + "reminderMinutesBefore": "{minutes, plural, =1{1 minuutti ennen} other{{minutes} minuuttia ennen}}", + "reminderAtStart": "Alkamishetkellä", + "reminderHoursBefore": "{hours, plural, =1{1 tunti ennen} other{{hours} tuntia ennen}}", + "reminderDaysBefore": "{days, plural, =1{1 päivä ennen} other{{days} päivää ennen}}", + "availabilityFree": "Vapaa", + "availabilityTentative": "Alustava", + "availabilityOutOfOffice": "Poissa toimistolta", + "availabilityWorkingElsewhere": "Työskentelee muualla", + "visibilityDefault": "Oletus", + "visibilityPublic": "Julkinen", + "visibilityPrivate": "Yksityinen", + "visibilityConfidential": "Luottamuksellinen", + "sensitivityNormal": "Normaali", + "sensitivityPersonal": "Henkilökohtainen", + "tasks": "Tehtävät", + "allTasks": "Kaikki tehtävät", + "tasksInList": "Luettelon {title} tehtävät", + "taskLists": "Tehtäväluettelot", + "navigation": "Siirtyminen", + "mainMenu": "Päävalikko", + "keyboardShortcuts": "Pikanäppäimet", + "shortcutGroupGeneral": "Yleiset", + "shortcutKeyboardShortcutsDescription": "Näytä tämä pikanäppäinluettelo", + "shortcutGroupNavigation": "Siirtyminen", + "shortcutNextPeriod": "Seuraava ajanjakso", + "shortcutNextPeriodDescription": "Seuraava viikko viikkonäkymässä, seuraava kuukausi kuukausinäkymässä ja niin edelleen", + "shortcutPreviousPeriod": "Edellinen ajanjakso", + "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ä", + "shortcutDayView": "Päivänäkymä", + "shortcutWeekView": "Viikkonäkymä", + "shortcutMonthView": "Kuukausinäkymä", + "shortcutYearView": "Vuosinäkymä", + "shortcutAgendaView": "Agendanäkymä", + "shortcutGroupCreateAndEdit": "Luominen ja muokkaaminen", + "shortcutSaveItem": "Tallenna tapahtuma tai tehtävä", + "shortcutDeleteItem": "Poista tapahtuma tai tehtävä", + "shortcutGroupTaskEditing": "Tehtävien muokkaaminen", + "shortcutCancelEditing": "Peruuta muokkaaminen", + "shortcutCancelEditingDescription": "Sulje tehtävän muokkaus tai tehtävän tiedot", + "shortcutGroupCompactAgenda": "Kompakti agenda", + "shortcutRefreshCompactAgendaDescription": "Päivitä kompaktin agendan ikkuna", + "shortcutHideCompactAgendaDescription": "Piilota kompaktin agendan ikkuna", + "aboutBusyMax": "Tietoja BusyMaxista", + "aboutBusyMaxDescription": "Tehtävät ja kalenteri", + "website": "Verkkosivusto", + "reportAnIssue": "Ilmoita ongelmasta", + "sendFeedback": "Lähetä palautetta", + "feedbackSubmit": "Lähetä", + "feedbackCategory": "Luokka", + "feedbackSelectCategory": "Valitse luokka", + "feedbackCategoryProblem": "Ongelma tai virhe", + "feedbackCategoryFeature": "Ominaisuuspyyntö", + "feedbackCategoryPrivacySecurity": "Tietosuoja- tai turvallisuushuoli", + "feedbackCategoryUsability": "Käytettävyyshuoli", + "feedbackCategoryOther": "Muu", + "feedbackSubject": "Aihe", + "feedbackDetailedMessage": "Yksityiskohtainen viesti", + "feedbackReplyEmail": "Vastaussähköposti (valinnainen)", + "feedbackIncludeTechnicalDetails": "Sisällytä tekniset tiedot", + "feedbackTechnicalDetailsDisclosure": "Lisää vain Linux-käyttöjärjestelmäsi version ja sovelluksen alueasetuksen. Lokeja, tilitietoja, tiedostonimiä tai muita diagnostiikkatietoja ei lisätä.", + "feedbackCategoryRequired": "Valitse luokka.", + "feedbackSubjectLengthError": "Aiheen pituuden on oltava 3–120 merkkiä.", + "feedbackMessageLengthError": "Viestin pituuden on oltava 10–5 000 merkkiä.", + "feedbackInvalidEmail": "Anna kelvollinen sähköpostiosoite.", + "feedbackConnectionError": "BusyStackiin ei saatu yhteyttä. Tarkista yhteys ja yritä uudelleen.", + "feedbackTimeoutError": "Pyyntö aikakatkaistiin. Palautettasi ei ole tyhjennetty. Yritä uudelleen.", + "feedbackRateLimitedError": "Tästä verkosta on lähetetty liian monta palautetta. Odota ja yritä uudelleen.", + "feedbackRejectedError": "Palvelin hylkäsi lähetyksen. Tarkista kentät ja yritä uudelleen.", + "feedbackServerError": "BusyStack ei voi vastaanottaa palautettasi juuri nyt. Palautettasi ei ole tyhjennetty. Yritä uudelleen.", + "feedbackSuccess": "Palaute lähetetty. Viite: {id}", + "toggleSidebar": "Näytä tai piilota sivupalkki", + "accounts": "Tilit", + "currentAccount": "Nykyinen tili", + "switchAccount": "Vaihda tiliä", + "addGoogleAccount": "Lisää Google-tili", + "addMicrosoftAccount": "Lisää Microsoft-tili", + "googleProvider": "Google", + "microsoftProvider": "Microsoft", + "signedInAccount": "Kirjautunut", + "removeAccount": "Poista tili…", + "removingAccount": "Poistetaan tiliä…", + "removeAccountDescription": "Lopeta synkronointi ja poista tämän tilin tiedot tältä laitteelta.", + "removeAccountTitle": "Poistetaanko {account} BusyMaxista?", + "removeAccountConfirmation": "Tämä poistaa välimuistissa olevat tehtävät, kalenterit, tapahtumat, muistutukset ja odottavat offline-muutokset tältä laitteelta. Synkronoimattomat muutokset menetetään. Mitään ei poisteta Googlesta tai Microsoftista.", + "revokeGoogleAccess": "Peruuta myös BusyMaxin käyttöoikeus tähän Google-tiliin", + "revokeGoogleAccessDescription": "Käyttöoikeus on myönnettävä uudelleen ennen tilin yhdistämistä.", + "removeAccountAction": "Poista tili", + "removeAccountFailed": "Tilin poistamista ei voitu viimeistellä. Yritä uudelleen.", + "accountRemovedGoogleRevokeFailed": "Tili poistettiin tältä laitteelta, mutta BusyMax ei voinut peruuttaa Google-käyttöoikeutta. Voit peruuttaa sen Google-tililtäsi.", + "newList": "Uusi luettelo", + "signInToViewTaskLists": "Kirjaudu sisään nähdäksesi tehtäväluettelot.", + "noTaskListsSynced": "Tehtäväluetteloita ei ole vielä synkronoitu.", + "listActions": "Luettelon toiminnot", + "rename": "Nimeä uudelleen", + "delete": "Poista", + "renameList": "Nimeä luettelo uudelleen", + "deleteList": "Poista luettelo", + "builtInMicrosoftList": "Sisäänrakennettu", + "builtInMicrosoftListCannotRenameDelete": "Microsoft To Do -sovelluksen sisäänrakennettuja luetteloita ei voi nimetä uudelleen tai poistaa.", + "deleteListConfirmation": "Poistetaanko \"{title}\" Google Tasksista?", + "deleteEvent": "Poista tapahtuma", + "title": "Nimi", + "create": "Luo", + "newTask": "Uusi tehtävä", + "clearCompleted": "Tyhjennä valmiit", + "refreshList": "Päivitä luettelo", + "refreshAll": "Päivitä kaikki", + "listRefreshed": "Luettelo päivitetty.", + "allTasksRefreshed": "Kaikki tilit päivitetty.", + "exportedFile": "Viety kohteeseen {path}", + "exportFailed": "Vienti epäonnistui: {error}", + "refreshFailed": "Päivitys epäonnistui: {error}", + "selectOrCreateTaskList": "Valitse tai luo tehtäväluettelo aloittaaksesi.", + "signInToViewTasks": "Kirjaudu sisään nähdäksesi tehtävät.", + "noTasks": "Ei tehtäviä.", + "noTasksYet": "Ei vielä tehtäviä", + "noTasksYetMessage": "Luo tehtävä tai päivitä tilisi aloittaaksesi.", + "noTasksInList": "Tässä luettelossa ei ole tehtäviä.", + "overdue": "Myöhässä", + "today": "Tänään", + "tomorrow": "Huomenna", + "upcoming": "Tulossa", + "noDate": "Ei päivämäärää", + "completed": "Valmiit", + "duePrefix": "Eräpäivä {date}", + "dateTimeDisplay": "{date} klo {time}", + "taskDetails": "Tehtävän tiedot", + "editTask": "Muokkaa tehtävää", + "noTaskSelected": "Tehtävää ei ole valittu.", + "noTaskSelectedHelper": "Valitse tehtävä nähdäksesi ja muokataksesi sen tietoja.", + "taskUnavailable": "Tehtävä ei ole käytettävissä.", + "signInToEditTasks": "Kirjaudu sisään muokataksesi tehtäviä.", + "refreshTask": "Päivitä tehtävä", + "primarySection": "Ensisijaiset tiedot", + "statusSection": "Tila", + "openStatus": "Avoin", + "doneStatus": "Valmis", + "notes": "Muistiinpanot", + "dueDate": "Eräpäivä", + "clearDueDate": "Tyhjennä eräpäivä", + "dueTime": "Erääntymisaika", + "startDate": "Alkamispäivä", + "startTime": "Alkamisaika", + "endDate": "Päättymispäivä", + "endTime": "Päättymisaika", + "reminderDate": "Muistutuspäivä", + "reminderTime": "Muistutusaika", + "reminder": "Muistutus", + "addReminder": "Lisää muistutus", + "addGuest": "Lisää vieras", + "addGuestEmail": "Lisää vieraan sähköpostiosoite", + "removeReminder": "Poista muistutus", + "off": "Ei käytössä", + "repeat": "Toisto", + "repeatNone": "Ei toistoa", + "noneValue": "Ei mitään", + "repeatDaily": "Päivittäin", + "repeatWeekly": "Viikoittain", + "repeatMonthly": "Kuukausittain", + "repeatYearly": "Vuosittain", + "importance": "Tärkeys", + "importanceLow": "Pieni", + "importanceNormal": "Normaali", + "importanceHigh": "Suuri", + "categories": "Luokat", + "scheduleSection": "Aikataulu", + "dueGroup": "Eräpäivä", + "startGroup": "Alku", + "reminderGroup": "Muistutus", + "organizationSection": "Järjestely", + "actionsSection": "Toiminnot", + "advancedSection": "Lisäasetukset", + "addCategory": "Lisää luokka", + "list": "Luettelo", + "microsoftMoveUnsupported": "Luettelosta toiseen siirtämistä ei tueta Microsoft To Do -tileillä tässä versiossa.", + "createSubtask": "Luo alitehtävä", + "moveToTop": "Siirrä ylimmäksi", + "deleteTask": "Poista tehtävä", + "newSubtask": "Uusi alitehtävä", + "deleteTaskConfirmation": "Poistetaanko \"{title}\" Google Tasksista?", + "metadata": "Metatiedot", + "id": "Tunnus", + "etag": "ETag", + "updated": "Päivitetty", + "parent": "Ylätehtävä", + "position": "Sijainti", + "webLink": "Verkkolinkki", + "assignment": "Määritys", + "localState": "Paikallinen tila", + "pendingSync": "Odottaa synkronointia", + "synced": "Synkronoitu", + "account": "Tili", + "sync": "Synkronointi", + "manualFullSync": "Manuaalinen täysi synkronointi", + "runInBackgroundWhenClosed": "Jatka toimintaa, kun ikkuna suljetaan", + "showTrayIcon": "Näytä ilmoitusalueen kuvake", + "startMinimizedToTray": "Käynnistä pienennettynä ilmoitusalueelle", + "requiresTrayIcon": "Vaatii ilmoitusalueen kuvakkeen.", + "syncComplete": "Synkronointi valmis.", + "syncFailed": "Synkronointi epäonnistui: {error}", + "notifySyncFailures": "Ilmoita synkronointivirheistä", + "notifyConflicts": "Ilmoita ristiriidoista", + "notifyDueToday": "Ilmoita tänään erääntyvistä", + "eventReminders": "Tapahtumamuistutukset", + "taskReminders": "Tehtävämuistutukset", + "notificationDetailLevel": "Ilmoitusten yksityiskohtaisuus", + "notificationDetailPrivate": "Yksityinen", + "notificationDetailNormal": "Normaali", + "quietHours": "Hiljaiset tunnit", + "quietHoursDescription": "Keskeytä ilmoitukset tällä ajanjaksolla.", + "quietHoursStart": "Hiljaisten tuntien alku", + "quietHoursEnd": "Hiljaisten tuntien loppu", + "notifications": "Ilmoitukset", + "appearance": "Ulkoasu", + "theme": "Teema", + "themeSystem": "Järjestelmä", + "themeLight": "Vaalea", + "themeDark": "Tumma", + "themeFamily": "Teemaperhe", + "themeFamilyYaru": "Ubuntun oma (Yaru)", + "localization": "Lokalisointi", + "currentLocale": "Nykyinen alueasetus", + "privacy": "Tietosuoja", + "redactTaskContentInDiagnostics": "Peitä tehtävien sisältö diagnostiikassa", + "developerDiagnostics": "Kehittäjän diagnostiikka", + "diagnostics": "Diagnostiikka", + "apiInspectorDisabled": "Näytä API-tarkastaja", + "googleTasksApi": "Google Tasks API", + "discoveryRevision": "Discovery-versio: {revision}", + "implementedMethods": "Toteutetut metodit", + "supportsTasksScopes": "Tukee tasks- ja tasks.readonly-käyttöoikeusalueita", + "requiresTasksScope": "Vaatii tasks-käyttöoikeusalueen", + "blockedPendingOperations": "Estetyt odottavat toiminnot", + "signInToInspectPendingOperations": "Kirjaudu sisään tarkastellaksesi odottavia toimintoja.", + "noBlockedPendingOperations": "Ei estettyjä odottavia toimintoja.", + "operationActions": "Toiminnon valinnat", + "pendingOpListId": "luettelo={id}", + "pendingOpTaskId": "tehtävä={id}", + "pendingOpAttempts": "yritykset={count}", + "retry": "Yritä uudelleen", + "discard": "Hylkää", + "discardChanges": "Hylätäänkö muutokset?", + "discardChangesConfirmation": "Tämä hylkää tehtävän tallentamattomat muutokset.", + "retryCompleted": "Uudelleenyritys valmis.", + "discardPendingOperation": "Hylätäänkö odottava toiminto?", + "discardPendingOperationConfirmation": "Tämä poistaa estetyn paikallisen toiminnon. Seuraava synkronointi päivittää tiedot Google Tasksista.", + "pendingOperationDiscarded": "Odottava toiminto hylätty.", + "syncFailureNotificationTitle": "BusyMaxin synkronointi epäonnistui", + "syncFailureNotificationBody": "Taustasynkronointi epäonnistui. {message}", + "conflictNotificationTitle": "BusyMaxin synkronointiristiriita", + "conflictNotificationBody": "Odottava paikallinen muutos estettiin. {summary}", + "dueTodayNotificationTitle": "Tänään erääntyvät tehtävät", + "dueTodayNotificationBody": "{count, plural, =1{Yksi tehtävä erääntyy tänään.} other{{count} tehtävää erääntyy tänään.}}", + "eventReminderNotificationTitle": "Tapahtumamuistutus", + "taskReminderNotificationTitle": "Tehtävämuistutus", + "eventReminderNotificationBody": "Tapahtuma alkaa pian.", + "taskReminderNotificationBody": "Tehtävän määräaika lähestyy.", + "notificationOpenAction": "Avaa", + "notificationDetailsHidden": "Yksityiskohdat on piilotettu tietosuoja-asetusten vuoksi.", + "previousMonth": "Edellinen kuukausi", + "nextMonth": "Seuraava kuukausi", + "openMonthView": "Avaa kuukausinäkymä", + "previousYear": "Edellinen vuosi", + "nextYear": "Seuraava vuosi", + "openYearView": "Avaa vuosinäkymä", + "weekNumberTooltip": "Viikko {number}", + "resizeAllDayPanel": "Muuta koko päivän paneelin kokoa", + "scheduleItemCount": "{count, plural, =1{1 kohde} other{{count} kohdetta}}", + "readOnlyCalendar": "Tämä kalenteri on kirjoitussuojattu.", + "selectTimeZone": "Valitse aikavyöhyke", + "searchLocations": "Hae sijainteja", + "noLocationsFound": "Sijainteja ei löytynyt", + "deleteCalendarConfirmation": "Poistetaanko \"{title}\"?" +} diff --git a/lib/l10n/generated/app_localizations_fi.dart b/lib/l10n/generated/app_localizations_fi.dart new file mode 100644 index 0000000..4a3f0a1 --- /dev/null +++ b/lib/l10n/generated/app_localizations_fi.dart @@ -0,0 +1,1301 @@ +// ignore: unused_import +import 'package:intl/intl.dart' as intl; +import 'app_localizations.dart'; + +// ignore_for_file: type=lint + +/// The translations for Finnish (`fi`). +class AppLocalizationsFi extends AppLocalizations { + AppLocalizationsFi([String locale = 'fi']) : super(locale); + + @override + String get appTitle => 'BusyMax'; + + @override + String get connectGoogleAccount => + 'Yhdistä Google- ja Microsoft-tilit kalenterien ja tehtävien synkronointia varten.'; + + @override + String get googlePermissionsConsentNotice => + 'Valitse Googlen käyttöoikeusnäkymässä sekä Kalenteri- että Tehtävät-käyttöoikeudet.'; + + @override + String get googlePermissionsRequiredRetry => + 'Google Kalenterin ja Google Tasksin käyttöoikeudet vaaditaan. Yritä uudelleen ja valitse molemmat valintaruudut.'; + + @override + String get finishSetup => 'Viimeistele määritys'; + + @override + String get continueSetup => 'Jatka'; + + @override + String get onboardingSetupTitle => 'Määritä BusyMax'; + + @override + String get onboardingAccountsStepTitle => 'Yhdistä tilit'; + + @override + String get onboardingAccountsStepDescription => + 'Lisää kaikki haluamasi Google- ja Microsoft-tilit. BusyMax synkronoi kunkin tilin kalenterit, tapahtumat, tehtäväluettelot ja tehtävät.'; + + @override + String get onboardingPreferencesStepTitle => 'Valitse järjestelmäasetukset'; + + @override + String get onboardingPreferencesStepDescription => + 'Määritä työpöytätoiminnot, muistutukset, ilmoitusten yksityiskohdat ja ulkoasu ennen aikataulun avaamista.'; + + @override + String get signInWithGoogle => 'Kirjaudu Google-tilillä'; + + @override + String get signInWithMicrosoft => 'Kirjaudu Microsoft-tilillä'; + + @override + String get googleTasksProvider => 'Google Tasks'; + + @override + String get microsoftTodoProvider => 'Microsoft To Do'; + + @override + String get providerNotConfigured => + 'Tätä palveluntarjoajaa ei ole määritetty.'; + + @override + String get waitingForGoogleSignIn => 'Odotetaan Google-kirjautumista...'; + + @override + String get waitingForMicrosoftSignIn => + 'Odotetaan Microsoft-kirjautumista...'; + + @override + String get microsoftSignInNotConfigured => + 'Microsoft-kirjautumista ei ole määritetty. Aseta MICROSOFT_OAUTH_CLIENT_ID.'; + + @override + String get cancel => 'Peruuta'; + + @override + String get close => 'Sulje'; + + @override + String get exit => 'Lopeta'; + + @override + String get options => 'Valinnat'; + + @override + String get hide => 'Piilota'; + + @override + String get show => 'Näytä'; + + @override + String get export => 'Vie'; + + @override + String get save => 'Tallenna'; + + @override + String get settings => 'Asetukset'; + + @override + String get all => 'Kaikki'; + + @override + String get calendarEvents => 'Tapahtumat'; + + @override + String get calendarTasks => 'Tehtävät'; + + @override + String get calendar => 'Kalenteri'; + + @override + String get calendars => 'Kalenterit'; + + @override + String get newEvent => 'Uusi tapahtuma'; + + @override + String get refreshCalendar => 'Päivitä kalenteri'; + + @override + String get openInProvider => 'Avaa palveluntarjoajassa'; + + @override + String get hideFromSchedule => 'Piilota aikataulusta'; + + @override + String get showInSchedule => 'Näytä aikataulussa'; + + @override + String get noCalendarsSynced => 'Kalentereita ei ole vielä synkronoitu.'; + + @override + String get allDay => 'Koko päivä'; + + @override + String moreItems(int count) { + return '+$count muuta'; + } + + @override + String get noEventsOrTasks => 'Ei tapahtumia tai tehtäviä'; + + @override + String get scheduleLoading => 'Ladataan aikataulua...'; + + @override + String get scheduleUnavailable => 'Aikataulu ei ole käytettävissä'; + + @override + String get scheduleNoSources => + 'Ei näkyviä kalentereita tai tehtäväluetteloita'; + + @override + String get scheduleNoSourcesDescription => + 'Valitse asetuksissa näytettävät kohteet ja päivitä sitten.'; + + @override + String get scheduleSignInRequired => 'Yhdistä tili'; + + @override + String get scheduleSignInDescription => + 'Kirjaudu sisään synkronoidaksesi kalenterit ja tehtävät.'; + + @override + String get scheduleNoSearchResults => 'Ei vastaavia tapahtumia tai tehtäviä'; + + @override + String get scheduleNoSearchResultsDescription => + 'Kokeile toista hakua tai tyhjennä nykyiset suodattimet.'; + + @override + String get trayAgendaLoading => 'Ladataan agendaa...'; + + @override + String get trayAgendaSignInRequired => 'Kirjaudu sisään nähdäksesi agendan.'; + + @override + String get trayAgendaNoSources => + 'Ei näkyviä kalentereita tai tehtäväluetteloita.'; + + @override + String get trayAgendaOpenBusyMax => 'Avaa sovellus'; + + @override + String get trayAgendaRefresh => 'Päivitä'; + + @override + String get trayAgendaError => 'Agenda ei ole käytettävissä'; + + @override + String get compactAgendaTitle => 'Agenda'; + + @override + String get compactAgendaSubtitle => 'Tulossa'; + + @override + String get compactAgendaOverdue => 'Myöhässä'; + + @override + String get compactAgendaClear => 'Ei mitään juuri nyt'; + + @override + String get compactAgendaOpenBusyMax => 'Avaa BusyMax'; + + @override + String get compactAgendaHide => 'Piilota'; + + @override + String get compactAgendaNewTask => 'Uusi tehtävä'; + + @override + String get compactAgendaRetry => 'Yritä uudelleen'; + + @override + String get compactAgendaRefresh => 'Päivitä'; + + @override + String get compactAgendaAllDay => 'Koko päivä'; + + @override + String get compactAgendaDueToday => 'Erääntyy tänään'; + + @override + String get compactAgendaDueTomorrow => 'Erääntyy huomenna'; + + @override + String compactAgendaDueOn(String date) { + return 'Erääntyy $date'; + } + + @override + String get compactAgendaMoreOverdue => 'Lataa lisää myöhässä olevia tehtäviä'; + + @override + String get agendaLoadMoreOverdue => 'Lataa lisää myöhässä olevia tehtäviä'; + + @override + String get agendaLoadMoreNoDate => 'Lataa lisää päiväämättömiä tehtäviä'; + + @override + String get viewDay => 'Päivä'; + + @override + String get viewWeek => 'Viikko'; + + @override + String get viewMonth => 'Kuukausi'; + + @override + String get viewYear => 'Vuosi'; + + @override + String get viewAgenda => 'Agenda'; + + @override + String get scheduleSettings => 'Aikataulu'; + + @override + String get scheduleDisplaySettings => 'Aikataulun näyttö'; + + @override + String get scheduleDisplayHoursDescription => + 'Päivä- ja viikkonäkymät avautuvat näiden kellonaikojen välille. Aikaiset ja myöhäiset kohteet laajentavat aluetta tarvittaessa.'; + + @override + String get scheduleDayStartsAt => 'Päivä alkaa'; + + @override + String get scheduleDayEndsAt => 'Päivä päättyy'; + + @override + String get sourceCalendar => 'Kalenteri'; + + @override + String get sourceTaskList => 'Tehtäväluettelo'; + + @override + String get createChoiceTitle => 'Luo'; + + @override + String get createEventAtTime => 'Tapahtuma'; + + @override + String get createTaskAtDate => 'Tehtävä'; + + @override + String get editEvent => 'Muokkaa tapahtumaa'; + + @override + String get eventTitle => 'Tapahtuman nimi'; + + @override + String get location => 'Sijainti'; + + @override + String get timeSlot => 'Ajankohta'; + + @override + String get startDateTime => 'Alkamispäivä ja -aika'; + + @override + String get endDateTime => 'Päättymispäivä ja -aika'; + + @override + String get doesNotRepeat => 'Ei toistu'; + + @override + String get defaultReminder => 'Oletusmuistutus'; + + @override + String get guests => 'Vieraat'; + + @override + String get noGuests => 'Ei vieraita'; + + @override + String get description => 'Kuvaus'; + + @override + String get availabilityShowAs => 'Saatavuus / Näytä tilana'; + + @override + String get busy => 'Varattu'; + + @override + String get visibility => 'Näkyvyys'; + + @override + String get defaultVisibility => 'Oletusnäkyvyys'; + + @override + String get conference => 'Verkkokokous'; + + @override + String get noConference => 'Ei verkkokokousta'; + + @override + String get providerCalendar => 'Palveluntarjoajan kalenteri'; + + @override + String get formatBoldShortLabel => 'L'; + + @override + String get formatBoldTooltip => 'Lihavointi'; + + @override + String get formatItalicShortLabel => 'K'; + + @override + String get formatItalicTooltip => 'Kursivointi'; + + @override + String get formatUnderlineShortLabel => 'A'; + + @override + String get formatUnderlineTooltip => 'Alleviivaus'; + + @override + String reminderMinutesBefore(int minutes) { + String _temp0 = intl.Intl.pluralLogic( + minutes, + locale: localeName, + other: '$minutes minuuttia ennen', + one: '1 minuutti ennen', + ); + return '$_temp0'; + } + + @override + String get reminderAtStart => 'Alkamishetkellä'; + + @override + String reminderHoursBefore(int hours) { + String _temp0 = intl.Intl.pluralLogic( + hours, + locale: localeName, + other: '$hours tuntia ennen', + one: '1 tunti ennen', + ); + return '$_temp0'; + } + + @override + String reminderDaysBefore(int days) { + String _temp0 = intl.Intl.pluralLogic( + days, + locale: localeName, + other: '$days päivää ennen', + one: '1 päivä ennen', + ); + return '$_temp0'; + } + + @override + String get availabilityFree => 'Vapaa'; + + @override + String get availabilityTentative => 'Alustava'; + + @override + String get availabilityOutOfOffice => 'Poissa toimistolta'; + + @override + String get availabilityWorkingElsewhere => 'Työskentelee muualla'; + + @override + String get visibilityDefault => 'Oletus'; + + @override + String get visibilityPublic => 'Julkinen'; + + @override + String get visibilityPrivate => 'Yksityinen'; + + @override + String get visibilityConfidential => 'Luottamuksellinen'; + + @override + String get sensitivityNormal => 'Normaali'; + + @override + String get sensitivityPersonal => 'Henkilökohtainen'; + + @override + String get tasks => 'Tehtävät'; + + @override + String get allTasks => 'Kaikki tehtävät'; + + @override + String tasksInList(String title) { + return 'Luettelon $title tehtävät'; + } + + @override + String get taskLists => 'Tehtäväluettelot'; + + @override + String get navigation => 'Siirtyminen'; + + @override + String get mainMenu => 'Päävalikko'; + + @override + String get keyboardShortcuts => 'Pikanäppäimet'; + + @override + String get shortcutGroupGeneral => 'Yleiset'; + + @override + String get shortcutKeyboardShortcutsDescription => + 'Näytä tämä pikanäppäinluettelo'; + + @override + String get shortcutGroupNavigation => 'Siirtyminen'; + + @override + String get shortcutNextPeriod => 'Seuraava ajanjakso'; + + @override + String get shortcutNextPeriodDescription => + 'Seuraava viikko viikkonäkymässä, seuraava kuukausi kuukausinäkymässä ja niin edelleen'; + + @override + String get shortcutPreviousPeriod => 'Edellinen ajanjakso'; + + @override + String get shortcutPreviousPeriodDescription => + 'Edellinen viikko viikkonäkymässä, edellinen kuukausi kuukausinäkymässä ja niin edelleen'; + + @override + String get shortcutJumpToToday => 'Siirry tähän päivään'; + + @override + String get shortcutGroupView => 'Näkymä'; + + @override + String get shortcutDayView => 'Päivänäkymä'; + + @override + String get shortcutWeekView => 'Viikkonäkymä'; + + @override + String get shortcutMonthView => 'Kuukausinäkymä'; + + @override + String get shortcutYearView => 'Vuosinäkymä'; + + @override + String get shortcutAgendaView => 'Agendanäkymä'; + + @override + String get shortcutGroupCreateAndEdit => 'Luominen ja muokkaaminen'; + + @override + String get shortcutSaveItem => 'Tallenna tapahtuma tai tehtävä'; + + @override + String get shortcutDeleteItem => 'Poista tapahtuma tai tehtävä'; + + @override + String get shortcutGroupTaskEditing => 'Tehtävien muokkaaminen'; + + @override + String get shortcutCancelEditing => 'Peruuta muokkaaminen'; + + @override + String get shortcutCancelEditingDescription => + 'Sulje tehtävän muokkaus tai tehtävän tiedot'; + + @override + String get shortcutGroupCompactAgenda => 'Kompakti agenda'; + + @override + String get shortcutRefreshCompactAgendaDescription => + 'Päivitä kompaktin agendan ikkuna'; + + @override + String get shortcutHideCompactAgendaDescription => + 'Piilota kompaktin agendan ikkuna'; + + @override + String get aboutBusyMax => 'Tietoja BusyMaxista'; + + @override + String get aboutBusyMaxDescription => 'Tehtävät ja kalenteri'; + + @override + String get website => 'Verkkosivusto'; + + @override + String get reportAnIssue => 'Ilmoita ongelmasta'; + + @override + String get sendFeedback => 'Lähetä palautetta'; + + @override + String get feedbackSubmit => 'Lähetä'; + + @override + String get feedbackCategory => 'Luokka'; + + @override + String get feedbackSelectCategory => 'Valitse luokka'; + + @override + String get feedbackCategoryProblem => 'Ongelma tai virhe'; + + @override + String get feedbackCategoryFeature => 'Ominaisuuspyyntö'; + + @override + String get feedbackCategoryPrivacySecurity => + 'Tietosuoja- tai turvallisuushuoli'; + + @override + String get feedbackCategoryUsability => 'Käytettävyyshuoli'; + + @override + String get feedbackCategoryOther => 'Muu'; + + @override + String get feedbackSubject => 'Aihe'; + + @override + String get feedbackDetailedMessage => 'Yksityiskohtainen viesti'; + + @override + String get feedbackReplyEmail => 'Vastaussähköposti (valinnainen)'; + + @override + String get feedbackIncludeTechnicalDetails => 'Sisällytä tekniset tiedot'; + + @override + String get feedbackTechnicalDetailsDisclosure => + 'Lisää vain Linux-käyttöjärjestelmäsi version ja sovelluksen alueasetuksen. Lokeja, tilitietoja, tiedostonimiä tai muita diagnostiikkatietoja ei lisätä.'; + + @override + String get feedbackCategoryRequired => 'Valitse luokka.'; + + @override + String get feedbackSubjectLengthError => + 'Aiheen pituuden on oltava 3–120 merkkiä.'; + + @override + String get feedbackMessageLengthError => + 'Viestin pituuden on oltava 10–5 000 merkkiä.'; + + @override + String get feedbackInvalidEmail => 'Anna kelvollinen sähköpostiosoite.'; + + @override + String get feedbackConnectionError => + 'BusyStackiin ei saatu yhteyttä. Tarkista yhteys ja yritä uudelleen.'; + + @override + String get feedbackTimeoutError => + 'Pyyntö aikakatkaistiin. Palautettasi ei ole tyhjennetty. Yritä uudelleen.'; + + @override + String get feedbackRateLimitedError => + 'Tästä verkosta on lähetetty liian monta palautetta. Odota ja yritä uudelleen.'; + + @override + String get feedbackRejectedError => + 'Palvelin hylkäsi lähetyksen. Tarkista kentät ja yritä uudelleen.'; + + @override + String get feedbackServerError => + 'BusyStack ei voi vastaanottaa palautettasi juuri nyt. Palautettasi ei ole tyhjennetty. Yritä uudelleen.'; + + @override + String feedbackSuccess(String id) { + return 'Palaute lähetetty. Viite: $id'; + } + + @override + String get toggleSidebar => 'Näytä tai piilota sivupalkki'; + + @override + String get accounts => 'Tilit'; + + @override + String get currentAccount => 'Nykyinen tili'; + + @override + String get switchAccount => 'Vaihda tiliä'; + + @override + String get addGoogleAccount => 'Lisää Google-tili'; + + @override + String get addMicrosoftAccount => 'Lisää Microsoft-tili'; + + @override + String get googleProvider => 'Google'; + + @override + String get microsoftProvider => 'Microsoft'; + + @override + String get signedInAccount => 'Kirjautunut'; + + @override + String get removeAccount => 'Poista tili…'; + + @override + String get removingAccount => 'Poistetaan tiliä…'; + + @override + String get removeAccountDescription => + 'Lopeta synkronointi ja poista tämän tilin tiedot tältä laitteelta.'; + + @override + String removeAccountTitle(String account) { + return 'Poistetaanko $account BusyMaxista?'; + } + + @override + String get removeAccountConfirmation => + 'Tämä poistaa välimuistissa olevat tehtävät, kalenterit, tapahtumat, muistutukset ja odottavat offline-muutokset tältä laitteelta. Synkronoimattomat muutokset menetetään. Mitään ei poisteta Googlesta tai Microsoftista.'; + + @override + String get revokeGoogleAccess => + 'Peruuta myös BusyMaxin käyttöoikeus tähän Google-tiliin'; + + @override + String get revokeGoogleAccessDescription => + 'Käyttöoikeus on myönnettävä uudelleen ennen tilin yhdistämistä.'; + + @override + String get removeAccountAction => 'Poista tili'; + + @override + String get removeAccountFailed => + 'Tilin poistamista ei voitu viimeistellä. Yritä uudelleen.'; + + @override + String get accountRemovedGoogleRevokeFailed => + 'Tili poistettiin tältä laitteelta, mutta BusyMax ei voinut peruuttaa Google-käyttöoikeutta. Voit peruuttaa sen Google-tililtäsi.'; + + @override + String get newList => 'Uusi luettelo'; + + @override + String get signInToViewTaskLists => + 'Kirjaudu sisään nähdäksesi tehtäväluettelot.'; + + @override + String get noTaskListsSynced => + 'Tehtäväluetteloita ei ole vielä synkronoitu.'; + + @override + String get listActions => 'Luettelon toiminnot'; + + @override + String get rename => 'Nimeä uudelleen'; + + @override + String get delete => 'Poista'; + + @override + String get renameList => 'Nimeä luettelo uudelleen'; + + @override + String get deleteList => 'Poista luettelo'; + + @override + String get builtInMicrosoftList => 'Sisäänrakennettu'; + + @override + String get builtInMicrosoftListCannotRenameDelete => + 'Microsoft To Do -sovelluksen sisäänrakennettuja luetteloita ei voi nimetä uudelleen tai poistaa.'; + + @override + String deleteListConfirmation(String title) { + return 'Poistetaanko \"$title\" Google Tasksista?'; + } + + @override + String get deleteEvent => 'Poista tapahtuma'; + + @override + String get title => 'Nimi'; + + @override + String get create => 'Luo'; + + @override + String get newTask => 'Uusi tehtävä'; + + @override + String get clearCompleted => 'Tyhjennä valmiit'; + + @override + String get refreshList => 'Päivitä luettelo'; + + @override + String get refreshAll => 'Päivitä kaikki'; + + @override + String get listRefreshed => 'Luettelo päivitetty.'; + + @override + String get allTasksRefreshed => 'Kaikki tilit päivitetty.'; + + @override + String exportedFile(String path) { + return 'Viety kohteeseen $path'; + } + + @override + String exportFailed(String error) { + return 'Vienti epäonnistui: $error'; + } + + @override + String refreshFailed(String error) { + return 'Päivitys epäonnistui: $error'; + } + + @override + String get selectOrCreateTaskList => + 'Valitse tai luo tehtäväluettelo aloittaaksesi.'; + + @override + String get signInToViewTasks => 'Kirjaudu sisään nähdäksesi tehtävät.'; + + @override + String get noTasks => 'Ei tehtäviä.'; + + @override + String get noTasksYet => 'Ei vielä tehtäviä'; + + @override + String get noTasksYetMessage => + 'Luo tehtävä tai päivitä tilisi aloittaaksesi.'; + + @override + String get noTasksInList => 'Tässä luettelossa ei ole tehtäviä.'; + + @override + String get overdue => 'Myöhässä'; + + @override + String get today => 'Tänään'; + + @override + String get tomorrow => 'Huomenna'; + + @override + String get upcoming => 'Tulossa'; + + @override + String get noDate => 'Ei päivämäärää'; + + @override + String get completed => 'Valmiit'; + + @override + String duePrefix(String date) { + return 'Eräpäivä $date'; + } + + @override + String dateTimeDisplay(String date, String time) { + return '$date klo $time'; + } + + @override + String get taskDetails => 'Tehtävän tiedot'; + + @override + String get editTask => 'Muokkaa tehtävää'; + + @override + String get noTaskSelected => 'Tehtävää ei ole valittu.'; + + @override + String get noTaskSelectedHelper => + 'Valitse tehtävä nähdäksesi ja muokataksesi sen tietoja.'; + + @override + String get taskUnavailable => 'Tehtävä ei ole käytettävissä.'; + + @override + String get signInToEditTasks => 'Kirjaudu sisään muokataksesi tehtäviä.'; + + @override + String get refreshTask => 'Päivitä tehtävä'; + + @override + String get primarySection => 'Ensisijaiset tiedot'; + + @override + String get statusSection => 'Tila'; + + @override + String get openStatus => 'Avoin'; + + @override + String get doneStatus => 'Valmis'; + + @override + String get notes => 'Muistiinpanot'; + + @override + String get dueDate => 'Eräpäivä'; + + @override + String get clearDueDate => 'Tyhjennä eräpäivä'; + + @override + String get dueTime => 'Erääntymisaika'; + + @override + String get startDate => 'Alkamispäivä'; + + @override + String get startTime => 'Alkamisaika'; + + @override + String get endDate => 'Päättymispäivä'; + + @override + String get endTime => 'Päättymisaika'; + + @override + String get reminderDate => 'Muistutuspäivä'; + + @override + String get reminderTime => 'Muistutusaika'; + + @override + String get reminder => 'Muistutus'; + + @override + String get addReminder => 'Lisää muistutus'; + + @override + String get addGuest => 'Lisää vieras'; + + @override + String get addGuestEmail => 'Lisää vieraan sähköpostiosoite'; + + @override + String get removeReminder => 'Poista muistutus'; + + @override + String get off => 'Ei käytössä'; + + @override + String get repeat => 'Toisto'; + + @override + String get repeatNone => 'Ei toistoa'; + + @override + String get noneValue => 'Ei mitään'; + + @override + String get repeatDaily => 'Päivittäin'; + + @override + String get repeatWeekly => 'Viikoittain'; + + @override + String get repeatMonthly => 'Kuukausittain'; + + @override + String get repeatYearly => 'Vuosittain'; + + @override + String get importance => 'Tärkeys'; + + @override + String get importanceLow => 'Pieni'; + + @override + String get importanceNormal => 'Normaali'; + + @override + String get importanceHigh => 'Suuri'; + + @override + String get categories => 'Luokat'; + + @override + String get scheduleSection => 'Aikataulu'; + + @override + String get dueGroup => 'Eräpäivä'; + + @override + String get startGroup => 'Alku'; + + @override + String get reminderGroup => 'Muistutus'; + + @override + String get organizationSection => 'Järjestely'; + + @override + String get actionsSection => 'Toiminnot'; + + @override + String get advancedSection => 'Lisäasetukset'; + + @override + String get addCategory => 'Lisää luokka'; + + @override + String get list => 'Luettelo'; + + @override + String get microsoftMoveUnsupported => + 'Luettelosta toiseen siirtämistä ei tueta Microsoft To Do -tileillä tässä versiossa.'; + + @override + String get createSubtask => 'Luo alitehtävä'; + + @override + String get moveToTop => 'Siirrä ylimmäksi'; + + @override + String get deleteTask => 'Poista tehtävä'; + + @override + String get newSubtask => 'Uusi alitehtävä'; + + @override + String deleteTaskConfirmation(String title) { + return 'Poistetaanko \"$title\" Google Tasksista?'; + } + + @override + String get metadata => 'Metatiedot'; + + @override + String get id => 'Tunnus'; + + @override + String get etag => 'ETag'; + + @override + String get updated => 'Päivitetty'; + + @override + String get parent => 'Ylätehtävä'; + + @override + String get position => 'Sijainti'; + + @override + String get webLink => 'Verkkolinkki'; + + @override + String get assignment => 'Määritys'; + + @override + String get localState => 'Paikallinen tila'; + + @override + String get pendingSync => 'Odottaa synkronointia'; + + @override + String get synced => 'Synkronoitu'; + + @override + String get account => 'Tili'; + + @override + String get sync => 'Synkronointi'; + + @override + String get manualFullSync => 'Manuaalinen täysi synkronointi'; + + @override + String get runInBackgroundWhenClosed => + 'Jatka toimintaa, kun ikkuna suljetaan'; + + @override + String get showTrayIcon => 'Näytä ilmoitusalueen kuvake'; + + @override + String get startMinimizedToTray => 'Käynnistä pienennettynä ilmoitusalueelle'; + + @override + String get requiresTrayIcon => 'Vaatii ilmoitusalueen kuvakkeen.'; + + @override + String get syncComplete => 'Synkronointi valmis.'; + + @override + String syncFailed(String error) { + return 'Synkronointi epäonnistui: $error'; + } + + @override + String get notifySyncFailures => 'Ilmoita synkronointivirheistä'; + + @override + String get notifyConflicts => 'Ilmoita ristiriidoista'; + + @override + String get notifyDueToday => 'Ilmoita tänään erääntyvistä'; + + @override + String get eventReminders => 'Tapahtumamuistutukset'; + + @override + String get taskReminders => 'Tehtävämuistutukset'; + + @override + String get notificationDetailLevel => 'Ilmoitusten yksityiskohtaisuus'; + + @override + String get notificationDetailPrivate => 'Yksityinen'; + + @override + String get notificationDetailNormal => 'Normaali'; + + @override + String get quietHours => 'Hiljaiset tunnit'; + + @override + String get quietHoursDescription => + 'Keskeytä ilmoitukset tällä ajanjaksolla.'; + + @override + String get quietHoursStart => 'Hiljaisten tuntien alku'; + + @override + String get quietHoursEnd => 'Hiljaisten tuntien loppu'; + + @override + String get notifications => 'Ilmoitukset'; + + @override + String get appearance => 'Ulkoasu'; + + @override + String get theme => 'Teema'; + + @override + String get themeSystem => 'Järjestelmä'; + + @override + String get themeLight => 'Vaalea'; + + @override + String get themeDark => 'Tumma'; + + @override + String get themeFamily => 'Teemaperhe'; + + @override + String get themeFamilyYaru => 'Ubuntun oma (Yaru)'; + + @override + String get localization => 'Lokalisointi'; + + @override + String get currentLocale => 'Nykyinen alueasetus'; + + @override + String get privacy => 'Tietosuoja'; + + @override + String get redactTaskContentInDiagnostics => + 'Peitä tehtävien sisältö diagnostiikassa'; + + @override + String get developerDiagnostics => 'Kehittäjän diagnostiikka'; + + @override + String get diagnostics => 'Diagnostiikka'; + + @override + String get apiInspectorDisabled => 'Näytä API-tarkastaja'; + + @override + String get googleTasksApi => 'Google Tasks API'; + + @override + String discoveryRevision(String revision) { + return 'Discovery-versio: $revision'; + } + + @override + String get implementedMethods => 'Toteutetut metodit'; + + @override + String get supportsTasksScopes => + 'Tukee tasks- ja tasks.readonly-käyttöoikeusalueita'; + + @override + String get requiresTasksScope => 'Vaatii tasks-käyttöoikeusalueen'; + + @override + String get blockedPendingOperations => 'Estetyt odottavat toiminnot'; + + @override + String get signInToInspectPendingOperations => + 'Kirjaudu sisään tarkastellaksesi odottavia toimintoja.'; + + @override + String get noBlockedPendingOperations => 'Ei estettyjä odottavia toimintoja.'; + + @override + String get operationActions => 'Toiminnon valinnat'; + + @override + String pendingOpListId(String id) { + return 'luettelo=$id'; + } + + @override + String pendingOpTaskId(String id) { + return 'tehtävä=$id'; + } + + @override + String pendingOpAttempts(int count) { + return 'yritykset=$count'; + } + + @override + String get retry => 'Yritä uudelleen'; + + @override + String get discard => 'Hylkää'; + + @override + String get discardChanges => 'Hylätäänkö muutokset?'; + + @override + String get discardChangesConfirmation => + 'Tämä hylkää tehtävän tallentamattomat muutokset.'; + + @override + String get retryCompleted => 'Uudelleenyritys valmis.'; + + @override + String get discardPendingOperation => 'Hylätäänkö odottava toiminto?'; + + @override + String get discardPendingOperationConfirmation => + 'Tämä poistaa estetyn paikallisen toiminnon. Seuraava synkronointi päivittää tiedot Google Tasksista.'; + + @override + String get pendingOperationDiscarded => 'Odottava toiminto hylätty.'; + + @override + String get syncFailureNotificationTitle => + 'BusyMaxin synkronointi epäonnistui'; + + @override + String syncFailureNotificationBody(String message) { + return 'Taustasynkronointi epäonnistui. $message'; + } + + @override + String get conflictNotificationTitle => 'BusyMaxin synkronointiristiriita'; + + @override + String conflictNotificationBody(String summary) { + return 'Odottava paikallinen muutos estettiin. $summary'; + } + + @override + String get dueTodayNotificationTitle => 'Tänään erääntyvät tehtävät'; + + @override + String dueTodayNotificationBody(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count tehtävää erääntyy tänään.', + one: 'Yksi tehtävä erääntyy tänään.', + ); + return '$_temp0'; + } + + @override + String get eventReminderNotificationTitle => 'Tapahtumamuistutus'; + + @override + String get taskReminderNotificationTitle => 'Tehtävämuistutus'; + + @override + String get eventReminderNotificationBody => 'Tapahtuma alkaa pian.'; + + @override + String get taskReminderNotificationBody => 'Tehtävän määräaika lähestyy.'; + + @override + String get notificationOpenAction => 'Avaa'; + + @override + String get notificationDetailsHidden => + 'Yksityiskohdat on piilotettu tietosuoja-asetusten vuoksi.'; + + @override + String get previousMonth => 'Edellinen kuukausi'; + + @override + String get nextMonth => 'Seuraava kuukausi'; + + @override + String get openMonthView => 'Avaa kuukausinäkymä'; + + @override + String get previousYear => 'Edellinen vuosi'; + + @override + String get nextYear => 'Seuraava vuosi'; + + @override + String get openYearView => 'Avaa vuosinäkymä'; + + @override + String weekNumberTooltip(int number) { + return 'Viikko $number'; + } + + @override + String get resizeAllDayPanel => 'Muuta koko päivän paneelin kokoa'; + + @override + String scheduleItemCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count kohdetta', + one: '1 kohde', + ); + return '$_temp0'; + } + + @override + String get readOnlyCalendar => 'Tämä kalenteri on kirjoitussuojattu.'; + + @override + String get selectTimeZone => 'Valitse aikavyöhyke'; + + @override + String get searchLocations => 'Hae sijainteja'; + + @override + String get noLocationsFound => 'Sijainteja ei löytynyt'; + + @override + String deleteCalendarConfirmation(String title) { + return 'Poistetaanko \"$title\"?'; + } +} From fb93c92c959d30b6216b9024fbb9fd2668f58cff Mon Sep 17 00:00:00 2001 From: albert Date: Wed, 29 Jul 2026 15:45:29 -0700 Subject: [PATCH 30/73] Update German localization strings --- lib/l10n/app_de.arb | 45 +++++++++------- lib/l10n/generated/app_localizations_de.dart | 55 +++++++++++++------- 2 files changed, 60 insertions(+), 40 deletions(-) diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index 92156f4..02fb01f 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -3,7 +3,7 @@ "appTitle": "BusyMax", "connectGoogleAccount": "Verbinden Sie Google- und Microsoft-Konten, um Kalender und Aufgaben zu synchronisieren.", "googlePermissionsConsentNotice": "Wählen Sie auf dem Google-Berechtigungsbildschirm sowohl Kalender- als auch Aufgabenberechtigungen aus.", - "googlePermissionsRequiredRetry": "Google Calendar- und Google Tasks-Berechtigungen sind erforderlich. Versuchen Sie es erneut und wählen Sie beide Kontrollkästchen aus.", + "googlePermissionsRequiredRetry": "Die Berechtigungen für Google Kalender und Google Tasks sind erforderlich. Versuchen Sie es erneut und aktivieren Sie beide Kontrollkästchen.", "finishSetup": "Einrichtung abschließen", "continueSetup": "Weiter", "onboardingSetupTitle": "BusyMax einrichten", @@ -40,16 +40,16 @@ "showInSchedule": "Im Zeitplan anzeigen", "noCalendarsSynced": "Noch keine Kalender synchronisiert.", "allDay": "Ganztägig", - "moreItems": "+{count} weitere", + "moreItems": "+{count} mehr", "noEventsOrTasks": "Keine Termine oder Aufgaben", "scheduleLoading": "Zeitplan wird geladen...", "scheduleUnavailable": "Zeitplan nicht verfügbar", "scheduleNoSources": "Keine sichtbaren Kalender oder Aufgabenlisten", - "scheduleNoSourcesDescription": "Wählen Sie in den Einstellungen aus, was angezeigt werden soll, und aktualisieren Sie anschließend.", + "scheduleNoSourcesDescription": "Wählen Sie in den Einstellungen aus, was angezeigt werden soll, und aktualisieren Sie anschließend den Zeitplan.", "scheduleSignInRequired": "Konto verbinden", "scheduleSignInDescription": "Melden Sie sich an, um Kalender und Aufgaben zu synchronisieren.", "scheduleNoSearchResults": "Keine passenden Termine oder Aufgaben", - "scheduleNoSearchResultsDescription": "Versuchen Sie eine andere Suche oder löschen Sie die aktuellen Filter.", + "scheduleNoSearchResultsDescription": "Versuchen Sie es mit einer anderen Suche oder setzen Sie die aktuellen Filter zurück.", "trayAgendaLoading": "Agenda wird geladen...", "trayAgendaSignInRequired": "Melden Sie sich an, um die Agenda anzuzeigen.", "trayAgendaNoSources": "Keine sichtbaren Kalender oder Aufgabenlisten.", @@ -79,7 +79,7 @@ "viewAgenda": "Agenda", "scheduleSettings": "Zeitplan", "scheduleDisplaySettings": "Zeitplananzeige", - "scheduleDisplayHoursDescription": "Tages- und Wochenansicht öffnen innerhalb dieser Zeiten. Frühe und späte Einträge erweitern den Bereich bei Bedarf.", + "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", "sourceCalendar": "Kalender", @@ -121,7 +121,7 @@ "availabilityFree": "Frei", "availabilityTentative": "Mit Vorbehalt", "availabilityOutOfOffice": "Abwesend", - "availabilityWorkingElsewhere": "An einem anderen Ort", + "availabilityWorkingElsewhere": "An einem anderen Ort tätig", "visibilityDefault": "Standard", "visibilityPublic": "Öffentlich", "visibilityPrivate": "Privat", @@ -142,7 +142,7 @@ "shortcutNextPeriodDescription": "Nächste Woche in der Wochenansicht, nächster Monat in der Monatsansicht usw.", "shortcutPreviousPeriod": "Vorheriger Zeitraum", "shortcutPreviousPeriodDescription": "Vorherige Woche in der Wochenansicht, vorheriger Monat in der Monatsansicht usw.", - "shortcutJumpToToday": "Zu Heute springen", + "shortcutJumpToToday": "Zum heutigen Tag springen", "shortcutGroupView": "Ansicht", "shortcutDayView": "Tagesansicht", "shortcutWeekView": "Wochenansicht", @@ -159,7 +159,7 @@ "shortcutRefreshCompactAgendaDescription": "Das kompakte Agenda-Fenster aktualisieren", "shortcutHideCompactAgendaDescription": "Das kompakte Agenda-Fenster ausblenden", "aboutBusyMax": "Über BusyMax", - "aboutBusyMaxDescription": "ToDo und Kalender", + "aboutBusyMaxDescription": "Aufgaben und Kalender", "website": "Website", "reportAnIssue": "Problem melden", "sendFeedback": "Feedback senden", @@ -173,7 +173,7 @@ "feedbackCategoryOther": "Sonstiges", "feedbackSubject": "Betreff", "feedbackDetailedMessage": "Ausführliche Nachricht", - "feedbackReplyEmail": "E-Mail-Adresse für Antwort (optional)", + "feedbackReplyEmail": "E-Mail-Adresse für Antworten (optional)", "feedbackIncludeTechnicalDetails": "Technische Details hinzufügen", "feedbackTechnicalDetailsDisclosure": "Fügt nur die Version Ihres Linux-Betriebssystems und die Spracheinstellung der Anwendung hinzu. Es werden keine Protokolle, Kontodaten, Dateinamen oder anderen Diagnosedaten hinzugefügt.", "feedbackCategoryRequired": "Wählen Sie eine Kategorie aus.", @@ -217,7 +217,7 @@ "builtInMicrosoftList": "Integriert", "builtInMicrosoftListCannotRenameDelete": "Integrierte Microsoft To Do-Listen können nicht umbenannt oder gelöscht werden.", "deleteListConfirmation": "\"{title}\" aus Google Tasks löschen?", - "deleteEvent": "Ereignis löschen", + "deleteEvent": "Termin löschen", "title": "Titel", "create": "Erstellen", "newTask": "Neue Aufgabe", @@ -231,11 +231,11 @@ "exportFailed": "Export fehlgeschlagen: {error}", "@exportFailed": {"placeholders": {"error": {"type": "String"}}}, "refreshFailed": "Aktualisierung fehlgeschlagen: {error}", - "selectOrCreateTaskList": "Wählen oder erstellen Sie eine Aufgabenliste.", + "selectOrCreateTaskList": "Wählen oder erstellen Sie zunächst eine Aufgabenliste.", "signInToViewTasks": "Melden Sie sich an, um Aufgaben zu sehen.", "noTasks": "Keine Aufgaben.", "noTasksYet": "Noch keine Aufgaben", - "noTasksYetMessage": "Erstellen Sie eine Aufgabe oder aktualisieren Sie Ihre Konten.", + "noTasksYetMessage": "Erstellen Sie eine Aufgabe oder aktualisieren Sie Ihre Konten, um loszulegen.", "noTasksInList": "Keine Aufgaben in dieser Liste.", "overdue": "Überfällig", "today": "Heute", @@ -295,7 +295,7 @@ "list": "Liste", "microsoftMoveUnsupported": "Das Verschieben zwischen Listen wird für Microsoft To Do-Konten in dieser Version nicht unterstützt.", "createSubtask": "Unteraufgabe erstellen", - "moveToTop": "Nach oben verschieben", + "moveToTop": "Ganz nach oben verschieben", "deleteTask": "Aufgabe löschen", "newSubtask": "Neue Unteraufgabe", "deleteTaskConfirmation": "\"{title}\" aus Google Tasks löschen?", @@ -303,7 +303,7 @@ "id": "ID", "etag": "ETag", "updated": "Aktualisiert", - "parent": "Übergeordnet", + "parent": "Übergeordnete Aufgabe", "position": "Position", "webLink": "Weblink", "assignment": "Zuweisung", @@ -333,12 +333,12 @@ "quietHoursEnd": "Ende der Ruhezeit", "notifications": "Benachrichtigungen", "appearance": "Darstellung", - "theme": "Theme", + "theme": "Design", "themeSystem": "System", "themeLight": "Hell", "themeDark": "Dunkel", - "themeFamily": "Theme-Familie", - "themeFamilyYaru": "Natives Ubuntu (Yaru)", + "themeFamily": "Designfamilie", + "themeFamilyYaru": "Natives Ubuntu-Design (Yaru)", "localization": "Lokalisierung", "currentLocale": "Aktuelle Sprache", "privacy": "Datenschutz", @@ -349,8 +349,8 @@ "googleTasksApi": "Google Tasks API", "discoveryRevision": "Discovery-Revision: {revision}", "implementedMethods": "Implementierte Methoden", - "supportsTasksScopes": "Unterstützt tasks- und tasks.readonly-Berechtigungen", - "requiresTasksScope": "Benötigt tasks-Berechtigung", + "supportsTasksScopes": "Unterstützt die Berechtigungsbereiche tasks und tasks.readonly", + "requiresTasksScope": "Erfordert den Berechtigungsbereich tasks", "blockedPendingOperations": "Blockierte ausstehende Vorgänge", "signInToInspectPendingOperations": "Melden Sie sich an, um ausstehende Vorgänge zu prüfen.", "noBlockedPendingOperations": "Keine blockierten ausstehenden Vorgänge.", @@ -364,7 +364,7 @@ "discardChangesConfirmation": "Dies verwirft ungespeicherte Änderungen an dieser Aufgabe.", "retryCompleted": "Erneuter Versuch abgeschlossen.", "discardPendingOperation": "Ausstehenden Vorgang verwerfen?", - "discardPendingOperationConfirmation": "Dies entfernt den blockierten lokalen Vorgang. Die nächste Synchronisierung aktualisiert von Google Tasks.", + "discardPendingOperationConfirmation": "Dadurch wird der blockierte lokale Vorgang entfernt. Bei der nächsten Synchronisierung werden die Daten aus Google Tasks neu geladen.", "pendingOperationDiscarded": "Ausstehender Vorgang verworfen.", "syncFailureNotificationTitle": "BusyMax-Synchronisierung fehlgeschlagen", "syncFailureNotificationBody": "Hintergrundsynchronisierung fehlgeschlagen. {message}", @@ -372,6 +372,11 @@ "conflictNotificationBody": "Eine ausstehende lokale Änderung wurde blockiert. {summary}", "dueTodayNotificationTitle": "Heute fällige Aufgaben", "dueTodayNotificationBody": "{count, plural, =1{Eine Aufgabe ist heute fällig.} other{{count} Aufgaben sind heute fällig.}}", + "eventReminderNotificationTitle": "Terminerinnerung", + "taskReminderNotificationTitle": "Aufgabenerinnerung", + "eventReminderNotificationBody": "Der Termin beginnt bald.", + "taskReminderNotificationBody": "Die Aufgabe ist bald fällig.", + "notificationOpenAction": "Öffnen", "notificationDetailsHidden": "Details werden durch Datenschutzeinstellungen ausgeblendet.", "previousMonth": "Vorheriger Monat", "nextMonth": "Nächster Monat", diff --git a/lib/l10n/generated/app_localizations_de.dart b/lib/l10n/generated/app_localizations_de.dart index 88242db..adf85b7 100644 --- a/lib/l10n/generated/app_localizations_de.dart +++ b/lib/l10n/generated/app_localizations_de.dart @@ -21,7 +21,7 @@ class AppLocalizationsDe extends AppLocalizations { @override String get googlePermissionsRequiredRetry => - 'Google Calendar- und Google Tasks-Berechtigungen sind erforderlich. Versuchen Sie es erneut und wählen Sie beide Kontrollkästchen aus.'; + 'Die Berechtigungen für Google Kalender und Google Tasks sind erforderlich. Versuchen Sie es erneut und aktivieren Sie beide Kontrollkästchen.'; @override String get finishSetup => 'Einrichtung abschließen'; @@ -136,7 +136,7 @@ class AppLocalizationsDe extends AppLocalizations { @override String moreItems(int count) { - return '+$count weitere'; + return '+$count mehr'; } @override @@ -154,7 +154,7 @@ class AppLocalizationsDe extends AppLocalizations { @override String get scheduleNoSourcesDescription => - 'Wählen Sie in den Einstellungen aus, was angezeigt werden soll, und aktualisieren Sie anschließend.'; + 'Wählen Sie in den Einstellungen aus, was angezeigt werden soll, und aktualisieren Sie anschließend den Zeitplan.'; @override String get scheduleSignInRequired => 'Konto verbinden'; @@ -168,7 +168,7 @@ class AppLocalizationsDe extends AppLocalizations { @override String get scheduleNoSearchResultsDescription => - 'Versuchen Sie eine andere Suche oder löschen Sie die aktuellen Filter.'; + 'Versuchen Sie es mit einer anderen Suche oder setzen Sie die aktuellen Filter zurück.'; @override String get trayAgendaLoading => 'Agenda wird geladen...'; @@ -263,7 +263,7 @@ class AppLocalizationsDe extends AppLocalizations { @override String get scheduleDisplayHoursDescription => - 'Tages- und Wochenansicht öffnen innerhalb dieser Zeiten. Frühe und späte Einträge erweitern den Bereich bei Bedarf.'; + 'In der Tages- und Wochenansicht wird zunächst dieser Zeitraum angezeigt. Frühere oder spätere Einträge erweitern ihn bei Bedarf.'; @override String get scheduleDayStartsAt => 'Tag beginnt um'; @@ -404,7 +404,7 @@ class AppLocalizationsDe extends AppLocalizations { String get availabilityOutOfOffice => 'Abwesend'; @override - String get availabilityWorkingElsewhere => 'An einem anderen Ort'; + String get availabilityWorkingElsewhere => 'An einem anderen Ort tätig'; @override String get visibilityDefault => 'Standard'; @@ -472,7 +472,7 @@ class AppLocalizationsDe extends AppLocalizations { 'Vorherige Woche in der Wochenansicht, vorheriger Monat in der Monatsansicht usw.'; @override - String get shortcutJumpToToday => 'Zu Heute springen'; + String get shortcutJumpToToday => 'Zum heutigen Tag springen'; @override String get shortcutGroupView => 'Ansicht'; @@ -526,7 +526,7 @@ class AppLocalizationsDe extends AppLocalizations { String get aboutBusyMax => 'Über BusyMax'; @override - String get aboutBusyMaxDescription => 'ToDo und Kalender'; + String get aboutBusyMaxDescription => 'Aufgaben und Kalender'; @override String get website => 'Website'; @@ -570,7 +570,7 @@ class AppLocalizationsDe extends AppLocalizations { String get feedbackDetailedMessage => 'Ausführliche Nachricht'; @override - String get feedbackReplyEmail => 'E-Mail-Adresse für Antwort (optional)'; + String get feedbackReplyEmail => 'E-Mail-Adresse für Antworten (optional)'; @override String get feedbackIncludeTechnicalDetails => 'Technische Details hinzufügen'; @@ -722,7 +722,7 @@ class AppLocalizationsDe extends AppLocalizations { } @override - String get deleteEvent => 'Ereignis löschen'; + String get deleteEvent => 'Termin löschen'; @override String get title => 'Titel'; @@ -765,7 +765,7 @@ class AppLocalizationsDe extends AppLocalizations { @override String get selectOrCreateTaskList => - 'Wählen oder erstellen Sie eine Aufgabenliste.'; + 'Wählen oder erstellen Sie zunächst eine Aufgabenliste.'; @override String get signInToViewTasks => 'Melden Sie sich an, um Aufgaben zu sehen.'; @@ -778,7 +778,7 @@ class AppLocalizationsDe extends AppLocalizations { @override String get noTasksYetMessage => - 'Erstellen Sie eine Aufgabe oder aktualisieren Sie Ihre Konten.'; + 'Erstellen Sie eine Aufgabe oder aktualisieren Sie Ihre Konten, um loszulegen.'; @override String get noTasksInList => 'Keine Aufgaben in dieser Liste.'; @@ -965,7 +965,7 @@ class AppLocalizationsDe extends AppLocalizations { String get createSubtask => 'Unteraufgabe erstellen'; @override - String get moveToTop => 'Nach oben verschieben'; + String get moveToTop => 'Ganz nach oben verschieben'; @override String get deleteTask => 'Aufgabe löschen'; @@ -991,7 +991,7 @@ class AppLocalizationsDe extends AppLocalizations { String get updated => 'Aktualisiert'; @override - String get parent => 'Übergeordnet'; + String get parent => 'Übergeordnete Aufgabe'; @override String get position => 'Position'; @@ -1088,7 +1088,7 @@ class AppLocalizationsDe extends AppLocalizations { String get appearance => 'Darstellung'; @override - String get theme => 'Theme'; + String get theme => 'Design'; @override String get themeSystem => 'System'; @@ -1100,10 +1100,10 @@ class AppLocalizationsDe extends AppLocalizations { String get themeDark => 'Dunkel'; @override - String get themeFamily => 'Theme-Familie'; + String get themeFamily => 'Designfamilie'; @override - String get themeFamilyYaru => 'Natives Ubuntu (Yaru)'; + String get themeFamilyYaru => 'Natives Ubuntu-Design (Yaru)'; @override String get localization => 'Lokalisierung'; @@ -1140,10 +1140,10 @@ class AppLocalizationsDe extends AppLocalizations { @override String get supportsTasksScopes => - 'Unterstützt tasks- und tasks.readonly-Berechtigungen'; + 'Unterstützt die Berechtigungsbereiche tasks und tasks.readonly'; @override - String get requiresTasksScope => 'Benötigt tasks-Berechtigung'; + String get requiresTasksScope => 'Erfordert den Berechtigungsbereich tasks'; @override String get blockedPendingOperations => 'Blockierte ausstehende Vorgänge'; @@ -1195,7 +1195,7 @@ class AppLocalizationsDe extends AppLocalizations { @override String get discardPendingOperationConfirmation => - 'Dies entfernt den blockierten lokalen Vorgang. Die nächste Synchronisierung aktualisiert von Google Tasks.'; + 'Dadurch wird der blockierte lokale Vorgang entfernt. Bei der nächsten Synchronisierung werden die Daten aus Google Tasks neu geladen.'; @override String get pendingOperationDiscarded => 'Ausstehender Vorgang verworfen.'; @@ -1231,6 +1231,21 @@ class AppLocalizationsDe extends AppLocalizations { return '$_temp0'; } + @override + String get eventReminderNotificationTitle => 'Terminerinnerung'; + + @override + String get taskReminderNotificationTitle => 'Aufgabenerinnerung'; + + @override + String get eventReminderNotificationBody => 'Der Termin beginnt bald.'; + + @override + String get taskReminderNotificationBody => 'Die Aufgabe ist bald fällig.'; + + @override + String get notificationOpenAction => 'Öffnen'; + @override String get notificationDetailsHidden => 'Details werden durch Datenschutzeinstellungen ausgeblendet.'; From 9e4532ef8398913e68c76d986992fb3fe4ffe3b3 Mon Sep 17 00:00:00 2001 From: albert Date: Wed, 29 Jul 2026 16:06:08 -0700 Subject: [PATCH 31/73] Update Finish and Spanish localizations --- lib/l10n/app_en.arb | 5 ++ lib/l10n/app_es.arb | 49 +++++++++------- lib/l10n/app_fi.arb | 22 +++---- lib/l10n/generated/app_localizations_en.dart | 15 +++++ lib/l10n/generated/app_localizations_es.dart | 61 +++++++++++++------- lib/l10n/generated/app_localizations_fi.dart | 23 ++++---- 6 files changed, 109 insertions(+), 66 deletions(-) diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 5de657a..b0e7a9c 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -394,6 +394,11 @@ "dueTodayNotificationTitle": "Tasks due today", "dueTodayNotificationBody": "{count, plural, =1{One task is due today.} other{{count} tasks are due today.}}", "@dueTodayNotificationBody": {"placeholders": {"count": {"type": "int"}}}, + "eventReminderNotificationTitle": "Event reminder", + "taskReminderNotificationTitle": "Task reminder", + "eventReminderNotificationBody": "Event starts soon.", + "taskReminderNotificationBody": "Task is due soon.", + "notificationOpenAction": "Open", "notificationDetailsHidden": "Details are hidden by privacy settings.", "previousMonth": "Previous month", "nextMonth": "Next month", diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index fa38a71..b344a34 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -10,7 +10,7 @@ "onboardingAccountsStepTitle": "Conectar cuentas", "onboardingAccountsStepDescription": "Añade todas las cuentas de Google y Microsoft que quieras usar. BusyMax sincroniza calendarios, eventos, listas de tareas y tareas de cada cuenta.", "onboardingPreferencesStepTitle": "Elegir ajustes del sistema", - "onboardingPreferencesStepDescription": "Configura el comportamiento de escritorio, recordatorios, detalle de notificaciones y apariencia antes de abrir tu agenda.", + "onboardingPreferencesStepDescription": "Configura el comportamiento de la aplicación en el escritorio, los recordatorios, el nivel de detalle de las notificaciones y la apariencia antes de abrir tu agenda.", "signInWithGoogle": "Iniciar sesión con Google", "signInWithMicrosoft": "Iniciar sesión con Microsoft", "googleTasksProvider": "Google Tasks", @@ -35,7 +35,7 @@ "calendars": "Calendarios", "newEvent": "Nuevo evento", "refreshCalendar": "Actualizar calendario", - "openInProvider": "Abrir en proveedor", + "openInProvider": "Abrir en el proveedor", "hideFromSchedule": "Ocultar de la agenda", "showInSchedule": "Mostrar en la agenda", "noCalendarsSynced": "Aún no hay calendarios sincronizados.", @@ -45,7 +45,7 @@ "scheduleLoading": "Cargando la agenda...", "scheduleUnavailable": "Agenda no disponible", "scheduleNoSources": "No hay calendarios ni listas de tareas visibles", - "scheduleNoSourcesDescription": "Elige qué mostrar en Ajustes y, después, actualiza.", + "scheduleNoSourcesDescription": "Elige qué mostrar en Configuración y, después, actualiza la agenda.", "scheduleSignInRequired": "Conectar una cuenta", "scheduleSignInDescription": "Inicia sesión para sincronizar calendarios y tareas.", "scheduleNoSearchResults": "No hay eventos ni tareas coincidentes", @@ -79,7 +79,7 @@ "viewAgenda": "Agenda", "scheduleSettings": "Agenda", "scheduleDisplaySettings": "Visualización de agenda", - "scheduleDisplayHoursDescription": "Las vistas Día y Semana se abren dentro de este horario. Los elementos tempranos y tardíos amplían el intervalo si hace falta.", + "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", "sourceCalendar": "Calendario", @@ -142,7 +142,7 @@ "shortcutNextPeriodDescription": "Semana siguiente en la vista semanal, mes siguiente en la vista mensual, etc.", "shortcutPreviousPeriod": "Periodo anterior", "shortcutPreviousPeriodDescription": "Semana anterior en la vista semanal, mes anterior en la vista mensual, etc.", - "shortcutJumpToToday": "Ir a hoy", + "shortcutJumpToToday": "Ir a la fecha de hoy", "shortcutGroupView": "Vista", "shortcutDayView": "Vista de día", "shortcutWeekView": "Vista de semana", @@ -159,7 +159,7 @@ "shortcutRefreshCompactAgendaDescription": "Actualizar la ventana de agenda compacta", "shortcutHideCompactAgendaDescription": "Ocultar la ventana de agenda compacta", "aboutBusyMax": "Acerca de BusyMax", - "aboutBusyMaxDescription": "ToDo y calendario", + "aboutBusyMaxDescription": "Tareas y calendario", "website": "Sitio web", "reportAnIssue": "Informar de un problema", "sendFeedback": "Enviar comentarios", @@ -173,7 +173,7 @@ "feedbackCategoryOther": "Otro", "feedbackSubject": "Asunto", "feedbackDetailedMessage": "Mensaje detallado", - "feedbackReplyEmail": "Correo electrónico de respuesta (opcional)", + "feedbackReplyEmail": "Correo electrónico para recibir una respuesta (opcional)", "feedbackIncludeTechnicalDetails": "Incluir detalles técnicos", "feedbackTechnicalDetailsDisclosure": "Incluye únicamente la versión del sistema operativo Linux y la configuración regional de la aplicación. No se incluyen registros, datos de cuenta, nombres de archivos ni otros diagnósticos.", "feedbackCategoryRequired": "Selecciona una categoría.", @@ -181,12 +181,12 @@ "feedbackMessageLengthError": "El mensaje debe tener entre 10 y 5.000 caracteres.", "feedbackInvalidEmail": "Introduce una dirección de correo electrónico válida.", "feedbackConnectionError": "No se pudo conectar con BusyStack. Comprueba tu conexión e inténtalo de nuevo.", - "feedbackTimeoutError": "La solicitud agotó el tiempo de espera. Tus comentarios no se han borrado; inténtalo de nuevo.", + "feedbackTimeoutError": "Se agotó el tiempo de espera de la solicitud. Tus comentarios no se han borrado; inténtalo de nuevo.", "feedbackRateLimitedError": "Se han enviado demasiados comentarios desde esta red. Espera e inténtalo de nuevo.", "feedbackRejectedError": "El servidor rechazó el envío. Revisa los campos e inténtalo de nuevo.", "feedbackServerError": "BusyStack no puede aceptar tus comentarios ahora. Tus comentarios no se han borrado; inténtalo de nuevo.", "feedbackSuccess": "Comentarios enviados. Referencia: {id}", - "toggleSidebar": "Alternar barra lateral", + "toggleSidebar": "Mostrar u ocultar la barra lateral", "accounts": "Cuentas", "currentAccount": "Cuenta actual", "switchAccount": "Cambiar cuenta", @@ -205,7 +205,7 @@ "revokeGoogleAccessDescription": "Tendrás que volver a conceder acceso antes de reconectar la cuenta.", "removeAccountAction": "Eliminar cuenta", "removeAccountFailed": "No se pudo terminar de eliminar la cuenta. Inténtalo de nuevo.", - "accountRemovedGoogleRevokeFailed": "La cuenta se eliminó de este dispositivo, pero BusyMax no pudo revocar el acceso de Google. Puedes revocarlo desde tu cuenta de Google.", + "accountRemovedGoogleRevokeFailed": "La cuenta se eliminó de este dispositivo, pero BusyMax no pudo revocar su acceso a tu cuenta de Google. Puedes revocarlo desde tu cuenta de Google.", "newList": "Nueva lista", "signInToViewTaskLists": "Inicia sesión para ver las listas de tareas.", "noTaskListsSynced": "Aún no hay listas de tareas sincronizadas.", @@ -231,7 +231,7 @@ "exportFailed": "Error al exportar: {error}", "@exportFailed": {"placeholders": {"error": {"type": "String"}}}, "refreshFailed": "Error al actualizar: {error}", - "selectOrCreateTaskList": "Selecciona o crea una lista de tareas.", + "selectOrCreateTaskList": "Selecciona o crea una lista de tareas para empezar.", "signInToViewTasks": "Inicia sesión para ver las tareas.", "noTasks": "No hay tareas.", "noTasksYet": "Aún no hay tareas", @@ -293,9 +293,9 @@ "advancedSection": "Avanzado", "addCategory": "Añadir categoría", "list": "Lista", - "microsoftMoveUnsupported": "Mover tareas entre listas no es compatible con las cuentas de Microsoft To Do en esta versión.", + "microsoftMoveUnsupported": "En esta versión, no se pueden mover tareas entre listas en cuentas de Microsoft To Do.", "createSubtask": "Crear subtarea", - "moveToTop": "Mover arriba", + "moveToTop": "Mover al principio", "deleteTask": "Eliminar tarea", "newSubtask": "Nueva subtarea", "deleteTaskConfirmation": "¿Eliminar \"{title}\" de Google Tasks?", @@ -303,7 +303,7 @@ "id": "ID", "etag": "ETag", "updated": "Actualizada", - "parent": "Padre", + "parent": "Tarea principal", "position": "Posición", "webLink": "Enlace web", "assignment": "Asignación", @@ -321,24 +321,24 @@ "syncFailed": "Error de sincronización: {error}", "notifySyncFailures": "Notificaciones de errores de sincronización", "notifyConflicts": "Notificaciones de conflictos", - "notifyDueToday": "Notificaciones de tareas para hoy", + "notifyDueToday": "Notificaciones de tareas que vencen hoy", "eventReminders": "Recordatorios de eventos", "taskReminders": "Recordatorios de tareas", "notificationDetailLevel": "Nivel de detalle de las notificaciones", "notificationDetailPrivate": "Privado", "notificationDetailNormal": "Normal", - "quietHours": "Horario silencioso", + "quietHours": "Horario de silencio", "quietHoursDescription": "Pausar las notificaciones durante este período.", - "quietHoursStart": "Inicio del horario silencioso", - "quietHoursEnd": "Fin del horario silencioso", + "quietHoursStart": "Inicio del horario de silencio", + "quietHoursEnd": "Fin del horario de silencio", "notifications": "Notificaciones", "appearance": "Apariencia", "theme": "Tema", "themeSystem": "Sistema", "themeLight": "Claro", "themeDark": "Oscuro", - "themeFamily": "Familia de tema", - "themeFamilyYaru": "Ubuntu nativo (Yaru)", + "themeFamily": "Familia de temas", + "themeFamilyYaru": "Tema nativo de Ubuntu (Yaru)", "localization": "Localización", "currentLocale": "Configuración regional actual", "privacy": "Privacidad", @@ -354,7 +354,7 @@ "blockedPendingOperations": "Operaciones pendientes bloqueadas", "signInToInspectPendingOperations": "Inicia sesión para inspeccionar operaciones pendientes.", "noBlockedPendingOperations": "No hay operaciones pendientes bloqueadas.", - "operationActions": "Acciones de operación", + "operationActions": "Acciones de la operación", "pendingOpListId": "lista={id}", "pendingOpTaskId": "tarea={id}", "pendingOpAttempts": "intentos={count}", @@ -364,7 +364,7 @@ "discardChangesConfirmation": "Esto descarta las ediciones no guardadas de esta tarea.", "retryCompleted": "Reintento completado.", "discardPendingOperation": "¿Descartar operación pendiente?", - "discardPendingOperationConfirmation": "Esto elimina la operación local bloqueada. La próxima sincronización actualizará desde Google Tasks.", + "discardPendingOperationConfirmation": "Esto elimina la operación local bloqueada. En la próxima sincronización, se volverán a cargar los datos desde Google Tasks.", "pendingOperationDiscarded": "Operación pendiente descartada.", "syncFailureNotificationTitle": "Falló la sincronización de BusyMax", "syncFailureNotificationBody": "Falló la sincronización en segundo plano. {message}", @@ -372,6 +372,11 @@ "conflictNotificationBody": "Se bloqueó un cambio local pendiente. {summary}", "dueTodayNotificationTitle": "Tareas que vencen hoy", "dueTodayNotificationBody": "{count, plural, =1{Una tarea vence hoy.} other{{count} tareas vencen hoy.}}", + "eventReminderNotificationTitle": "Recordatorio de evento", + "taskReminderNotificationTitle": "Recordatorio de tarea", + "eventReminderNotificationBody": "El evento empieza pronto.", + "taskReminderNotificationBody": "La tarea vence pronto.", + "notificationOpenAction": "Abrir", "notificationDetailsHidden": "Los detalles están ocultos por la configuración de privacidad.", "previousMonth": "Mes anterior", "nextMonth": "Mes siguiente", diff --git a/lib/l10n/app_fi.arb b/lib/l10n/app_fi.arb index 21fb557..f9ba2ee 100644 --- a/lib/l10n/app_fi.arb +++ b/lib/l10n/app_fi.arb @@ -10,7 +10,7 @@ "onboardingAccountsStepTitle": "Yhdistä tilit", "onboardingAccountsStepDescription": "Lisää kaikki haluamasi Google- ja Microsoft-tilit. BusyMax synkronoi kunkin tilin kalenterit, tapahtumat, tehtäväluettelot ja tehtävät.", "onboardingPreferencesStepTitle": "Valitse järjestelmäasetukset", - "onboardingPreferencesStepDescription": "Määritä työpöytätoiminnot, muistutukset, ilmoitusten yksityiskohdat ja ulkoasu ennen aikataulun avaamista.", + "onboardingPreferencesStepDescription": "Määritä sovelluksen toiminta työpöydällä, muistutukset, ilmoitusten yksityiskohtaisuus ja ulkoasu ennen aikataulun avaamista.", "signInWithGoogle": "Kirjaudu Google-tilillä", "signInWithMicrosoft": "Kirjaudu Microsoft-tilillä", "googleTasksProvider": "Google Tasks", @@ -35,7 +35,7 @@ "calendars": "Kalenterit", "newEvent": "Uusi tapahtuma", "refreshCalendar": "Päivitä kalenteri", - "openInProvider": "Avaa palveluntarjoajassa", + "openInProvider": "Avaa palvelussa", "hideFromSchedule": "Piilota aikataulusta", "showInSchedule": "Näytä aikataulussa", "noCalendarsSynced": "Kalentereita ei ole vielä synkronoitu.", @@ -45,7 +45,7 @@ "scheduleLoading": "Ladataan aikataulua...", "scheduleUnavailable": "Aikataulu ei ole käytettävissä", "scheduleNoSources": "Ei näkyviä kalentereita tai tehtäväluetteloita", - "scheduleNoSourcesDescription": "Valitse asetuksissa näytettävät kohteet ja päivitä sitten.", + "scheduleNoSourcesDescription": "Valitse asetuksissa, mitä näytetään, ja päivitä sitten näkymä.", "scheduleSignInRequired": "Yhdistä tili", "scheduleSignInDescription": "Kirjaudu sisään synkronoidaksesi kalenterit ja tehtävät.", "scheduleNoSearchResults": "Ei vastaavia tapahtumia tai tehtäviä", @@ -79,7 +79,7 @@ "viewAgenda": "Agenda", "scheduleSettings": "Aikataulu", "scheduleDisplaySettings": "Aikataulun näyttö", - "scheduleDisplayHoursDescription": "Päivä- ja viikkonäkymät avautuvat näiden kellonaikojen välille. Aikaiset ja myöhäiset kohteet laajentavat aluetta tarvittaessa.", + "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", "sourceCalendar": "Kalenteri", @@ -165,12 +165,12 @@ "feedbackSelectCategory": "Valitse luokka", "feedbackCategoryProblem": "Ongelma tai virhe", "feedbackCategoryFeature": "Ominaisuuspyyntö", - "feedbackCategoryPrivacySecurity": "Tietosuoja- tai turvallisuushuoli", + "feedbackCategoryPrivacySecurity": "Tietosuojaan tai tietoturvaan liittyvä huoli", "feedbackCategoryUsability": "Käytettävyyshuoli", "feedbackCategoryOther": "Muu", "feedbackSubject": "Aihe", "feedbackDetailedMessage": "Yksityiskohtainen viesti", - "feedbackReplyEmail": "Vastaussähköposti (valinnainen)", + "feedbackReplyEmail": "Sähköpostiosoite vastausta varten (valinnainen)", "feedbackIncludeTechnicalDetails": "Sisällytä tekniset tiedot", "feedbackTechnicalDetailsDisclosure": "Lisää vain Linux-käyttöjärjestelmäsi version ja sovelluksen alueasetuksen. Lokeja, tilitietoja, tiedostonimiä tai muita diagnostiikkatietoja ei lisätä.", "feedbackCategoryRequired": "Valitse luokka.", @@ -321,10 +321,10 @@ "notificationDetailLevel": "Ilmoitusten yksityiskohtaisuus", "notificationDetailPrivate": "Yksityinen", "notificationDetailNormal": "Normaali", - "quietHours": "Hiljaiset tunnit", + "quietHours": "Hiljainen aika", "quietHoursDescription": "Keskeytä ilmoitukset tällä ajanjaksolla.", - "quietHoursStart": "Hiljaisten tuntien alku", - "quietHoursEnd": "Hiljaisten tuntien loppu", + "quietHoursStart": "Hiljaisen ajan alku", + "quietHoursEnd": "Hiljaisen ajan loppu", "notifications": "Ilmoitukset", "appearance": "Ulkoasu", "theme": "Teema", @@ -348,7 +348,7 @@ "blockedPendingOperations": "Estetyt odottavat toiminnot", "signInToInspectPendingOperations": "Kirjaudu sisään tarkastellaksesi odottavia toimintoja.", "noBlockedPendingOperations": "Ei estettyjä odottavia toimintoja.", - "operationActions": "Toiminnon valinnat", + "operationActions": "Toiminnot", "pendingOpListId": "luettelo={id}", "pendingOpTaskId": "tehtävä={id}", "pendingOpAttempts": "yritykset={count}", @@ -356,7 +356,7 @@ "discard": "Hylkää", "discardChanges": "Hylätäänkö muutokset?", "discardChangesConfirmation": "Tämä hylkää tehtävän tallentamattomat muutokset.", - "retryCompleted": "Uudelleenyritys valmis.", + "retryCompleted": "Uudelleenyritys suoritettu.", "discardPendingOperation": "Hylätäänkö odottava toiminto?", "discardPendingOperationConfirmation": "Tämä poistaa estetyn paikallisen toiminnon. Seuraava synkronointi päivittää tiedot Google Tasksista.", "pendingOperationDiscarded": "Odottava toiminto hylätty.", diff --git a/lib/l10n/generated/app_localizations_en.dart b/lib/l10n/generated/app_localizations_en.dart index f5f9e3d..2fd547a 100644 --- a/lib/l10n/generated/app_localizations_en.dart +++ b/lib/l10n/generated/app_localizations_en.dart @@ -1214,6 +1214,21 @@ class AppLocalizationsEn extends AppLocalizations { return '$_temp0'; } + @override + String get eventReminderNotificationTitle => 'Event reminder'; + + @override + String get taskReminderNotificationTitle => 'Task reminder'; + + @override + String get eventReminderNotificationBody => 'Event starts soon.'; + + @override + String get taskReminderNotificationBody => 'Task is due soon.'; + + @override + String get notificationOpenAction => 'Open'; + @override String get notificationDetailsHidden => 'Details are hidden by privacy settings.'; diff --git a/lib/l10n/generated/app_localizations_es.dart b/lib/l10n/generated/app_localizations_es.dart index 3abb1fc..e4f7b5e 100644 --- a/lib/l10n/generated/app_localizations_es.dart +++ b/lib/l10n/generated/app_localizations_es.dart @@ -44,7 +44,7 @@ class AppLocalizationsEs extends AppLocalizations { @override String get onboardingPreferencesStepDescription => - 'Configura el comportamiento de escritorio, recordatorios, detalle de notificaciones y apariencia antes de abrir tu agenda.'; + 'Configura el comportamiento de la aplicación en el escritorio, los recordatorios, el nivel de detalle de las notificaciones y la apariencia antes de abrir tu agenda.'; @override String get signInWithGoogle => 'Iniciar sesión con Google'; @@ -122,7 +122,7 @@ class AppLocalizationsEs extends AppLocalizations { String get refreshCalendar => 'Actualizar calendario'; @override - String get openInProvider => 'Abrir en proveedor'; + String get openInProvider => 'Abrir en el proveedor'; @override String get hideFromSchedule => 'Ocultar de la agenda'; @@ -156,7 +156,7 @@ class AppLocalizationsEs extends AppLocalizations { @override String get scheduleNoSourcesDescription => - 'Elige qué mostrar en Ajustes y, después, actualiza.'; + 'Elige qué mostrar en Configuración y, después, actualiza la agenda.'; @override String get scheduleSignInRequired => 'Conectar una cuenta'; @@ -265,7 +265,7 @@ class AppLocalizationsEs extends AppLocalizations { @override String get scheduleDisplayHoursDescription => - 'Las vistas Día y Semana se abren dentro de este horario. Los elementos tempranos y tardíos amplían el intervalo si hace falta.'; + 'Las vistas de día y semana muestran inicialmente este intervalo horario. Los elementos anteriores o posteriores lo amplían cuando es necesario.'; @override String get scheduleDayStartsAt => 'El día empieza a las'; @@ -474,7 +474,7 @@ class AppLocalizationsEs extends AppLocalizations { 'Semana anterior en la vista semanal, mes anterior en la vista mensual, etc.'; @override - String get shortcutJumpToToday => 'Ir a hoy'; + String get shortcutJumpToToday => 'Ir a la fecha de hoy'; @override String get shortcutGroupView => 'Vista'; @@ -528,7 +528,7 @@ class AppLocalizationsEs extends AppLocalizations { String get aboutBusyMax => 'Acerca de BusyMax'; @override - String get aboutBusyMaxDescription => 'ToDo y calendario'; + String get aboutBusyMaxDescription => 'Tareas y calendario'; @override String get website => 'Sitio web'; @@ -571,7 +571,8 @@ class AppLocalizationsEs extends AppLocalizations { String get feedbackDetailedMessage => 'Mensaje detallado'; @override - String get feedbackReplyEmail => 'Correo electrónico de respuesta (opcional)'; + String get feedbackReplyEmail => + 'Correo electrónico para recibir una respuesta (opcional)'; @override String get feedbackIncludeTechnicalDetails => 'Incluir detalles técnicos'; @@ -601,7 +602,7 @@ class AppLocalizationsEs extends AppLocalizations { @override String get feedbackTimeoutError => - 'La solicitud agotó el tiempo de espera. Tus comentarios no se han borrado; inténtalo de nuevo.'; + 'Se agotó el tiempo de espera de la solicitud. Tus comentarios no se han borrado; inténtalo de nuevo.'; @override String get feedbackRateLimitedError => @@ -621,7 +622,7 @@ class AppLocalizationsEs extends AppLocalizations { } @override - String get toggleSidebar => 'Alternar barra lateral'; + String get toggleSidebar => 'Mostrar u ocultar la barra lateral'; @override String get accounts => 'Cuentas'; @@ -683,7 +684,7 @@ class AppLocalizationsEs extends AppLocalizations { @override String get accountRemovedGoogleRevokeFailed => - 'La cuenta se eliminó de este dispositivo, pero BusyMax no pudo revocar el acceso de Google. Puedes revocarlo desde tu cuenta de Google.'; + 'La cuenta se eliminó de este dispositivo, pero BusyMax no pudo revocar su acceso a tu cuenta de Google. Puedes revocarlo desde tu cuenta de Google.'; @override String get newList => 'Nueva lista'; @@ -765,7 +766,8 @@ class AppLocalizationsEs extends AppLocalizations { } @override - String get selectOrCreateTaskList => 'Selecciona o crea una lista de tareas.'; + String get selectOrCreateTaskList => + 'Selecciona o crea una lista de tareas para empezar.'; @override String get signInToViewTasks => 'Inicia sesión para ver las tareas.'; @@ -958,13 +960,13 @@ class AppLocalizationsEs extends AppLocalizations { @override String get microsoftMoveUnsupported => - 'Mover tareas entre listas no es compatible con las cuentas de Microsoft To Do en esta versión.'; + 'En esta versión, no se pueden mover tareas entre listas en cuentas de Microsoft To Do.'; @override String get createSubtask => 'Crear subtarea'; @override - String get moveToTop => 'Mover arriba'; + String get moveToTop => 'Mover al principio'; @override String get deleteTask => 'Eliminar tarea'; @@ -990,7 +992,7 @@ class AppLocalizationsEs extends AppLocalizations { String get updated => 'Actualizada'; @override - String get parent => 'Padre'; + String get parent => 'Tarea principal'; @override String get position => 'Posición'; @@ -1049,7 +1051,7 @@ class AppLocalizationsEs extends AppLocalizations { String get notifyConflicts => 'Notificaciones de conflictos'; @override - String get notifyDueToday => 'Notificaciones de tareas para hoy'; + String get notifyDueToday => 'Notificaciones de tareas que vencen hoy'; @override String get eventReminders => 'Recordatorios de eventos'; @@ -1068,17 +1070,17 @@ class AppLocalizationsEs extends AppLocalizations { String get notificationDetailNormal => 'Normal'; @override - String get quietHours => 'Horario silencioso'; + String get quietHours => 'Horario de silencio'; @override String get quietHoursDescription => 'Pausar las notificaciones durante este período.'; @override - String get quietHoursStart => 'Inicio del horario silencioso'; + String get quietHoursStart => 'Inicio del horario de silencio'; @override - String get quietHoursEnd => 'Fin del horario silencioso'; + String get quietHoursEnd => 'Fin del horario de silencio'; @override String get notifications => 'Notificaciones'; @@ -1099,10 +1101,10 @@ class AppLocalizationsEs extends AppLocalizations { String get themeDark => 'Oscuro'; @override - String get themeFamily => 'Familia de tema'; + String get themeFamily => 'Familia de temas'; @override - String get themeFamilyYaru => 'Ubuntu nativo (Yaru)'; + String get themeFamilyYaru => 'Tema nativo de Ubuntu (Yaru)'; @override String get localization => 'Localización'; @@ -1156,7 +1158,7 @@ class AppLocalizationsEs extends AppLocalizations { 'No hay operaciones pendientes bloqueadas.'; @override - String get operationActions => 'Acciones de operación'; + String get operationActions => 'Acciones de la operación'; @override String pendingOpListId(String id) { @@ -1194,7 +1196,7 @@ class AppLocalizationsEs extends AppLocalizations { @override String get discardPendingOperationConfirmation => - 'Esto elimina la operación local bloqueada. La próxima sincronización actualizará desde Google Tasks.'; + 'Esto elimina la operación local bloqueada. En la próxima sincronización, se volverán a cargar los datos desde Google Tasks.'; @override String get pendingOperationDiscarded => 'Operación pendiente descartada.'; @@ -1231,6 +1233,21 @@ class AppLocalizationsEs extends AppLocalizations { return '$_temp0'; } + @override + String get eventReminderNotificationTitle => 'Recordatorio de evento'; + + @override + String get taskReminderNotificationTitle => 'Recordatorio de tarea'; + + @override + String get eventReminderNotificationBody => 'El evento empieza pronto.'; + + @override + String get taskReminderNotificationBody => 'La tarea vence pronto.'; + + @override + String get notificationOpenAction => 'Abrir'; + @override String get notificationDetailsHidden => 'Los detalles están ocultos por la configuración de privacidad.'; diff --git a/lib/l10n/generated/app_localizations_fi.dart b/lib/l10n/generated/app_localizations_fi.dart index 4a3f0a1..30f2eea 100644 --- a/lib/l10n/generated/app_localizations_fi.dart +++ b/lib/l10n/generated/app_localizations_fi.dart @@ -44,7 +44,7 @@ class AppLocalizationsFi extends AppLocalizations { @override String get onboardingPreferencesStepDescription => - 'Määritä työpöytätoiminnot, muistutukset, ilmoitusten yksityiskohdat ja ulkoasu ennen aikataulun avaamista.'; + 'Määritä sovelluksen toiminta työpöydällä, muistutukset, ilmoitusten yksityiskohtaisuus ja ulkoasu ennen aikataulun avaamista.'; @override String get signInWithGoogle => 'Kirjaudu Google-tilillä'; @@ -122,7 +122,7 @@ class AppLocalizationsFi extends AppLocalizations { String get refreshCalendar => 'Päivitä kalenteri'; @override - String get openInProvider => 'Avaa palveluntarjoajassa'; + String get openInProvider => 'Avaa palvelussa'; @override String get hideFromSchedule => 'Piilota aikataulusta'; @@ -156,7 +156,7 @@ class AppLocalizationsFi extends AppLocalizations { @override String get scheduleNoSourcesDescription => - 'Valitse asetuksissa näytettävät kohteet ja päivitä sitten.'; + 'Valitse asetuksissa, mitä näytetään, ja päivitä sitten näkymä.'; @override String get scheduleSignInRequired => 'Yhdistä tili'; @@ -264,7 +264,7 @@ class AppLocalizationsFi extends AppLocalizations { @override String get scheduleDisplayHoursDescription => - 'Päivä- ja viikkonäkymät avautuvat näiden kellonaikojen välille. Aikaiset ja myöhäiset kohteet laajentavat aluetta tarvittaessa.'; + '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.'; @override String get scheduleDayStartsAt => 'Päivä alkaa'; @@ -555,7 +555,7 @@ class AppLocalizationsFi extends AppLocalizations { @override String get feedbackCategoryPrivacySecurity => - 'Tietosuoja- tai turvallisuushuoli'; + 'Tietosuojaan tai tietoturvaan liittyvä huoli'; @override String get feedbackCategoryUsability => 'Käytettävyyshuoli'; @@ -570,7 +570,8 @@ class AppLocalizationsFi extends AppLocalizations { String get feedbackDetailedMessage => 'Yksityiskohtainen viesti'; @override - String get feedbackReplyEmail => 'Vastaussähköposti (valinnainen)'; + String get feedbackReplyEmail => + 'Sähköpostiosoite vastausta varten (valinnainen)'; @override String get feedbackIncludeTechnicalDetails => 'Sisällytä tekniset tiedot'; @@ -1065,17 +1066,17 @@ class AppLocalizationsFi extends AppLocalizations { String get notificationDetailNormal => 'Normaali'; @override - String get quietHours => 'Hiljaiset tunnit'; + String get quietHours => 'Hiljainen aika'; @override String get quietHoursDescription => 'Keskeytä ilmoitukset tällä ajanjaksolla.'; @override - String get quietHoursStart => 'Hiljaisten tuntien alku'; + String get quietHoursStart => 'Hiljaisen ajan alku'; @override - String get quietHoursEnd => 'Hiljaisten tuntien loppu'; + String get quietHoursEnd => 'Hiljaisen ajan loppu'; @override String get notifications => 'Ilmoitukset'; @@ -1152,7 +1153,7 @@ class AppLocalizationsFi extends AppLocalizations { String get noBlockedPendingOperations => 'Ei estettyjä odottavia toimintoja.'; @override - String get operationActions => 'Toiminnon valinnat'; + String get operationActions => 'Toiminnot'; @override String pendingOpListId(String id) { @@ -1183,7 +1184,7 @@ class AppLocalizationsFi extends AppLocalizations { 'Tämä hylkää tehtävän tallentamattomat muutokset.'; @override - String get retryCompleted => 'Uudelleenyritys valmis.'; + String get retryCompleted => 'Uudelleenyritys suoritettu.'; @override String get discardPendingOperation => 'Hylätäänkö odottava toiminto?'; From 9b74154b87d57e67ee38eed872c345db2defe1f5 Mon Sep 17 00:00:00 2001 From: albert Date: Wed, 29 Jul 2026 16:12:35 -0700 Subject: [PATCH 32/73] Add native grouped list style support for time zone dialog. Enhance styling options and improve layout responsiveness. Refactor notification string handling to utilize localized strings. Update time picker labels to use MaterialLocalizations. Introduce NativeGroupedListStyle for consistent visual presentation in native dialogs. --- .../desktop_notification_service.dart | 103 ++----- .../desktop_date_time_fields.dart | 5 +- .../time_zone_selection_dialog.dart | 19 ++ .../platform/linux_header_bar_service.dart | 1 - lib/src/platform/native_dialog_service.dart | 60 ++++ linux/runner/my_application.cc | 260 ++++++++++++++---- test/app/localization_audit_test.dart | 32 +++ test/app/native_ui_audit_test.dart | 105 ++++++- .../desktop_notification_service_test.dart | 41 ++- test/platform/native_dialog_service_test.dart | 32 +++ 10 files changed, 510 insertions(+), 148 deletions(-) diff --git a/lib/src/features/notifications/desktop_notification_service.dart b/lib/src/features/notifications/desktop_notification_service.dart index 6db401a..67f23b6 100644 --- a/lib/src/features/notifications/desktop_notification_service.dart +++ b/lib/src/features/notifications/desktop_notification_service.dart @@ -3,6 +3,7 @@ import 'dart:ui'; import 'package:desktop_notifications/desktop_notifications.dart'; +import '../../../l10n/generated/app_localizations.dart'; import '../../app/app_settings.dart'; import '../../core/logging/redacting_logger.dart'; @@ -261,87 +262,31 @@ class NotificationStrings { }); factory NotificationStrings.forLocale(Locale locale) { - return switch (locale.languageCode) { - 'de' => NotificationStrings.german, - 'fr' => NotificationStrings.french, - 'es' => NotificationStrings.spanish, - _ => NotificationStrings.english, - }; + final supportedLocale = AppLocalizations.supportedLocales.firstWhere( + (candidate) => candidate.languageCode == locale.languageCode, + orElse: () => const Locale('en'), + ); + return NotificationStrings.fromLocalizations( + lookupAppLocalizations(supportedLocale), + ); } - static final english = NotificationStrings( - syncFailureTitle: 'BusyMax sync failed', - conflictTitle: 'BusyMax sync conflict', - dueTodayTitle: 'Tasks due today', - eventReminderTitle: 'Event reminder', - taskReminderTitle: 'Task reminder', - detailsHidden: 'Details are hidden by privacy settings.', - eventReminderBody: 'Event starts soon.', - taskReminderBody: 'Task is due soon.', - openAction: 'Open', - syncFailureBody: (message) => 'Background sync failed. $message', - conflictBody: (summary) => 'A pending local change was blocked. $summary', - dueTodayBody: (count) => - count == 1 ? 'One task is due today.' : '$count tasks are due today.', - ); - - static final german = NotificationStrings( - syncFailureTitle: 'BusyMax-Synchronisierung fehlgeschlagen', - conflictTitle: 'BusyMax-Synchronisierungskonflikt', - dueTodayTitle: 'Heute fällige Aufgaben', - eventReminderTitle: 'Terminerinnerung', - taskReminderTitle: 'Aufgabenerinnerung', - detailsHidden: - 'Details werden durch Datenschutzeinstellungen ausgeblendet.', - eventReminderBody: 'Der Termin beginnt bald.', - taskReminderBody: 'Die Aufgabe ist bald fällig.', - openAction: 'Öffnen', - syncFailureBody: (message) => - 'Hintergrundsynchronisierung fehlgeschlagen. $message', - conflictBody: (summary) => - 'Eine ausstehende lokale Änderung wurde blockiert. $summary', - dueTodayBody: (count) => count == 1 - ? 'Eine Aufgabe ist heute fällig.' - : '$count Aufgaben sind heute fällig.', - ); - - static final french = NotificationStrings( - syncFailureTitle: 'Échec de la synchronisation BusyMax', - conflictTitle: 'Conflit de synchronisation BusyMax', - dueTodayTitle: 'Tâches dues aujourd’hui', - eventReminderTitle: 'Rappel d’événement', - taskReminderTitle: 'Rappel de tâche', - detailsHidden: - 'Les détails sont masqués par les paramètres de confidentialité.', - eventReminderBody: 'L’événement commence bientôt.', - taskReminderBody: 'La tâche arrive bientôt à échéance.', - openAction: 'Ouvrir', - syncFailureBody: (message) => - 'La synchronisation en arrière-plan a échoué. $message', - conflictBody: (summary) => - 'Une modification locale en attente a été bloquée. $summary', - dueTodayBody: (count) => count == 1 - ? 'Une tâche est due aujourd’hui.' - : '$count tâches sont dues aujourd’hui.', - ); - - static final spanish = NotificationStrings( - syncFailureTitle: 'Falló la sincronización de BusyMax', - conflictTitle: 'Conflicto de sincronización de BusyMax', - dueTodayTitle: 'Tareas que vencen hoy', - eventReminderTitle: 'Recordatorio de evento', - taskReminderTitle: 'Recordatorio de tarea', - detailsHidden: - 'Los detalles están ocultos por la configuración de privacidad.', - eventReminderBody: 'El evento empieza pronto.', - taskReminderBody: 'La tarea vence pronto.', - openAction: 'Abrir', - syncFailureBody: (message) => - 'Falló la sincronización en segundo plano. $message', - conflictBody: (summary) => 'Se bloqueó un cambio local pendiente. $summary', - dueTodayBody: (count) => - count == 1 ? 'Una tarea vence hoy.' : '$count tareas vencen hoy.', - ); + factory NotificationStrings.fromLocalizations(AppLocalizations l10n) { + return NotificationStrings( + syncFailureTitle: l10n.syncFailureNotificationTitle, + conflictTitle: l10n.conflictNotificationTitle, + dueTodayTitle: l10n.dueTodayNotificationTitle, + eventReminderTitle: l10n.eventReminderNotificationTitle, + taskReminderTitle: l10n.taskReminderNotificationTitle, + detailsHidden: l10n.notificationDetailsHidden, + eventReminderBody: l10n.eventReminderNotificationBody, + taskReminderBody: l10n.taskReminderNotificationBody, + openAction: l10n.notificationOpenAction, + syncFailureBody: l10n.syncFailureNotificationBody, + conflictBody: l10n.conflictNotificationBody, + dueTodayBody: l10n.dueTodayNotificationBody, + ); + } final String syncFailureTitle; final String conflictTitle; diff --git a/lib/src/features/tasks/presentation/desktop_date_time_fields.dart b/lib/src/features/tasks/presentation/desktop_date_time_fields.dart index 4f4c1d5..dd648a9 100644 --- a/lib/src/features/tasks/presentation/desktop_date_time_fields.dart +++ b/lib/src/features/tasks/presentation/desktop_date_time_fields.dart @@ -1067,6 +1067,7 @@ class _DesktopTimeValueDialogState extends State<_DesktopTimeValueDialog> { @override Widget build(BuildContext context) { + final materialLocalizations = MaterialLocalizations.of(context); return BusyMaxContentPopoverSurface( arrowSide: widget.arrowSide, arrowAlignment: widget.arrowAlignment, @@ -1087,7 +1088,7 @@ class _DesktopTimeValueDialogState extends State<_DesktopTimeValueDialog> { buttonWidth: _timePickerInputColumnWidth, controller: _hourController, focusNode: _hourFocusNode, - label: 'Hour', + label: materialLocalizations.timePickerHourLabel, onIncrement: () => _changeHour(1), onDecrement: () => _changeHour(-1), ), @@ -1110,7 +1111,7 @@ class _DesktopTimeValueDialogState extends State<_DesktopTimeValueDialog> { buttonWidth: _timePickerInputColumnWidth, controller: _minuteController, focusNode: _minuteFocusNode, - label: 'Minute', + label: materialLocalizations.timePickerMinuteLabel, onIncrement: () => _changeMinute(1), onDecrement: () => _changeMinute(-1), ), diff --git a/lib/src/features/tasks/presentation/time_zone_selection_dialog.dart b/lib/src/features/tasks/presentation/time_zone_selection_dialog.dart index c1a9ade..849be75 100644 --- a/lib/src/features/tasks/presentation/time_zone_selection_dialog.dart +++ b/lib/src/features/tasks/presentation/time_zone_selection_dialog.dart @@ -6,6 +6,7 @@ import 'package:yaru/yaru.dart'; import '../../../app/busymax_dialogs.dart'; import '../../../app/busymax_design.dart'; +import '../../../app/busymax_surface_colors.dart'; import '../../../core/time/time_zone_catalog.dart'; import '../../../l10n/l10n.dart'; import '../../../platform/native_dialog_service.dart'; @@ -22,6 +23,8 @@ Future showBusyMaxTimeZoneSelectionDialog( return null; } final l10n = context.l10n; + final theme = Theme.of(context); + final surfaceColors = BusyMaxSurfaceColors.of(context); final nativeResult = await const NativeDialogService().selectTimeZone( title: l10n.selectTimeZone, searchPlaceholder: l10n.searchLocations, @@ -39,6 +42,22 @@ Future showBusyMaxTimeZoneSelectionDialog( searchText: result.searchText, ), ], + groupedListStyle: NativeGroupedListStyle( + surfaceColor: busyMaxGroupedSurfaceColor(context), + dividerColor: surfaceColors.cardShade, + sectionHeaderColor: theme.colorScheme.onSurfaceVariant, + primaryTextColor: theme.colorScheme.onSurface, + secondaryTextColor: surfaceColors.mutedForeground, + hoverColor: busyMaxRowHoverColor(context), + shadowColor: + CardTheme.of(context).shadowColor ?? theme.colorScheme.shadow, + outlineColor: theme.colorScheme.outline, + highContrast: MediaQuery.highContrastOf(context), + radius: BusyMaxRadius.md.round(), + sectionTopSpacing: BusyMaxSpacing.lg.round(), + sectionHorizontalPadding: BusyMaxSpacing.xs.round(), + titleBottomSpacing: BusyMaxSpacing.sm.round(), + ), ); if (nativeResult.available) { return nativeResult.selectedTimeZone; diff --git a/lib/src/platform/linux_header_bar_service.dart b/lib/src/platform/linux_header_bar_service.dart index 78a3382..57ffde9 100644 --- a/lib/src/platform/linux_header_bar_service.dart +++ b/lib/src/platform/linux_header_bar_service.dart @@ -874,7 +874,6 @@ class _BusyMaxOnboardingControlsState { Object.hash(visible, canGoBack, canContinue, backLabel, continueLabel); } -@visibleForTesting String busyMaxCssColor(Color color) { final rgb = color.toARGB32() & 0x00ffffff; if (color.a >= 1) { diff --git a/lib/src/platform/native_dialog_service.dart b/lib/src/platform/native_dialog_service.dart index a7859af..3ee39f2 100644 --- a/lib/src/platform/native_dialog_service.dart +++ b/lib/src/platform/native_dialog_service.dart @@ -1,6 +1,8 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; +import 'linux_header_bar_service.dart'; + @visibleForTesting const nativeDialogChannelName = 'busymax/native_dialogs'; @@ -71,6 +73,62 @@ class NativeTimeZoneSelectionResult { final String? selectedTimeZone; } +/// Semantic presentation values shared with Flutter's grouped-list component. +/// +/// The Linux host keeps its native GTK input and Handy rows, while these +/// values prevent the native result groups from developing a separate visual +/// language from BusyMaxGroupedList. +@immutable +class NativeGroupedListStyle { + const NativeGroupedListStyle({ + required this.surfaceColor, + required this.dividerColor, + required this.sectionHeaderColor, + required this.primaryTextColor, + required this.secondaryTextColor, + required this.hoverColor, + required this.shadowColor, + required this.outlineColor, + required this.highContrast, + required this.radius, + required this.sectionTopSpacing, + required this.sectionHorizontalPadding, + required this.titleBottomSpacing, + }); + + final Color surfaceColor; + final Color dividerColor; + final Color sectionHeaderColor; + final Color primaryTextColor; + final Color secondaryTextColor; + final Color hoverColor; + final Color shadowColor; + final Color outlineColor; + final bool highContrast; + final int radius; + final int sectionTopSpacing; + final int sectionHorizontalPadding; + final int titleBottomSpacing; + + Map toMessage() { + return { + 'surfaceColor': busyMaxCssColor(surfaceColor), + 'dividerColor': busyMaxCssColor(dividerColor), + 'sectionHeaderColor': busyMaxCssColor(sectionHeaderColor), + 'primaryTextColor': busyMaxCssColor(primaryTextColor), + 'secondaryTextColor': busyMaxCssColor(secondaryTextColor), + 'hoverColor': busyMaxCssColor(hoverColor), + 'shadowColor': busyMaxCssColor(shadowColor), + 'outlineColor': busyMaxCssColor(outlineColor), + 'highContrast': highContrast, + 'radius': radius, + 'sectionTopSpacing': sectionTopSpacing, + 'sectionHorizontalPadding': sectionHorizontalPadding, + 'titleBottomSpacing': titleBottomSpacing, + }; + } +} + /// Presents confirmation UI owned by the host desktop toolkit. /// /// Linux implements this with a transient `GtkMessageDialog`. Other hosts can @@ -114,6 +172,7 @@ class NativeDialogService { required String noResultsLabel, required String selectedTimeZone, required List options, + required NativeGroupedListStyle groupedListStyle, }) async { try { final selected = await _channel.invokeMethod('selectTimeZone', { @@ -122,6 +181,7 @@ class NativeDialogService { 'noResultsLabel': noResultsLabel, 'selectedTimeZone': selectedTimeZone, 'options': options.map((option) => option.toMessage()).toList(), + 'groupedListStyle': groupedListStyle.toMessage(), }); return NativeTimeZoneSelectionResult( available: true, diff --git a/linux/runner/my_application.cc b/linux/runner/my_application.cc index d35c44f..d9f969e 100644 --- a/linux/runner/my_application.cc +++ b/linux/runner/my_application.cc @@ -88,8 +88,6 @@ constexpr char kNativeTimeZoneRowStyleClass[] = "busymax-time-zone-row"; constexpr gint kNativeTimeZoneDialogWidth = 520; constexpr gint kNativeTimeZoneDialogContentHeight = 420; constexpr size_t kNativeTimeZoneResultLimit = 250; -constexpr gdouble kNativeTimeZonePrimaryTextOpacity = 0.82; -constexpr gdouble kNativeTimeZoneSecondaryTextOpacity = 0.62; constexpr char kNativePopoverStyleClass[] = "busymax-native-popover"; constexpr char kHeaderMenuDepthStyleClass[] = "busymax-header-menu-depth"; @@ -580,6 +578,22 @@ struct NativeTimeZoneOption { gint region_match_rank; }; +struct NativeGroupedListStyle { + const gchar* surface_color; + const gchar* divider_color; + const gchar* section_header_color; + const gchar* primary_text_color; + const gchar* secondary_text_color; + const gchar* hover_color; + const gchar* shadow_color; + const gchar* outline_color; + gboolean high_contrast; + gint radius; + gint section_top_spacing; + gint section_horizontal_padding; + gint title_bottom_spacing; +}; + struct NativeTimeZoneDialogState { GtkWidget* window; GtkWidget* results; @@ -786,6 +800,8 @@ static void rebuild_native_time_zone_results( gtk_style_context_add_class( gtk_widget_get_style_context(current_group), kNativeTimeZoneGroupStyleClass); + gtk_widget_set_hexpand(current_group, TRUE); + gtk_widget_set_halign(current_group, GTK_ALIGN_FILL); hdy_preferences_group_set_title( HDY_PREFERENCES_GROUP(current_group), current_region); gtk_box_pack_start(GTK_BOX(state->results), current_group, FALSE, FALSE, @@ -795,8 +811,12 @@ static void rebuild_native_time_zone_results( GtkWidget* row = hdy_action_row_new(); gtk_style_context_add_class(gtk_widget_get_style_context(row), kNativeTimeZoneRowStyleClass); + gtk_widget_set_hexpand(row, TRUE); + gtk_widget_set_halign(row, GTK_ALIGN_FILL); hdy_preferences_row_set_title(HDY_PREFERENCES_ROW(row), option->title); hdy_action_row_set_subtitle(HDY_ACTION_ROW(row), option->subtitle); + hdy_action_row_set_title_lines(HDY_ACTION_ROW(row), 1); + hdy_action_row_set_subtitle_lines(HDY_ACTION_ROW(row), 1); hdy_action_row_set_icon_name(HDY_ACTION_ROW(row), "mark-location-symbolic"); gtk_list_box_row_set_activatable(GTK_LIST_BOX_ROW(row), TRUE); @@ -874,6 +894,158 @@ static GPtrArray* parse_native_time_zone_options(FlValue* args) { return options; } +static gboolean is_native_grouped_list_color(const gchar* value) { + if (value == nullptr) { + return FALSE; + } + GdkRGBA parsed = {}; + return gdk_rgba_parse(&parsed, value); +} + +static gboolean parse_native_grouped_list_style( + FlValue* args, + NativeGroupedListStyle* style) { + if (args == nullptr || fl_value_get_type(args) != FL_VALUE_TYPE_MAP) { + return FALSE; + } + FlValue* value = fl_value_lookup_string(args, "groupedListStyle"); + if (value == nullptr || fl_value_get_type(value) != FL_VALUE_TYPE_MAP) { + return FALSE; + } + + style->surface_color = fl_lookup_string_arg(value, "surfaceColor"); + style->divider_color = fl_lookup_string_arg(value, "dividerColor"); + style->section_header_color = + fl_lookup_string_arg(value, "sectionHeaderColor"); + style->primary_text_color = + fl_lookup_string_arg(value, "primaryTextColor"); + style->secondary_text_color = + fl_lookup_string_arg(value, "secondaryTextColor"); + style->hover_color = fl_lookup_string_arg(value, "hoverColor"); + style->shadow_color = fl_lookup_string_arg(value, "shadowColor"); + style->outline_color = fl_lookup_string_arg(value, "outlineColor"); + + gint64 radius = 0; + gint64 section_top_spacing = 0; + gint64 section_horizontal_padding = 0; + gint64 title_bottom_spacing = 0; + if (!fl_lookup_optional_bool_arg(value, "highContrast", + &style->high_contrast) || + !fl_lookup_int_arg(value, "radius", &radius) || + !fl_lookup_int_arg(value, "sectionTopSpacing", + §ion_top_spacing) || + !fl_lookup_int_arg(value, "sectionHorizontalPadding", + §ion_horizontal_padding) || + !fl_lookup_int_arg(value, "titleBottomSpacing", + &title_bottom_spacing)) { + return FALSE; + } + + const gchar* colors[] = { + style->surface_color, style->divider_color, + style->section_header_color, style->primary_text_color, + style->secondary_text_color, style->hover_color, + style->shadow_color, style->outline_color, + }; + for (const gchar* color : colors) { + if (!is_native_grouped_list_color(color)) { + return FALSE; + } + } + + if (radius < 0 || radius > 64 || + section_top_spacing < 0 || section_top_spacing > 128 || + section_horizontal_padding < 0 || + section_horizontal_padding > 128 || + title_bottom_spacing < 0 || title_bottom_spacing > 128) { + return FALSE; + } + style->radius = static_cast(radius); + style->section_top_spacing = static_cast(section_top_spacing); + style->section_horizontal_padding = + static_cast(section_horizontal_padding); + style->title_bottom_spacing = static_cast(title_bottom_spacing); + return TRUE; +} + +static GtkCssProvider* create_native_grouped_list_provider( + const NativeGroupedListStyle* style, + GError** error) { + g_autofree gchar* css = g_strdup_printf( + "window.%s .%s {" + "background-color: transparent;" + "background-image: none;" + "}" + "window.%s .%s {" + "margin-top: %dpx;" + "}" + "window.%s .%s > box > label.heading," + "window.%s .%s > box > label.h4 {" + "color: %s;" + "margin-bottom: %dpx;" + "}" + "window.%s .%s list {" + "background-color: %s;" + "background-image: none;" + "border: %dpx solid %s;" + "border-radius: %dpx;" + "box-shadow: 0 2px 6px 2px alpha(%s, 0.03)," + "0 1px 3px 1px alpha(%s, 0.07)," + "0 0 0 1px alpha(%s, 0.03);" + "}" + "window.%s row.%s," + "window.%s row.%s:backdrop {" + "background-color: transparent;" + "background-image: none;" + "border: none;" + "box-shadow: none;" + "color: %s;" + "}" + "window.%s row.%s:not(:last-child) {" + "border-bottom: 1px solid %s;" + "}" + "window.%s row.%s label.title {" + "color: %s;" + "}" + "window.%s row.%s label.subtitle," + "window.%s row.%s label.dim-label {" + "color: %s;" + "}" + "window.%s row.%s:hover:not(:disabled) {" + "background-color: %s;" + "background-image: none;" + "}", + kNativeTimeZoneDialogStyleClass, kNativeTimeZoneResultsStyleClass, + kNativeTimeZoneDialogStyleClass, kNativeTimeZoneGroupStyleClass, + style->section_top_spacing, + kNativeTimeZoneDialogStyleClass, kNativeTimeZoneGroupStyleClass, + kNativeTimeZoneDialogStyleClass, kNativeTimeZoneGroupStyleClass, + style->section_header_color, style->title_bottom_spacing, + kNativeTimeZoneDialogStyleClass, kNativeTimeZoneGroupStyleClass, + style->surface_color, style->high_contrast ? 1 : 0, + style->outline_color, style->radius, style->shadow_color, + style->shadow_color, style->shadow_color, + kNativeTimeZoneDialogStyleClass, kNativeTimeZoneRowStyleClass, + kNativeTimeZoneDialogStyleClass, kNativeTimeZoneRowStyleClass, + style->primary_text_color, + kNativeTimeZoneDialogStyleClass, kNativeTimeZoneRowStyleClass, + style->divider_color, + kNativeTimeZoneDialogStyleClass, kNativeTimeZoneRowStyleClass, + style->primary_text_color, + kNativeTimeZoneDialogStyleClass, kNativeTimeZoneRowStyleClass, + kNativeTimeZoneDialogStyleClass, kNativeTimeZoneRowStyleClass, + style->secondary_text_color, + kNativeTimeZoneDialogStyleClass, kNativeTimeZoneRowStyleClass, + style->hover_color); + GtkCssProvider* provider = gtk_css_provider_new(); + gtk_css_provider_load_from_data(provider, css, -1, error); + if (error != nullptr && *error != nullptr) { + g_object_unref(provider); + return nullptr; + } + return provider; +} + static void handle_native_time_zone_selection(FlMethodCall* method_call, FlValue* args, GtkWindow* parent) { @@ -885,16 +1057,31 @@ static void handle_native_time_zone_selection(FlMethodCall* method_call, const gchar* selected_time_zone = fl_lookup_string_arg(args, "selectedTimeZone"); GPtrArray* options = parse_native_time_zone_options(args); + NativeGroupedListStyle grouped_list_style = {}; if (title == nullptr || search_placeholder == nullptr || no_results_label == nullptr || selected_time_zone == nullptr || - options == nullptr || options->len == 0) { + options == nullptr || options->len == 0 || + !parse_native_grouped_list_style(args, &grouped_list_style)) { if (options != nullptr) { g_ptr_array_unref(options); } fl_method_call_respond_error( method_call, "invalid-arguments", "The timezone dialog requires localized labels, a selected timezone, " - "and a non-empty option list.", + "a non-empty option list, and valid grouped-list presentation values.", + nullptr, nullptr); + return; + } + + g_autoptr(GError) css_error = nullptr; + g_autoptr(GtkCssProvider) grouped_list_provider = + create_native_grouped_list_provider(&grouped_list_style, &css_error); + if (grouped_list_provider == nullptr) { + g_ptr_array_unref(options); + fl_method_call_respond_error( + method_call, "invalid-arguments", + css_error != nullptr ? css_error->message + : "The grouped-list presentation is invalid.", nullptr, nullptr); return; } @@ -915,6 +1102,10 @@ static void handle_native_time_zone_selection(FlMethodCall* method_call, gtk_window_set_resizable(GTK_WINDOW(window), FALSE); gtk_window_set_default_size(GTK_WINDOW(window), kNativeTimeZoneDialogWidth, -1); + GdkScreen* screen = gtk_widget_get_screen(window); + gtk_style_context_add_provider_for_screen( + screen, GTK_STYLE_PROVIDER(grouped_list_provider), + GTK_STYLE_PROVIDER_PRIORITY_APPLICATION + 1); GtkWidget* window_root = gtk_box_new(GTK_ORIENTATION_VERTICAL, 0); gtk_container_add(GTK_CONTAINER(window), window_root); @@ -948,12 +1139,21 @@ static void handle_native_time_zone_selection(FlMethodCall* method_call, GTK_POLICY_NEVER, GTK_POLICY_AUTOMATIC); gtk_scrolled_window_set_shadow_type(GTK_SCROLLED_WINDOW(scrolled), GTK_SHADOW_NONE); + gtk_scrolled_window_set_propagate_natural_width( + GTK_SCROLLED_WINDOW(scrolled), FALSE); + gtk_scrolled_window_set_max_content_width( + GTK_SCROLLED_WINDOW(scrolled), kNativeTimeZoneDialogWidth - 36); + gtk_widget_set_hexpand(scrolled, TRUE); gtk_widget_set_vexpand(scrolled, TRUE); gtk_box_pack_start(GTK_BOX(root), scrolled, TRUE, TRUE, 0); - GtkWidget* results = gtk_box_new(GTK_ORIENTATION_VERTICAL, 12); + GtkWidget* results = gtk_box_new(GTK_ORIENTATION_VERTICAL, 0); gtk_style_context_add_class(gtk_widget_get_style_context(results), kNativeTimeZoneResultsStyleClass); + gtk_widget_set_margin_start( + results, grouped_list_style.section_horizontal_padding); + gtk_widget_set_margin_end( + results, grouped_list_style.section_horizontal_padding); gtk_container_add(GTK_CONTAINER(scrolled), results); GMainLoop* loop = g_main_loop_new(nullptr, FALSE); @@ -992,6 +1192,8 @@ static void handle_native_time_zone_selection(FlMethodCall* method_call, g_main_loop_unref(loop); g_free(state.result); g_ptr_array_unref(options); + gtk_style_context_remove_provider_for_screen( + screen, GTK_STYLE_PROVIDER(grouped_list_provider)); } struct NativeDialogHandlerData { @@ -1761,35 +1963,6 @@ static void refresh_header_bar_css(MyApplication* self) { "background-color: %s;" "background-image: none;" "border-radius: 0 0 %dpx %dpx;" - "}" - "window.%s .%s {" - "background-color: transparent;" - "background-image: none;" - "}" - "window.%s .%s list {" - "background-color: shade(%s, 1.06);" - "background-image: none;" - "border: none;" - "border-radius: 8px;" - "box-shadow: none;" - "}" - "window.%s .%s row," - "window.%s .%s row:backdrop {" - "background-color: transparent;" - "background-image: none;" - "border: none;" - "box-shadow: none;" - "}" - "window.%s .%s label {" - "color: alpha(%s, %.2f);" - "}" - "window.%s .%s label.subtitle," - "window.%s .%s label.dim-label {" - "color: alpha(%s, %.2f);" - "}" - "window.%s .%s row:hover:not(:disabled) {" - "background-color: alpha(%s, 0.08);" - "background-image: none;" "}", kNativeDialogStyleClass, kNativeTimeZoneDialogStyleClass, kNativeDialogStyleClass, kNativeTimeZoneDialogStyleClass, @@ -1801,24 +1974,7 @@ static void refresh_header_bar_css(MyApplication* self) { kNativeDialogCornerRadius, kNativeDialogStyleClass, kNativeTimeZoneDialogStyleClass, kNativeDialogStyleClass, kNativeTimeZoneDialogStyleClass, dialog_background_color, - kNativeDialogCornerRadius, kNativeDialogCornerRadius, - kNativeTimeZoneDialogStyleClass, kNativeTimeZoneResultsStyleClass, - kNativeTimeZoneDialogStyleClass, kNativeTimeZoneGroupStyleClass, - dialog_background_color, kNativeTimeZoneDialogStyleClass, - kNativeTimeZoneRowStyleClass, kNativeTimeZoneDialogStyleClass, - kNativeTimeZoneRowStyleClass, kNativeTimeZoneDialogStyleClass, - kNativeTimeZoneResultsStyleClass, foreground_color, - self->header_bar_high_contrast - ? 1.0 - : kNativeTimeZonePrimaryTextOpacity, - kNativeTimeZoneDialogStyleClass, kNativeTimeZoneRowStyleClass, - kNativeTimeZoneDialogStyleClass, kNativeTimeZoneRowStyleClass, - foreground_color, - self->header_bar_high_contrast - ? 1.0 - : kNativeTimeZoneSecondaryTextOpacity, - kNativeTimeZoneDialogStyleClass, - kNativeTimeZoneRowStyleClass, foreground_color); + kNativeDialogCornerRadius, kNativeDialogCornerRadius); const gchar* modal_barrier_color = css_color_or( self->header_bar_modal_barrier_color, kDefaultModalBarrierColor); const gboolean use_legacy_yaru_compatibility = diff --git a/test/app/localization_audit_test.dart b/test/app/localization_audit_test.dart index da5e274..b565874 100644 --- a/test/app/localization_audit_test.dart +++ b/test/app/localization_audit_test.dart @@ -1,6 +1,8 @@ import 'dart:convert'; import 'dart:io'; +import 'package:busymax/l10n/generated/app_localizations.dart'; +import 'package:flutter/widgets.dart'; import 'package:flutter_test/flutter_test.dart'; void main() { @@ -56,6 +58,33 @@ void main() { expect(failures, isEmpty, reason: failures.join('\n')); }); + + test('Finnish is generated and exposed as a supported locale', () { + const locale = Locale('fi'); + final localizations = lookupAppLocalizations(locale); + + expect(AppLocalizations.supportedLocales, contains(locale)); + expect(localizations.settings, 'Asetukset'); + expect(localizations.today, 'Tänään'); + }); + + test('Russian is generated and exposed as a supported locale', () { + const locale = Locale('ru'); + final localizations = lookupAppLocalizations(locale); + + expect(AppLocalizations.supportedLocales, contains(locale)); + expect(localizations.settings, 'Настройки'); + expect(localizations.today, 'Сегодня'); + }); + + test('Portuguese is generated and exposed as a supported locale', () { + const locale = Locale('pt'); + final localizations = lookupAppLocalizations(locale); + + expect(AppLocalizations.supportedLocales, contains(locale)); + expect(localizations.settings, 'Definições'); + expect(localizations.today, 'Hoje'); + }); } const _auditedUiPaths = [ @@ -71,7 +100,10 @@ const _auditedUiPaths = [ const _translatedArbPaths = [ 'lib/l10n/app_de.arb', 'lib/l10n/app_es.arb', + 'lib/l10n/app_fi.arb', 'lib/l10n/app_fr.arb', + 'lib/l10n/app_pt.arb', + 'lib/l10n/app_ru.arb', ]; final _userFacingLiteralPattern = RegExp( diff --git a/test/app/native_ui_audit_test.dart b/test/app/native_ui_audit_test.dart index 9c5a56e..1d70028 100644 --- a/test/app/native_ui_audit_test.dart +++ b/test/app/native_ui_audit_test.dart @@ -1395,9 +1395,59 @@ void main() { expect(runner, contains('native_time_zone_option_match_rank')); expect(runner, contains('compare_native_time_zone_options')); expect(runner, contains('g_ptr_array_sort_with_data')); + expect(runner, contains('parse_native_grouped_list_style')); + expect(runner, contains('create_native_grouped_list_provider')); + expect( + runner, + contains('gtk_scrolled_window_set_propagate_natural_width'), + ); + expect(runner, contains('gtk_scrolled_window_set_max_content_width')); + expect( + runner, + contains('hdy_action_row_set_title_lines(HDY_ACTION_ROW(row), 1)'), + ); + expect( + runner, + contains('hdy_action_row_set_subtitle_lines(HDY_ACTION_ROW(row), 1)'), + ); + expect( + nativeSelector, + contains( + 'gtk_widget_set_margin_start(\n' + ' results, grouped_list_style.section_horizontal_padding)', + ), + ); + expect( + nativeSelector, + contains( + 'gtk_widget_set_margin_end(\n' + ' results, grouped_list_style.section_horizontal_padding)', + ), + ); expect(service, contains("invokeMethod('selectTimeZone'")); + expect(service, contains('class NativeGroupedListStyle')); + expect( + service, + contains("'groupedListStyle': groupedListStyle.toMessage()"), + ); expect(selector, contains('NativeDialogService().selectTimeZone(')); expect(selector, contains('BusyMaxGroupedList(')); + expect(selector, contains('busyMaxGroupedSurfaceColor(context)')); + expect(selector, contains('dividerColor: surfaceColors.cardShade')); + expect(selector, contains('busyMaxRowHoverColor(context)')); + expect(selector, contains('radius: BusyMaxRadius.md.round()')); + expect( + selector, + contains('sectionTopSpacing: BusyMaxSpacing.lg.round()'), + ); + expect( + selector, + contains('sectionHorizontalPadding: BusyMaxSpacing.xs.round()'), + ); + expect( + selector, + contains('titleBottomSpacing: BusyMaxSpacing.sm.round()'), + ); }); test('application activation presents only the application window', () { @@ -1533,8 +1583,12 @@ void main() { 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;', @@ -1578,6 +1632,19 @@ void main() { 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 =', ); @@ -1914,22 +1981,38 @@ void main() { contains('"border-radius: 0 0 %dpx %dpx;"'), ); expect(nativeTimeZoneDialogCss, contains('"box-shadow: none;"')); - expect( - '"border: none;"'.allMatches(nativeTimeZoneDialogCss).length, - greaterThanOrEqualTo(3), - ); + expect('"border: none;"'.allMatches(nativeTimeZoneDialogCss).length, 2); + expect(nativeTimeZoneDialogCss, isNot(contains('shade('))); expect( nativeTimeZoneDialogCss, - contains('"background-color: shade(%s, 1.06);"'), + isNot(contains('kNativeTimeZoneGroupStyleClass')), ); expect( nativeTimeZoneDialogCss, - contains('kNativeTimeZoneGroupStyleClass'), - ); - expect(nativeTimeZoneDialogCss, contains('kNativeTimeZoneRowStyleClass')); - expect(nativeTimeZoneDialogCss, contains('"color: alpha(%s, %.2f);"')); - expect(source, contains('kNativeTimeZonePrimaryTextOpacity = 0.82')); - expect(source, contains('kNativeTimeZoneSecondaryTextOpacity = 0.62')); + 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;'), diff --git a/test/features/notifications/desktop_notification_service_test.dart b/test/features/notifications/desktop_notification_service_test.dart index e944566..75da512 100644 --- a/test/features/notifications/desktop_notification_service_test.dart +++ b/test/features/notifications/desktop_notification_service_test.dart @@ -49,17 +49,52 @@ void main() { expect(backend.notifications, isEmpty); }); - test('notification strings localize supported locales', () async { + test('notification strings use the Finnish ARB catalog', () async { final backend = _FakeNotificationBackend(); final service = DesktopNotificationService( backend: backend, settings: AppSettings.defaults().copyWith(notifyDueToday: true), - locale: const Locale('es'), + locale: const Locale('fi'), ); await service.notifyDueToday(2); - expect(backend.notifications.single.summary, 'Tareas que vencen hoy'); + expect(backend.notifications.single.summary, 'Tänään erääntyvät tehtävät'); + expect(backend.notifications.single.body, '2 tehtävää erääntyy tänään.'); + }); + + test('notification strings use Russian plural rules', () async { + final backend = _FakeNotificationBackend(); + final service = DesktopNotificationService( + backend: backend, + settings: AppSettings.defaults().copyWith(notifyDueToday: true), + locale: const Locale('ru'), + ); + + await service.notifyDueToday(22); + + expect(backend.notifications.single.summary, 'Задачи на сегодня'); + expect( + backend.notifications.single.body, + 'Сегодня нужно выполнить 22 задачи.', + ); + }); + + test('notification strings use the Portuguese ARB catalog', () async { + final backend = _FakeNotificationBackend(); + final service = DesktopNotificationService( + backend: backend, + settings: AppSettings.defaults().copyWith(notifyDueToday: true), + locale: const Locale('pt'), + ); + + await service.notifyDueToday(2); + + expect(backend.notifications.single.summary, 'Tarefas com prazo para hoje'); + expect( + backend.notifications.single.body, + 'Há 2 tarefas com prazo para hoje.', + ); }); test('reminder notification details are visible by default', () async { diff --git a/test/platform/native_dialog_service_test.dart b/test/platform/native_dialog_service_test.dart index 9df26bc..2a378d9 100644 --- a/test/platform/native_dialog_service_test.dart +++ b/test/platform/native_dialog_service_test.dart @@ -6,6 +6,21 @@ void main() { TestWidgetsFlutterBinding.ensureInitialized(); const channel = MethodChannel('busymax_test/native_dialogs'); + const groupedListStyle = NativeGroupedListStyle( + surfaceColor: Color(0xFF444444), + dividerColor: Color(0x5C000000), + sectionHeaderColor: Color(0xFFCCCCCC), + primaryTextColor: Color(0xFFEFEFEF), + secondaryTextColor: Color(0xFFBBBBBB), + hoverColor: Color(0x1FFFFFFF), + shadowColor: Color(0xFF000000), + outlineColor: Color(0x1FFFFFFF), + highContrast: false, + radius: 8, + sectionTopSpacing: 16, + sectionHorizontalPadding: 4, + titleBottomSpacing: 8, + ); tearDown(() { TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger @@ -103,6 +118,7 @@ void main() { searchText: 'Vancouver\nAmerica/Vancouver\nCA', ), ], + groupedListStyle: groupedListStyle, ); expect(result.available, isTrue); @@ -124,6 +140,21 @@ void main() { 'searchText': 'Vancouver\nAmerica/Vancouver\nCA', }, ], + 'groupedListStyle': { + 'surfaceColor': '#444444', + 'dividerColor': 'rgba(0,0,0,0.36)', + 'sectionHeaderColor': '#CCCCCC', + 'primaryTextColor': '#EFEFEF', + 'secondaryTextColor': '#BBBBBB', + 'hoverColor': 'rgba(255,255,255,0.12)', + 'shadowColor': '#000000', + 'outlineColor': 'rgba(255,255,255,0.12)', + 'highContrast': false, + 'radius': 8, + 'sectionTopSpacing': 16, + 'sectionHorizontalPadding': 4, + 'titleBottomSpacing': 8, + }, }); }); @@ -150,6 +181,7 @@ void main() { searchText: 'UTC\nEtc/UTC', ), ], + groupedListStyle: groupedListStyle, ); expect(result.available, isTrue); From 6e507322ba1742d2f6c048247aa735e888396236 Mon Sep 17 00:00:00 2001 From: albert Date: Wed, 29 Jul 2026 16:26:40 -0700 Subject: [PATCH 33/73] add russian localization --- lib/l10n/app_ru.arb | 389 ++++++ lib/l10n/generated/app_localizations_ru.dart | 1310 ++++++++++++++++++ 2 files changed, 1699 insertions(+) create mode 100644 lib/l10n/app_ru.arb create mode 100644 lib/l10n/generated/app_localizations_ru.dart diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb new file mode 100644 index 0000000..7ce4ca5 --- /dev/null +++ b/lib/l10n/app_ru.arb @@ -0,0 +1,389 @@ +{ + "@@locale": "ru", + "appTitle": "BusyMax", + "connectGoogleAccount": "Подключите аккаунты Google и Microsoft, чтобы синхронизировать календари и задачи.", + "googlePermissionsConsentNotice": "На экране разрешений Google выберите разрешения и для Календаря, и для Задач.", + "googlePermissionsRequiredRetry": "Необходимы разрешения для Google Календаря и Google Tasks. Повторите попытку и установите оба флажка.", + "finishSetup": "Завершить настройку", + "continueSetup": "Продолжить", + "onboardingSetupTitle": "Настройка BusyMax", + "onboardingAccountsStepTitle": "Подключите аккаунты", + "onboardingAccountsStepDescription": "Добавьте все аккаунты Google и Microsoft, которые хотите использовать. BusyMax синхронизирует календари, события, списки задач и задачи из каждого аккаунта.", + "onboardingPreferencesStepTitle": "Выберите системные параметры", + "onboardingPreferencesStepDescription": "Настройте поведение на рабочем столе, напоминания, содержимое уведомлений и внешний вид, прежде чем открыть расписание.", + "signInWithGoogle": "Войти через Google", + "signInWithMicrosoft": "Войти через Microsoft", + "googleTasksProvider": "Google Tasks", + "microsoftTodoProvider": "Microsoft To Do", + "providerNotConfigured": "Этот поставщик не настроен.", + "waitingForGoogleSignIn": "Ожидание входа через Google...", + "waitingForMicrosoftSignIn": "Ожидание входа через Microsoft...", + "microsoftSignInNotConfigured": "Вход через Microsoft не настроен. Задайте MICROSOFT_OAUTH_CLIENT_ID.", + "cancel": "Отмена", + "close": "Закрыть", + "exit": "Выйти", + "options": "Параметры", + "hide": "Скрыть", + "show": "Показать", + "export": "Экспортировать", + "save": "Сохранить", + "settings": "Настройки", + "all": "Все", + "calendarEvents": "События", + "calendarTasks": "Задачи", + "calendar": "Календарь", + "calendars": "Календари", + "newEvent": "Новое событие", + "refreshCalendar": "Обновить календарь", + "openInProvider": "Открыть у поставщика", + "hideFromSchedule": "Скрыть из расписания", + "showInSchedule": "Показывать в расписании", + "noCalendarsSynced": "Синхронизированных календарей пока нет.", + "allDay": "Весь день", + "moreItems": "+ ещё {count}", + "noEventsOrTasks": "Нет событий или задач", + "scheduleLoading": "Загрузка расписания...", + "scheduleUnavailable": "Расписание недоступно", + "scheduleNoSources": "Нет видимых календарей или списков задач", + "scheduleNoSourcesDescription": "Выберите в настройках, что нужно показывать, а затем обновите расписание.", + "scheduleSignInRequired": "Подключите аккаунт", + "scheduleSignInDescription": "Войдите, чтобы синхронизировать календари и задачи.", + "scheduleNoSearchResults": "Подходящих событий или задач нет", + "scheduleNoSearchResultsDescription": "Попробуйте изменить запрос или сбросить текущие фильтры.", + "trayAgendaLoading": "Загрузка повестки...", + "trayAgendaSignInRequired": "Войдите, чтобы просмотреть повестку.", + "trayAgendaNoSources": "Нет видимых календарей или списков задач.", + "trayAgendaOpenBusyMax": "Открыть приложение", + "trayAgendaRefresh": "Обновить", + "trayAgendaError": "Повестка недоступна", + "compactAgendaTitle": "Повестка", + "compactAgendaSubtitle": "Предстоящие", + "compactAgendaOverdue": "Просроченные", + "compactAgendaClear": "На ближайшее время всё свободно", + "compactAgendaOpenBusyMax": "Открыть BusyMax", + "compactAgendaHide": "Скрыть", + "compactAgendaNewTask": "Новая задача", + "compactAgendaRetry": "Повторить", + "compactAgendaRefresh": "Обновить", + "compactAgendaAllDay": "Весь день", + "compactAgendaDueToday": "Срок сегодня", + "compactAgendaDueTomorrow": "Срок завтра", + "compactAgendaDueOn": "Срок: {date}", + "compactAgendaMoreOverdue": "Загрузить ещё просроченные задачи", + "agendaLoadMoreOverdue": "Загрузить ещё просроченные задачи", + "agendaLoadMoreNoDate": "Загрузить ещё задачи без даты", + "viewDay": "День", + "viewWeek": "Неделя", + "viewMonth": "Месяц", + "viewYear": "Год", + "viewAgenda": "Повестка", + "scheduleSettings": "Расписание", + "scheduleDisplaySettings": "Отображение расписания", + "scheduleDisplayHoursDescription": "В представлениях дня и недели изначально отображается этот период. Более ранние или поздние записи при необходимости расширяют его.", + "scheduleDayStartsAt": "Начало дня", + "scheduleDayEndsAt": "Конец дня", + "sourceCalendar": "Календарь", + "sourceTaskList": "Список задач", + "createChoiceTitle": "Создать", + "createEventAtTime": "Событие", + "createTaskAtDate": "Задача", + "editEvent": "Изменить событие", + "eventTitle": "Название события", + "location": "Место", + "timeSlot": "Интервал времени", + "startDateTime": "Дата и время начала", + "endDateTime": "Дата и время окончания", + "doesNotRepeat": "Не повторяется", + "defaultReminder": "Напоминание по умолчанию", + "guests": "Гости", + "noGuests": "Нет гостей", + "description": "Описание", + "availabilityShowAs": "Доступность / Показывать как", + "busy": "Занят", + "visibility": "Видимость", + "defaultVisibility": "Видимость по умолчанию", + "conference": "Конференция", + "noConference": "Без конференции", + "providerCalendar": "Календарь поставщика", + "formatBoldShortLabel": "Ж", + "formatBoldTooltip": "Полужирный", + "formatItalicShortLabel": "К", + "formatItalicTooltip": "Курсив", + "formatUnderlineShortLabel": "Ч", + "formatUnderlineTooltip": "Подчёркнутый", + "reminderMinutesBefore": "{minutes, plural, one{За {minutes} минуту} few{За {minutes} минуты} many{За {minutes} минут} other{За {minutes} минуты}}", + "reminderAtStart": "В момент начала", + "reminderHoursBefore": "{hours, plural, one{За {hours} час} few{За {hours} часа} many{За {hours} часов} other{За {hours} часа}}", + "reminderDaysBefore": "{days, plural, one{За {days} день} few{За {days} дня} many{За {days} дней} other{За {days} дня}}", + "availabilityFree": "Свободен", + "availabilityTentative": "Под вопросом", + "availabilityOutOfOffice": "Не на работе", + "availabilityWorkingElsewhere": "Работает в другом месте", + "visibilityDefault": "По умолчанию", + "visibilityPublic": "Общедоступное", + "visibilityPrivate": "Личное", + "visibilityConfidential": "Конфиденциальное", + "sensitivityNormal": "Обычная", + "sensitivityPersonal": "Личная", + "tasks": "Задачи", + "allTasks": "Все задачи", + "tasksInList": "Задачи в списке «{title}»", + "taskLists": "Списки задач", + "navigation": "Навигация", + "mainMenu": "Главное меню", + "keyboardShortcuts": "Сочетания клавиш", + "shortcutGroupGeneral": "Общие", + "shortcutKeyboardShortcutsDescription": "Показать эту справку по сочетаниям клавиш", + "shortcutGroupNavigation": "Навигация", + "shortcutNextPeriod": "Следующий период", + "shortcutNextPeriodDescription": "Следующая неделя в представлении недели, следующий месяц в представлении месяца и так далее", + "shortcutPreviousPeriod": "Предыдущий период", + "shortcutPreviousPeriodDescription": "Предыдущая неделя в представлении недели, предыдущий месяц в представлении месяца и так далее", + "shortcutJumpToToday": "Перейти к сегодняшнему дню", + "shortcutGroupView": "Представление", + "shortcutDayView": "Представление дня", + "shortcutWeekView": "Представление недели", + "shortcutMonthView": "Представление месяца", + "shortcutYearView": "Представление года", + "shortcutAgendaView": "Представление повестки", + "shortcutGroupCreateAndEdit": "Создание и изменение", + "shortcutSaveItem": "Сохранить событие или задачу", + "shortcutDeleteItem": "Удалить событие или задачу", + "shortcutGroupTaskEditing": "Изменение задач", + "shortcutCancelEditing": "Отменить изменение", + "shortcutCancelEditingDescription": "Закрыть изменение задачи или сведения о ней", + "shortcutGroupCompactAgenda": "Компактная повестка", + "shortcutRefreshCompactAgendaDescription": "Обновить окно компактной повестки", + "shortcutHideCompactAgendaDescription": "Скрыть окно компактной повестки", + "aboutBusyMax": "О приложении BusyMax", + "aboutBusyMaxDescription": "Задачи и календарь", + "website": "Веб-сайт", + "reportAnIssue": "Сообщить о проблеме", + "sendFeedback": "Отправить отзыв", + "feedbackSubmit": "Отправить", + "feedbackCategory": "Категория", + "feedbackSelectCategory": "Выберите категорию", + "feedbackCategoryProblem": "Проблема или ошибка", + "feedbackCategoryFeature": "Запрос функции", + "feedbackCategoryPrivacySecurity": "Проблема конфиденциальности или безопасности", + "feedbackCategoryUsability": "Проблема удобства использования", + "feedbackCategoryOther": "Другое", + "feedbackSubject": "Тема", + "feedbackDetailedMessage": "Подробное сообщение", + "feedbackReplyEmail": "Адрес электронной почты для ответа (необязательно)", + "feedbackIncludeTechnicalDetails": "Включить технические сведения", + "feedbackTechnicalDetailsDisclosure": "Будут добавлены только версия операционной системы Linux и языковой стандарт приложения. Журналы, данные аккаунтов, имена файлов и другие диагностические сведения не включаются.", + "feedbackCategoryRequired": "Выберите категорию.", + "feedbackSubjectLengthError": "Тема должна содержать от 3 до 120 символов.", + "feedbackMessageLengthError": "Сообщение должно содержать от 10 до 5 000 символов.", + "feedbackInvalidEmail": "Введите действительный адрес электронной почты.", + "feedbackConnectionError": "Не удалось подключиться к BusyStack. Проверьте подключение и повторите попытку.", + "feedbackTimeoutError": "Время ожидания запроса истекло. Ваш отзыв не был удалён. Повторите попытку.", + "feedbackRateLimitedError": "Из этой сети было отправлено слишком много отзывов. Подождите и повторите попытку.", + "feedbackRejectedError": "Сервер отклонил отправку. Проверьте поля и повторите попытку.", + "feedbackServerError": "BusyStack сейчас не может принять ваш отзыв. Ваш отзыв не был удалён. Повторите попытку.", + "feedbackSuccess": "Отзыв отправлен. Номер: {id}", + "toggleSidebar": "Показать или скрыть боковую панель", + "accounts": "Аккаунты", + "currentAccount": "Текущий аккаунт", + "switchAccount": "Сменить аккаунт", + "addGoogleAccount": "Добавить аккаунт Google", + "addMicrosoftAccount": "Добавить аккаунт Microsoft", + "googleProvider": "Google", + "microsoftProvider": "Microsoft", + "signedInAccount": "Выполнен вход", + "removeAccount": "Удалить аккаунт…", + "removingAccount": "Удаление аккаунта…", + "removeAccountDescription": "Остановить синхронизацию и удалить данные этого аккаунта с устройства.", + "removeAccountTitle": "Удалить {account} из BusyMax?", + "removeAccountConfirmation": "С устройства будут удалены кэшированные задачи, календари, события, напоминания и ожидающие автономные изменения. Несинхронизированные изменения будут потеряны. Из Google и Microsoft ничего не удаляется.", + "revokeGoogleAccess": "Также отозвать у BusyMax доступ к этому аккаунту Google", + "revokeGoogleAccessDescription": "Перед повторным подключением аккаунта потребуется снова предоставить доступ.", + "removeAccountAction": "Удалить аккаунт", + "removeAccountFailed": "Не удалось завершить удаление аккаунта. Повторите попытку.", + "accountRemovedGoogleRevokeFailed": "Аккаунт удалён с устройства, но BusyMax не удалось отозвать доступ Google. Это можно сделать в аккаунте Google.", + "newList": "Новый список", + "signInToViewTaskLists": "Войдите, чтобы просмотреть списки задач.", + "noTaskListsSynced": "Синхронизированных списков задач пока нет.", + "listActions": "Действия со списком", + "rename": "Переименовать", + "delete": "Удалить", + "renameList": "Переименовать список", + "deleteList": "Удалить список", + "builtInMicrosoftList": "Встроенный", + "builtInMicrosoftListCannotRenameDelete": "Встроенные списки Microsoft To Do нельзя переименовывать или удалять.", + "deleteListConfirmation": "Удалить «{title}» из Google Tasks?", + "deleteEvent": "Удалить событие", + "title": "Название", + "create": "Создать", + "newTask": "Новая задача", + "clearCompleted": "Удалить завершённые", + "refreshList": "Обновить список", + "refreshAll": "Обновить всё", + "listRefreshed": "Список обновлён.", + "allTasksRefreshed": "Все аккаунты обновлены.", + "exportedFile": "Экспортировано в {path}", + "exportFailed": "Не удалось экспортировать: {error}", + "refreshFailed": "Не удалось обновить: {error}", + "selectOrCreateTaskList": "Сначала выберите или создайте список задач.", + "signInToViewTasks": "Войдите, чтобы просмотреть задачи.", + "noTasks": "Задач нет.", + "noTasksYet": "Задач пока нет", + "noTasksYetMessage": "Создайте задачу или обновите аккаунты, чтобы начать.", + "noTasksInList": "В этом списке нет задач.", + "overdue": "Просроченные", + "today": "Сегодня", + "tomorrow": "Завтра", + "upcoming": "Предстоящие", + "noDate": "Без даты", + "completed": "Завершённые", + "duePrefix": "Срок: {date}", + "dateTimeDisplay": "{date}, {time}", + "taskDetails": "Сведения о задаче", + "editTask": "Изменить задачу", + "noTaskSelected": "Задача не выбрана.", + "noTaskSelectedHelper": "Выберите задачу, чтобы просмотреть и изменить её сведения.", + "taskUnavailable": "Задача недоступна.", + "signInToEditTasks": "Войдите, чтобы изменять задачи.", + "refreshTask": "Обновить задачу", + "primarySection": "Основные сведения", + "statusSection": "Состояние", + "openStatus": "Открыта", + "doneStatus": "Выполнена", + "notes": "Заметки", + "dueDate": "Срок", + "clearDueDate": "Очистить срок", + "dueTime": "Время выполнения", + "startDate": "Дата начала", + "startTime": "Время начала", + "endDate": "Дата окончания", + "endTime": "Время окончания", + "reminderDate": "Дата напоминания", + "reminderTime": "Время напоминания", + "reminder": "Напоминание", + "addReminder": "Добавить напоминание", + "addGuest": "Добавить гостя", + "addGuestEmail": "Добавить адрес гостя", + "removeReminder": "Удалить напоминание", + "off": "Выкл.", + "repeat": "Повтор", + "repeatNone": "Не повторять", + "noneValue": "Нет", + "repeatDaily": "Ежедневно", + "repeatWeekly": "Еженедельно", + "repeatMonthly": "Ежемесячно", + "repeatYearly": "Ежегодно", + "importance": "Важность", + "importanceLow": "Низкая", + "importanceNormal": "Обычная", + "importanceHigh": "Высокая", + "categories": "Категории", + "scheduleSection": "Расписание", + "dueGroup": "Срок", + "startGroup": "Начало", + "reminderGroup": "Напоминание", + "organizationSection": "Организация", + "actionsSection": "Действия", + "advancedSection": "Дополнительно", + "addCategory": "Добавить категорию", + "list": "Список", + "microsoftMoveUnsupported": "В этой версии перенос между списками для аккаунтов Microsoft To Do не поддерживается.", + "createSubtask": "Создать подзадачу", + "moveToTop": "Переместить в самый верх", + "deleteTask": "Удалить задачу", + "newSubtask": "Новая подзадача", + "deleteTaskConfirmation": "Удалить «{title}» из Google Tasks?", + "metadata": "Метаданные", + "id": "Идентификатор", + "etag": "ETag", + "updated": "Обновлено", + "parent": "Родительская задача", + "position": "Позиция", + "webLink": "Веб-ссылка", + "assignment": "Назначение", + "localState": "Локальное состояние", + "pendingSync": "Ожидает синхронизации", + "synced": "Синхронизировано", + "account": "Аккаунт", + "sync": "Синхронизация", + "manualFullSync": "Полная синхронизация вручную", + "runInBackgroundWhenClosed": "Продолжать работу после закрытия окна", + "showTrayIcon": "Показывать значок в области уведомлений", + "startMinimizedToTray": "Запускать свёрнутым в область уведомлений", + "requiresTrayIcon": "Требуется значок в области уведомлений.", + "syncComplete": "Синхронизация завершена.", + "syncFailed": "Синхронизация не удалась: {error}", + "notifySyncFailures": "Уведомлять об ошибках синхронизации", + "notifyConflicts": "Уведомлять о конфликтах", + "notifyDueToday": "Уведомлять о задачах на сегодня", + "eventReminders": "Напоминания о событиях", + "taskReminders": "Напоминания о задачах", + "notificationDetailLevel": "Подробность уведомлений", + "notificationDetailPrivate": "Конфиденциальные", + "notificationDetailNormal": "Обычные", + "quietHours": "Период тишины", + "quietHoursDescription": "Приостановить уведомления на этот период.", + "quietHoursStart": "Начало периода тишины", + "quietHoursEnd": "Конец периода тишины", + "notifications": "Уведомления", + "appearance": "Внешний вид", + "theme": "Тема", + "themeSystem": "Системная", + "themeLight": "Светлая", + "themeDark": "Тёмная", + "themeFamily": "Семейство тем", + "themeFamilyYaru": "Родная тема Ubuntu (Yaru)", + "localization": "Локализация", + "currentLocale": "Текущий языковой стандарт", + "privacy": "Конфиденциальность", + "redactTaskContentInDiagnostics": "Скрывать содержимое задач в диагностике", + "developerDiagnostics": "Диагностика для разработчиков", + "diagnostics": "Диагностика", + "apiInspectorDisabled": "Показать инспектор API", + "googleTasksApi": "API Google Tasks", + "discoveryRevision": "Версия Discovery: {revision}", + "implementedMethods": "Реализованные методы", + "supportsTasksScopes": "Поддерживает области разрешений tasks и tasks.readonly", + "requiresTasksScope": "Требуется область разрешений tasks", + "blockedPendingOperations": "Заблокированные ожидающие операции", + "signInToInspectPendingOperations": "Войдите, чтобы просмотреть ожидающие операции.", + "noBlockedPendingOperations": "Заблокированных ожидающих операций нет.", + "operationActions": "Действия с операцией", + "pendingOpListId": "список={id}", + "pendingOpTaskId": "задача={id}", + "pendingOpAttempts": "попытки={count}", + "retry": "Повторить", + "discard": "Отбросить", + "discardChanges": "Отбросить изменения?", + "discardChangesConfirmation": "Несохранённые изменения этой задачи будут отброшены.", + "retryCompleted": "Повторная попытка завершена.", + "discardPendingOperation": "Отбросить ожидающую операцию?", + "discardPendingOperationConfirmation": "Заблокированная локальная операция будет удалена. При следующей синхронизации данные будут заново загружены из Google Tasks.", + "pendingOperationDiscarded": "Ожидающая операция отброшена.", + "syncFailureNotificationTitle": "Сбой синхронизации BusyMax", + "syncFailureNotificationBody": "Сбой фоновой синхронизации. {message}", + "conflictNotificationTitle": "Конфликт синхронизации BusyMax", + "conflictNotificationBody": "Ожидающее локальное изменение было заблокировано. {summary}", + "dueTodayNotificationTitle": "Задачи на сегодня", + "dueTodayNotificationBody": "{count, plural, one{Сегодня нужно выполнить {count} задачу.} few{Сегодня нужно выполнить {count} задачи.} many{Сегодня нужно выполнить {count} задач.} other{Сегодня нужно выполнить {count} задачи.}}", + "eventReminderNotificationTitle": "Напоминание о событии", + "taskReminderNotificationTitle": "Напоминание о задаче", + "eventReminderNotificationBody": "Событие скоро начнётся.", + "taskReminderNotificationBody": "Срок выполнения задачи скоро наступит.", + "notificationOpenAction": "Открыть", + "notificationDetailsHidden": "Сведения скрыты настройками конфиденциальности.", + "previousMonth": "Предыдущий месяц", + "nextMonth": "Следующий месяц", + "openMonthView": "Открыть представление месяца", + "previousYear": "Предыдущий год", + "nextYear": "Следующий год", + "openYearView": "Открыть представление года", + "weekNumberTooltip": "Неделя {number}", + "resizeAllDayPanel": "Изменить размер панели событий на весь день", + "scheduleItemCount": "{count, plural, one{{count} элемент} few{{count} элемента} many{{count} элементов} other{{count} элемента}}", + "readOnlyCalendar": "Этот календарь доступен только для чтения.", + "selectTimeZone": "Выберите часовой пояс", + "searchLocations": "Поиск мест", + "noLocationsFound": "Места не найдены", + "deleteCalendarConfirmation": "Удалить «{title}»?" +} diff --git a/lib/l10n/generated/app_localizations_ru.dart b/lib/l10n/generated/app_localizations_ru.dart new file mode 100644 index 0000000..0cb0373 --- /dev/null +++ b/lib/l10n/generated/app_localizations_ru.dart @@ -0,0 +1,1310 @@ +// ignore: unused_import +import 'package:intl/intl.dart' as intl; +import 'app_localizations.dart'; + +// ignore_for_file: type=lint + +/// The translations for Russian (`ru`). +class AppLocalizationsRu extends AppLocalizations { + AppLocalizationsRu([String locale = 'ru']) : super(locale); + + @override + String get appTitle => 'BusyMax'; + + @override + String get connectGoogleAccount => + 'Подключите аккаунты Google и Microsoft, чтобы синхронизировать календари и задачи.'; + + @override + String get googlePermissionsConsentNotice => + 'На экране разрешений Google выберите разрешения и для Календаря, и для Задач.'; + + @override + String get googlePermissionsRequiredRetry => + 'Необходимы разрешения для Google Календаря и Google Tasks. Повторите попытку и установите оба флажка.'; + + @override + String get finishSetup => 'Завершить настройку'; + + @override + String get continueSetup => 'Продолжить'; + + @override + String get onboardingSetupTitle => 'Настройка BusyMax'; + + @override + String get onboardingAccountsStepTitle => 'Подключите аккаунты'; + + @override + String get onboardingAccountsStepDescription => + 'Добавьте все аккаунты Google и Microsoft, которые хотите использовать. BusyMax синхронизирует календари, события, списки задач и задачи из каждого аккаунта.'; + + @override + String get onboardingPreferencesStepTitle => 'Выберите системные параметры'; + + @override + String get onboardingPreferencesStepDescription => + 'Настройте поведение на рабочем столе, напоминания, содержимое уведомлений и внешний вид, прежде чем открыть расписание.'; + + @override + String get signInWithGoogle => 'Войти через Google'; + + @override + String get signInWithMicrosoft => 'Войти через Microsoft'; + + @override + String get googleTasksProvider => 'Google Tasks'; + + @override + String get microsoftTodoProvider => 'Microsoft To Do'; + + @override + String get providerNotConfigured => 'Этот поставщик не настроен.'; + + @override + String get waitingForGoogleSignIn => 'Ожидание входа через Google...'; + + @override + String get waitingForMicrosoftSignIn => 'Ожидание входа через Microsoft...'; + + @override + String get microsoftSignInNotConfigured => + 'Вход через Microsoft не настроен. Задайте MICROSOFT_OAUTH_CLIENT_ID.'; + + @override + String get cancel => 'Отмена'; + + @override + String get close => 'Закрыть'; + + @override + String get exit => 'Выйти'; + + @override + String get options => 'Параметры'; + + @override + String get hide => 'Скрыть'; + + @override + String get show => 'Показать'; + + @override + String get export => 'Экспортировать'; + + @override + String get save => 'Сохранить'; + + @override + String get settings => 'Настройки'; + + @override + String get all => 'Все'; + + @override + String get calendarEvents => 'События'; + + @override + String get calendarTasks => 'Задачи'; + + @override + String get calendar => 'Календарь'; + + @override + String get calendars => 'Календари'; + + @override + String get newEvent => 'Новое событие'; + + @override + String get refreshCalendar => 'Обновить календарь'; + + @override + String get openInProvider => 'Открыть у поставщика'; + + @override + String get hideFromSchedule => 'Скрыть из расписания'; + + @override + String get showInSchedule => 'Показывать в расписании'; + + @override + String get noCalendarsSynced => 'Синхронизированных календарей пока нет.'; + + @override + String get allDay => 'Весь день'; + + @override + String moreItems(int count) { + return '+ ещё $count'; + } + + @override + String get noEventsOrTasks => 'Нет событий или задач'; + + @override + String get scheduleLoading => 'Загрузка расписания...'; + + @override + String get scheduleUnavailable => 'Расписание недоступно'; + + @override + String get scheduleNoSources => 'Нет видимых календарей или списков задач'; + + @override + String get scheduleNoSourcesDescription => + 'Выберите в настройках, что нужно показывать, а затем обновите расписание.'; + + @override + String get scheduleSignInRequired => 'Подключите аккаунт'; + + @override + String get scheduleSignInDescription => + 'Войдите, чтобы синхронизировать календари и задачи.'; + + @override + String get scheduleNoSearchResults => 'Подходящих событий или задач нет'; + + @override + String get scheduleNoSearchResultsDescription => + 'Попробуйте изменить запрос или сбросить текущие фильтры.'; + + @override + String get trayAgendaLoading => 'Загрузка повестки...'; + + @override + String get trayAgendaSignInRequired => 'Войдите, чтобы просмотреть повестку.'; + + @override + String get trayAgendaNoSources => 'Нет видимых календарей или списков задач.'; + + @override + String get trayAgendaOpenBusyMax => 'Открыть приложение'; + + @override + String get trayAgendaRefresh => 'Обновить'; + + @override + String get trayAgendaError => 'Повестка недоступна'; + + @override + String get compactAgendaTitle => 'Повестка'; + + @override + String get compactAgendaSubtitle => 'Предстоящие'; + + @override + String get compactAgendaOverdue => 'Просроченные'; + + @override + String get compactAgendaClear => 'На ближайшее время всё свободно'; + + @override + String get compactAgendaOpenBusyMax => 'Открыть BusyMax'; + + @override + String get compactAgendaHide => 'Скрыть'; + + @override + String get compactAgendaNewTask => 'Новая задача'; + + @override + String get compactAgendaRetry => 'Повторить'; + + @override + String get compactAgendaRefresh => 'Обновить'; + + @override + String get compactAgendaAllDay => 'Весь день'; + + @override + String get compactAgendaDueToday => 'Срок сегодня'; + + @override + String get compactAgendaDueTomorrow => 'Срок завтра'; + + @override + String compactAgendaDueOn(String date) { + return 'Срок: $date'; + } + + @override + String get compactAgendaMoreOverdue => 'Загрузить ещё просроченные задачи'; + + @override + String get agendaLoadMoreOverdue => 'Загрузить ещё просроченные задачи'; + + @override + String get agendaLoadMoreNoDate => 'Загрузить ещё задачи без даты'; + + @override + String get viewDay => 'День'; + + @override + String get viewWeek => 'Неделя'; + + @override + String get viewMonth => 'Месяц'; + + @override + String get viewYear => 'Год'; + + @override + String get viewAgenda => 'Повестка'; + + @override + String get scheduleSettings => 'Расписание'; + + @override + String get scheduleDisplaySettings => 'Отображение расписания'; + + @override + String get scheduleDisplayHoursDescription => + 'В представлениях дня и недели изначально отображается этот период. Более ранние или поздние записи при необходимости расширяют его.'; + + @override + String get scheduleDayStartsAt => 'Начало дня'; + + @override + String get scheduleDayEndsAt => 'Конец дня'; + + @override + String get sourceCalendar => 'Календарь'; + + @override + String get sourceTaskList => 'Список задач'; + + @override + String get createChoiceTitle => 'Создать'; + + @override + String get createEventAtTime => 'Событие'; + + @override + String get createTaskAtDate => 'Задача'; + + @override + String get editEvent => 'Изменить событие'; + + @override + String get eventTitle => 'Название события'; + + @override + String get location => 'Место'; + + @override + String get timeSlot => 'Интервал времени'; + + @override + String get startDateTime => 'Дата и время начала'; + + @override + String get endDateTime => 'Дата и время окончания'; + + @override + String get doesNotRepeat => 'Не повторяется'; + + @override + String get defaultReminder => 'Напоминание по умолчанию'; + + @override + String get guests => 'Гости'; + + @override + String get noGuests => 'Нет гостей'; + + @override + String get description => 'Описание'; + + @override + String get availabilityShowAs => 'Доступность / Показывать как'; + + @override + String get busy => 'Занят'; + + @override + String get visibility => 'Видимость'; + + @override + String get defaultVisibility => 'Видимость по умолчанию'; + + @override + String get conference => 'Конференция'; + + @override + String get noConference => 'Без конференции'; + + @override + String get providerCalendar => 'Календарь поставщика'; + + @override + String get formatBoldShortLabel => 'Ж'; + + @override + String get formatBoldTooltip => 'Полужирный'; + + @override + String get formatItalicShortLabel => 'К'; + + @override + String get formatItalicTooltip => 'Курсив'; + + @override + String get formatUnderlineShortLabel => 'Ч'; + + @override + String get formatUnderlineTooltip => 'Подчёркнутый'; + + @override + String reminderMinutesBefore(int minutes) { + String _temp0 = intl.Intl.pluralLogic( + minutes, + locale: localeName, + other: 'За $minutes минуты', + many: 'За $minutes минут', + few: 'За $minutes минуты', + one: 'За $minutes минуту', + ); + return '$_temp0'; + } + + @override + String get reminderAtStart => 'В момент начала'; + + @override + String reminderHoursBefore(int hours) { + String _temp0 = intl.Intl.pluralLogic( + hours, + locale: localeName, + other: 'За $hours часа', + many: 'За $hours часов', + few: 'За $hours часа', + one: 'За $hours час', + ); + return '$_temp0'; + } + + @override + String reminderDaysBefore(int days) { + String _temp0 = intl.Intl.pluralLogic( + days, + locale: localeName, + other: 'За $days дня', + many: 'За $days дней', + few: 'За $days дня', + one: 'За $days день', + ); + return '$_temp0'; + } + + @override + String get availabilityFree => 'Свободен'; + + @override + String get availabilityTentative => 'Под вопросом'; + + @override + String get availabilityOutOfOffice => 'Не на работе'; + + @override + String get availabilityWorkingElsewhere => 'Работает в другом месте'; + + @override + String get visibilityDefault => 'По умолчанию'; + + @override + String get visibilityPublic => 'Общедоступное'; + + @override + String get visibilityPrivate => 'Личное'; + + @override + String get visibilityConfidential => 'Конфиденциальное'; + + @override + String get sensitivityNormal => 'Обычная'; + + @override + String get sensitivityPersonal => 'Личная'; + + @override + String get tasks => 'Задачи'; + + @override + String get allTasks => 'Все задачи'; + + @override + String tasksInList(String title) { + return 'Задачи в списке «$title»'; + } + + @override + String get taskLists => 'Списки задач'; + + @override + String get navigation => 'Навигация'; + + @override + String get mainMenu => 'Главное меню'; + + @override + String get keyboardShortcuts => 'Сочетания клавиш'; + + @override + String get shortcutGroupGeneral => 'Общие'; + + @override + String get shortcutKeyboardShortcutsDescription => + 'Показать эту справку по сочетаниям клавиш'; + + @override + String get shortcutGroupNavigation => 'Навигация'; + + @override + String get shortcutNextPeriod => 'Следующий период'; + + @override + String get shortcutNextPeriodDescription => + 'Следующая неделя в представлении недели, следующий месяц в представлении месяца и так далее'; + + @override + String get shortcutPreviousPeriod => 'Предыдущий период'; + + @override + String get shortcutPreviousPeriodDescription => + 'Предыдущая неделя в представлении недели, предыдущий месяц в представлении месяца и так далее'; + + @override + String get shortcutJumpToToday => 'Перейти к сегодняшнему дню'; + + @override + String get shortcutGroupView => 'Представление'; + + @override + String get shortcutDayView => 'Представление дня'; + + @override + String get shortcutWeekView => 'Представление недели'; + + @override + String get shortcutMonthView => 'Представление месяца'; + + @override + String get shortcutYearView => 'Представление года'; + + @override + String get shortcutAgendaView => 'Представление повестки'; + + @override + String get shortcutGroupCreateAndEdit => 'Создание и изменение'; + + @override + String get shortcutSaveItem => 'Сохранить событие или задачу'; + + @override + String get shortcutDeleteItem => 'Удалить событие или задачу'; + + @override + String get shortcutGroupTaskEditing => 'Изменение задач'; + + @override + String get shortcutCancelEditing => 'Отменить изменение'; + + @override + String get shortcutCancelEditingDescription => + 'Закрыть изменение задачи или сведения о ней'; + + @override + String get shortcutGroupCompactAgenda => 'Компактная повестка'; + + @override + String get shortcutRefreshCompactAgendaDescription => + 'Обновить окно компактной повестки'; + + @override + String get shortcutHideCompactAgendaDescription => + 'Скрыть окно компактной повестки'; + + @override + String get aboutBusyMax => 'О приложении BusyMax'; + + @override + String get aboutBusyMaxDescription => 'Задачи и календарь'; + + @override + String get website => 'Веб-сайт'; + + @override + String get reportAnIssue => 'Сообщить о проблеме'; + + @override + String get sendFeedback => 'Отправить отзыв'; + + @override + String get feedbackSubmit => 'Отправить'; + + @override + String get feedbackCategory => 'Категория'; + + @override + String get feedbackSelectCategory => 'Выберите категорию'; + + @override + String get feedbackCategoryProblem => 'Проблема или ошибка'; + + @override + String get feedbackCategoryFeature => 'Запрос функции'; + + @override + String get feedbackCategoryPrivacySecurity => + 'Проблема конфиденциальности или безопасности'; + + @override + String get feedbackCategoryUsability => 'Проблема удобства использования'; + + @override + String get feedbackCategoryOther => 'Другое'; + + @override + String get feedbackSubject => 'Тема'; + + @override + String get feedbackDetailedMessage => 'Подробное сообщение'; + + @override + String get feedbackReplyEmail => + 'Адрес электронной почты для ответа (необязательно)'; + + @override + String get feedbackIncludeTechnicalDetails => 'Включить технические сведения'; + + @override + String get feedbackTechnicalDetailsDisclosure => + 'Будут добавлены только версия операционной системы Linux и языковой стандарт приложения. Журналы, данные аккаунтов, имена файлов и другие диагностические сведения не включаются.'; + + @override + String get feedbackCategoryRequired => 'Выберите категорию.'; + + @override + String get feedbackSubjectLengthError => + 'Тема должна содержать от 3 до 120 символов.'; + + @override + String get feedbackMessageLengthError => + 'Сообщение должно содержать от 10 до 5 000 символов.'; + + @override + String get feedbackInvalidEmail => + 'Введите действительный адрес электронной почты.'; + + @override + String get feedbackConnectionError => + 'Не удалось подключиться к BusyStack. Проверьте подключение и повторите попытку.'; + + @override + String get feedbackTimeoutError => + 'Время ожидания запроса истекло. Ваш отзыв не был удалён. Повторите попытку.'; + + @override + String get feedbackRateLimitedError => + 'Из этой сети было отправлено слишком много отзывов. Подождите и повторите попытку.'; + + @override + String get feedbackRejectedError => + 'Сервер отклонил отправку. Проверьте поля и повторите попытку.'; + + @override + String get feedbackServerError => + 'BusyStack сейчас не может принять ваш отзыв. Ваш отзыв не был удалён. Повторите попытку.'; + + @override + String feedbackSuccess(String id) { + return 'Отзыв отправлен. Номер: $id'; + } + + @override + String get toggleSidebar => 'Показать или скрыть боковую панель'; + + @override + String get accounts => 'Аккаунты'; + + @override + String get currentAccount => 'Текущий аккаунт'; + + @override + String get switchAccount => 'Сменить аккаунт'; + + @override + String get addGoogleAccount => 'Добавить аккаунт Google'; + + @override + String get addMicrosoftAccount => 'Добавить аккаунт Microsoft'; + + @override + String get googleProvider => 'Google'; + + @override + String get microsoftProvider => 'Microsoft'; + + @override + String get signedInAccount => 'Выполнен вход'; + + @override + String get removeAccount => 'Удалить аккаунт…'; + + @override + String get removingAccount => 'Удаление аккаунта…'; + + @override + String get removeAccountDescription => + 'Остановить синхронизацию и удалить данные этого аккаунта с устройства.'; + + @override + String removeAccountTitle(String account) { + return 'Удалить $account из BusyMax?'; + } + + @override + String get removeAccountConfirmation => + 'С устройства будут удалены кэшированные задачи, календари, события, напоминания и ожидающие автономные изменения. Несинхронизированные изменения будут потеряны. Из Google и Microsoft ничего не удаляется.'; + + @override + String get revokeGoogleAccess => + 'Также отозвать у BusyMax доступ к этому аккаунту Google'; + + @override + String get revokeGoogleAccessDescription => + 'Перед повторным подключением аккаунта потребуется снова предоставить доступ.'; + + @override + String get removeAccountAction => 'Удалить аккаунт'; + + @override + String get removeAccountFailed => + 'Не удалось завершить удаление аккаунта. Повторите попытку.'; + + @override + String get accountRemovedGoogleRevokeFailed => + 'Аккаунт удалён с устройства, но BusyMax не удалось отозвать доступ Google. Это можно сделать в аккаунте Google.'; + + @override + String get newList => 'Новый список'; + + @override + String get signInToViewTaskLists => + 'Войдите, чтобы просмотреть списки задач.'; + + @override + String get noTaskListsSynced => 'Синхронизированных списков задач пока нет.'; + + @override + String get listActions => 'Действия со списком'; + + @override + String get rename => 'Переименовать'; + + @override + String get delete => 'Удалить'; + + @override + String get renameList => 'Переименовать список'; + + @override + String get deleteList => 'Удалить список'; + + @override + String get builtInMicrosoftList => 'Встроенный'; + + @override + String get builtInMicrosoftListCannotRenameDelete => + 'Встроенные списки Microsoft To Do нельзя переименовывать или удалять.'; + + @override + String deleteListConfirmation(String title) { + return 'Удалить «$title» из Google Tasks?'; + } + + @override + String get deleteEvent => 'Удалить событие'; + + @override + String get title => 'Название'; + + @override + String get create => 'Создать'; + + @override + String get newTask => 'Новая задача'; + + @override + String get clearCompleted => 'Удалить завершённые'; + + @override + String get refreshList => 'Обновить список'; + + @override + String get refreshAll => 'Обновить всё'; + + @override + String get listRefreshed => 'Список обновлён.'; + + @override + String get allTasksRefreshed => 'Все аккаунты обновлены.'; + + @override + String exportedFile(String path) { + return 'Экспортировано в $path'; + } + + @override + String exportFailed(String error) { + return 'Не удалось экспортировать: $error'; + } + + @override + String refreshFailed(String error) { + return 'Не удалось обновить: $error'; + } + + @override + String get selectOrCreateTaskList => + 'Сначала выберите или создайте список задач.'; + + @override + String get signInToViewTasks => 'Войдите, чтобы просмотреть задачи.'; + + @override + String get noTasks => 'Задач нет.'; + + @override + String get noTasksYet => 'Задач пока нет'; + + @override + String get noTasksYetMessage => + 'Создайте задачу или обновите аккаунты, чтобы начать.'; + + @override + String get noTasksInList => 'В этом списке нет задач.'; + + @override + String get overdue => 'Просроченные'; + + @override + String get today => 'Сегодня'; + + @override + String get tomorrow => 'Завтра'; + + @override + String get upcoming => 'Предстоящие'; + + @override + String get noDate => 'Без даты'; + + @override + String get completed => 'Завершённые'; + + @override + String duePrefix(String date) { + return 'Срок: $date'; + } + + @override + String dateTimeDisplay(String date, String time) { + return '$date, $time'; + } + + @override + String get taskDetails => 'Сведения о задаче'; + + @override + String get editTask => 'Изменить задачу'; + + @override + String get noTaskSelected => 'Задача не выбрана.'; + + @override + String get noTaskSelectedHelper => + 'Выберите задачу, чтобы просмотреть и изменить её сведения.'; + + @override + String get taskUnavailable => 'Задача недоступна.'; + + @override + String get signInToEditTasks => 'Войдите, чтобы изменять задачи.'; + + @override + String get refreshTask => 'Обновить задачу'; + + @override + String get primarySection => 'Основные сведения'; + + @override + String get statusSection => 'Состояние'; + + @override + String get openStatus => 'Открыта'; + + @override + String get doneStatus => 'Выполнена'; + + @override + String get notes => 'Заметки'; + + @override + String get dueDate => 'Срок'; + + @override + String get clearDueDate => 'Очистить срок'; + + @override + String get dueTime => 'Время выполнения'; + + @override + String get startDate => 'Дата начала'; + + @override + String get startTime => 'Время начала'; + + @override + String get endDate => 'Дата окончания'; + + @override + String get endTime => 'Время окончания'; + + @override + String get reminderDate => 'Дата напоминания'; + + @override + String get reminderTime => 'Время напоминания'; + + @override + String get reminder => 'Напоминание'; + + @override + String get addReminder => 'Добавить напоминание'; + + @override + String get addGuest => 'Добавить гостя'; + + @override + String get addGuestEmail => 'Добавить адрес гостя'; + + @override + String get removeReminder => 'Удалить напоминание'; + + @override + String get off => 'Выкл.'; + + @override + String get repeat => 'Повтор'; + + @override + String get repeatNone => 'Не повторять'; + + @override + String get noneValue => 'Нет'; + + @override + String get repeatDaily => 'Ежедневно'; + + @override + String get repeatWeekly => 'Еженедельно'; + + @override + String get repeatMonthly => 'Ежемесячно'; + + @override + String get repeatYearly => 'Ежегодно'; + + @override + String get importance => 'Важность'; + + @override + String get importanceLow => 'Низкая'; + + @override + String get importanceNormal => 'Обычная'; + + @override + String get importanceHigh => 'Высокая'; + + @override + String get categories => 'Категории'; + + @override + String get scheduleSection => 'Расписание'; + + @override + String get dueGroup => 'Срок'; + + @override + String get startGroup => 'Начало'; + + @override + String get reminderGroup => 'Напоминание'; + + @override + String get organizationSection => 'Организация'; + + @override + String get actionsSection => 'Действия'; + + @override + String get advancedSection => 'Дополнительно'; + + @override + String get addCategory => 'Добавить категорию'; + + @override + String get list => 'Список'; + + @override + String get microsoftMoveUnsupported => + 'В этой версии перенос между списками для аккаунтов Microsoft To Do не поддерживается.'; + + @override + String get createSubtask => 'Создать подзадачу'; + + @override + String get moveToTop => 'Переместить в самый верх'; + + @override + String get deleteTask => 'Удалить задачу'; + + @override + String get newSubtask => 'Новая подзадача'; + + @override + String deleteTaskConfirmation(String title) { + return 'Удалить «$title» из Google Tasks?'; + } + + @override + String get metadata => 'Метаданные'; + + @override + String get id => 'Идентификатор'; + + @override + String get etag => 'ETag'; + + @override + String get updated => 'Обновлено'; + + @override + String get parent => 'Родительская задача'; + + @override + String get position => 'Позиция'; + + @override + String get webLink => 'Веб-ссылка'; + + @override + String get assignment => 'Назначение'; + + @override + String get localState => 'Локальное состояние'; + + @override + String get pendingSync => 'Ожидает синхронизации'; + + @override + String get synced => 'Синхронизировано'; + + @override + String get account => 'Аккаунт'; + + @override + String get sync => 'Синхронизация'; + + @override + String get manualFullSync => 'Полная синхронизация вручную'; + + @override + String get runInBackgroundWhenClosed => + 'Продолжать работу после закрытия окна'; + + @override + String get showTrayIcon => 'Показывать значок в области уведомлений'; + + @override + String get startMinimizedToTray => + 'Запускать свёрнутым в область уведомлений'; + + @override + String get requiresTrayIcon => 'Требуется значок в области уведомлений.'; + + @override + String get syncComplete => 'Синхронизация завершена.'; + + @override + String syncFailed(String error) { + return 'Синхронизация не удалась: $error'; + } + + @override + String get notifySyncFailures => 'Уведомлять об ошибках синхронизации'; + + @override + String get notifyConflicts => 'Уведомлять о конфликтах'; + + @override + String get notifyDueToday => 'Уведомлять о задачах на сегодня'; + + @override + String get eventReminders => 'Напоминания о событиях'; + + @override + String get taskReminders => 'Напоминания о задачах'; + + @override + String get notificationDetailLevel => 'Подробность уведомлений'; + + @override + String get notificationDetailPrivate => 'Конфиденциальные'; + + @override + String get notificationDetailNormal => 'Обычные'; + + @override + String get quietHours => 'Период тишины'; + + @override + String get quietHoursDescription => + 'Приостановить уведомления на этот период.'; + + @override + String get quietHoursStart => 'Начало периода тишины'; + + @override + String get quietHoursEnd => 'Конец периода тишины'; + + @override + String get notifications => 'Уведомления'; + + @override + String get appearance => 'Внешний вид'; + + @override + String get theme => 'Тема'; + + @override + String get themeSystem => 'Системная'; + + @override + String get themeLight => 'Светлая'; + + @override + String get themeDark => 'Тёмная'; + + @override + String get themeFamily => 'Семейство тем'; + + @override + String get themeFamilyYaru => 'Родная тема Ubuntu (Yaru)'; + + @override + String get localization => 'Локализация'; + + @override + String get currentLocale => 'Текущий языковой стандарт'; + + @override + String get privacy => 'Конфиденциальность'; + + @override + String get redactTaskContentInDiagnostics => + 'Скрывать содержимое задач в диагностике'; + + @override + String get developerDiagnostics => 'Диагностика для разработчиков'; + + @override + String get diagnostics => 'Диагностика'; + + @override + String get apiInspectorDisabled => 'Показать инспектор API'; + + @override + String get googleTasksApi => 'API Google Tasks'; + + @override + String discoveryRevision(String revision) { + return 'Версия Discovery: $revision'; + } + + @override + String get implementedMethods => 'Реализованные методы'; + + @override + String get supportsTasksScopes => + 'Поддерживает области разрешений tasks и tasks.readonly'; + + @override + String get requiresTasksScope => 'Требуется область разрешений tasks'; + + @override + String get blockedPendingOperations => 'Заблокированные ожидающие операции'; + + @override + String get signInToInspectPendingOperations => + 'Войдите, чтобы просмотреть ожидающие операции.'; + + @override + String get noBlockedPendingOperations => + 'Заблокированных ожидающих операций нет.'; + + @override + String get operationActions => 'Действия с операцией'; + + @override + String pendingOpListId(String id) { + return 'список=$id'; + } + + @override + String pendingOpTaskId(String id) { + return 'задача=$id'; + } + + @override + String pendingOpAttempts(int count) { + return 'попытки=$count'; + } + + @override + String get retry => 'Повторить'; + + @override + String get discard => 'Отбросить'; + + @override + String get discardChanges => 'Отбросить изменения?'; + + @override + String get discardChangesConfirmation => + 'Несохранённые изменения этой задачи будут отброшены.'; + + @override + String get retryCompleted => 'Повторная попытка завершена.'; + + @override + String get discardPendingOperation => 'Отбросить ожидающую операцию?'; + + @override + String get discardPendingOperationConfirmation => + 'Заблокированная локальная операция будет удалена. При следующей синхронизации данные будут заново загружены из Google Tasks.'; + + @override + String get pendingOperationDiscarded => 'Ожидающая операция отброшена.'; + + @override + String get syncFailureNotificationTitle => 'Сбой синхронизации BusyMax'; + + @override + String syncFailureNotificationBody(String message) { + return 'Сбой фоновой синхронизации. $message'; + } + + @override + String get conflictNotificationTitle => 'Конфликт синхронизации BusyMax'; + + @override + String conflictNotificationBody(String summary) { + return 'Ожидающее локальное изменение было заблокировано. $summary'; + } + + @override + String get dueTodayNotificationTitle => 'Задачи на сегодня'; + + @override + String dueTodayNotificationBody(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'Сегодня нужно выполнить $count задачи.', + many: 'Сегодня нужно выполнить $count задач.', + few: 'Сегодня нужно выполнить $count задачи.', + one: 'Сегодня нужно выполнить $count задачу.', + ); + return '$_temp0'; + } + + @override + String get eventReminderNotificationTitle => 'Напоминание о событии'; + + @override + String get taskReminderNotificationTitle => 'Напоминание о задаче'; + + @override + String get eventReminderNotificationBody => 'Событие скоро начнётся.'; + + @override + String get taskReminderNotificationBody => + 'Срок выполнения задачи скоро наступит.'; + + @override + String get notificationOpenAction => 'Открыть'; + + @override + String get notificationDetailsHidden => + 'Сведения скрыты настройками конфиденциальности.'; + + @override + String get previousMonth => 'Предыдущий месяц'; + + @override + String get nextMonth => 'Следующий месяц'; + + @override + String get openMonthView => 'Открыть представление месяца'; + + @override + String get previousYear => 'Предыдущий год'; + + @override + String get nextYear => 'Следующий год'; + + @override + String get openYearView => 'Открыть представление года'; + + @override + String weekNumberTooltip(int number) { + return 'Неделя $number'; + } + + @override + String get resizeAllDayPanel => 'Изменить размер панели событий на весь день'; + + @override + String scheduleItemCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count элемента', + many: '$count элементов', + few: '$count элемента', + one: '$count элемент', + ); + return '$_temp0'; + } + + @override + String get readOnlyCalendar => 'Этот календарь доступен только для чтения.'; + + @override + String get selectTimeZone => 'Выберите часовой пояс'; + + @override + String get searchLocations => 'Поиск мест'; + + @override + String get noLocationsFound => 'Места не найдены'; + + @override + String deleteCalendarConfirmation(String title) { + return 'Удалить «$title»?'; + } +} From 639efc69ac15ec668c12783d26ee69faa86d575a Mon Sep 17 00:00:00 2001 From: albert Date: Wed, 29 Jul 2026 16:32:36 -0700 Subject: [PATCH 34/73] Add locale resolution callback and refactor BusyMaxModalEditorScaffold layout --- lib/src/app/busymax_app.dart | 2 ++ lib/src/app/busymax_design.dart | 54 ++++++++++++++++++++++++--------- 2 files changed, 42 insertions(+), 14 deletions(-) diff --git a/lib/src/app/busymax_app.dart b/lib/src/app/busymax_app.dart index 069721d..ed5b4f9 100644 --- a/lib/src/app/busymax_app.dart +++ b/lib/src/app/busymax_app.dart @@ -11,6 +11,7 @@ import '../platform/linux_header_bar_configuration_synchronizer.dart'; import '../platform/linux_header_bar_service.dart'; import '../platform/linux_window_service.dart'; import '../platform/main_window_command_bridge.dart'; +import '../l10n/locale_resolution.dart'; import 'app_bootstrap.dart'; import 'app_router.dart'; import 'busymax_keyboard_shortcuts_dialog.dart'; @@ -135,6 +136,7 @@ class _BusyMaxAppState extends ConsumerState { ...AppLocalizations.localizationsDelegates, ...GlobalUbuntuLocalizations.delegates, ], + localeResolutionCallback: resolveBusyMaxLocale, supportedLocales: AppLocalizations.supportedLocales, builder: (context, child) { final l10n = AppLocalizations.of(context); diff --git a/lib/src/app/busymax_design.dart b/lib/src/app/busymax_design.dart index 1c5468e..948338d 100644 --- a/lib/src/app/busymax_design.dart +++ b/lib/src/app/busymax_design.dart @@ -1194,9 +1194,12 @@ class BusyMaxSurfaceScope extends InheritedWidget { } } -Color busyMaxGroupedSurfaceColor(BuildContext context) { +Color busyMaxGroupedSurfaceColor( + BuildContext context, { + BusyMaxSurfaceRole? parentRole, +}) { final colors = BusyMaxSurfaceColors.of(context); - final role = BusyMaxSurfaceScope.roleOf(context); + final role = parentRole ?? BusyMaxSurfaceScope.roleOf(context); if (role == BusyMaxSurfaceRole.window) { // The native bridge already resolves the opaque card role against the // window. Reuse that authoritative value exactly instead of recomputing @@ -3349,6 +3352,36 @@ class BusyMaxTimeModeRow extends StatelessWidget { } } +class BusyMaxEditorScrollBody extends StatelessWidget { + const BusyMaxEditorScrollBody({ + super.key, + required this.child, + this.maxWidth = 640, + }); + + final Widget child; + final double maxWidth; + + @override + Widget build(BuildContext context) { + return YaruScrollViewUndershoot.builder( + endUndershoot: false, + builder: (context, controller) => BusyMaxClamp( + maxWidth: maxWidth, + margin: EdgeInsets.zero, + padding: const EdgeInsets.fromLTRB( + BusyMaxSpacing.lg, + BusyMaxSpacing.headerInset, + BusyMaxSpacing.lg, + 0, + ), + controller: controller, + child: child, + ), + ); + } +} + class BusyMaxModalEditorScaffold extends StatelessWidget { const BusyMaxModalEditorScaffold({ super.key, @@ -3392,19 +3425,12 @@ class BusyMaxModalEditorScaffold extends StatelessWidget { saving: saving, cancelEnabled: cancelEnabled, ), - const SizedBox(height: BusyMaxSpacing.headerInset), Flexible( - child: SingleChildScrollView( - child: BusyMaxClamp( - maxWidth: contentMaxWidth, - margin: EdgeInsets.zero, - padding: const EdgeInsets.symmetric( - horizontal: BusyMaxSpacing.lg, - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: children, - ), + child: BusyMaxEditorScrollBody( + maxWidth: contentMaxWidth, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: children, ), ), ), From a12891ded51ccaeba60ce6c74dd1641b35ea2b3e Mon Sep 17 00:00:00 2001 From: albert Date: Wed, 29 Jul 2026 16:32:49 -0700 Subject: [PATCH 35/73] Refactor locale handling in desktop notifications and update time zone dialog styling --- .../desktop_notification_service.dart | 7 +- .../presentation/compact_agenda_app.dart | 2 + .../presentation/task_details_editor.dart | 8 +- .../time_zone_selection_dialog.dart | 5 +- test/app/busymax_grouped_surface_test.dart | 84 +++++++++++++++++++ test/app/localization_audit_test.dart | 76 +++++++++++++++++ test/app/native_ui_audit_test.dart | 7 +- .../desktop_notification_service_test.dart | 47 +++++++++++ .../presentation/task_details_pane_test.dart | 7 ++ 9 files changed, 230 insertions(+), 13 deletions(-) diff --git a/lib/src/features/notifications/desktop_notification_service.dart b/lib/src/features/notifications/desktop_notification_service.dart index 67f23b6..a4d4eb4 100644 --- a/lib/src/features/notifications/desktop_notification_service.dart +++ b/lib/src/features/notifications/desktop_notification_service.dart @@ -6,6 +6,7 @@ import 'package:desktop_notifications/desktop_notifications.dart'; import '../../../l10n/generated/app_localizations.dart'; import '../../app/app_settings.dart'; import '../../core/logging/redacting_logger.dart'; +import '../../l10n/locale_resolution.dart'; typedef DesktopNotificationActionHandler = Future Function(String action); @@ -262,9 +263,9 @@ class NotificationStrings { }); factory NotificationStrings.forLocale(Locale locale) { - final supportedLocale = AppLocalizations.supportedLocales.firstWhere( - (candidate) => candidate.languageCode == locale.languageCode, - orElse: () => const Locale('en'), + final supportedLocale = resolveBusyMaxLocale( + locale, + AppLocalizations.supportedLocales, ); return NotificationStrings.fromLocalizations( lookupAppLocalizations(supportedLocale), diff --git a/lib/src/features/schedule/presentation/compact_agenda_app.dart b/lib/src/features/schedule/presentation/compact_agenda_app.dart index 23818bd..8908db2 100644 --- a/lib/src/features/schedule/presentation/compact_agenda_app.dart +++ b/lib/src/features/schedule/presentation/compact_agenda_app.dart @@ -15,6 +15,7 @@ import '../../../app/app_bootstrap.dart'; import '../../../app/app_theme.dart'; import '../../../app/busymax_design.dart'; import '../../../app/system_accent.dart'; +import '../../../l10n/locale_resolution.dart'; import '../../../platform/gtk_font_service.dart'; import '../../../platform/busymax_window_args.dart'; import '../application/compact_agenda_data.dart'; @@ -327,6 +328,7 @@ class _BusyMaxCompactAgendaAppState ...AppLocalizations.localizationsDelegates, ...GlobalUbuntuLocalizations.delegates, ], + localeResolutionCallback: resolveBusyMaxLocale, supportedLocales: AppLocalizations.supportedLocales, home: const Scaffold( backgroundColor: Colors.transparent, diff --git a/lib/src/features/tasks/presentation/task_details_editor.dart b/lib/src/features/tasks/presentation/task_details_editor.dart index b10654b..485bb31 100644 --- a/lib/src/features/tasks/presentation/task_details_editor.dart +++ b/lib/src/features/tasks/presentation/task_details_editor.dart @@ -188,14 +188,8 @@ class _TaskDetailsEditorState extends State { onCancel: _cancel, onSave: _save, ), - const SizedBox(height: BusyMaxSpacing.headerInset), Expanded( - child: BusyMaxClamp( - maxWidth: 640, - margin: EdgeInsets.zero, - padding: const EdgeInsets.symmetric( - horizontal: BusyMaxSpacing.lg, - ), + child: BusyMaxEditorScrollBody( child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ diff --git a/lib/src/features/tasks/presentation/time_zone_selection_dialog.dart b/lib/src/features/tasks/presentation/time_zone_selection_dialog.dart index 849be75..7855128 100644 --- a/lib/src/features/tasks/presentation/time_zone_selection_dialog.dart +++ b/lib/src/features/tasks/presentation/time_zone_selection_dialog.dart @@ -43,7 +43,10 @@ Future showBusyMaxTimeZoneSelectionDialog( ), ], groupedListStyle: NativeGroupedListStyle( - surfaceColor: busyMaxGroupedSurfaceColor(context), + surfaceColor: busyMaxGroupedSurfaceColor( + context, + parentRole: BusyMaxSurfaceRole.dialog, + ), dividerColor: surfaceColors.cardShade, sectionHeaderColor: theme.colorScheme.onSurfaceVariant, primaryTextColor: theme.colorScheme.onSurface, diff --git a/test/app/busymax_grouped_surface_test.dart b/test/app/busymax_grouped_surface_test.dart index 977d073..8511c91 100644 --- a/test/app/busymax_grouped_surface_test.dart +++ b/test/app/busymax_grouped_surface_test.dart @@ -328,6 +328,45 @@ void main() { ); }); + testWidgets('dialog grouped surface stays raised in dark mode', ( + tester, + ) async { + final theme = BusyMaxYaruTheme.build( + brightness: Brightness.dark, + accentColor: const Color(0xFF3584E4), + ); + final colors = theme.extension()!; + late Color resolvedSurface; + + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: BusyMaxSurfaceScope( + role: BusyMaxSurfaceRole.window, + child: Builder( + builder: (context) { + resolvedSurface = busyMaxGroupedSurfaceColor( + context, + parentRole: BusyMaxSurfaceRole.dialog, + ); + return const SizedBox.shrink(); + }, + ), + ), + ), + ); + + expect( + resolvedSurface.toARGB32(), + Color.alphaBlend(colors.groupedSurface, colors.dialog).toARGB32(), + ); + expect(resolvedSurface, isNot(colors.dialog)); + expect( + resolvedSurface.computeLuminance(), + greaterThan(colors.dialog.computeLuminance()), + ); + }); + testWidgets('disabled grouped subtitles use the semantic disabled role', ( tester, ) async { @@ -2026,6 +2065,51 @@ void main() { expect(dialogSemantics.explicitChildNodes, isTrue); }); + testWidgets('modal editor shows Yaru undershoot below its fixed header', ( + tester, + ) async { + await tester.pumpWidget( + _testApp( + SizedBox( + height: 260, + child: BusyMaxModalEditorScaffold( + title: 'Edit event', + cancelLabel: 'Cancel', + saveLabel: 'Save', + onCancel: () {}, + onSave: null, + children: [ + for (var index = 0; index < 8; index++) + SizedBox(height: 64, child: Text('Editor row $index')), + ], + ), + ), + ), + ); + await tester.pumpAndSettle(); + + final undershoot = find.byType(YaruScrollViewUndershoot); + final shadow = find.descendant( + of: undershoot, + matching: find.byType(AnimatedOpacity), + ); + final scrollView = find.descendant( + of: undershoot, + matching: find.byType(SingleChildScrollView), + ); + final titleTop = tester.getTopLeft(find.text('Edit event')).dy; + + expect(undershoot, findsOneWidget); + expect(shadow, findsOneWidget); + expect(tester.widget(shadow).opacity, 0); + + await tester.drag(scrollView, const Offset(0, -120)); + await tester.pumpAndSettle(); + + expect(tester.widget(shadow).opacity, 1); + expect(tester.getTopLeft(find.text('Edit event')).dy, titleTop); + }); + testWidgets('dialog actions wrap at narrow localized text widths', ( tester, ) async { diff --git a/test/app/localization_audit_test.dart b/test/app/localization_audit_test.dart index b565874..9779339 100644 --- a/test/app/localization_audit_test.dart +++ b/test/app/localization_audit_test.dart @@ -2,6 +2,7 @@ import 'dart:convert'; import 'dart:io'; import 'package:busymax/l10n/generated/app_localizations.dart'; +import 'package:busymax/src/l10n/locale_resolution.dart'; import 'package:flutter/widgets.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -85,6 +86,75 @@ void main() { expect(localizations.settings, 'Definições'); expect(localizations.today, 'Hoje'); }); + + test('Hindi is generated and exposed as a supported locale', () { + const locale = Locale('hi'); + final localizations = lookupAppLocalizations(locale); + + expect(AppLocalizations.supportedLocales, contains(locale)); + expect(localizations.settings, 'सेटिंग्स'); + expect(localizations.today, 'आज'); + }); + + test('Japanese is generated and exposed as a supported locale', () { + const locale = Locale('ja'); + final localizations = lookupAppLocalizations(locale); + + expect(AppLocalizations.supportedLocales, contains(locale)); + expect(localizations.settings, '設定'); + expect(localizations.today, '今日'); + }); + + test('Korean is generated and exposed as a supported locale', () { + const locale = Locale('ko'); + final localizations = lookupAppLocalizations(locale); + + expect(AppLocalizations.supportedLocales, contains(locale)); + expect(localizations.settings, '설정'); + expect(localizations.today, '오늘'); + }); + + test('both Chinese scripts are generated and supported', () { + const simplified = Locale.fromSubtags( + languageCode: 'zh', + scriptCode: 'Hans', + ); + const traditional = Locale.fromSubtags( + languageCode: 'zh', + scriptCode: 'Hant', + ); + + expect(AppLocalizations.supportedLocales, contains(simplified)); + expect(AppLocalizations.supportedLocales, contains(traditional)); + expect(lookupAppLocalizations(simplified).settings, '设置'); + expect(lookupAppLocalizations(traditional).settings, '設定'); + }); + + test('Chinese regions resolve to the appropriate script', () { + const simplified = Locale.fromSubtags( + languageCode: 'zh', + scriptCode: 'Hans', + ); + const traditional = Locale.fromSubtags( + languageCode: 'zh', + scriptCode: 'Hant', + ); + + expect( + resolveBusyMaxLocale( + const Locale('zh', 'CN'), + AppLocalizations.supportedLocales, + ), + simplified, + ); + expect( + resolveBusyMaxLocale( + const Locale('zh', 'TW'), + AppLocalizations.supportedLocales, + ), + traditional, + ); + }); } const _auditedUiPaths = [ @@ -102,8 +172,14 @@ const _translatedArbPaths = [ 'lib/l10n/app_es.arb', 'lib/l10n/app_fi.arb', 'lib/l10n/app_fr.arb', + 'lib/l10n/app_hi.arb', + 'lib/l10n/app_ja.arb', + 'lib/l10n/app_ko.arb', 'lib/l10n/app_pt.arb', 'lib/l10n/app_ru.arb', + 'lib/l10n/app_zh.arb', + 'lib/l10n/app_zh_Hans.arb', + 'lib/l10n/app_zh_Hant.arb', ]; final _userFacingLiteralPattern = RegExp( diff --git a/test/app/native_ui_audit_test.dart b/test/app/native_ui_audit_test.dart index 1d70028..504c21e 100644 --- a/test/app/native_ui_audit_test.dart +++ b/test/app/native_ui_audit_test.dart @@ -186,6 +186,9 @@ void main() { ).readAsStringSync(); expect(design, contains('class BusyMaxClamp')); + expect(design, contains('class BusyMaxEditorScrollBody')); + expect(design, contains('YaruScrollViewUndershoot.builder(')); + expect(design, contains('endUndershoot: false')); expect(design, contains('class BusyMaxGroupedList')); expect(design, contains('class BusyMaxActionRow')); expect(design, contains('class BusyMaxComboRow')); @@ -220,7 +223,7 @@ void main() { expect(calendarRow, contains('required this.entry')); expect(calendarRow, isNot(contains('TextField('))); - expect(taskDetails, contains('BusyMaxClamp')); + expect(taskDetails, contains('BusyMaxEditorScrollBody')); expect(taskDetails, contains('BusyMaxGroupedList')); expect(taskDetails, contains('BusyMaxActionRow')); expect(taskDetails, contains('BusyMaxComboRow')); @@ -1432,7 +1435,7 @@ void main() { ); expect(selector, contains('NativeDialogService().selectTimeZone(')); expect(selector, contains('BusyMaxGroupedList(')); - expect(selector, contains('busyMaxGroupedSurfaceColor(context)')); + expect(selector, contains('parentRole: BusyMaxSurfaceRole.dialog')); expect(selector, contains('dividerColor: surfaceColors.cardShade')); expect(selector, contains('busyMaxRowHoverColor(context)')); expect(selector, contains('radius: BusyMaxRadius.md.round()')); diff --git a/test/features/notifications/desktop_notification_service_test.dart b/test/features/notifications/desktop_notification_service_test.dart index 75da512..e56ee19 100644 --- a/test/features/notifications/desktop_notification_service_test.dart +++ b/test/features/notifications/desktop_notification_service_test.dart @@ -97,6 +97,53 @@ void main() { ); }); + test('notification strings use the new Asian ARB catalogs', () async { + final cases = <({Locale locale, String summary, String body})>[ + ( + locale: const Locale('hi'), + summary: 'आज देय कार्य', + body: 'आज 2 कार्य देय हैं।', + ), + ( + locale: const Locale('ja'), + summary: '今日が期限のタスク', + body: '今日が期限のタスクが2件あります。', + ), + ( + locale: const Locale('ko'), + summary: '오늘 마감인 할 일', + body: '오늘 마감인 할 일이 2개 있습니다.', + ), + ( + locale: const Locale.fromSubtags( + languageCode: 'zh', + scriptCode: 'Hans', + ), + summary: '今天到期的任务', + body: '今天有 2 项任务到期。', + ), + ( + locale: const Locale('zh', 'TW'), + summary: '今天到期的待辦事項', + body: '今天有 2 項待辦事項到期。', + ), + ]; + + for (final testCase in cases) { + final backend = _FakeNotificationBackend(); + final service = DesktopNotificationService( + backend: backend, + settings: AppSettings.defaults().copyWith(notifyDueToday: true), + locale: testCase.locale, + ); + + await service.notifyDueToday(2); + + expect(backend.notifications.single.summary, testCase.summary); + expect(backend.notifications.single.body, testCase.body); + } + }); + test('reminder notification details are visible by default', () async { final backend = _FakeNotificationBackend(); final service = DesktopNotificationService( diff --git a/test/features/tasks/presentation/task_details_pane_test.dart b/test/features/tasks/presentation/task_details_pane_test.dart index fcff72a..3ac6a18 100644 --- a/test/features/tasks/presentation/task_details_pane_test.dart +++ b/test/features/tasks/presentation/task_details_pane_test.dart @@ -64,6 +64,13 @@ void main() { expect(find.text('Edit Task'), findsOneWidget); expect(find.text('Task details'), findsNothing); expect(find.text('Save'), findsOneWidget); + expect( + find.descendant( + of: find.byType(TaskDetailsEditor), + matching: find.byType(YaruScrollViewUndershoot), + ), + findsOneWidget, + ); }); testWidgets('Cancel and Save use natural-width themed controls', ( From 18035d620d9d7aa72a5e085557a9376545fbebb2 Mon Sep 17 00:00:00 2001 From: albert Date: Wed, 29 Jul 2026 16:33:01 -0700 Subject: [PATCH 36/73] Add locale resolution function to support Chinese script variations --- lib/src/l10n/locale_resolution.dart | 78 +++++++++++++++++++++++++++++ 1 file changed, 78 insertions(+) create mode 100644 lib/src/l10n/locale_resolution.dart diff --git a/lib/src/l10n/locale_resolution.dart b/lib/src/l10n/locale_resolution.dart new file mode 100644 index 0000000..3b7e4a8 --- /dev/null +++ b/lib/src/l10n/locale_resolution.dart @@ -0,0 +1,78 @@ +import 'package:flutter/widgets.dart'; + +const _traditionalChineseRegions = {'HK', 'MO', 'TW'}; +const _simplifiedChineseRegions = {'CN', 'MY', 'SG'}; + +Locale resolveBusyMaxLocale( + Locale? requestedLocale, + Iterable supportedLocales, +) { + final supported = supportedLocales.toList(growable: false); + final english = + _firstMatching(supported, (locale) => locale.languageCode == 'en') ?? + (supported.isNotEmpty ? supported.first : const Locale('en')); + + if (requestedLocale == null) { + return english; + } + + final exact = _firstMatching( + supported, + (locale) => locale == requestedLocale, + ); + if (exact != null) { + return exact; + } + + if (requestedLocale.languageCode == 'zh') { + final scriptCode = + requestedLocale.scriptCode ?? + _chineseScriptForRegion(requestedLocale.countryCode); + if (scriptCode != null) { + final scriptMatch = _firstMatching( + supported, + (locale) => + locale.languageCode == 'zh' && locale.scriptCode == scriptCode, + ); + if (scriptMatch != null) { + return scriptMatch; + } + } + } + + return _firstMatching( + supported, + (locale) => + locale.languageCode == requestedLocale.languageCode && + locale.scriptCode == null && + locale.countryCode == null, + ) ?? + _firstMatching( + supported, + (locale) => locale.languageCode == requestedLocale.languageCode, + ) ?? + english; +} + +String? _chineseScriptForRegion(String? countryCode) { + final normalized = countryCode?.toUpperCase(); + if (_traditionalChineseRegions.contains(normalized)) { + return 'Hant'; + } + if (_simplifiedChineseRegions.contains(normalized)) { + return 'Hans'; + } + return null; +} + +Locale? _firstMatching( + Iterable locales, + bool Function(Locale locale) predicate, +) { + for (final locale in locales) { + if (predicate(locale)) { + return locale; + } + } + return null; +} From 5c5ee3c78b034536fd6e6af2f0da963720fe17b0 Mon Sep 17 00:00:00 2001 From: albert Date: Wed, 29 Jul 2026 16:33:43 -0700 Subject: [PATCH 37/73] Add new locales, update fr and ru --- lib/l10n/app_fr.arb | 51 +- lib/l10n/app_hi.arb | 389 ++ lib/l10n/app_ja.arb | 389 ++ lib/l10n/app_ko.arb | 389 ++ lib/l10n/app_pt.arb | 389 ++ lib/l10n/app_ru.arb | 32 +- lib/l10n/app_zh.arb | 389 ++ lib/l10n/app_zh_Hans.arb | 389 ++ lib/l10n/app_zh_Hant.arb | 389 ++ lib/l10n/generated/app_localizations.dart | 89 +- lib/l10n/generated/app_localizations_fr.dart | 62 +- lib/l10n/generated/app_localizations_hi.dart | 1296 ++++++ lib/l10n/generated/app_localizations_ja.dart | 1269 ++++++ lib/l10n/generated/app_localizations_ko.dart | 1269 ++++++ lib/l10n/generated/app_localizations_pt.dart | 1306 ++++++ lib/l10n/generated/app_localizations_ru.dart | 32 +- lib/l10n/generated/app_localizations_zh.dart | 3771 ++++++++++++++++++ 17 files changed, 11820 insertions(+), 80 deletions(-) create mode 100644 lib/l10n/app_hi.arb create mode 100644 lib/l10n/app_ja.arb create mode 100644 lib/l10n/app_ko.arb create mode 100644 lib/l10n/app_pt.arb create mode 100644 lib/l10n/app_zh.arb create mode 100644 lib/l10n/app_zh_Hans.arb create mode 100644 lib/l10n/app_zh_Hant.arb create mode 100644 lib/l10n/generated/app_localizations_hi.dart create mode 100644 lib/l10n/generated/app_localizations_ja.dart create mode 100644 lib/l10n/generated/app_localizations_ko.dart create mode 100644 lib/l10n/generated/app_localizations_pt.dart create mode 100644 lib/l10n/generated/app_localizations_zh.dart diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index b0e5339..6779af4 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -10,7 +10,7 @@ "onboardingAccountsStepTitle": "Connecter des comptes", "onboardingAccountsStepDescription": "Ajoutez tous les comptes Google et Microsoft que vous voulez utiliser. BusyMax synchronise les calendriers, événements, listes de tâches et tâches de chaque compte.", "onboardingPreferencesStepTitle": "Choisir les paramètres système", - "onboardingPreferencesStepDescription": "Réglez le comportement du bureau, les rappels, le détail des notifications et l’apparence avant d’ouvrir votre planning.", + "onboardingPreferencesStepDescription": "Réglez le comportement de l’application sur le bureau, les rappels, le niveau de détail des notifications et l’apparence avant d’ouvrir votre planning.", "signInWithGoogle": "Se connecter avec Google", "signInWithMicrosoft": "Se connecter avec Microsoft", "googleTasksProvider": "Google Tasks", @@ -35,7 +35,7 @@ "calendars": "Calendriers", "newEvent": "Nouvel événement", "refreshCalendar": "Actualiser le calendrier", - "openInProvider": "Ouvrir chez le fournisseur", + "openInProvider": "Ouvrir dans le service", "hideFromSchedule": "Masquer du planning", "showInSchedule": "Afficher dans le planning", "noCalendarsSynced": "Aucun calendrier synchronisé.", @@ -79,7 +79,7 @@ "viewAgenda": "Agenda", "scheduleSettings": "Planning", "scheduleDisplaySettings": "Affichage du planning", - "scheduleDisplayHoursDescription": "Les vues Jour et Semaine s’ouvrent dans cette plage horaire. Les éléments tôt ou tardifs l’élargissent si nécessaire.", + "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 à", "sourceCalendar": "Calendrier", @@ -142,7 +142,7 @@ "shortcutNextPeriodDescription": "Semaine suivante en vue semaine, mois suivant en vue mois, etc.", "shortcutPreviousPeriod": "Période précédente", "shortcutPreviousPeriodDescription": "Semaine précédente en vue semaine, mois précédent en vue mois, etc.", - "shortcutJumpToToday": "Aller à aujourd'hui", + "shortcutJumpToToday": "Aller à la date du jour", "shortcutGroupView": "Affichage", "shortcutDayView": "Vue jour", "shortcutWeekView": "Vue semaine", @@ -159,7 +159,7 @@ "shortcutRefreshCompactAgendaDescription": "Actualiser la fenêtre d'agenda compact", "shortcutHideCompactAgendaDescription": "Masquer la fenêtre d'agenda compact", "aboutBusyMax": "À propos de BusyMax", - "aboutBusyMaxDescription": "ToDo et calendrier", + "aboutBusyMaxDescription": "Tâches et calendrier", "website": "Site web", "reportAnIssue": "Signaler un problème", "sendFeedback": "Envoyer des commentaires", @@ -205,7 +205,7 @@ "revokeGoogleAccessDescription": "Vous devrez accorder à nouveau l’accès avant de reconnecter le compte.", "removeAccountAction": "Supprimer le compte", "removeAccountFailed": "Impossible de terminer la suppression du compte. Réessayez.", - "accountRemovedGoogleRevokeFailed": "Le compte a été supprimé de cet appareil, mais BusyMax n’a pas pu révoquer l’accès Google. Vous pouvez le révoquer dans votre compte Google.", + "accountRemovedGoogleRevokeFailed": "Le compte a été supprimé de cet appareil, mais BusyMax n’a pas pu révoquer son accès à votre compte Google. Vous pouvez révoquer cet accès depuis votre compte Google.", "newList": "Nouvelle liste", "signInToViewTaskLists": "Connectez-vous pour voir les listes de tâches.", "noTaskListsSynced": "Aucune liste de tâches synchronisée.", @@ -221,7 +221,7 @@ "title": "Titre", "create": "Créer", "newTask": "Nouvelle tâche", - "clearCompleted": "Effacer les terminées", + "clearCompleted": "Effacer les tâches terminées", "refreshList": "Actualiser la liste", "refreshAll": "Tout actualiser", "listRefreshed": "Liste actualisée.", @@ -231,7 +231,7 @@ "exportFailed": "Échec de l’export : {error}", "@exportFailed": {"placeholders": {"error": {"type": "String"}}}, "refreshFailed": "Échec de l’actualisation : {error}", - "selectOrCreateTaskList": "Sélectionnez ou créez une liste de tâches.", + "selectOrCreateTaskList": "Sélectionnez ou créez une liste de tâches pour commencer.", "signInToViewTasks": "Connectez-vous pour voir les tâches.", "noTasks": "Aucune tâche.", "noTasksYet": "Aucune tâche pour le moment", @@ -295,7 +295,7 @@ "list": "Liste", "microsoftMoveUnsupported": "Le déplacement entre listes n’est pas pris en charge pour les comptes Microsoft To Do dans cette version.", "createSubtask": "Créer une sous-tâche", - "moveToTop": "Déplacer en haut", + "moveToTop": "Déplacer tout en haut", "deleteTask": "Supprimer la tâche", "newSubtask": "Nouvelle sous-tâche", "deleteTaskConfirmation": "Supprimer « {title} » de Google Tasks ?", @@ -303,10 +303,10 @@ "id": "ID", "etag": "ETag", "updated": "Mis à jour", - "parent": "Parent", + "parent": "Tâche parente", "position": "Position", "webLink": "Lien web", - "assignment": "Assignation", + "assignment": "Attribution", "localState": "État local", "pendingSync": "Synchronisation en attente", "synced": "Synchronisé", @@ -327,18 +327,18 @@ "notificationDetailLevel": "Niveau de détail des notifications", "notificationDetailPrivate": "Privé", "notificationDetailNormal": "Normal", - "quietHours": "Plages horaires silencieuses", + "quietHours": "Période de silence", "quietHoursDescription": "Mettre les notifications en pause pendant cette période.", - "quietHoursStart": "Début des plages silencieuses", - "quietHoursEnd": "Fin des plages silencieuses", + "quietHoursStart": "Début de la période de silence", + "quietHoursEnd": "Fin de la période de silence", "notifications": "Notifications", "appearance": "Apparence", "theme": "Thème", "themeSystem": "Système", "themeLight": "Clair", "themeDark": "Sombre", - "themeFamily": "Famille de thème", - "themeFamilyYaru": "Ubuntu natif (Yaru)", + "themeFamily": "Famille de thèmes", + "themeFamilyYaru": "Thème natif d’Ubuntu (Yaru)", "localization": "Localisation", "currentLocale": "Paramètres régionaux actuels", "privacy": "Confidentialité", @@ -359,19 +359,24 @@ "pendingOpTaskId": "tâche={id}", "pendingOpAttempts": "tentatives={count}", "retry": "Réessayer", - "discard": "Ignorer", - "discardChanges": "Ignorer les modifications ?", - "discardChangesConfirmation": "Cela ignore les modifications non enregistrées de cette tâche.", + "discard": "Abandonner", + "discardChanges": "Abandonner les modifications ?", + "discardChangesConfirmation": "Les modifications non enregistrées apportées à cette tâche seront perdues.", "retryCompleted": "Nouvelle tentative terminée.", - "discardPendingOperation": "Ignorer l’opération en attente ?", - "discardPendingOperationConfirmation": "Cela supprime l’opération locale bloquée. La prochaine synchronisation actualisera depuis Google Tasks.", - "pendingOperationDiscarded": "Opération en attente ignorée.", + "discardPendingOperation": "Abandonner l’opération en attente ?", + "discardPendingOperationConfirmation": "Cette action supprime l’opération locale bloquée. Lors de la prochaine synchronisation, les données seront rechargées depuis Google Tasks.", + "pendingOperationDiscarded": "Opération en attente abandonnée.", "syncFailureNotificationTitle": "Échec de la synchronisation BusyMax", "syncFailureNotificationBody": "La synchronisation en arrière-plan a échoué. {message}", "conflictNotificationTitle": "Conflit de synchronisation BusyMax", "conflictNotificationBody": "Une modification locale en attente a été bloquée. {summary}", "dueTodayNotificationTitle": "Tâches dues aujourd’hui", "dueTodayNotificationBody": "{count, plural, =1{Une tâche est due aujourd’hui.} other{{count} tâches sont dues aujourd’hui.}}", + "eventReminderNotificationTitle": "Rappel d’événement", + "taskReminderNotificationTitle": "Rappel de tâche", + "eventReminderNotificationBody": "L’événement commence bientôt.", + "taskReminderNotificationBody": "La tâche arrive bientôt à échéance.", + "notificationOpenAction": "Ouvrir", "notificationDetailsHidden": "Les détails sont masqués par les paramètres de confidentialité.", "previousMonth": "Mois précédent", "nextMonth": "Mois suivant", @@ -388,6 +393,6 @@ "selectTimeZone": "Sélectionner le fuseau horaire", "searchLocations": "Rechercher des lieux", "noLocationsFound": "Aucun lieu trouvé", - "deleteCalendarConfirmation": "Supprimer \"{title}\" ?", + "deleteCalendarConfirmation": "Supprimer « {title} » ?", "@deleteCalendarConfirmation": {"placeholders": {"title": {"type": "String"}}} } diff --git a/lib/l10n/app_hi.arb b/lib/l10n/app_hi.arb new file mode 100644 index 0000000..0210ee9 --- /dev/null +++ b/lib/l10n/app_hi.arb @@ -0,0 +1,389 @@ +{ + "@@locale": "hi", + "appTitle": "BusyMax", + "connectGoogleAccount": "कैलेंडर और कार्य सिंक करने के लिए Google और Microsoft खाते कनेक्ट करें।", + "googlePermissionsConsentNotice": "Google अनुमति स्क्रीन पर, कैलेंडर और कार्य दोनों अनुमतियाँ चुनें।", + "googlePermissionsRequiredRetry": "Google Calendar और Google Tasks की अनुमतियाँ आवश्यक हैं। फिर से कोशिश करें और दोनों चेकबॉक्स चुनें।", + "finishSetup": "सेटअप पूरा करें", + "continueSetup": "जारी रखें", + "onboardingSetupTitle": "BusyMax सेट अप करें", + "onboardingAccountsStepTitle": "खाते कनेक्ट करें", + "onboardingAccountsStepDescription": "वे सभी Google और Microsoft खाते जोड़ें जिन्हें आप उपयोग करना चाहते हैं। BusyMax प्रत्येक खाते के कैलेंडर, ईवेंट, कार्य सूचियाँ और कार्य सिंक करता है।", + "onboardingPreferencesStepTitle": "सिस्टम सेटिंग्स चुनें", + "onboardingPreferencesStepDescription": "अपना शेड्यूल खोलने से पहले डेस्कटॉप व्यवहार, रिमाइंडर, सूचना विवरण और दिखावट सेट करें।", + "signInWithGoogle": "Google से साइन इन करें", + "signInWithMicrosoft": "Microsoft से साइन इन करें", + "googleTasksProvider": "Google Tasks", + "microsoftTodoProvider": "Microsoft To Do", + "providerNotConfigured": "यह प्रदाता कॉन्फ़िगर नहीं किया गया है।", + "waitingForGoogleSignIn": "Google साइन-इन की प्रतीक्षा हो रही है...", + "waitingForMicrosoftSignIn": "Microsoft साइन-इन की प्रतीक्षा हो रही है...", + "microsoftSignInNotConfigured": "Microsoft साइन-इन कॉन्फ़िगर नहीं है। MICROSOFT_OAUTH_CLIENT_ID सेट करें।", + "cancel": "रद्द करें", + "close": "बंद करें", + "exit": "बाहर निकलें", + "options": "विकल्प", + "hide": "छिपाएँ", + "show": "दिखाएँ", + "export": "निर्यात करें", + "save": "सहेजें", + "settings": "सेटिंग्स", + "all": "सभी", + "calendarEvents": "ईवेंट", + "calendarTasks": "कार्य", + "calendar": "कैलेंडर", + "calendars": "कैलेंडर", + "newEvent": "नया ईवेंट", + "refreshCalendar": "कैलेंडर रीफ़्रेश करें", + "openInProvider": "प्रदाता में खोलें", + "hideFromSchedule": "शेड्यूल से छिपाएँ", + "showInSchedule": "शेड्यूल में दिखाएँ", + "noCalendarsSynced": "अभी तक कोई कैलेंडर सिंक नहीं हुआ है।", + "allDay": "पूरे दिन", + "moreItems": "+{count} और", + "noEventsOrTasks": "कोई ईवेंट या कार्य नहीं", + "scheduleLoading": "शेड्यूल लोड हो रहा है...", + "scheduleUnavailable": "शेड्यूल उपलब्ध नहीं है", + "scheduleNoSources": "कोई दिखाई देने वाला कैलेंडर या कार्य सूची नहीं", + "scheduleNoSourcesDescription": "सेटिंग्स में चुनें कि क्या दिखाना है, फिर रीफ़्रेश करें।", + "scheduleSignInRequired": "खाता कनेक्ट करें", + "scheduleSignInDescription": "कैलेंडर और कार्य सिंक करने के लिए साइन इन करें।", + "scheduleNoSearchResults": "कोई मिलता-जुलता ईवेंट या कार्य नहीं", + "scheduleNoSearchResultsDescription": "कोई दूसरी खोज आज़माएँ या मौजूदा फ़िल्टर हटाएँ।", + "trayAgendaLoading": "कार्यसूची लोड हो रही है...", + "trayAgendaSignInRequired": "कार्यसूची दिखाने के लिए साइन इन करें।", + "trayAgendaNoSources": "कोई दिखाई देने वाला कैलेंडर या कार्य सूची नहीं।", + "trayAgendaOpenBusyMax": "ऐप खोलें", + "trayAgendaRefresh": "रीफ़्रेश करें", + "trayAgendaError": "कार्यसूची उपलब्ध नहीं है", + "compactAgendaTitle": "कार्यसूची", + "compactAgendaSubtitle": "आगामी", + "compactAgendaOverdue": "समय सीमा बीत चुकी", + "compactAgendaClear": "अभी कुछ नहीं", + "compactAgendaOpenBusyMax": "BusyMax खोलें", + "compactAgendaHide": "छिपाएँ", + "compactAgendaNewTask": "नया कार्य", + "compactAgendaRetry": "फिर से कोशिश करें", + "compactAgendaRefresh": "रीफ़्रेश करें", + "compactAgendaAllDay": "पूरे दिन", + "compactAgendaDueToday": "आज देय", + "compactAgendaDueTomorrow": "कल देय", + "compactAgendaDueOn": "{date} को देय", + "compactAgendaMoreOverdue": "समय सीमा बीत चुके और कार्य लोड करें", + "agendaLoadMoreOverdue": "समय सीमा बीत चुके और कार्य लोड करें", + "agendaLoadMoreNoDate": "बिना तारीख वाले और कार्य लोड करें", + "viewDay": "दिन", + "viewWeek": "सप्ताह", + "viewMonth": "महीना", + "viewYear": "वर्ष", + "viewAgenda": "कार्यसूची", + "scheduleSettings": "शेड्यूल", + "scheduleDisplaySettings": "शेड्यूल प्रदर्शन", + "scheduleDisplayHoursDescription": "दिन और सप्ताह दृश्य शुरू में यह समयावधि दिखाते हैं। आवश्यकता होने पर पहले या बाद के आइटम इस सीमा को बढ़ाते हैं।", + "scheduleDayStartsAt": "दिन शुरू होता है", + "scheduleDayEndsAt": "दिन समाप्त होता है", + "sourceCalendar": "कैलेंडर", + "sourceTaskList": "कार्य सूची", + "createChoiceTitle": "बनाएँ", + "createEventAtTime": "ईवेंट", + "createTaskAtDate": "कार्य", + "editEvent": "ईवेंट संपादित करें", + "eventTitle": "ईवेंट का शीर्षक", + "location": "स्थान", + "timeSlot": "समयावधि", + "startDateTime": "शुरू होने की तारीख/समय", + "endDateTime": "समाप्त होने की तारीख/समय", + "doesNotRepeat": "दोहराया नहीं जाता", + "defaultReminder": "डिफ़ॉल्ट रिमाइंडर", + "guests": "अतिथि", + "noGuests": "कोई अतिथि नहीं", + "description": "विवरण", + "availabilityShowAs": "उपलब्धता / इस रूप में दिखाएँ", + "busy": "व्यस्त", + "visibility": "दृश्यता", + "defaultVisibility": "डिफ़ॉल्ट दृश्यता", + "conference": "कॉन्फ़्रेंस", + "noConference": "कोई कॉन्फ़्रेंस नहीं", + "providerCalendar": "प्रदाता कैलेंडर", + "formatBoldShortLabel": "B", + "formatBoldTooltip": "बोल्ड", + "formatItalicShortLabel": "I", + "formatItalicTooltip": "इटैलिक", + "formatUnderlineShortLabel": "U", + "formatUnderlineTooltip": "रेखांकित", + "reminderMinutesBefore": "{minutes, plural, =1{1 मिनट पहले} other{{minutes} मिनट पहले}}", + "reminderAtStart": "शुरू होने पर", + "reminderHoursBefore": "{hours, plural, =1{1 घंटा पहले} other{{hours} घंटे पहले}}", + "reminderDaysBefore": "{days, plural, =1{1 दिन पहले} other{{days} दिन पहले}}", + "availabilityFree": "खाली", + "availabilityTentative": "अस्थायी", + "availabilityOutOfOffice": "कार्यालय से बाहर", + "availabilityWorkingElsewhere": "किसी अन्य स्थान पर कार्यरत", + "visibilityDefault": "डिफ़ॉल्ट", + "visibilityPublic": "सार्वजनिक", + "visibilityPrivate": "निजी", + "visibilityConfidential": "गोपनीय", + "sensitivityNormal": "सामान्य", + "sensitivityPersonal": "व्यक्तिगत", + "tasks": "कार्य", + "allTasks": "सभी कार्य", + "tasksInList": "{title} में कार्य", + "taskLists": "कार्य सूचियाँ", + "navigation": "नेविगेशन", + "mainMenu": "मुख्य मेन्यू", + "keyboardShortcuts": "कीबोर्ड शॉर्टकट", + "shortcutGroupGeneral": "सामान्य", + "shortcutKeyboardShortcutsDescription": "यह शॉर्टकट संदर्भ दिखाएँ", + "shortcutGroupNavigation": "नेविगेशन", + "shortcutNextPeriod": "अगली अवधि", + "shortcutNextPeriodDescription": "सप्ताह दृश्य में अगला सप्ताह, महीने के दृश्य में अगला महीना, इत्यादि", + "shortcutPreviousPeriod": "पिछली अवधि", + "shortcutPreviousPeriodDescription": "सप्ताह दृश्य में पिछला सप्ताह, महीने के दृश्य में पिछला महीना, इत्यादि", + "shortcutJumpToToday": "आज पर जाएँ", + "shortcutGroupView": "दृश्य", + "shortcutDayView": "दिन का दृश्य", + "shortcutWeekView": "सप्ताह का दृश्य", + "shortcutMonthView": "महीने का दृश्य", + "shortcutYearView": "वर्ष का दृश्य", + "shortcutAgendaView": "कार्यसूची दृश्य", + "shortcutGroupCreateAndEdit": "बनाएँ और संपादित करें", + "shortcutSaveItem": "ईवेंट या कार्य सहेजें", + "shortcutDeleteItem": "ईवेंट या कार्य मिटाएँ", + "shortcutGroupTaskEditing": "कार्य संपादन", + "shortcutCancelEditing": "संपादन रद्द करें", + "shortcutCancelEditingDescription": "कार्य संपादन या कार्य विवरण बंद करें", + "shortcutGroupCompactAgenda": "संक्षिप्त कार्यसूची", + "shortcutRefreshCompactAgendaDescription": "संक्षिप्त कार्यसूची विंडो रीफ़्रेश करें", + "shortcutHideCompactAgendaDescription": "संक्षिप्त कार्यसूची विंडो छिपाएँ", + "aboutBusyMax": "BusyMax के बारे में", + "aboutBusyMaxDescription": "कार्य और कैलेंडर", + "website": "वेबसाइट", + "reportAnIssue": "समस्या की रिपोर्ट करें", + "sendFeedback": "प्रतिक्रिया भेजें", + "feedbackSubmit": "सबमिट करें", + "feedbackCategory": "श्रेणी", + "feedbackSelectCategory": "श्रेणी चुनें", + "feedbackCategoryProblem": "समस्या या बग", + "feedbackCategoryFeature": "सुविधा का अनुरोध", + "feedbackCategoryPrivacySecurity": "गोपनीयता या सुरक्षा संबंधी चिंता", + "feedbackCategoryUsability": "उपयोगिता संबंधी चिंता", + "feedbackCategoryOther": "अन्य", + "feedbackSubject": "विषय", + "feedbackDetailedMessage": "विस्तृत संदेश", + "feedbackReplyEmail": "जवाब के लिए ईमेल (वैकल्पिक)", + "feedbackIncludeTechnicalDetails": "तकनीकी विवरण शामिल करें", + "feedbackTechnicalDetailsDisclosure": "केवल आपके Linux ऑपरेटिंग सिस्टम का संस्करण और ऐप का स्थान-भाषा जोड़ा जाता है। कोई लॉग, खाता डेटा, फ़ाइल नाम या अन्य निदान शामिल नहीं किया जाता।", + "feedbackCategoryRequired": "श्रेणी चुनें।", + "feedbackSubjectLengthError": "विषय 3 से 120 वर्णों के बीच होना चाहिए।", + "feedbackMessageLengthError": "संदेश 10 से 5,000 वर्णों के बीच होना चाहिए।", + "feedbackInvalidEmail": "मान्य ईमेल पता दर्ज करें।", + "feedbackConnectionError": "BusyStack से कनेक्ट नहीं हो सका। अपना कनेक्शन जाँचें और फिर कोशिश करें।", + "feedbackTimeoutError": "अनुरोध का समय समाप्त हो गया। आपकी प्रतिक्रिया हटाई नहीं गई है; फिर से कोशिश करें।", + "feedbackRateLimitedError": "इस नेटवर्क से बहुत अधिक प्रतिक्रियाएँ भेजी गई हैं। प्रतीक्षा करें और फिर कोशिश करें।", + "feedbackRejectedError": "सर्वर ने सबमिशन अस्वीकार कर दिया। फ़ील्ड की समीक्षा करें और फिर कोशिश करें।", + "feedbackServerError": "BusyStack अभी आपकी प्रतिक्रिया स्वीकार नहीं कर सका। आपकी प्रतिक्रिया हटाई नहीं गई है; फिर से कोशिश करें।", + "feedbackSuccess": "प्रतिक्रिया भेज दी गई। संदर्भ: {id}", + "toggleSidebar": "साइडबार दिखाएँ या छिपाएँ", + "accounts": "खाते", + "currentAccount": "मौजूदा खाता", + "switchAccount": "खाता बदलें", + "addGoogleAccount": "Google खाता जोड़ें", + "addMicrosoftAccount": "Microsoft खाता जोड़ें", + "googleProvider": "Google", + "microsoftProvider": "Microsoft", + "signedInAccount": "साइन इन है", + "removeAccount": "खाता हटाएँ…", + "removingAccount": "खाता हटाया जा रहा है…", + "removeAccountDescription": "सिंक करना बंद करें और इस डिवाइस से इस खाते का डेटा हटाएँ।", + "removeAccountTitle": "BusyMax से {account} हटाएँ?", + "removeAccountConfirmation": "इससे कैश किए गए कार्य, कैलेंडर, ईवेंट, रिमाइंडर और लंबित ऑफ़लाइन बदलाव इस डिवाइस से मिट जाएँगे। सिंक न किए गए बदलाव खो जाएँगे। Google या Microsoft से कुछ भी नहीं मिटेगा।", + "revokeGoogleAccess": "इस Google खाते से BusyMax की पहुँच भी रद्द करें", + "revokeGoogleAccessDescription": "दोबारा कनेक्ट करने से पहले आपको फिर से पहुँच देनी होगी।", + "removeAccountAction": "खाता हटाएँ", + "removeAccountFailed": "खाता हटाना पूरा नहीं हो सका। फिर से कोशिश करें।", + "accountRemovedGoogleRevokeFailed": "खाता इस डिवाइस से हटा दिया गया, लेकिन BusyMax Google की पहुँच रद्द नहीं कर सका। आप इसे अपने Google खाते से रद्द कर सकते हैं।", + "newList": "नई सूची", + "signInToViewTaskLists": "कार्य सूचियाँ देखने के लिए साइन इन करें।", + "noTaskListsSynced": "अभी तक कोई कार्य सूची सिंक नहीं हुई है।", + "listActions": "सूची की कार्रवाइयाँ", + "rename": "नाम बदलें", + "delete": "मिटाएँ", + "renameList": "सूची का नाम बदलें", + "deleteList": "सूची मिटाएँ", + "builtInMicrosoftList": "अंतर्निहित", + "builtInMicrosoftListCannotRenameDelete": "Microsoft To Do की अंतर्निहित सूचियों का नाम बदला या उन्हें मिटाया नहीं जा सकता।", + "deleteListConfirmation": "Google Tasks से “{title}” मिटाएँ?", + "deleteEvent": "ईवेंट मिटाएँ", + "title": "शीर्षक", + "create": "बनाएँ", + "newTask": "नया कार्य", + "clearCompleted": "पूरे हुए कार्य हटाएँ", + "refreshList": "सूची रीफ़्रेश करें", + "refreshAll": "सभी रीफ़्रेश करें", + "listRefreshed": "सूची रीफ़्रेश हो गई।", + "allTasksRefreshed": "सभी खाते रीफ़्रेश हो गए।", + "exportedFile": "{path} में निर्यात किया गया", + "exportFailed": "निर्यात विफल: {error}", + "refreshFailed": "रीफ़्रेश विफल: {error}", + "selectOrCreateTaskList": "शुरू करने के लिए कार्य सूची चुनें या बनाएँ।", + "signInToViewTasks": "कार्य देखने के लिए साइन इन करें।", + "noTasks": "कोई कार्य नहीं।", + "noTasksYet": "अभी तक कोई कार्य नहीं", + "noTasksYetMessage": "शुरू करने के लिए कार्य बनाएँ या अपने खाते रीफ़्रेश करें।", + "noTasksInList": "इस सूची में कोई कार्य नहीं है।", + "overdue": "समय सीमा बीत चुकी", + "today": "आज", + "tomorrow": "कल", + "upcoming": "आगामी", + "noDate": "कोई तारीख नहीं", + "completed": "पूर्ण", + "duePrefix": "{date} को देय", + "dateTimeDisplay": "{date} · {time}", + "taskDetails": "कार्य का विवरण", + "editTask": "कार्य संपादित करें", + "noTaskSelected": "कोई कार्य नहीं चुना गया।", + "noTaskSelectedHelper": "विवरण देखने और संपादित करने के लिए कोई कार्य चुनें।", + "taskUnavailable": "कार्य उपलब्ध नहीं है।", + "signInToEditTasks": "कार्य संपादित करने के लिए साइन इन करें।", + "refreshTask": "कार्य रीफ़्रेश करें", + "primarySection": "मुख्य", + "statusSection": "स्थिति", + "openStatus": "खुला", + "doneStatus": "पूर्ण", + "notes": "नोट्स", + "dueDate": "देय तारीख", + "clearDueDate": "देय तारीख हटाएँ", + "dueTime": "देय समय", + "startDate": "शुरू होने की तारीख", + "startTime": "शुरू होने का समय", + "endDate": "समाप्ति तारीख", + "endTime": "समाप्ति समय", + "reminderDate": "रिमाइंडर की तारीख", + "reminderTime": "रिमाइंडर का समय", + "reminder": "रिमाइंडर", + "addReminder": "रिमाइंडर जोड़ें", + "addGuest": "अतिथि जोड़ें", + "addGuestEmail": "अतिथि का ईमेल जोड़ें", + "removeReminder": "रिमाइंडर हटाएँ", + "off": "बंद", + "repeat": "दोहराएँ", + "repeatNone": "कभी नहीं", + "noneValue": "कोई नहीं", + "repeatDaily": "प्रतिदिन", + "repeatWeekly": "हर सप्ताह", + "repeatMonthly": "हर महीने", + "repeatYearly": "हर वर्ष", + "importance": "महत्त्व", + "importanceLow": "कम", + "importanceNormal": "सामान्य", + "importanceHigh": "अधिक", + "categories": "श्रेणियाँ", + "scheduleSection": "शेड्यूल", + "dueGroup": "देय", + "startGroup": "शुरुआत", + "reminderGroup": "रिमाइंडर", + "organizationSection": "व्यवस्था", + "actionsSection": "कार्रवाइयाँ", + "advancedSection": "उन्नत", + "addCategory": "श्रेणी जोड़ें", + "list": "सूची", + "microsoftMoveUnsupported": "इस संस्करण में Microsoft To Do खातों के लिए सूचियों के बीच कार्य ले जाना समर्थित नहीं है।", + "createSubtask": "उपकार्य बनाएँ", + "moveToTop": "सबसे ऊपर ले जाएँ", + "deleteTask": "कार्य मिटाएँ", + "newSubtask": "नया उपकार्य", + "deleteTaskConfirmation": "Google Tasks से “{title}” मिटाएँ?", + "metadata": "मेटाडेटा", + "id": "आईडी", + "etag": "ETag", + "updated": "अपडेट किया गया", + "parent": "मूल कार्य", + "position": "स्थान", + "webLink": "वेब लिंक", + "assignment": "असाइनमेंट", + "localState": "स्थानीय स्थिति", + "pendingSync": "सिंक लंबित", + "synced": "सिंक किया गया", + "account": "खाता", + "sync": "सिंक", + "manualFullSync": "मैन्युअल पूर्ण सिंक", + "runInBackgroundWhenClosed": "विंडो बंद होने पर भी चलते रहें", + "showTrayIcon": "ट्रे आइकन दिखाएँ", + "startMinimizedToTray": "ट्रे में छोटा होकर शुरू करें", + "requiresTrayIcon": "ट्रे आइकन आवश्यक है।", + "syncComplete": "सिंक पूरा हुआ।", + "syncFailed": "सिंक विफल: {error}", + "notifySyncFailures": "सिंक विफल होने की सूचनाएँ", + "notifyConflicts": "टकराव की सूचनाएँ", + "notifyDueToday": "आज देय कार्यों की सूचनाएँ", + "eventReminders": "ईवेंट रिमाइंडर", + "taskReminders": "कार्य रिमाइंडर", + "notificationDetailLevel": "सूचना विवरण का स्तर", + "notificationDetailPrivate": "निजी", + "notificationDetailNormal": "सामान्य", + "quietHours": "शांत समय", + "quietHoursDescription": "इस अवधि के दौरान सूचनाएँ रोकें।", + "quietHoursStart": "शांत समय की शुरुआत", + "quietHoursEnd": "शांत समय की समाप्ति", + "notifications": "सूचनाएँ", + "appearance": "दिखावट", + "theme": "थीम", + "themeSystem": "सिस्टम", + "themeLight": "हल्की", + "themeDark": "गहरी", + "themeFamily": "थीम परिवार", + "themeFamilyYaru": "मूल Ubuntu (Yaru)", + "localization": "स्थानीयकरण", + "currentLocale": "मौजूदा स्थान-भाषा", + "privacy": "गोपनीयता", + "redactTaskContentInDiagnostics": "निदान में कार्य सामग्री छिपाएँ", + "developerDiagnostics": "डेवलपर निदान", + "diagnostics": "निदान", + "apiInspectorDisabled": "API इंस्पेक्टर दिखाएँ", + "googleTasksApi": "Google Tasks API", + "discoveryRevision": "डिस्कवरी संशोधन: {revision}", + "implementedMethods": "लागू की गई विधियाँ", + "supportsTasksScopes": "tasks और tasks.readonly स्कोप समर्थित हैं", + "requiresTasksScope": "tasks स्कोप आवश्यक है", + "blockedPendingOperations": "अवरुद्ध लंबित कार्रवाइयाँ", + "signInToInspectPendingOperations": "लंबित कार्रवाइयाँ देखने के लिए साइन इन करें।", + "noBlockedPendingOperations": "कोई अवरुद्ध लंबित कार्रवाई नहीं है।", + "operationActions": "कार्रवाई के विकल्प", + "pendingOpListId": "सूची={id}", + "pendingOpTaskId": "कार्य={id}", + "pendingOpAttempts": "प्रयास={count}", + "retry": "फिर से कोशिश करें", + "discard": "छोड़ें", + "discardChanges": "बदलाव छोड़ें?", + "discardChangesConfirmation": "इससे इस कार्य के सहेजे न गए बदलाव छोड़ दिए जाएँगे।", + "retryCompleted": "दोबारा प्रयास पूरा हुआ।", + "discardPendingOperation": "लंबित कार्रवाई छोड़ें?", + "discardPendingOperationConfirmation": "इससे अवरुद्ध स्थानीय कार्रवाई हट जाती है। अगला सिंक Google Tasks से डेटा रीफ़्रेश करेगा।", + "pendingOperationDiscarded": "लंबित कार्रवाई छोड़ दी गई।", + "syncFailureNotificationTitle": "BusyMax सिंक विफल", + "syncFailureNotificationBody": "बैकग्राउंड सिंक विफल हुआ। {message}", + "conflictNotificationTitle": "BusyMax सिंक टकराव", + "conflictNotificationBody": "एक लंबित स्थानीय बदलाव अवरुद्ध हो गया। {summary}", + "dueTodayNotificationTitle": "आज देय कार्य", + "dueTodayNotificationBody": "{count, plural, =1{आज एक कार्य देय है।} other{आज {count} कार्य देय हैं।}}", + "eventReminderNotificationTitle": "ईवेंट रिमाइंडर", + "taskReminderNotificationTitle": "कार्य रिमाइंडर", + "eventReminderNotificationBody": "ईवेंट जल्द शुरू होगा।", + "taskReminderNotificationBody": "कार्य जल्द देय है।", + "notificationOpenAction": "खोलें", + "notificationDetailsHidden": "गोपनीयता सेटिंग्स के कारण विवरण छिपे हुए हैं।", + "previousMonth": "पिछला महीना", + "nextMonth": "अगला महीना", + "openMonthView": "महीने का दृश्य खोलें", + "previousYear": "पिछला वर्ष", + "nextYear": "अगला वर्ष", + "openYearView": "वर्ष का दृश्य खोलें", + "weekNumberTooltip": "सप्ताह {number}", + "resizeAllDayPanel": "पूरे दिन वाले पैनल का आकार बदलें", + "scheduleItemCount": "{count, plural, =1{1 आइटम} other{{count} आइटम}}", + "readOnlyCalendar": "यह कैलेंडर केवल पढ़ने योग्य है।", + "selectTimeZone": "समय क्षेत्र चुनें", + "searchLocations": "स्थान खोजें", + "noLocationsFound": "कोई स्थान नहीं मिला", + "deleteCalendarConfirmation": "“{title}” मिटाएँ?" +} diff --git a/lib/l10n/app_ja.arb b/lib/l10n/app_ja.arb new file mode 100644 index 0000000..b460770 --- /dev/null +++ b/lib/l10n/app_ja.arb @@ -0,0 +1,389 @@ +{ + "@@locale": "ja", + "appTitle": "BusyMax", + "connectGoogleAccount": "Google と Microsoft のアカウントを接続して、カレンダーとタスクを同期します。", + "googlePermissionsConsentNotice": "Google の権限画面で、カレンダーとタスクの両方の権限を選択してください。", + "googlePermissionsRequiredRetry": "Google カレンダーと Google Tasks の権限が必要です。もう一度試して、両方のチェックボックスを選択してください。", + "finishSetup": "セットアップを完了", + "continueSetup": "続行", + "onboardingSetupTitle": "BusyMax をセットアップ", + "onboardingAccountsStepTitle": "アカウントを接続", + "onboardingAccountsStepDescription": "使用するすべての Google アカウントと Microsoft アカウントを追加してください。BusyMax は各アカウントのカレンダー、予定、タスクリスト、タスクを同期します。", + "onboardingPreferencesStepTitle": "システム設定を選択", + "onboardingPreferencesStepDescription": "スケジュールを開く前に、デスクトップでの動作、リマインダー、通知の詳細度、外観を設定します。", + "signInWithGoogle": "Google でサインイン", + "signInWithMicrosoft": "Microsoft でサインイン", + "googleTasksProvider": "Google Tasks", + "microsoftTodoProvider": "Microsoft To Do", + "providerNotConfigured": "このプロバイダーは設定されていません。", + "waitingForGoogleSignIn": "Google のサインインを待機しています...", + "waitingForMicrosoftSignIn": "Microsoft のサインインを待機しています...", + "microsoftSignInNotConfigured": "Microsoft のサインインが設定されていません。MICROSOFT_OAUTH_CLIENT_ID を設定してください。", + "cancel": "キャンセル", + "close": "閉じる", + "exit": "終了", + "options": "オプション", + "hide": "非表示", + "show": "表示", + "export": "エクスポート", + "save": "保存", + "settings": "設定", + "all": "すべて", + "calendarEvents": "予定", + "calendarTasks": "タスク", + "calendar": "カレンダー", + "calendars": "カレンダー", + "newEvent": "新しい予定", + "refreshCalendar": "カレンダーを更新", + "openInProvider": "プロバイダーで開く", + "hideFromSchedule": "スケジュールから非表示", + "showInSchedule": "スケジュールに表示", + "noCalendarsSynced": "同期済みのカレンダーはまだありません。", + "allDay": "終日", + "moreItems": "他 {count} 件", + "noEventsOrTasks": "予定またはタスクはありません", + "scheduleLoading": "スケジュールを読み込んでいます...", + "scheduleUnavailable": "スケジュールを利用できません", + "scheduleNoSources": "表示できるカレンダーまたはタスクリストがありません", + "scheduleNoSourcesDescription": "設定で表示する項目を選択してから、更新してください。", + "scheduleSignInRequired": "アカウントを接続", + "scheduleSignInDescription": "カレンダーとタスクを同期するにはサインインしてください。", + "scheduleNoSearchResults": "一致する予定またはタスクはありません", + "scheduleNoSearchResultsDescription": "別の条件で検索するか、現在のフィルターを解除してください。", + "trayAgendaLoading": "予定一覧を読み込んでいます...", + "trayAgendaSignInRequired": "予定一覧を表示するにはサインインしてください。", + "trayAgendaNoSources": "表示できるカレンダーまたはタスクリストがありません。", + "trayAgendaOpenBusyMax": "アプリを開く", + "trayAgendaRefresh": "更新", + "trayAgendaError": "予定一覧を利用できません", + "compactAgendaTitle": "予定一覧", + "compactAgendaSubtitle": "今後の予定", + "compactAgendaOverdue": "期限超過", + "compactAgendaClear": "今のところ予定なし", + "compactAgendaOpenBusyMax": "BusyMax を開く", + "compactAgendaHide": "非表示", + "compactAgendaNewTask": "新しいタスク", + "compactAgendaRetry": "再試行", + "compactAgendaRefresh": "更新", + "compactAgendaAllDay": "終日", + "compactAgendaDueToday": "今日が期限", + "compactAgendaDueTomorrow": "明日が期限", + "compactAgendaDueOn": "期限: {date}", + "compactAgendaMoreOverdue": "期限切れのタスクをさらに読み込む", + "agendaLoadMoreOverdue": "期限切れのタスクをさらに読み込む", + "agendaLoadMoreNoDate": "日付のないタスクをさらに読み込む", + "viewDay": "日", + "viewWeek": "週", + "viewMonth": "月", + "viewYear": "年", + "viewAgenda": "予定一覧", + "scheduleSettings": "スケジュール", + "scheduleDisplaySettings": "スケジュール表示", + "scheduleDisplayHoursDescription": "日表示と週表示では、最初にこの時間範囲が表示されます。必要に応じて、範囲外の早い項目や遅い項目まで表示範囲が広がります。", + "scheduleDayStartsAt": "一日の開始時刻", + "scheduleDayEndsAt": "一日の終了時刻", + "sourceCalendar": "カレンダー", + "sourceTaskList": "タスクリスト", + "createChoiceTitle": "作成", + "createEventAtTime": "予定", + "createTaskAtDate": "タスク", + "editEvent": "予定を編集", + "eventTitle": "予定のタイトル", + "location": "場所", + "timeSlot": "時間帯", + "startDateTime": "開始日時", + "endDateTime": "終了日時", + "doesNotRepeat": "繰り返さない", + "defaultReminder": "デフォルトのリマインダー", + "guests": "ゲスト", + "noGuests": "ゲストなし", + "description": "説明", + "availabilityShowAs": "空き時間情報 / 表示方法", + "busy": "予定あり", + "visibility": "公開設定", + "defaultVisibility": "デフォルトの公開設定", + "conference": "会議", + "noConference": "会議なし", + "providerCalendar": "プロバイダーのカレンダー", + "formatBoldShortLabel": "B", + "formatBoldTooltip": "太字", + "formatItalicShortLabel": "I", + "formatItalicTooltip": "斜体", + "formatUnderlineShortLabel": "U", + "formatUnderlineTooltip": "下線", + "reminderMinutesBefore": "{minutes, plural, =1{1分前} other{{minutes}分前}}", + "reminderAtStart": "開始時刻", + "reminderHoursBefore": "{hours, plural, =1{1時間前} other{{hours}時間前}}", + "reminderDaysBefore": "{days, plural, =1{1日前} other{{days}日前}}", + "availabilityFree": "空き時間", + "availabilityTentative": "仮の予定", + "availabilityOutOfOffice": "外出中", + "availabilityWorkingElsewhere": "別の場所で勤務", + "visibilityDefault": "デフォルト", + "visibilityPublic": "一般公開", + "visibilityPrivate": "非公開", + "visibilityConfidential": "機密", + "sensitivityNormal": "標準", + "sensitivityPersonal": "個人用", + "tasks": "タスク", + "allTasks": "すべてのタスク", + "tasksInList": "{title} のタスク", + "taskLists": "タスクリスト", + "navigation": "ナビゲーション", + "mainMenu": "メインメニュー", + "keyboardShortcuts": "キーボードショートカット", + "shortcutGroupGeneral": "全般", + "shortcutKeyboardShortcutsDescription": "このショートカット一覧を表示", + "shortcutGroupNavigation": "ナビゲーション", + "shortcutNextPeriod": "次の期間", + "shortcutNextPeriodDescription": "週表示では次の週、月表示では次の月というように移動します", + "shortcutPreviousPeriod": "前の期間", + "shortcutPreviousPeriodDescription": "週表示では前の週、月表示では前の月というように移動します", + "shortcutJumpToToday": "今日に移動", + "shortcutGroupView": "表示", + "shortcutDayView": "日表示", + "shortcutWeekView": "週表示", + "shortcutMonthView": "月表示", + "shortcutYearView": "年表示", + "shortcutAgendaView": "予定一覧表示", + "shortcutGroupCreateAndEdit": "作成と編集", + "shortcutSaveItem": "予定またはタスクを保存", + "shortcutDeleteItem": "予定またはタスクを削除", + "shortcutGroupTaskEditing": "タスクの編集", + "shortcutCancelEditing": "編集をキャンセル", + "shortcutCancelEditingDescription": "タスクの編集または詳細を閉じる", + "shortcutGroupCompactAgenda": "コンパクト予定一覧", + "shortcutRefreshCompactAgendaDescription": "コンパクト予定一覧ウィンドウを更新", + "shortcutHideCompactAgendaDescription": "コンパクト予定一覧ウィンドウを非表示", + "aboutBusyMax": "BusyMax について", + "aboutBusyMaxDescription": "タスクとカレンダー", + "website": "ウェブサイト", + "reportAnIssue": "問題を報告", + "sendFeedback": "フィードバックを送信", + "feedbackSubmit": "送信", + "feedbackCategory": "カテゴリー", + "feedbackSelectCategory": "カテゴリーを選択", + "feedbackCategoryProblem": "問題またはバグ", + "feedbackCategoryFeature": "機能のリクエスト", + "feedbackCategoryPrivacySecurity": "プライバシーまたはセキュリティに関する懸念", + "feedbackCategoryUsability": "使いやすさに関する懸念", + "feedbackCategoryOther": "その他", + "feedbackSubject": "件名", + "feedbackDetailedMessage": "詳しい内容", + "feedbackReplyEmail": "返信先メールアドレス(任意)", + "feedbackIncludeTechnicalDetails": "技術情報を含める", + "feedbackTechnicalDetailsDisclosure": "Linux オペレーティングシステムのバージョンとアプリのロケールのみが追加されます。ログ、アカウントデータ、ファイル名、その他の診断情報は含まれません。", + "feedbackCategoryRequired": "カテゴリーを選択してください。", + "feedbackSubjectLengthError": "件名は3文字以上120文字以下にしてください。", + "feedbackMessageLengthError": "メッセージは10文字以上5,000文字以下にしてください。", + "feedbackInvalidEmail": "有効なメールアドレスを入力してください。", + "feedbackConnectionError": "BusyStack に接続できませんでした。接続を確認して、もう一度お試しください。", + "feedbackTimeoutError": "リクエストがタイムアウトしました。フィードバックは消去されていません。もう一度お試しください。", + "feedbackRateLimitedError": "このネットワークから送信されたフィードバックが多すぎます。しばらく待ってから、もう一度お試しください。", + "feedbackRejectedError": "サーバーが送信を拒否しました。入力内容を確認して、もう一度お試しください。", + "feedbackServerError": "現在、BusyStack はフィードバックを受け付けられません。フィードバックは消去されていません。もう一度お試しください。", + "feedbackSuccess": "フィードバックを送信しました。参照番号: {id}", + "toggleSidebar": "サイドバーの表示を切り替え", + "accounts": "アカウント", + "currentAccount": "現在のアカウント", + "switchAccount": "アカウントを切り替え", + "addGoogleAccount": "Google アカウントを追加", + "addMicrosoftAccount": "Microsoft アカウントを追加", + "googleProvider": "Google", + "microsoftProvider": "Microsoft", + "signedInAccount": "サインイン済み", + "removeAccount": "アカウントを削除…", + "removingAccount": "アカウントを削除しています…", + "removeAccountDescription": "同期を停止し、このアカウントのデータをこのデバイスから削除します。", + "removeAccountTitle": "BusyMax から {account} を削除しますか?", + "removeAccountConfirmation": "このデバイスにキャッシュされたタスク、カレンダー、予定、リマインダー、保留中のオフライン変更が削除されます。同期されていない変更は失われます。Google または Microsoft から削除されるデータはありません。", + "revokeGoogleAccess": "この Google アカウントへの BusyMax のアクセス権も取り消す", + "revokeGoogleAccessDescription": "再接続する前に、もう一度アクセスを許可する必要があります。", + "removeAccountAction": "アカウントを削除", + "removeAccountFailed": "アカウントの削除を完了できませんでした。もう一度お試しください。", + "accountRemovedGoogleRevokeFailed": "アカウントはこのデバイスから削除されましたが、BusyMax は Google へのアクセス権を取り消せませんでした。Google アカウントから取り消すことができます。", + "newList": "新しいリスト", + "signInToViewTaskLists": "タスクリストを表示するにはサインインしてください。", + "noTaskListsSynced": "同期済みのタスクリストはまだありません。", + "listActions": "リストの操作", + "rename": "名前を変更", + "delete": "削除", + "renameList": "リスト名を変更", + "deleteList": "リストを削除", + "builtInMicrosoftList": "組み込み", + "builtInMicrosoftListCannotRenameDelete": "Microsoft To Do の組み込みリストは、名前の変更や削除ができません。", + "deleteListConfirmation": "Google Tasks から「{title}」を削除しますか?", + "deleteEvent": "予定を削除", + "title": "タイトル", + "create": "作成", + "newTask": "新しいタスク", + "clearCompleted": "完了済みを消去", + "refreshList": "リストを更新", + "refreshAll": "すべて更新", + "listRefreshed": "リストを更新しました。", + "allTasksRefreshed": "すべてのアカウントを更新しました。", + "exportedFile": "{path} にエクスポートしました", + "exportFailed": "エクスポートに失敗しました: {error}", + "refreshFailed": "更新に失敗しました: {error}", + "selectOrCreateTaskList": "開始するには、タスクリストを選択または作成してください。", + "signInToViewTasks": "タスクを表示するにはサインインしてください。", + "noTasks": "タスクはありません。", + "noTasksYet": "タスクはまだありません", + "noTasksYetMessage": "タスクを作成するか、アカウントを更新して始めましょう。", + "noTasksInList": "このリストにタスクはありません。", + "overdue": "期限超過", + "today": "今日", + "tomorrow": "明日", + "upcoming": "今後", + "noDate": "日付なし", + "completed": "完了", + "duePrefix": "期限: {date}", + "dateTimeDisplay": "{date} · {time}", + "taskDetails": "タスクの詳細", + "editTask": "タスクを編集", + "noTaskSelected": "タスクが選択されていません。", + "noTaskSelectedHelper": "詳細を表示して編集するタスクを選択してください。", + "taskUnavailable": "タスクを利用できません。", + "signInToEditTasks": "タスクを編集するにはサインインしてください。", + "refreshTask": "タスクを更新", + "primarySection": "基本情報", + "statusSection": "ステータス", + "openStatus": "未完了", + "doneStatus": "完了", + "notes": "メモ", + "dueDate": "期限日", + "clearDueDate": "期限日を消去", + "dueTime": "期限時刻", + "startDate": "開始日", + "startTime": "開始時刻", + "endDate": "終了日", + "endTime": "終了時刻", + "reminderDate": "リマインダーの日付", + "reminderTime": "リマインダーの時刻", + "reminder": "リマインダー", + "addReminder": "リマインダーを追加", + "addGuest": "ゲストを追加", + "addGuestEmail": "ゲストのメールアドレスを追加", + "removeReminder": "リマインダーを削除", + "off": "オフ", + "repeat": "繰り返し", + "repeatNone": "なし", + "noneValue": "なし", + "repeatDaily": "毎日", + "repeatWeekly": "毎週", + "repeatMonthly": "毎月", + "repeatYearly": "毎年", + "importance": "重要度", + "importanceLow": "低", + "importanceNormal": "標準", + "importanceHigh": "高", + "categories": "カテゴリー", + "scheduleSection": "スケジュール", + "dueGroup": "期限", + "startGroup": "開始", + "reminderGroup": "リマインダー", + "organizationSection": "整理", + "actionsSection": "操作", + "advancedSection": "詳細設定", + "addCategory": "カテゴリーを追加", + "list": "リスト", + "microsoftMoveUnsupported": "このバージョンでは、Microsoft To Do アカウントのリスト間でタスクを移動できません。", + "createSubtask": "サブタスクを作成", + "moveToTop": "一番上に移動", + "deleteTask": "タスクを削除", + "newSubtask": "新しいサブタスク", + "deleteTaskConfirmation": "Google Tasks から「{title}」を削除しますか?", + "metadata": "メタデータ", + "id": "ID", + "etag": "ETag", + "updated": "更新日時", + "parent": "親タスク", + "position": "位置", + "webLink": "ウェブリンク", + "assignment": "割り当て", + "localState": "ローカル状態", + "pendingSync": "同期待ち", + "synced": "同期済み", + "account": "アカウント", + "sync": "同期", + "manualFullSync": "手動ですべて同期", + "runInBackgroundWhenClosed": "ウィンドウを閉じてもバックグラウンドで実行を続ける", + "showTrayIcon": "トレイアイコンを表示", + "startMinimizedToTray": "トレイに最小化して起動", + "requiresTrayIcon": "トレイアイコンが必要です。", + "syncComplete": "同期が完了しました。", + "syncFailed": "同期に失敗しました: {error}", + "notifySyncFailures": "同期失敗時に通知", + "notifyConflicts": "競合時に通知", + "notifyDueToday": "今日が期限のタスクを通知", + "eventReminders": "予定のリマインダー", + "taskReminders": "タスクのリマインダー", + "notificationDetailLevel": "通知の詳細度", + "notificationDetailPrivate": "非公開", + "notificationDetailNormal": "標準", + "quietHours": "通知を停止する時間", + "quietHoursDescription": "この時間帯は通知を一時停止します。", + "quietHoursStart": "通知停止の開始時刻", + "quietHoursEnd": "通知停止の終了時刻", + "notifications": "通知", + "appearance": "外観", + "theme": "テーマ", + "themeSystem": "システム", + "themeLight": "ライト", + "themeDark": "ダーク", + "themeFamily": "テーマファミリー", + "themeFamilyYaru": "Ubuntu ネイティブ(Yaru)", + "localization": "言語と地域", + "currentLocale": "現在のロケール", + "privacy": "プライバシー", + "redactTaskContentInDiagnostics": "診断情報でタスクの内容を伏せる", + "developerDiagnostics": "開発者向け診断", + "diagnostics": "診断", + "apiInspectorDisabled": "API インスペクターを表示", + "googleTasksApi": "Google Tasks API", + "discoveryRevision": "Discovery リビジョン: {revision}", + "implementedMethods": "実装済みメソッド", + "supportsTasksScopes": "tasks および tasks.readonly スコープをサポート", + "requiresTasksScope": "tasks スコープが必要", + "blockedPendingOperations": "ブロックされた保留中の操作", + "signInToInspectPendingOperations": "保留中の操作を確認するにはサインインしてください。", + "noBlockedPendingOperations": "ブロックされた保留中の操作はありません。", + "operationActions": "操作のアクション", + "pendingOpListId": "リスト={id}", + "pendingOpTaskId": "タスク={id}", + "pendingOpAttempts": "試行回数={count}", + "retry": "再試行", + "discard": "破棄", + "discardChanges": "変更を破棄しますか?", + "discardChangesConfirmation": "このタスクの未保存の編集内容を破棄します。", + "retryCompleted": "再試行が完了しました。", + "discardPendingOperation": "保留中の操作を破棄しますか?", + "discardPendingOperationConfirmation": "ブロックされたローカル操作を削除します。次回の同期時に Google Tasks からデータが再取得されます。", + "pendingOperationDiscarded": "保留中の操作を破棄しました。", + "syncFailureNotificationTitle": "BusyMax の同期に失敗", + "syncFailureNotificationBody": "バックグラウンド同期に失敗しました。{message}", + "conflictNotificationTitle": "BusyMax の同期競合", + "conflictNotificationBody": "保留中のローカル変更がブロックされました。{summary}", + "dueTodayNotificationTitle": "今日が期限のタスク", + "dueTodayNotificationBody": "{count, plural, =1{今日が期限のタスクが1件あります。} other{今日が期限のタスクが{count}件あります。}}", + "eventReminderNotificationTitle": "予定のリマインダー", + "taskReminderNotificationTitle": "タスクのリマインダー", + "eventReminderNotificationBody": "予定がまもなく始まります。", + "taskReminderNotificationBody": "タスクの期限が近づいています。", + "notificationOpenAction": "開く", + "notificationDetailsHidden": "プライバシー設定により詳細は非表示です。", + "previousMonth": "前の月", + "nextMonth": "次の月", + "openMonthView": "月表示を開く", + "previousYear": "前の年", + "nextYear": "次の年", + "openYearView": "年表示を開く", + "weekNumberTooltip": "第{number}週", + "resizeAllDayPanel": "終日パネルのサイズを変更", + "scheduleItemCount": "{count, plural, =1{1件} other{{count}件}}", + "readOnlyCalendar": "このカレンダーは読み取り専用です。", + "selectTimeZone": "タイムゾーンを選択", + "searchLocations": "場所を検索", + "noLocationsFound": "場所が見つかりません", + "deleteCalendarConfirmation": "「{title}」を削除しますか?" +} diff --git a/lib/l10n/app_ko.arb b/lib/l10n/app_ko.arb new file mode 100644 index 0000000..67f5f99 --- /dev/null +++ b/lib/l10n/app_ko.arb @@ -0,0 +1,389 @@ +{ + "@@locale": "ko", + "appTitle": "BusyMax", + "connectGoogleAccount": "Google 및 Microsoft 계정을 연결하여 캘린더와 할 일을 동기화하세요.", + "googlePermissionsConsentNotice": "Google 권한 화면에서 캘린더와 할 일 권한을 모두 선택하세요.", + "googlePermissionsRequiredRetry": "Google Calendar 및 Google Tasks 권한이 필요합니다. 다시 시도하여 두 체크박스를 모두 선택하세요.", + "finishSetup": "설정 완료", + "continueSetup": "계속", + "onboardingSetupTitle": "BusyMax 설정", + "onboardingAccountsStepTitle": "계정 연결", + "onboardingAccountsStepDescription": "사용할 Google 및 Microsoft 계정을 모두 추가하세요. BusyMax는 각 계정의 캘린더, 일정, 할 일 목록 및 할 일을 동기화합니다.", + "onboardingPreferencesStepTitle": "시스템 설정 선택", + "onboardingPreferencesStepDescription": "일정을 열기 전에 데스크톱 동작, 미리 알림, 알림 세부 수준 및 화면 모양을 설정하세요.", + "signInWithGoogle": "Google로 로그인", + "signInWithMicrosoft": "Microsoft로 로그인", + "googleTasksProvider": "Google Tasks", + "microsoftTodoProvider": "Microsoft To Do", + "providerNotConfigured": "이 공급자는 구성되지 않았습니다.", + "waitingForGoogleSignIn": "Google 로그인을 기다리는 중...", + "waitingForMicrosoftSignIn": "Microsoft 로그인을 기다리는 중...", + "microsoftSignInNotConfigured": "Microsoft 로그인이 구성되지 않았습니다. MICROSOFT_OAUTH_CLIENT_ID를 설정하세요.", + "cancel": "취소", + "close": "닫기", + "exit": "종료", + "options": "옵션", + "hide": "숨기기", + "show": "표시", + "export": "내보내기", + "save": "저장", + "settings": "설정", + "all": "모두", + "calendarEvents": "일정", + "calendarTasks": "할 일", + "calendar": "캘린더", + "calendars": "캘린더", + "newEvent": "새 일정", + "refreshCalendar": "캘린더 새로 고침", + "openInProvider": "공급자에서 열기", + "hideFromSchedule": "일정에서 숨기기", + "showInSchedule": "일정에 표시", + "noCalendarsSynced": "아직 동기화된 캘린더가 없습니다.", + "allDay": "하루 종일", + "moreItems": "+{count}개 더 보기", + "noEventsOrTasks": "일정 또는 할 일이 없습니다", + "scheduleLoading": "일정을 불러오는 중...", + "scheduleUnavailable": "일정을 사용할 수 없습니다", + "scheduleNoSources": "표시할 캘린더 또는 할 일 목록이 없습니다", + "scheduleNoSourcesDescription": "설정에서 표시할 항목을 선택한 다음 새로 고침하세요.", + "scheduleSignInRequired": "계정 연결", + "scheduleSignInDescription": "캘린더와 할 일을 동기화하려면 로그인하세요.", + "scheduleNoSearchResults": "일치하는 일정 또는 할 일이 없습니다", + "scheduleNoSearchResultsDescription": "다른 검색어를 사용하거나 현재 필터를 지우세요.", + "trayAgendaLoading": "일정 목록을 불러오는 중...", + "trayAgendaSignInRequired": "일정 목록을 표시하려면 로그인하세요.", + "trayAgendaNoSources": "표시할 캘린더 또는 할 일 목록이 없습니다.", + "trayAgendaOpenBusyMax": "앱 열기", + "trayAgendaRefresh": "새로 고침", + "trayAgendaError": "일정 목록을 사용할 수 없습니다", + "compactAgendaTitle": "일정 목록", + "compactAgendaSubtitle": "예정", + "compactAgendaOverdue": "기한 지남", + "compactAgendaClear": "현재 예정 없음", + "compactAgendaOpenBusyMax": "BusyMax 열기", + "compactAgendaHide": "숨기기", + "compactAgendaNewTask": "새 할 일", + "compactAgendaRetry": "다시 시도", + "compactAgendaRefresh": "새로 고침", + "compactAgendaAllDay": "하루 종일", + "compactAgendaDueToday": "오늘 마감", + "compactAgendaDueTomorrow": "내일 마감", + "compactAgendaDueOn": "{date} 마감", + "compactAgendaMoreOverdue": "기한이 지난 할 일 더 불러오기", + "agendaLoadMoreOverdue": "기한이 지난 할 일 더 불러오기", + "agendaLoadMoreNoDate": "날짜 없는 할 일 더 불러오기", + "viewDay": "일", + "viewWeek": "주", + "viewMonth": "월", + "viewYear": "년", + "viewAgenda": "일정 목록", + "scheduleSettings": "일정", + "scheduleDisplaySettings": "일정 표시", + "scheduleDisplayHoursDescription": "일간 및 주간 보기는 처음에 이 시간 범위를 표시합니다. 필요한 경우 더 이르거나 늦은 항목에 맞춰 범위가 확장됩니다.", + "scheduleDayStartsAt": "하루 시작 시간", + "scheduleDayEndsAt": "하루 종료 시간", + "sourceCalendar": "캘린더", + "sourceTaskList": "할 일 목록", + "createChoiceTitle": "만들기", + "createEventAtTime": "일정", + "createTaskAtDate": "할 일", + "editEvent": "일정 편집", + "eventTitle": "일정 제목", + "location": "위치", + "timeSlot": "시간대", + "startDateTime": "시작 날짜/시간", + "endDateTime": "종료 날짜/시간", + "doesNotRepeat": "반복 안 함", + "defaultReminder": "기본 미리 알림", + "guests": "참석자", + "noGuests": "참석자 없음", + "description": "설명", + "availabilityShowAs": "상태 / 다음으로 표시", + "busy": "바쁨", + "visibility": "공개 범위", + "defaultVisibility": "기본 공개 범위", + "conference": "회의", + "noConference": "회의 없음", + "providerCalendar": "공급자 캘린더", + "formatBoldShortLabel": "B", + "formatBoldTooltip": "굵게", + "formatItalicShortLabel": "I", + "formatItalicTooltip": "기울임꼴", + "formatUnderlineShortLabel": "U", + "formatUnderlineTooltip": "밑줄", + "reminderMinutesBefore": "{minutes, plural, =1{1분 전} other{{minutes}분 전}}", + "reminderAtStart": "시작 시간", + "reminderHoursBefore": "{hours, plural, =1{1시간 전} other{{hours}시간 전}}", + "reminderDaysBefore": "{days, plural, =1{1일 전} other{{days}일 전}}", + "availabilityFree": "한가함", + "availabilityTentative": "미정", + "availabilityOutOfOffice": "부재중", + "availabilityWorkingElsewhere": "다른 장소에서 근무", + "visibilityDefault": "기본값", + "visibilityPublic": "공개", + "visibilityPrivate": "비공개", + "visibilityConfidential": "기밀", + "sensitivityNormal": "일반", + "sensitivityPersonal": "개인", + "tasks": "할 일", + "allTasks": "모든 할 일", + "tasksInList": "{title}의 할 일", + "taskLists": "할 일 목록", + "navigation": "탐색", + "mainMenu": "주 메뉴", + "keyboardShortcuts": "키보드 단축키", + "shortcutGroupGeneral": "일반", + "shortcutKeyboardShortcutsDescription": "이 단축키 도움말 표시", + "shortcutGroupNavigation": "탐색", + "shortcutNextPeriod": "다음 기간", + "shortcutNextPeriodDescription": "주간 보기에서는 다음 주, 월간 보기에서는 다음 달로 이동하는 식입니다", + "shortcutPreviousPeriod": "이전 기간", + "shortcutPreviousPeriodDescription": "주간 보기에서는 이전 주, 월간 보기에서는 이전 달로 이동하는 식입니다", + "shortcutJumpToToday": "오늘로 이동", + "shortcutGroupView": "보기", + "shortcutDayView": "일간 보기", + "shortcutWeekView": "주간 보기", + "shortcutMonthView": "월간 보기", + "shortcutYearView": "연간 보기", + "shortcutAgendaView": "일정 목록 보기", + "shortcutGroupCreateAndEdit": "만들기 및 편집", + "shortcutSaveItem": "일정 또는 할 일 저장", + "shortcutDeleteItem": "일정 또는 할 일 삭제", + "shortcutGroupTaskEditing": "할 일 편집", + "shortcutCancelEditing": "편집 취소", + "shortcutCancelEditingDescription": "할 일 편집 또는 할 일 세부 정보 닫기", + "shortcutGroupCompactAgenda": "간단 일정 목록", + "shortcutRefreshCompactAgendaDescription": "간단 일정 목록 창 새로 고침", + "shortcutHideCompactAgendaDescription": "간단 일정 목록 창 숨기기", + "aboutBusyMax": "BusyMax 정보", + "aboutBusyMaxDescription": "할 일 및 캘린더", + "website": "웹사이트", + "reportAnIssue": "문제 신고", + "sendFeedback": "의견 보내기", + "feedbackSubmit": "제출", + "feedbackCategory": "범주", + "feedbackSelectCategory": "범주 선택", + "feedbackCategoryProblem": "문제 또는 버그", + "feedbackCategoryFeature": "기능 요청", + "feedbackCategoryPrivacySecurity": "개인정보 보호 또는 보안 우려", + "feedbackCategoryUsability": "사용성 관련 의견", + "feedbackCategoryOther": "기타", + "feedbackSubject": "제목", + "feedbackDetailedMessage": "자세한 내용", + "feedbackReplyEmail": "답변 받을 이메일 주소(선택 사항)", + "feedbackIncludeTechnicalDetails": "기술 세부 정보 포함", + "feedbackTechnicalDetailsDisclosure": "Linux 운영 체제 버전과 앱 로캘만 추가됩니다. 로그, 계정 데이터, 파일 이름 또는 기타 진단 정보는 포함되지 않습니다.", + "feedbackCategoryRequired": "범주를 선택하세요.", + "feedbackSubjectLengthError": "제목은 3~120자여야 합니다.", + "feedbackMessageLengthError": "메시지는 10~5,000자여야 합니다.", + "feedbackInvalidEmail": "올바른 이메일 주소를 입력하세요.", + "feedbackConnectionError": "BusyStack에 연결할 수 없습니다. 연결을 확인하고 다시 시도하세요.", + "feedbackTimeoutError": "요청 시간이 초과되었습니다. 의견은 지워지지 않았습니다. 다시 시도하세요.", + "feedbackRateLimitedError": "이 네트워크에서 너무 많은 의견이 제출되었습니다. 잠시 기다린 후 다시 시도하세요.", + "feedbackRejectedError": "서버가 제출을 거부했습니다. 입력란을 검토하고 다시 시도하세요.", + "feedbackServerError": "현재 BusyStack에서 의견을 받을 수 없습니다. 의견은 지워지지 않았습니다. 다시 시도하세요.", + "feedbackSuccess": "의견을 보냈습니다. 참조: {id}", + "toggleSidebar": "사이드바 표시 전환", + "accounts": "계정", + "currentAccount": "현재 계정", + "switchAccount": "계정 전환", + "addGoogleAccount": "Google 계정 추가", + "addMicrosoftAccount": "Microsoft 계정 추가", + "googleProvider": "Google", + "microsoftProvider": "Microsoft", + "signedInAccount": "로그인됨", + "removeAccount": "계정 삭제…", + "removingAccount": "계정 삭제 중…", + "removeAccountDescription": "동기화를 중지하고 이 기기에서 이 계정의 데이터를 삭제합니다.", + "removeAccountTitle": "BusyMax에서 {account} 계정을 삭제할까요?", + "removeAccountConfirmation": "이 기기에서 캐시된 할 일, 캘린더, 일정, 미리 알림 및 보류 중인 오프라인 변경 사항이 삭제됩니다. 동기화되지 않은 변경 사항은 사라집니다. Google 또는 Microsoft에서는 아무것도 삭제되지 않습니다.", + "revokeGoogleAccess": "이 Google 계정에 대한 BusyMax의 액세스 권한도 취소", + "revokeGoogleAccessDescription": "다시 연결하기 전에 액세스 권한을 다시 부여해야 합니다.", + "removeAccountAction": "계정 삭제", + "removeAccountFailed": "계정 삭제를 완료할 수 없습니다. 다시 시도하세요.", + "accountRemovedGoogleRevokeFailed": "이 기기에서 계정은 삭제되었지만 BusyMax가 Google 액세스 권한을 취소하지 못했습니다. Google 계정에서 직접 취소할 수 있습니다.", + "newList": "새 목록", + "signInToViewTaskLists": "할 일 목록을 보려면 로그인하세요.", + "noTaskListsSynced": "아직 동기화된 할 일 목록이 없습니다.", + "listActions": "목록 작업", + "rename": "이름 바꾸기", + "delete": "삭제", + "renameList": "목록 이름 바꾸기", + "deleteList": "목록 삭제", + "builtInMicrosoftList": "기본 제공", + "builtInMicrosoftListCannotRenameDelete": "Microsoft To Do의 기본 제공 목록은 이름을 바꾸거나 삭제할 수 없습니다.", + "deleteListConfirmation": "Google Tasks에서 “{title}” 목록을 삭제할까요?", + "deleteEvent": "일정 삭제", + "title": "제목", + "create": "만들기", + "newTask": "새 할 일", + "clearCompleted": "완료된 항목 지우기", + "refreshList": "목록 새로 고침", + "refreshAll": "모두 새로 고침", + "listRefreshed": "목록을 새로 고쳤습니다.", + "allTasksRefreshed": "모든 계정을 새로 고쳤습니다.", + "exportedFile": "{path}(으)로 내보냈습니다", + "exportFailed": "내보내기 실패: {error}", + "refreshFailed": "새로 고침 실패: {error}", + "selectOrCreateTaskList": "시작하려면 할 일 목록을 선택하거나 만드세요.", + "signInToViewTasks": "할 일을 보려면 로그인하세요.", + "noTasks": "할 일이 없습니다.", + "noTasksYet": "아직 할 일이 없습니다", + "noTasksYetMessage": "할 일을 만들거나 계정을 새로 고쳐 시작하세요.", + "noTasksInList": "이 목록에 할 일이 없습니다.", + "overdue": "기한 지남", + "today": "오늘", + "tomorrow": "내일", + "upcoming": "예정", + "noDate": "날짜 없음", + "completed": "완료", + "duePrefix": "{date} 마감", + "dateTimeDisplay": "{date} · {time}", + "taskDetails": "할 일 세부 정보", + "editTask": "할 일 편집", + "noTaskSelected": "선택된 할 일이 없습니다.", + "noTaskSelectedHelper": "세부 정보를 보고 편집할 할 일을 선택하세요.", + "taskUnavailable": "할 일을 사용할 수 없습니다.", + "signInToEditTasks": "할 일을 편집하려면 로그인하세요.", + "refreshTask": "할 일 새로 고침", + "primarySection": "기본", + "statusSection": "상태", + "openStatus": "진행 중", + "doneStatus": "완료", + "notes": "메모", + "dueDate": "마감일", + "clearDueDate": "마감일 지우기", + "dueTime": "마감 시간", + "startDate": "시작일", + "startTime": "시작 시간", + "endDate": "종료일", + "endTime": "종료 시간", + "reminderDate": "미리 알림 날짜", + "reminderTime": "미리 알림 시간", + "reminder": "미리 알림", + "addReminder": "미리 알림 추가", + "addGuest": "참석자 추가", + "addGuestEmail": "참석자 이메일 추가", + "removeReminder": "미리 알림 삭제", + "off": "끔", + "repeat": "반복", + "repeatNone": "없음", + "noneValue": "없음", + "repeatDaily": "매일", + "repeatWeekly": "매주", + "repeatMonthly": "매월", + "repeatYearly": "매년", + "importance": "중요도", + "importanceLow": "낮음", + "importanceNormal": "보통", + "importanceHigh": "높음", + "categories": "범주", + "scheduleSection": "일정", + "dueGroup": "마감", + "startGroup": "시작", + "reminderGroup": "미리 알림", + "organizationSection": "구성", + "actionsSection": "작업", + "advancedSection": "고급", + "addCategory": "범주 추가", + "list": "목록", + "microsoftMoveUnsupported": "이 버전에서는 Microsoft To Do 계정의 목록 간에 할 일을 이동할 수 없습니다.", + "createSubtask": "하위 할 일 만들기", + "moveToTop": "맨 위로 이동", + "deleteTask": "할 일 삭제", + "newSubtask": "새 하위 할 일", + "deleteTaskConfirmation": "Google Tasks에서 “{title}” 항목을 삭제할까요?", + "metadata": "메타데이터", + "id": "ID", + "etag": "ETag", + "updated": "업데이트됨", + "parent": "상위 할 일", + "position": "위치", + "webLink": "웹 링크", + "assignment": "할당", + "localState": "로컬 상태", + "pendingSync": "동기화 보류 중", + "synced": "동기화됨", + "account": "계정", + "sync": "동기화", + "manualFullSync": "수동 전체 동기화", + "runInBackgroundWhenClosed": "창을 닫아도 계속 실행", + "showTrayIcon": "트레이 아이콘 표시", + "startMinimizedToTray": "트레이에 최소화하여 시작", + "requiresTrayIcon": "트레이 아이콘이 필요합니다.", + "syncComplete": "동기화가 완료되었습니다.", + "syncFailed": "동기화 실패: {error}", + "notifySyncFailures": "동기화 실패 알림", + "notifyConflicts": "충돌 알림", + "notifyDueToday": "오늘 마감인 할 일 알림", + "eventReminders": "일정 미리 알림", + "taskReminders": "할 일 미리 알림", + "notificationDetailLevel": "알림 세부 수준", + "notificationDetailPrivate": "비공개", + "notificationDetailNormal": "일반", + "quietHours": "방해 금지 시간", + "quietHoursDescription": "이 시간 동안 알림을 일시 중지합니다.", + "quietHoursStart": "방해 금지 시작 시간", + "quietHoursEnd": "방해 금지 종료 시간", + "notifications": "알림", + "appearance": "화면 모양", + "theme": "테마", + "themeSystem": "시스템", + "themeLight": "라이트", + "themeDark": "다크", + "themeFamily": "테마 계열", + "themeFamilyYaru": "Ubuntu 기본 테마(Yaru)", + "localization": "언어 및 지역", + "currentLocale": "현재 로캘", + "privacy": "개인정보 보호", + "redactTaskContentInDiagnostics": "진단 정보에서 할 일 내용 숨기기", + "developerDiagnostics": "개발자 진단", + "diagnostics": "진단", + "apiInspectorDisabled": "API 검사기 표시", + "googleTasksApi": "Google Tasks API", + "discoveryRevision": "검색 버전: {revision}", + "implementedMethods": "구현된 메서드", + "supportsTasksScopes": "tasks 및 tasks.readonly 범위 지원", + "requiresTasksScope": "tasks 범위 필요", + "blockedPendingOperations": "차단된 보류 작업", + "signInToInspectPendingOperations": "보류 작업을 확인하려면 로그인하세요.", + "noBlockedPendingOperations": "차단된 보류 작업이 없습니다.", + "operationActions": "작업 동작", + "pendingOpListId": "목록={id}", + "pendingOpTaskId": "할 일={id}", + "pendingOpAttempts": "시도={count}", + "retry": "다시 시도", + "discard": "버리기", + "discardChanges": "변경 사항을 버릴까요?", + "discardChangesConfirmation": "이 할 일에서 저장하지 않은 편집 내용을 버립니다.", + "retryCompleted": "다시 시도했습니다.", + "discardPendingOperation": "보류 작업을 버릴까요?", + "discardPendingOperationConfirmation": "차단된 로컬 작업을 삭제합니다. 다음 동기화에서 Google Tasks의 데이터를 새로 불러옵니다.", + "pendingOperationDiscarded": "보류 작업을 버렸습니다.", + "syncFailureNotificationTitle": "BusyMax 동기화 실패", + "syncFailureNotificationBody": "백그라운드 동기화에 실패했습니다. {message}", + "conflictNotificationTitle": "BusyMax 동기화 충돌", + "conflictNotificationBody": "보류 중인 로컬 변경 사항이 차단되었습니다. {summary}", + "dueTodayNotificationTitle": "오늘 마감인 할 일", + "dueTodayNotificationBody": "{count, plural, =1{오늘 마감인 할 일이 1개 있습니다.} other{오늘 마감인 할 일이 {count}개 있습니다.}}", + "eventReminderNotificationTitle": "일정 미리 알림", + "taskReminderNotificationTitle": "할 일 미리 알림", + "eventReminderNotificationBody": "일정이 곧 시작됩니다.", + "taskReminderNotificationBody": "할 일 마감이 얼마 남지 않았습니다.", + "notificationOpenAction": "열기", + "notificationDetailsHidden": "개인정보 보호 설정에 따라 세부 정보가 숨겨졌습니다.", + "previousMonth": "이전 달", + "nextMonth": "다음 달", + "openMonthView": "월간 보기 열기", + "previousYear": "이전 해", + "nextYear": "다음 해", + "openYearView": "연간 보기 열기", + "weekNumberTooltip": "{number}주차", + "resizeAllDayPanel": "종일 패널 크기 조절", + "scheduleItemCount": "{count, plural, =1{항목 1개} other{항목 {count}개}}", + "readOnlyCalendar": "이 캘린더는 읽기 전용입니다.", + "selectTimeZone": "시간대 선택", + "searchLocations": "위치 검색", + "noLocationsFound": "위치를 찾을 수 없습니다", + "deleteCalendarConfirmation": "“{title}” 캘린더를 삭제할까요?" +} diff --git a/lib/l10n/app_pt.arb b/lib/l10n/app_pt.arb new file mode 100644 index 0000000..65e30c3 --- /dev/null +++ b/lib/l10n/app_pt.arb @@ -0,0 +1,389 @@ +{ + "@@locale": "pt", + "appTitle": "BusyMax", + "connectGoogleAccount": "Ligue contas Google e Microsoft para sincronizar calendários e tarefas.", + "googlePermissionsConsentNotice": "No ecrã de autorizações da Google, selecione as autorizações do Calendário e das Tarefas.", + "googlePermissionsRequiredRetry": "As autorizações do Calendário Google e do Google Tasks são necessárias. Tente novamente e selecione ambas as caixas.", + "finishSetup": "Concluir configuração", + "continueSetup": "Continuar", + "onboardingSetupTitle": "Configurar o BusyMax", + "onboardingAccountsStepTitle": "Ligar contas", + "onboardingAccountsStepDescription": "Adicione todas as contas Google e Microsoft que pretende utilizar. O BusyMax sincroniza calendários, eventos, listas de tarefas e tarefas de cada conta.", + "onboardingPreferencesStepTitle": "Escolher definições do sistema", + "onboardingPreferencesStepDescription": "Configure o comportamento da aplicação no ambiente de trabalho, os lembretes, o nível de detalhe das notificações e o aspeto antes de abrir a agenda.", + "signInWithGoogle": "Iniciar sessão com a Google", + "signInWithMicrosoft": "Iniciar sessão com a Microsoft", + "googleTasksProvider": "Google Tasks", + "microsoftTodoProvider": "Microsoft To Do", + "providerNotConfigured": "Este fornecedor não está configurado.", + "waitingForGoogleSignIn": "A aguardar o início de sessão da Google...", + "waitingForMicrosoftSignIn": "A aguardar o início de sessão da Microsoft...", + "microsoftSignInNotConfigured": "O início de sessão da Microsoft não está configurado. Defina MICROSOFT_OAUTH_CLIENT_ID.", + "cancel": "Cancelar", + "close": "Fechar", + "exit": "Sair", + "options": "Opções", + "hide": "Ocultar", + "show": "Mostrar", + "export": "Exportar", + "save": "Guardar", + "settings": "Definições", + "all": "Tudo", + "calendarEvents": "Eventos", + "calendarTasks": "Tarefas", + "calendar": "Calendário", + "calendars": "Calendários", + "newEvent": "Novo evento", + "refreshCalendar": "Atualizar calendário", + "openInProvider": "Abrir no serviço", + "hideFromSchedule": "Ocultar da agenda", + "showInSchedule": "Mostrar na agenda", + "noCalendarsSynced": "Ainda não há calendários sincronizados.", + "allDay": "Todo o dia", + "moreItems": "+{count} mais", + "noEventsOrTasks": "Sem eventos ou tarefas", + "scheduleLoading": "A carregar agenda...", + "scheduleUnavailable": "Agenda indisponível", + "scheduleNoSources": "Sem calendários ou listas de tarefas visíveis", + "scheduleNoSourcesDescription": "Escolha o que pretende mostrar nas Definições e atualize a agenda.", + "scheduleSignInRequired": "Ligar uma conta", + "scheduleSignInDescription": "Inicie sessão para sincronizar calendários e tarefas.", + "scheduleNoSearchResults": "Nenhum evento ou tarefa correspondente", + "scheduleNoSearchResultsDescription": "Experimente outra pesquisa ou limpe os filtros atuais.", + "trayAgendaLoading": "A carregar agenda...", + "trayAgendaSignInRequired": "Inicie sessão para ver a agenda.", + "trayAgendaNoSources": "Sem calendários ou listas de tarefas visíveis.", + "trayAgendaOpenBusyMax": "Abrir aplicação", + "trayAgendaRefresh": "Atualizar", + "trayAgendaError": "Agenda indisponível", + "compactAgendaTitle": "Agenda", + "compactAgendaSubtitle": "Próximos", + "compactAgendaOverdue": "Em atraso", + "compactAgendaClear": "Livre por agora", + "compactAgendaOpenBusyMax": "Abrir o BusyMax", + "compactAgendaHide": "Ocultar", + "compactAgendaNewTask": "Nova tarefa", + "compactAgendaRetry": "Tentar novamente", + "compactAgendaRefresh": "Atualizar", + "compactAgendaAllDay": "Todo o dia", + "compactAgendaDueToday": "Prazo: hoje", + "compactAgendaDueTomorrow": "Prazo: amanhã", + "compactAgendaDueOn": "Prazo: {date}", + "compactAgendaMoreOverdue": "Carregar mais tarefas em atraso", + "agendaLoadMoreOverdue": "Carregar mais tarefas em atraso", + "agendaLoadMoreNoDate": "Carregar mais tarefas sem data", + "viewDay": "Dia", + "viewWeek": "Semana", + "viewMonth": "Mês", + "viewYear": "Ano", + "viewAgenda": "Agenda", + "scheduleSettings": "Agenda", + "scheduleDisplaySettings": "Apresentação da agenda", + "scheduleDisplayHoursDescription": "As vistas de dia e semana mostram inicialmente este intervalo horário. Os itens anteriores ou posteriores alargam-no quando necessário.", + "scheduleDayStartsAt": "O dia começa às", + "scheduleDayEndsAt": "O dia termina às", + "sourceCalendar": "Calendário", + "sourceTaskList": "Lista de tarefas", + "createChoiceTitle": "Criar", + "createEventAtTime": "Evento", + "createTaskAtDate": "Tarefa", + "editEvent": "Editar evento", + "eventTitle": "Título do evento", + "location": "Local", + "timeSlot": "Intervalo de tempo", + "startDateTime": "Data e hora de início", + "endDateTime": "Data e hora de fim", + "doesNotRepeat": "Não se repete", + "defaultReminder": "Lembrete predefinido", + "guests": "Convidados", + "noGuests": "Sem convidados", + "description": "Descrição", + "availabilityShowAs": "Disponibilidade / Mostrar como", + "busy": "Ocupado", + "visibility": "Visibilidade", + "defaultVisibility": "Visibilidade predefinida", + "conference": "Conferência", + "noConference": "Sem conferência", + "providerCalendar": "Calendário do fornecedor", + "formatBoldShortLabel": "N", + "formatBoldTooltip": "Negrito", + "formatItalicShortLabel": "I", + "formatItalicTooltip": "Itálico", + "formatUnderlineShortLabel": "S", + "formatUnderlineTooltip": "Sublinhado", + "reminderMinutesBefore": "{minutes, plural, =1{1 minuto antes} other{{minutes} minutos antes}}", + "reminderAtStart": "À hora de início", + "reminderHoursBefore": "{hours, plural, =1{1 hora antes} other{{hours} horas antes}}", + "reminderDaysBefore": "{days, plural, =1{1 dia antes} other{{days} dias antes}}", + "availabilityFree": "Livre", + "availabilityTentative": "Provisório", + "availabilityOutOfOffice": "Fora do escritório", + "availabilityWorkingElsewhere": "A trabalhar noutro local", + "visibilityDefault": "Predefinida", + "visibilityPublic": "Pública", + "visibilityPrivate": "Privada", + "visibilityConfidential": "Confidencial", + "sensitivityNormal": "Normal", + "sensitivityPersonal": "Pessoal", + "tasks": "Tarefas", + "allTasks": "Todas as tarefas", + "tasksInList": "Tarefas em {title}", + "taskLists": "Listas de tarefas", + "navigation": "Navegação", + "mainMenu": "Menu principal", + "keyboardShortcuts": "Atalhos de teclado", + "shortcutGroupGeneral": "Geral", + "shortcutKeyboardShortcutsDescription": "Mostrar esta referência de atalhos", + "shortcutGroupNavigation": "Navegação", + "shortcutNextPeriod": "Período seguinte", + "shortcutNextPeriodDescription": "Semana seguinte na vista semanal, mês seguinte na vista mensal e assim por diante", + "shortcutPreviousPeriod": "Período anterior", + "shortcutPreviousPeriodDescription": "Semana anterior na vista semanal, mês anterior na vista mensal e assim por diante", + "shortcutJumpToToday": "Ir para hoje", + "shortcutGroupView": "Vista", + "shortcutDayView": "Vista diária", + "shortcutWeekView": "Vista semanal", + "shortcutMonthView": "Vista mensal", + "shortcutYearView": "Vista anual", + "shortcutAgendaView": "Vista de agenda", + "shortcutGroupCreateAndEdit": "Criar e editar", + "shortcutSaveItem": "Guardar evento ou tarefa", + "shortcutDeleteItem": "Eliminar evento ou tarefa", + "shortcutGroupTaskEditing": "Edição de tarefas", + "shortcutCancelEditing": "Cancelar edição", + "shortcutCancelEditingDescription": "Fechar a edição ou os detalhes da tarefa", + "shortcutGroupCompactAgenda": "Agenda compacta", + "shortcutRefreshCompactAgendaDescription": "Atualizar a janela da agenda compacta", + "shortcutHideCompactAgendaDescription": "Ocultar a janela da agenda compacta", + "aboutBusyMax": "Acerca do BusyMax", + "aboutBusyMaxDescription": "Tarefas e calendário", + "website": "Site", + "reportAnIssue": "Comunicar um problema", + "sendFeedback": "Enviar comentários", + "feedbackSubmit": "Enviar", + "feedbackCategory": "Categoria", + "feedbackSelectCategory": "Selecione uma categoria", + "feedbackCategoryProblem": "Problema ou erro", + "feedbackCategoryFeature": "Pedido de funcionalidade", + "feedbackCategoryPrivacySecurity": "Questão de privacidade ou segurança", + "feedbackCategoryUsability": "Problema de utilização", + "feedbackCategoryOther": "Outro", + "feedbackSubject": "Assunto", + "feedbackDetailedMessage": "Mensagem detalhada", + "feedbackReplyEmail": "Endereço de e-mail para resposta (opcional)", + "feedbackIncludeTechnicalDetails": "Incluir detalhes técnicos", + "feedbackTechnicalDetailsDisclosure": "Adiciona apenas a versão do sistema operativo Linux e a configuração regional da aplicação. Não são incluídos registos, dados de contas, nomes de ficheiros nem outros diagnósticos.", + "feedbackCategoryRequired": "Selecione uma categoria.", + "feedbackSubjectLengthError": "O assunto deve ter entre 3 e 120 carateres.", + "feedbackMessageLengthError": "A mensagem deve ter entre 10 e 5 000 carateres.", + "feedbackInvalidEmail": "Introduza um endereço de e-mail válido.", + "feedbackConnectionError": "Não foi possível ligar ao BusyStack. Verifique a ligação e tente novamente.", + "feedbackTimeoutError": "O pedido excedeu o tempo limite. Os seus comentários não foram apagados; tente novamente.", + "feedbackRateLimitedError": "Foram enviados demasiados comentários a partir desta rede. Aguarde e tente novamente.", + "feedbackRejectedError": "O servidor rejeitou o envio. Reveja os campos e tente novamente.", + "feedbackServerError": "O BusyStack não pode aceitar os seus comentários neste momento. Os seus comentários não foram apagados; tente novamente.", + "feedbackSuccess": "Comentários enviados. Referência: {id}", + "toggleSidebar": "Mostrar ou ocultar a barra lateral", + "accounts": "Contas", + "currentAccount": "Conta atual", + "switchAccount": "Mudar de conta", + "addGoogleAccount": "Adicionar conta Google", + "addMicrosoftAccount": "Adicionar conta Microsoft", + "googleProvider": "Google", + "microsoftProvider": "Microsoft", + "signedInAccount": "Sessão iniciada", + "removeAccount": "Remover conta…", + "removingAccount": "A remover conta…", + "removeAccountDescription": "Parar a sincronização e remover os dados desta conta deste dispositivo.", + "removeAccountTitle": "Remover {account} do BusyMax?", + "removeAccountConfirmation": "Esta ação elimina deste dispositivo as tarefas, os calendários, os eventos, os lembretes e as alterações offline pendentes em cache. As alterações não sincronizadas serão perdidas. Nada será eliminado da Google ou da Microsoft.", + "revokeGoogleAccess": "Revogar também o acesso do BusyMax a esta conta Google", + "revokeGoogleAccessDescription": "Terá de conceder acesso novamente antes de voltar a ligar a conta.", + "removeAccountAction": "Remover conta", + "removeAccountFailed": "Não foi possível concluir a remoção da conta. Tente novamente.", + "accountRemovedGoogleRevokeFailed": "A conta foi removida deste dispositivo, mas não foi possível revogar o acesso do BusyMax à sua conta Google. Pode revogar esse acesso na sua conta Google.", + "newList": "Nova lista", + "signInToViewTaskLists": "Inicie sessão para ver as listas de tarefas.", + "noTaskListsSynced": "Ainda não há listas de tarefas sincronizadas.", + "listActions": "Ações da lista", + "rename": "Mudar o nome", + "delete": "Eliminar", + "renameList": "Mudar o nome da lista", + "deleteList": "Eliminar lista", + "builtInMicrosoftList": "Incorporada", + "builtInMicrosoftListCannotRenameDelete": "As listas incorporadas do Microsoft To Do não podem ser renomeadas nem eliminadas.", + "deleteListConfirmation": "Eliminar «{title}» do Google Tasks?", + "deleteEvent": "Eliminar evento", + "title": "Título", + "create": "Criar", + "newTask": "Nova tarefa", + "clearCompleted": "Limpar tarefas concluídas", + "refreshList": "Atualizar lista", + "refreshAll": "Atualizar tudo", + "listRefreshed": "Lista atualizada.", + "allTasksRefreshed": "Todas as contas foram atualizadas.", + "exportedFile": "Exportado para {path}", + "exportFailed": "Falha ao exportar: {error}", + "refreshFailed": "Falha ao atualizar: {error}", + "selectOrCreateTaskList": "Selecione ou crie uma lista de tarefas para começar.", + "signInToViewTasks": "Inicie sessão para ver as tarefas.", + "noTasks": "Sem tarefas.", + "noTasksYet": "Ainda não há tarefas", + "noTasksYetMessage": "Crie uma tarefa ou atualize as suas contas para começar.", + "noTasksInList": "Não há tarefas nesta lista.", + "overdue": "Em atraso", + "today": "Hoje", + "tomorrow": "Amanhã", + "upcoming": "Próximas", + "noDate": "Sem data", + "completed": "Concluídas", + "duePrefix": "Prazo: {date}", + "dateTimeDisplay": "{date}, {time}", + "taskDetails": "Detalhes da tarefa", + "editTask": "Editar tarefa", + "noTaskSelected": "Nenhuma tarefa selecionada.", + "noTaskSelectedHelper": "Selecione uma tarefa para ver e editar os detalhes.", + "taskUnavailable": "Tarefa indisponível.", + "signInToEditTasks": "Inicie sessão para editar tarefas.", + "refreshTask": "Atualizar tarefa", + "primarySection": "Principal", + "statusSection": "Estado", + "openStatus": "Aberta", + "doneStatus": "Concluída", + "notes": "Notas", + "dueDate": "Data limite", + "clearDueDate": "Limpar data limite", + "dueTime": "Hora limite", + "startDate": "Data de início", + "startTime": "Hora de início", + "endDate": "Data de fim", + "endTime": "Hora de fim", + "reminderDate": "Data do lembrete", + "reminderTime": "Hora do lembrete", + "reminder": "Lembrete", + "addReminder": "Adicionar lembrete", + "addGuest": "Adicionar convidado", + "addGuestEmail": "Adicionar e-mail do convidado", + "removeReminder": "Remover lembrete", + "off": "Desativado", + "repeat": "Repetição", + "repeatNone": "Não repetir", + "noneValue": "Nenhum", + "repeatDaily": "Diariamente", + "repeatWeekly": "Semanalmente", + "repeatMonthly": "Mensalmente", + "repeatYearly": "Anualmente", + "importance": "Importância", + "importanceLow": "Baixa", + "importanceNormal": "Normal", + "importanceHigh": "Alta", + "categories": "Categorias", + "scheduleSection": "Agenda", + "dueGroup": "Prazo", + "startGroup": "Início", + "reminderGroup": "Lembrete", + "organizationSection": "Organização", + "actionsSection": "Ações", + "advancedSection": "Avançado", + "addCategory": "Adicionar categoria", + "list": "Lista", + "microsoftMoveUnsupported": "Nesta versão, não é possível mover tarefas entre listas em contas Microsoft To Do.", + "createSubtask": "Criar subtarefa", + "moveToTop": "Mover para o início", + "deleteTask": "Eliminar tarefa", + "newSubtask": "Nova subtarefa", + "deleteTaskConfirmation": "Eliminar «{title}» do Google Tasks?", + "metadata": "Metadados", + "id": "ID", + "etag": "ETag", + "updated": "Atualizado", + "parent": "Tarefa principal", + "position": "Posição", + "webLink": "Ligação Web", + "assignment": "Atribuição", + "localState": "Estado local", + "pendingSync": "Sincronização pendente", + "synced": "Sincronizado", + "account": "Conta", + "sync": "Sincronização", + "manualFullSync": "Sincronização completa manual", + "runInBackgroundWhenClosed": "Continuar em execução quando a janela for fechada", + "showTrayIcon": "Mostrar ícone na área de notificação", + "startMinimizedToTray": "Iniciar minimizado na área de notificação", + "requiresTrayIcon": "Requer o ícone da área de notificação.", + "syncComplete": "Sincronização concluída.", + "syncFailed": "Falha na sincronização: {error}", + "notifySyncFailures": "Notificações de falhas de sincronização", + "notifyConflicts": "Notificações de conflitos", + "notifyDueToday": "Notificações de tarefas com prazo para hoje", + "eventReminders": "Lembretes de eventos", + "taskReminders": "Lembretes de tarefas", + "notificationDetailLevel": "Nível de detalhe das notificações", + "notificationDetailPrivate": "Privado", + "notificationDetailNormal": "Normal", + "quietHours": "Período de silêncio", + "quietHoursDescription": "Pausar as notificações durante este período.", + "quietHoursStart": "Início do período de silêncio", + "quietHoursEnd": "Fim do período de silêncio", + "notifications": "Notificações", + "appearance": "Aspeto", + "theme": "Tema", + "themeSystem": "Sistema", + "themeLight": "Claro", + "themeDark": "Escuro", + "themeFamily": "Família de temas", + "themeFamilyYaru": "Tema nativo do Ubuntu (Yaru)", + "localization": "Localização", + "currentLocale": "Configuração regional atual", + "privacy": "Privacidade", + "redactTaskContentInDiagnostics": "Ocultar o conteúdo das tarefas nos diagnósticos", + "developerDiagnostics": "Diagnósticos de programador", + "diagnostics": "Diagnósticos", + "apiInspectorDisabled": "Mostrar inspetor da API", + "googleTasksApi": "API do Google Tasks", + "discoveryRevision": "Revisão de descoberta: {revision}", + "implementedMethods": "Métodos implementados", + "supportsTasksScopes": "Suporta os âmbitos de autorização tasks e tasks.readonly", + "requiresTasksScope": "Requer o âmbito de autorização tasks", + "blockedPendingOperations": "Operações pendentes bloqueadas", + "signInToInspectPendingOperations": "Inicie sessão para inspecionar as operações pendentes.", + "noBlockedPendingOperations": "Não há operações pendentes bloqueadas.", + "operationActions": "Ações da operação", + "pendingOpListId": "lista={id}", + "pendingOpTaskId": "tarefa={id}", + "pendingOpAttempts": "tentativas={count}", + "retry": "Tentar novamente", + "discard": "Descartar", + "discardChanges": "Descartar alterações?", + "discardChangesConfirmation": "Esta ação descarta as alterações não guardadas nesta tarefa.", + "retryCompleted": "Nova tentativa concluída.", + "discardPendingOperation": "Descartar operação pendente?", + "discardPendingOperationConfirmation": "Esta ação remove a operação local bloqueada. Na próxima sincronização, os dados serão novamente carregados do Google Tasks.", + "pendingOperationDiscarded": "Operação pendente descartada.", + "syncFailureNotificationTitle": "Falha na sincronização do BusyMax", + "syncFailureNotificationBody": "A sincronização em segundo plano falhou. {message}", + "conflictNotificationTitle": "Conflito de sincronização do BusyMax", + "conflictNotificationBody": "Uma alteração local pendente foi bloqueada. {summary}", + "dueTodayNotificationTitle": "Tarefas com prazo para hoje", + "dueTodayNotificationBody": "{count, plural, =1{Há uma tarefa com prazo para hoje.} other{Há {count} tarefas com prazo para hoje.}}", + "eventReminderNotificationTitle": "Lembrete de evento", + "taskReminderNotificationTitle": "Lembrete de tarefa", + "eventReminderNotificationBody": "O evento começa em breve.", + "taskReminderNotificationBody": "O prazo da tarefa aproxima-se.", + "notificationOpenAction": "Abrir", + "notificationDetailsHidden": "Os detalhes estão ocultos pelas definições de privacidade.", + "previousMonth": "Mês anterior", + "nextMonth": "Mês seguinte", + "openMonthView": "Abrir vista mensal", + "previousYear": "Ano anterior", + "nextYear": "Ano seguinte", + "openYearView": "Abrir vista anual", + "weekNumberTooltip": "Semana {number}", + "resizeAllDayPanel": "Redimensionar o painel de dia inteiro", + "scheduleItemCount": "{count, plural, =1{1 item} other{{count} itens}}", + "readOnlyCalendar": "Este calendário é só de leitura.", + "selectTimeZone": "Selecionar fuso horário", + "searchLocations": "Pesquisar locais", + "noLocationsFound": "Nenhum local encontrado", + "deleteCalendarConfirmation": "Eliminar «{title}»?" +} diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index 7ce4ca5..dcc363a 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -10,7 +10,7 @@ "onboardingAccountsStepTitle": "Подключите аккаунты", "onboardingAccountsStepDescription": "Добавьте все аккаунты Google и Microsoft, которые хотите использовать. BusyMax синхронизирует календари, события, списки задач и задачи из каждого аккаунта.", "onboardingPreferencesStepTitle": "Выберите системные параметры", - "onboardingPreferencesStepDescription": "Настройте поведение на рабочем столе, напоминания, содержимое уведомлений и внешний вид, прежде чем открыть расписание.", + "onboardingPreferencesStepDescription": "Настройте поведение приложения на рабочем столе, напоминания, уровень детализации уведомлений и внешний вид, прежде чем открыть расписание.", "signInWithGoogle": "Войти через Google", "signInWithMicrosoft": "Войти через Microsoft", "googleTasksProvider": "Google Tasks", @@ -35,7 +35,7 @@ "calendars": "Календари", "newEvent": "Новое событие", "refreshCalendar": "Обновить календарь", - "openInProvider": "Открыть у поставщика", + "openInProvider": "Открыть в сервисе", "hideFromSchedule": "Скрыть из расписания", "showInSchedule": "Показывать в расписании", "noCalendarsSynced": "Синхронизированных календарей пока нет.", @@ -139,19 +139,19 @@ "shortcutNextPeriodDescription": "Следующая неделя в представлении недели, следующий месяц в представлении месяца и так далее", "shortcutPreviousPeriod": "Предыдущий период", "shortcutPreviousPeriodDescription": "Предыдущая неделя в представлении недели, предыдущий месяц в представлении месяца и так далее", - "shortcutJumpToToday": "Перейти к сегодняшнему дню", + "shortcutJumpToToday": "Перейти к сегодняшней дате", "shortcutGroupView": "Представление", "shortcutDayView": "Представление дня", "shortcutWeekView": "Представление недели", "shortcutMonthView": "Представление месяца", "shortcutYearView": "Представление года", "shortcutAgendaView": "Представление повестки", - "shortcutGroupCreateAndEdit": "Создание и изменение", + "shortcutGroupCreateAndEdit": "Создание и редактирование", "shortcutSaveItem": "Сохранить событие или задачу", "shortcutDeleteItem": "Удалить событие или задачу", - "shortcutGroupTaskEditing": "Изменение задач", - "shortcutCancelEditing": "Отменить изменение", - "shortcutCancelEditingDescription": "Закрыть изменение задачи или сведения о ней", + "shortcutGroupTaskEditing": "Редактирование задач", + "shortcutCancelEditing": "Отменить редактирование", + "shortcutCancelEditingDescription": "Выйти из режима редактирования задачи или закрыть сведения о ней", "shortcutGroupCompactAgenda": "Компактная повестка", "shortcutRefreshCompactAgendaDescription": "Обновить окно компактной повестки", "shortcutHideCompactAgendaDescription": "Скрыть окно компактной повестки", @@ -172,7 +172,7 @@ "feedbackDetailedMessage": "Подробное сообщение", "feedbackReplyEmail": "Адрес электронной почты для ответа (необязательно)", "feedbackIncludeTechnicalDetails": "Включить технические сведения", - "feedbackTechnicalDetailsDisclosure": "Будут добавлены только версия операционной системы Linux и языковой стандарт приложения. Журналы, данные аккаунтов, имена файлов и другие диагностические сведения не включаются.", + "feedbackTechnicalDetailsDisclosure": "Будут добавлены только версия операционной системы Linux и локаль приложения. Журналы, данные аккаунтов, имена файлов и другие диагностические сведения не включаются.", "feedbackCategoryRequired": "Выберите категорию.", "feedbackSubjectLengthError": "Тема должна содержать от 3 до 120 символов.", "feedbackMessageLengthError": "Сообщение должно содержать от 10 до 5 000 символов.", @@ -196,12 +196,12 @@ "removingAccount": "Удаление аккаунта…", "removeAccountDescription": "Остановить синхронизацию и удалить данные этого аккаунта с устройства.", "removeAccountTitle": "Удалить {account} из BusyMax?", - "removeAccountConfirmation": "С устройства будут удалены кэшированные задачи, календари, события, напоминания и ожидающие автономные изменения. Несинхронизированные изменения будут потеряны. Из Google и Microsoft ничего не удаляется.", + "removeAccountConfirmation": "С этого устройства будут удалены кэшированные задачи, календари, события, напоминания и локальные изменения, ожидающие синхронизации. Несинхронизированные изменения будут потеряны. В Google и Microsoft ничего не будет удалено.", "revokeGoogleAccess": "Также отозвать у BusyMax доступ к этому аккаунту Google", "revokeGoogleAccessDescription": "Перед повторным подключением аккаунта потребуется снова предоставить доступ.", "removeAccountAction": "Удалить аккаунт", "removeAccountFailed": "Не удалось завершить удаление аккаунта. Повторите попытку.", - "accountRemovedGoogleRevokeFailed": "Аккаунт удалён с устройства, но BusyMax не удалось отозвать доступ Google. Это можно сделать в аккаунте Google.", + "accountRemovedGoogleRevokeFailed": "Аккаунт удалён с этого устройства, но отозвать доступ BusyMax к Google не удалось. Вы можете отозвать доступ в аккаунте Google.", "newList": "Новый список", "signInToViewTaskLists": "Войдите, чтобы просмотреть списки задач.", "noTaskListsSynced": "Синхронизированных списков задач пока нет.", @@ -252,7 +252,7 @@ "doneStatus": "Выполнена", "notes": "Заметки", "dueDate": "Срок", - "clearDueDate": "Очистить срок", + "clearDueDate": "Удалить срок выполнения", "dueTime": "Время выполнения", "startDate": "Дата начала", "startTime": "Время начала", @@ -318,9 +318,9 @@ "notifyDueToday": "Уведомлять о задачах на сегодня", "eventReminders": "Напоминания о событиях", "taskReminders": "Напоминания о задачах", - "notificationDetailLevel": "Подробность уведомлений", - "notificationDetailPrivate": "Конфиденциальные", - "notificationDetailNormal": "Обычные", + "notificationDetailLevel": "Уровень детализации уведомлений", + "notificationDetailPrivate": "Конфиденциальный", + "notificationDetailNormal": "Обычный", "quietHours": "Период тишины", "quietHoursDescription": "Приостановить уведомления на этот период.", "quietHoursStart": "Начало периода тишины", @@ -332,9 +332,9 @@ "themeLight": "Светлая", "themeDark": "Тёмная", "themeFamily": "Семейство тем", - "themeFamilyYaru": "Родная тема Ubuntu (Yaru)", + "themeFamilyYaru": "Нативная тема Ubuntu (Yaru)", "localization": "Локализация", - "currentLocale": "Текущий языковой стандарт", + "currentLocale": "Текущая локаль", "privacy": "Конфиденциальность", "redactTaskContentInDiagnostics": "Скрывать содержимое задач в диагностике", "developerDiagnostics": "Диагностика для разработчиков", diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb new file mode 100644 index 0000000..549ccac --- /dev/null +++ b/lib/l10n/app_zh.arb @@ -0,0 +1,389 @@ +{ + "@@locale": "zh", + "appTitle": "BusyMax", + "connectGoogleAccount": "连接 Google 和 Microsoft 帐户以同步日历和任务。", + "googlePermissionsConsentNotice": "在 Google 权限页面上,同时选择日历和任务权限。", + "googlePermissionsRequiredRetry": "必须授予 Google 日历和 Google Tasks 权限。请重试并选中两个复选框。", + "finishSetup": "完成设置", + "continueSetup": "继续", + "onboardingSetupTitle": "设置 BusyMax", + "onboardingAccountsStepTitle": "连接帐户", + "onboardingAccountsStepDescription": "添加您要使用的所有 Google 和 Microsoft 帐户。BusyMax 会同步每个帐户中的日历、日程、任务列表和任务。", + "onboardingPreferencesStepTitle": "选择系统设置", + "onboardingPreferencesStepDescription": "打开日程前,请设置桌面行为、提醒、通知详细程度和外观。", + "signInWithGoogle": "使用 Google 登录", + "signInWithMicrosoft": "使用 Microsoft 登录", + "googleTasksProvider": "Google Tasks", + "microsoftTodoProvider": "Microsoft To Do", + "providerNotConfigured": "尚未配置此服务。", + "waitingForGoogleSignIn": "正在等待 Google 登录...", + "waitingForMicrosoftSignIn": "正在等待 Microsoft 登录...", + "microsoftSignInNotConfigured": "尚未配置 Microsoft 登录。请设置 MICROSOFT_OAUTH_CLIENT_ID。", + "cancel": "取消", + "close": "关闭", + "exit": "退出", + "options": "选项", + "hide": "隐藏", + "show": "显示", + "export": "导出", + "save": "保存", + "settings": "设置", + "all": "全部", + "calendarEvents": "日程", + "calendarTasks": "任务", + "calendar": "日历", + "calendars": "日历", + "newEvent": "新建日程", + "refreshCalendar": "刷新日历", + "openInProvider": "在服务中打开", + "hideFromSchedule": "从日程中隐藏", + "showInSchedule": "在日程中显示", + "noCalendarsSynced": "尚未同步任何日历。", + "allDay": "全天", + "moreItems": "还有 {count} 项", + "noEventsOrTasks": "没有日程或任务", + "scheduleLoading": "正在加载日程...", + "scheduleUnavailable": "日程不可用", + "scheduleNoSources": "没有可见的日历或任务列表", + "scheduleNoSourcesDescription": "请在设置中选择要显示的内容,然后刷新。", + "scheduleSignInRequired": "连接帐户", + "scheduleSignInDescription": "登录以同步日历和任务。", + "scheduleNoSearchResults": "没有匹配的日程或任务", + "scheduleNoSearchResultsDescription": "请尝试其他搜索内容或清除当前筛选条件。", + "trayAgendaLoading": "正在加载日程...", + "trayAgendaSignInRequired": "请登录以显示日程。", + "trayAgendaNoSources": "没有可见的日历或任务列表。", + "trayAgendaOpenBusyMax": "打开应用", + "trayAgendaRefresh": "刷新", + "trayAgendaError": "日程不可用", + "compactAgendaTitle": "日程", + "compactAgendaSubtitle": "即将开始", + "compactAgendaOverdue": "已逾期", + "compactAgendaClear": "目前空闲", + "compactAgendaOpenBusyMax": "打开 BusyMax", + "compactAgendaHide": "隐藏", + "compactAgendaNewTask": "新建任务", + "compactAgendaRetry": "重试", + "compactAgendaRefresh": "刷新", + "compactAgendaAllDay": "全天", + "compactAgendaDueToday": "今天到期", + "compactAgendaDueTomorrow": "明天到期", + "compactAgendaDueOn": "{date} 到期", + "compactAgendaMoreOverdue": "加载更多逾期任务", + "agendaLoadMoreOverdue": "加载更多逾期任务", + "agendaLoadMoreNoDate": "加载更多无日期任务", + "viewDay": "日", + "viewWeek": "周", + "viewMonth": "月", + "viewYear": "年", + "viewAgenda": "日程", + "scheduleSettings": "日程", + "scheduleDisplaySettings": "日程显示", + "scheduleDisplayHoursDescription": "日视图和周视图最初显示此时间范围。需要时,更早或更晚的项目会扩展该范围。", + "scheduleDayStartsAt": "每日开始时间", + "scheduleDayEndsAt": "每日结束时间", + "sourceCalendar": "日历", + "sourceTaskList": "任务列表", + "createChoiceTitle": "新建", + "createEventAtTime": "日程", + "createTaskAtDate": "任务", + "editEvent": "编辑日程", + "eventTitle": "日程标题", + "location": "地点", + "timeSlot": "时间段", + "startDateTime": "开始日期/时间", + "endDateTime": "结束日期/时间", + "doesNotRepeat": "不重复", + "defaultReminder": "默认提醒", + "guests": "参与者", + "noGuests": "没有参与者", + "description": "说明", + "availabilityShowAs": "空闲状态 / 显示为", + "busy": "忙碌", + "visibility": "可见性", + "defaultVisibility": "默认可见性", + "conference": "会议", + "noConference": "无会议", + "providerCalendar": "服务日历", + "formatBoldShortLabel": "B", + "formatBoldTooltip": "粗体", + "formatItalicShortLabel": "I", + "formatItalicTooltip": "斜体", + "formatUnderlineShortLabel": "U", + "formatUnderlineTooltip": "下划线", + "reminderMinutesBefore": "{minutes, plural, =1{1 分钟前} other{{minutes} 分钟前}}", + "reminderAtStart": "开始时", + "reminderHoursBefore": "{hours, plural, =1{1 小时前} other{{hours} 小时前}}", + "reminderDaysBefore": "{days, plural, =1{1 天前} other{{days} 天前}}", + "availabilityFree": "空闲", + "availabilityTentative": "暂定", + "availabilityOutOfOffice": "不在办公室", + "availabilityWorkingElsewhere": "在其他地点办公", + "visibilityDefault": "默认", + "visibilityPublic": "公开", + "visibilityPrivate": "私密", + "visibilityConfidential": "机密", + "sensitivityNormal": "普通", + "sensitivityPersonal": "个人", + "tasks": "任务", + "allTasks": "所有任务", + "tasksInList": "{title}中的任务", + "taskLists": "任务列表", + "navigation": "导航", + "mainMenu": "主菜单", + "keyboardShortcuts": "键盘快捷键", + "shortcutGroupGeneral": "常规", + "shortcutKeyboardShortcutsDescription": "显示此快捷键参考", + "shortcutGroupNavigation": "导航", + "shortcutNextPeriod": "下一时段", + "shortcutNextPeriodDescription": "在周视图中前往下一周,在月视图中前往下个月,依此类推", + "shortcutPreviousPeriod": "上一时段", + "shortcutPreviousPeriodDescription": "在周视图中前往上一周,在月视图中前往上个月,依此类推", + "shortcutJumpToToday": "跳转到今天", + "shortcutGroupView": "视图", + "shortcutDayView": "日视图", + "shortcutWeekView": "周视图", + "shortcutMonthView": "月视图", + "shortcutYearView": "年视图", + "shortcutAgendaView": "日程视图", + "shortcutGroupCreateAndEdit": "新建和编辑", + "shortcutSaveItem": "保存日程或任务", + "shortcutDeleteItem": "删除日程或任务", + "shortcutGroupTaskEditing": "任务编辑", + "shortcutCancelEditing": "取消编辑", + "shortcutCancelEditingDescription": "关闭任务编辑或任务详情", + "shortcutGroupCompactAgenda": "紧凑日程", + "shortcutRefreshCompactAgendaDescription": "刷新紧凑日程窗口", + "shortcutHideCompactAgendaDescription": "隐藏紧凑日程窗口", + "aboutBusyMax": "关于 BusyMax", + "aboutBusyMaxDescription": "任务和日历", + "website": "网站", + "reportAnIssue": "报告问题", + "sendFeedback": "发送反馈", + "feedbackSubmit": "提交", + "feedbackCategory": "类别", + "feedbackSelectCategory": "选择类别", + "feedbackCategoryProblem": "问题或错误", + "feedbackCategoryFeature": "功能请求", + "feedbackCategoryPrivacySecurity": "隐私或安全问题", + "feedbackCategoryUsability": "易用性问题", + "feedbackCategoryOther": "其他", + "feedbackSubject": "主题", + "feedbackDetailedMessage": "详细信息", + "feedbackReplyEmail": "用于接收回复的电子邮件地址(可选)", + "feedbackIncludeTechnicalDetails": "包含技术详情", + "feedbackTechnicalDetailsDisclosure": "仅添加您的 Linux 操作系统版本和应用区域设置。不包含日志、帐户数据、文件名或其他诊断信息。", + "feedbackCategoryRequired": "请选择类别。", + "feedbackSubjectLengthError": "主题必须为 3 至 120 个字符。", + "feedbackMessageLengthError": "消息必须为 10 至 5,000 个字符。", + "feedbackInvalidEmail": "请输入有效的电子邮件地址。", + "feedbackConnectionError": "无法连接到 BusyStack。请检查连接,然后重试。", + "feedbackTimeoutError": "请求超时。您的反馈尚未清除,请重试。", + "feedbackRateLimitedError": "从此网络发送的反馈过多。请稍后再试。", + "feedbackRejectedError": "服务器拒绝了提交。请检查各字段,然后重试。", + "feedbackServerError": "BusyStack 目前无法接收您的反馈。您的反馈尚未清除,请重试。", + "feedbackSuccess": "反馈已发送。参考编号:{id}", + "toggleSidebar": "显示或隐藏侧边栏", + "accounts": "帐户", + "currentAccount": "当前帐户", + "switchAccount": "切换帐户", + "addGoogleAccount": "添加 Google 帐户", + "addMicrosoftAccount": "添加 Microsoft 帐户", + "googleProvider": "Google", + "microsoftProvider": "Microsoft", + "signedInAccount": "已登录", + "removeAccount": "移除帐户…", + "removingAccount": "正在移除帐户…", + "removeAccountDescription": "停止同步并从此设备移除此帐户的数据。", + "removeAccountTitle": "从 BusyMax 中移除 {account}?", + "removeAccountConfirmation": "这会从此设备删除缓存的任务、日历、日程、提醒和待处理的离线更改。未同步的更改将丢失。不会从 Google 或 Microsoft 删除任何内容。", + "revokeGoogleAccess": "同时撤销 BusyMax 对此 Google 帐户的访问权限", + "revokeGoogleAccessDescription": "重新连接之前,您需要再次授予访问权限。", + "removeAccountAction": "移除帐户", + "removeAccountFailed": "无法完成帐户移除。请重试。", + "accountRemovedGoogleRevokeFailed": "已从此设备移除该帐户,但 BusyMax 无法撤销 Google 访问权限。您可以在 Google 帐户中撤销。", + "newList": "新建列表", + "signInToViewTaskLists": "登录以查看任务列表。", + "noTaskListsSynced": "尚未同步任何任务列表。", + "listActions": "列表操作", + "rename": "重命名", + "delete": "删除", + "renameList": "重命名列表", + "deleteList": "删除列表", + "builtInMicrosoftList": "内置", + "builtInMicrosoftListCannotRenameDelete": "无法重命名或删除 Microsoft To Do 内置列表。", + "deleteListConfirmation": "从 Google Tasks 中删除“{title}”?", + "deleteEvent": "删除日程", + "title": "标题", + "create": "新建", + "newTask": "新建任务", + "clearCompleted": "清除已完成项", + "refreshList": "刷新列表", + "refreshAll": "全部刷新", + "listRefreshed": "列表已刷新。", + "allTasksRefreshed": "所有帐户均已刷新。", + "exportedFile": "已导出到 {path}", + "exportFailed": "导出失败:{error}", + "refreshFailed": "刷新失败:{error}", + "selectOrCreateTaskList": "请选择或创建任务列表以开始使用。", + "signInToViewTasks": "登录以查看任务。", + "noTasks": "没有任务。", + "noTasksYet": "还没有任务", + "noTasksYetMessage": "创建任务或刷新帐户以开始使用。", + "noTasksInList": "此列表中没有任务。", + "overdue": "已逾期", + "today": "今天", + "tomorrow": "明天", + "upcoming": "即将开始", + "noDate": "无日期", + "completed": "已完成", + "duePrefix": "{date} 到期", + "dateTimeDisplay": "{date} · {time}", + "taskDetails": "任务详情", + "editTask": "编辑任务", + "noTaskSelected": "未选择任务。", + "noTaskSelectedHelper": "选择任务以查看和编辑详情。", + "taskUnavailable": "任务不可用。", + "signInToEditTasks": "登录以编辑任务。", + "refreshTask": "刷新任务", + "primarySection": "主要信息", + "statusSection": "状态", + "openStatus": "未完成", + "doneStatus": "已完成", + "notes": "备注", + "dueDate": "截止日期", + "clearDueDate": "清除截止日期", + "dueTime": "截止时间", + "startDate": "开始日期", + "startTime": "开始时间", + "endDate": "结束日期", + "endTime": "结束时间", + "reminderDate": "提醒日期", + "reminderTime": "提醒时间", + "reminder": "提醒", + "addReminder": "添加提醒", + "addGuest": "添加参与者", + "addGuestEmail": "添加参与者电子邮件", + "removeReminder": "移除提醒", + "off": "关闭", + "repeat": "重复", + "repeatNone": "不重复", + "noneValue": "无", + "repeatDaily": "每天", + "repeatWeekly": "每周", + "repeatMonthly": "每月", + "repeatYearly": "每年", + "importance": "重要性", + "importanceLow": "低", + "importanceNormal": "普通", + "importanceHigh": "高", + "categories": "类别", + "scheduleSection": "日程", + "dueGroup": "截止", + "startGroup": "开始", + "reminderGroup": "提醒", + "organizationSection": "整理", + "actionsSection": "操作", + "advancedSection": "高级", + "addCategory": "添加类别", + "list": "列表", + "microsoftMoveUnsupported": "此版本不支持在 Microsoft To Do 帐户的列表之间移动任务。", + "createSubtask": "创建子任务", + "moveToTop": "移到顶部", + "deleteTask": "删除任务", + "newSubtask": "新建子任务", + "deleteTaskConfirmation": "从 Google Tasks 中删除“{title}”?", + "metadata": "元数据", + "id": "ID", + "etag": "ETag", + "updated": "更新时间", + "parent": "父任务", + "position": "位置", + "webLink": "网页链接", + "assignment": "分配", + "localState": "本地状态", + "pendingSync": "等待同步", + "synced": "已同步", + "account": "帐户", + "sync": "同步", + "manualFullSync": "手动完整同步", + "runInBackgroundWhenClosed": "窗口关闭后继续在后台运行", + "showTrayIcon": "显示托盘图标", + "startMinimizedToTray": "启动时最小化到托盘", + "requiresTrayIcon": "需要托盘图标。", + "syncComplete": "同步完成。", + "syncFailed": "同步失败:{error}", + "notifySyncFailures": "同步失败通知", + "notifyConflicts": "冲突通知", + "notifyDueToday": "今天到期任务通知", + "eventReminders": "日程提醒", + "taskReminders": "任务提醒", + "notificationDetailLevel": "通知详细程度", + "notificationDetailPrivate": "私密", + "notificationDetailNormal": "普通", + "quietHours": "免打扰时段", + "quietHoursDescription": "在此时段暂停通知。", + "quietHoursStart": "免打扰开始时间", + "quietHoursEnd": "免打扰结束时间", + "notifications": "通知", + "appearance": "外观", + "theme": "主题", + "themeSystem": "系统", + "themeLight": "浅色", + "themeDark": "深色", + "themeFamily": "主题系列", + "themeFamilyYaru": "Ubuntu 原生主题(Yaru)", + "localization": "语言和区域", + "currentLocale": "当前区域设置", + "privacy": "隐私", + "redactTaskContentInDiagnostics": "在诊断信息中隐藏任务内容", + "developerDiagnostics": "开发者诊断", + "diagnostics": "诊断", + "apiInspectorDisabled": "显示 API 检查器", + "googleTasksApi": "Google Tasks API", + "discoveryRevision": "Discovery 修订版:{revision}", + "implementedMethods": "已实现的方法", + "supportsTasksScopes": "支持 tasks 和 tasks.readonly 权限范围", + "requiresTasksScope": "需要 tasks 权限范围", + "blockedPendingOperations": "被阻止的待处理操作", + "signInToInspectPendingOperations": "登录以检查待处理操作。", + "noBlockedPendingOperations": "没有被阻止的待处理操作。", + "operationActions": "操作选项", + "pendingOpListId": "列表={id}", + "pendingOpTaskId": "任务={id}", + "pendingOpAttempts": "尝试次数={count}", + "retry": "重试", + "discard": "舍弃", + "discardChanges": "舍弃更改?", + "discardChangesConfirmation": "这将舍弃对此任务所做的未保存编辑。", + "retryCompleted": "重试完成。", + "discardPendingOperation": "舍弃待处理操作?", + "discardPendingOperationConfirmation": "这将移除被阻止的本地操作。下次同步时将从 Google Tasks 刷新数据。", + "pendingOperationDiscarded": "已舍弃待处理操作。", + "syncFailureNotificationTitle": "BusyMax 同步失败", + "syncFailureNotificationBody": "后台同步失败。{message}", + "conflictNotificationTitle": "BusyMax 同步冲突", + "conflictNotificationBody": "一项待处理的本地更改被阻止。{summary}", + "dueTodayNotificationTitle": "今天到期的任务", + "dueTodayNotificationBody": "{count, plural, =1{今天有 1 项任务到期。} other{今天有 {count} 项任务到期。}}", + "eventReminderNotificationTitle": "日程提醒", + "taskReminderNotificationTitle": "任务提醒", + "eventReminderNotificationBody": "日程即将开始。", + "taskReminderNotificationBody": "任务即将到期。", + "notificationOpenAction": "打开", + "notificationDetailsHidden": "根据隐私设置,详细信息已隐藏。", + "previousMonth": "上个月", + "nextMonth": "下个月", + "openMonthView": "打开月视图", + "previousYear": "上一年", + "nextYear": "下一年", + "openYearView": "打开年视图", + "weekNumberTooltip": "第 {number} 周", + "resizeAllDayPanel": "调整全天面板的大小", + "scheduleItemCount": "{count, plural, =1{1 项} other{{count} 项}}", + "readOnlyCalendar": "此日历为只读。", + "selectTimeZone": "选择时区", + "searchLocations": "搜索地点", + "noLocationsFound": "未找到地点", + "deleteCalendarConfirmation": "删除“{title}”?" +} diff --git a/lib/l10n/app_zh_Hans.arb b/lib/l10n/app_zh_Hans.arb new file mode 100644 index 0000000..b738fe5 --- /dev/null +++ b/lib/l10n/app_zh_Hans.arb @@ -0,0 +1,389 @@ +{ + "@@locale": "zh_Hans", + "appTitle": "BusyMax", + "connectGoogleAccount": "连接 Google 和 Microsoft 帐户以同步日历和任务。", + "googlePermissionsConsentNotice": "在 Google 权限页面上,同时选择日历和任务权限。", + "googlePermissionsRequiredRetry": "必须授予 Google 日历和 Google Tasks 权限。请重试并选中两个复选框。", + "finishSetup": "完成设置", + "continueSetup": "继续", + "onboardingSetupTitle": "设置 BusyMax", + "onboardingAccountsStepTitle": "连接帐户", + "onboardingAccountsStepDescription": "添加您要使用的所有 Google 和 Microsoft 帐户。BusyMax 会同步每个帐户中的日历、日程、任务列表和任务。", + "onboardingPreferencesStepTitle": "选择系统设置", + "onboardingPreferencesStepDescription": "打开日程前,请设置桌面行为、提醒、通知详细程度和外观。", + "signInWithGoogle": "使用 Google 登录", + "signInWithMicrosoft": "使用 Microsoft 登录", + "googleTasksProvider": "Google Tasks", + "microsoftTodoProvider": "Microsoft To Do", + "providerNotConfigured": "尚未配置此服务。", + "waitingForGoogleSignIn": "正在等待 Google 登录...", + "waitingForMicrosoftSignIn": "正在等待 Microsoft 登录...", + "microsoftSignInNotConfigured": "尚未配置 Microsoft 登录。请设置 MICROSOFT_OAUTH_CLIENT_ID。", + "cancel": "取消", + "close": "关闭", + "exit": "退出", + "options": "选项", + "hide": "隐藏", + "show": "显示", + "export": "导出", + "save": "保存", + "settings": "设置", + "all": "全部", + "calendarEvents": "日程", + "calendarTasks": "任务", + "calendar": "日历", + "calendars": "日历", + "newEvent": "新建日程", + "refreshCalendar": "刷新日历", + "openInProvider": "在服务中打开", + "hideFromSchedule": "从日程中隐藏", + "showInSchedule": "在日程中显示", + "noCalendarsSynced": "尚未同步任何日历。", + "allDay": "全天", + "moreItems": "还有 {count} 项", + "noEventsOrTasks": "没有日程或任务", + "scheduleLoading": "正在加载日程...", + "scheduleUnavailable": "日程不可用", + "scheduleNoSources": "没有可见的日历或任务列表", + "scheduleNoSourcesDescription": "请在设置中选择要显示的内容,然后刷新。", + "scheduleSignInRequired": "连接帐户", + "scheduleSignInDescription": "登录以同步日历和任务。", + "scheduleNoSearchResults": "没有匹配的日程或任务", + "scheduleNoSearchResultsDescription": "请尝试其他搜索内容或清除当前筛选条件。", + "trayAgendaLoading": "正在加载日程...", + "trayAgendaSignInRequired": "请登录以显示日程。", + "trayAgendaNoSources": "没有可见的日历或任务列表。", + "trayAgendaOpenBusyMax": "打开应用", + "trayAgendaRefresh": "刷新", + "trayAgendaError": "日程不可用", + "compactAgendaTitle": "日程", + "compactAgendaSubtitle": "即将开始", + "compactAgendaOverdue": "已逾期", + "compactAgendaClear": "目前空闲", + "compactAgendaOpenBusyMax": "打开 BusyMax", + "compactAgendaHide": "隐藏", + "compactAgendaNewTask": "新建任务", + "compactAgendaRetry": "重试", + "compactAgendaRefresh": "刷新", + "compactAgendaAllDay": "全天", + "compactAgendaDueToday": "今天到期", + "compactAgendaDueTomorrow": "明天到期", + "compactAgendaDueOn": "{date} 到期", + "compactAgendaMoreOverdue": "加载更多逾期任务", + "agendaLoadMoreOverdue": "加载更多逾期任务", + "agendaLoadMoreNoDate": "加载更多无日期任务", + "viewDay": "日", + "viewWeek": "周", + "viewMonth": "月", + "viewYear": "年", + "viewAgenda": "日程", + "scheduleSettings": "日程", + "scheduleDisplaySettings": "日程显示", + "scheduleDisplayHoursDescription": "日视图和周视图最初显示此时间范围。需要时,更早或更晚的项目会扩展该范围。", + "scheduleDayStartsAt": "每日开始时间", + "scheduleDayEndsAt": "每日结束时间", + "sourceCalendar": "日历", + "sourceTaskList": "任务列表", + "createChoiceTitle": "新建", + "createEventAtTime": "日程", + "createTaskAtDate": "任务", + "editEvent": "编辑日程", + "eventTitle": "日程标题", + "location": "地点", + "timeSlot": "时间段", + "startDateTime": "开始日期/时间", + "endDateTime": "结束日期/时间", + "doesNotRepeat": "不重复", + "defaultReminder": "默认提醒", + "guests": "参与者", + "noGuests": "没有参与者", + "description": "说明", + "availabilityShowAs": "空闲状态 / 显示为", + "busy": "忙碌", + "visibility": "可见性", + "defaultVisibility": "默认可见性", + "conference": "会议", + "noConference": "无会议", + "providerCalendar": "服务日历", + "formatBoldShortLabel": "B", + "formatBoldTooltip": "粗体", + "formatItalicShortLabel": "I", + "formatItalicTooltip": "斜体", + "formatUnderlineShortLabel": "U", + "formatUnderlineTooltip": "下划线", + "reminderMinutesBefore": "{minutes, plural, =1{1 分钟前} other{{minutes} 分钟前}}", + "reminderAtStart": "开始时", + "reminderHoursBefore": "{hours, plural, =1{1 小时前} other{{hours} 小时前}}", + "reminderDaysBefore": "{days, plural, =1{1 天前} other{{days} 天前}}", + "availabilityFree": "空闲", + "availabilityTentative": "暂定", + "availabilityOutOfOffice": "不在办公室", + "availabilityWorkingElsewhere": "在其他地点办公", + "visibilityDefault": "默认", + "visibilityPublic": "公开", + "visibilityPrivate": "私密", + "visibilityConfidential": "机密", + "sensitivityNormal": "普通", + "sensitivityPersonal": "个人", + "tasks": "任务", + "allTasks": "所有任务", + "tasksInList": "{title}中的任务", + "taskLists": "任务列表", + "navigation": "导航", + "mainMenu": "主菜单", + "keyboardShortcuts": "键盘快捷键", + "shortcutGroupGeneral": "常规", + "shortcutKeyboardShortcutsDescription": "显示此快捷键参考", + "shortcutGroupNavigation": "导航", + "shortcutNextPeriod": "下一时段", + "shortcutNextPeriodDescription": "在周视图中前往下一周,在月视图中前往下个月,依此类推", + "shortcutPreviousPeriod": "上一时段", + "shortcutPreviousPeriodDescription": "在周视图中前往上一周,在月视图中前往上个月,依此类推", + "shortcutJumpToToday": "跳转到今天", + "shortcutGroupView": "视图", + "shortcutDayView": "日视图", + "shortcutWeekView": "周视图", + "shortcutMonthView": "月视图", + "shortcutYearView": "年视图", + "shortcutAgendaView": "日程视图", + "shortcutGroupCreateAndEdit": "新建和编辑", + "shortcutSaveItem": "保存日程或任务", + "shortcutDeleteItem": "删除日程或任务", + "shortcutGroupTaskEditing": "任务编辑", + "shortcutCancelEditing": "取消编辑", + "shortcutCancelEditingDescription": "关闭任务编辑或任务详情", + "shortcutGroupCompactAgenda": "紧凑日程", + "shortcutRefreshCompactAgendaDescription": "刷新紧凑日程窗口", + "shortcutHideCompactAgendaDescription": "隐藏紧凑日程窗口", + "aboutBusyMax": "关于 BusyMax", + "aboutBusyMaxDescription": "任务和日历", + "website": "网站", + "reportAnIssue": "报告问题", + "sendFeedback": "发送反馈", + "feedbackSubmit": "提交", + "feedbackCategory": "类别", + "feedbackSelectCategory": "选择类别", + "feedbackCategoryProblem": "问题或错误", + "feedbackCategoryFeature": "功能请求", + "feedbackCategoryPrivacySecurity": "隐私或安全问题", + "feedbackCategoryUsability": "易用性问题", + "feedbackCategoryOther": "其他", + "feedbackSubject": "主题", + "feedbackDetailedMessage": "详细信息", + "feedbackReplyEmail": "用于接收回复的电子邮件地址(可选)", + "feedbackIncludeTechnicalDetails": "包含技术详情", + "feedbackTechnicalDetailsDisclosure": "仅添加您的 Linux 操作系统版本和应用区域设置。不包含日志、帐户数据、文件名或其他诊断信息。", + "feedbackCategoryRequired": "请选择类别。", + "feedbackSubjectLengthError": "主题必须为 3 至 120 个字符。", + "feedbackMessageLengthError": "消息必须为 10 至 5,000 个字符。", + "feedbackInvalidEmail": "请输入有效的电子邮件地址。", + "feedbackConnectionError": "无法连接到 BusyStack。请检查连接,然后重试。", + "feedbackTimeoutError": "请求超时。您的反馈尚未清除,请重试。", + "feedbackRateLimitedError": "从此网络发送的反馈过多。请稍后再试。", + "feedbackRejectedError": "服务器拒绝了提交。请检查各字段,然后重试。", + "feedbackServerError": "BusyStack 目前无法接收您的反馈。您的反馈尚未清除,请重试。", + "feedbackSuccess": "反馈已发送。参考编号:{id}", + "toggleSidebar": "显示或隐藏侧边栏", + "accounts": "帐户", + "currentAccount": "当前帐户", + "switchAccount": "切换帐户", + "addGoogleAccount": "添加 Google 帐户", + "addMicrosoftAccount": "添加 Microsoft 帐户", + "googleProvider": "Google", + "microsoftProvider": "Microsoft", + "signedInAccount": "已登录", + "removeAccount": "移除帐户…", + "removingAccount": "正在移除帐户…", + "removeAccountDescription": "停止同步并从此设备移除此帐户的数据。", + "removeAccountTitle": "从 BusyMax 中移除 {account}?", + "removeAccountConfirmation": "这会从此设备删除缓存的任务、日历、日程、提醒和待处理的离线更改。未同步的更改将丢失。不会从 Google 或 Microsoft 删除任何内容。", + "revokeGoogleAccess": "同时撤销 BusyMax 对此 Google 帐户的访问权限", + "revokeGoogleAccessDescription": "重新连接之前,您需要再次授予访问权限。", + "removeAccountAction": "移除帐户", + "removeAccountFailed": "无法完成帐户移除。请重试。", + "accountRemovedGoogleRevokeFailed": "已从此设备移除该帐户,但 BusyMax 无法撤销 Google 访问权限。您可以在 Google 帐户中撤销。", + "newList": "新建列表", + "signInToViewTaskLists": "登录以查看任务列表。", + "noTaskListsSynced": "尚未同步任何任务列表。", + "listActions": "列表操作", + "rename": "重命名", + "delete": "删除", + "renameList": "重命名列表", + "deleteList": "删除列表", + "builtInMicrosoftList": "内置", + "builtInMicrosoftListCannotRenameDelete": "无法重命名或删除 Microsoft To Do 内置列表。", + "deleteListConfirmation": "从 Google Tasks 中删除“{title}”?", + "deleteEvent": "删除日程", + "title": "标题", + "create": "新建", + "newTask": "新建任务", + "clearCompleted": "清除已完成项", + "refreshList": "刷新列表", + "refreshAll": "全部刷新", + "listRefreshed": "列表已刷新。", + "allTasksRefreshed": "所有帐户均已刷新。", + "exportedFile": "已导出到 {path}", + "exportFailed": "导出失败:{error}", + "refreshFailed": "刷新失败:{error}", + "selectOrCreateTaskList": "请选择或创建任务列表以开始使用。", + "signInToViewTasks": "登录以查看任务。", + "noTasks": "没有任务。", + "noTasksYet": "还没有任务", + "noTasksYetMessage": "创建任务或刷新帐户以开始使用。", + "noTasksInList": "此列表中没有任务。", + "overdue": "已逾期", + "today": "今天", + "tomorrow": "明天", + "upcoming": "即将开始", + "noDate": "无日期", + "completed": "已完成", + "duePrefix": "{date} 到期", + "dateTimeDisplay": "{date} · {time}", + "taskDetails": "任务详情", + "editTask": "编辑任务", + "noTaskSelected": "未选择任务。", + "noTaskSelectedHelper": "选择任务以查看和编辑详情。", + "taskUnavailable": "任务不可用。", + "signInToEditTasks": "登录以编辑任务。", + "refreshTask": "刷新任务", + "primarySection": "主要信息", + "statusSection": "状态", + "openStatus": "未完成", + "doneStatus": "已完成", + "notes": "备注", + "dueDate": "截止日期", + "clearDueDate": "清除截止日期", + "dueTime": "截止时间", + "startDate": "开始日期", + "startTime": "开始时间", + "endDate": "结束日期", + "endTime": "结束时间", + "reminderDate": "提醒日期", + "reminderTime": "提醒时间", + "reminder": "提醒", + "addReminder": "添加提醒", + "addGuest": "添加参与者", + "addGuestEmail": "添加参与者电子邮件", + "removeReminder": "移除提醒", + "off": "关闭", + "repeat": "重复", + "repeatNone": "不重复", + "noneValue": "无", + "repeatDaily": "每天", + "repeatWeekly": "每周", + "repeatMonthly": "每月", + "repeatYearly": "每年", + "importance": "重要性", + "importanceLow": "低", + "importanceNormal": "普通", + "importanceHigh": "高", + "categories": "类别", + "scheduleSection": "日程", + "dueGroup": "截止", + "startGroup": "开始", + "reminderGroup": "提醒", + "organizationSection": "整理", + "actionsSection": "操作", + "advancedSection": "高级", + "addCategory": "添加类别", + "list": "列表", + "microsoftMoveUnsupported": "此版本不支持在 Microsoft To Do 帐户的列表之间移动任务。", + "createSubtask": "创建子任务", + "moveToTop": "移到顶部", + "deleteTask": "删除任务", + "newSubtask": "新建子任务", + "deleteTaskConfirmation": "从 Google Tasks 中删除“{title}”?", + "metadata": "元数据", + "id": "ID", + "etag": "ETag", + "updated": "更新时间", + "parent": "父任务", + "position": "位置", + "webLink": "网页链接", + "assignment": "分配", + "localState": "本地状态", + "pendingSync": "等待同步", + "synced": "已同步", + "account": "帐户", + "sync": "同步", + "manualFullSync": "手动完整同步", + "runInBackgroundWhenClosed": "窗口关闭后继续在后台运行", + "showTrayIcon": "显示托盘图标", + "startMinimizedToTray": "启动时最小化到托盘", + "requiresTrayIcon": "需要托盘图标。", + "syncComplete": "同步完成。", + "syncFailed": "同步失败:{error}", + "notifySyncFailures": "同步失败通知", + "notifyConflicts": "冲突通知", + "notifyDueToday": "今天到期任务通知", + "eventReminders": "日程提醒", + "taskReminders": "任务提醒", + "notificationDetailLevel": "通知详细程度", + "notificationDetailPrivate": "私密", + "notificationDetailNormal": "普通", + "quietHours": "免打扰时段", + "quietHoursDescription": "在此时段暂停通知。", + "quietHoursStart": "免打扰开始时间", + "quietHoursEnd": "免打扰结束时间", + "notifications": "通知", + "appearance": "外观", + "theme": "主题", + "themeSystem": "系统", + "themeLight": "浅色", + "themeDark": "深色", + "themeFamily": "主题系列", + "themeFamilyYaru": "Ubuntu 原生主题(Yaru)", + "localization": "语言和区域", + "currentLocale": "当前区域设置", + "privacy": "隐私", + "redactTaskContentInDiagnostics": "在诊断信息中隐藏任务内容", + "developerDiagnostics": "开发者诊断", + "diagnostics": "诊断", + "apiInspectorDisabled": "显示 API 检查器", + "googleTasksApi": "Google Tasks API", + "discoveryRevision": "Discovery 修订版:{revision}", + "implementedMethods": "已实现的方法", + "supportsTasksScopes": "支持 tasks 和 tasks.readonly 权限范围", + "requiresTasksScope": "需要 tasks 权限范围", + "blockedPendingOperations": "被阻止的待处理操作", + "signInToInspectPendingOperations": "登录以检查待处理操作。", + "noBlockedPendingOperations": "没有被阻止的待处理操作。", + "operationActions": "操作选项", + "pendingOpListId": "列表={id}", + "pendingOpTaskId": "任务={id}", + "pendingOpAttempts": "尝试次数={count}", + "retry": "重试", + "discard": "舍弃", + "discardChanges": "舍弃更改?", + "discardChangesConfirmation": "这将舍弃对此任务所做的未保存编辑。", + "retryCompleted": "重试完成。", + "discardPendingOperation": "舍弃待处理操作?", + "discardPendingOperationConfirmation": "这将移除被阻止的本地操作。下次同步时将从 Google Tasks 刷新数据。", + "pendingOperationDiscarded": "已舍弃待处理操作。", + "syncFailureNotificationTitle": "BusyMax 同步失败", + "syncFailureNotificationBody": "后台同步失败。{message}", + "conflictNotificationTitle": "BusyMax 同步冲突", + "conflictNotificationBody": "一项待处理的本地更改被阻止。{summary}", + "dueTodayNotificationTitle": "今天到期的任务", + "dueTodayNotificationBody": "{count, plural, =1{今天有 1 项任务到期。} other{今天有 {count} 项任务到期。}}", + "eventReminderNotificationTitle": "日程提醒", + "taskReminderNotificationTitle": "任务提醒", + "eventReminderNotificationBody": "日程即将开始。", + "taskReminderNotificationBody": "任务即将到期。", + "notificationOpenAction": "打开", + "notificationDetailsHidden": "根据隐私设置,详细信息已隐藏。", + "previousMonth": "上个月", + "nextMonth": "下个月", + "openMonthView": "打开月视图", + "previousYear": "上一年", + "nextYear": "下一年", + "openYearView": "打开年视图", + "weekNumberTooltip": "第 {number} 周", + "resizeAllDayPanel": "调整全天面板的大小", + "scheduleItemCount": "{count, plural, =1{1 项} other{{count} 项}}", + "readOnlyCalendar": "此日历为只读。", + "selectTimeZone": "选择时区", + "searchLocations": "搜索地点", + "noLocationsFound": "未找到地点", + "deleteCalendarConfirmation": "删除“{title}”?" +} diff --git a/lib/l10n/app_zh_Hant.arb b/lib/l10n/app_zh_Hant.arb new file mode 100644 index 0000000..90b5802 --- /dev/null +++ b/lib/l10n/app_zh_Hant.arb @@ -0,0 +1,389 @@ +{ + "@@locale": "zh_Hant", + "appTitle": "BusyMax", + "connectGoogleAccount": "連結 Google 和 Microsoft 帳戶以同步行事曆和待辦事項。", + "googlePermissionsConsentNotice": "在 Google 權限畫面中,同時選取行事曆和待辦事項權限。", + "googlePermissionsRequiredRetry": "必須授予 Google 日曆和 Google Tasks 權限。請再試一次並勾選兩個核取方塊。", + "finishSetup": "完成設定", + "continueSetup": "繼續", + "onboardingSetupTitle": "設定 BusyMax", + "onboardingAccountsStepTitle": "連結帳戶", + "onboardingAccountsStepDescription": "新增您要使用的所有 Google 和 Microsoft 帳戶。BusyMax 會同步每個帳戶中的行事曆、活動、待辦清單和待辦事項。", + "onboardingPreferencesStepTitle": "選擇系統設定", + "onboardingPreferencesStepDescription": "開啟行程前,請設定桌面行為、提醒、通知詳細程度和外觀。", + "signInWithGoogle": "使用 Google 登入", + "signInWithMicrosoft": "使用 Microsoft 登入", + "googleTasksProvider": "Google Tasks", + "microsoftTodoProvider": "Microsoft To Do", + "providerNotConfigured": "尚未設定此服務。", + "waitingForGoogleSignIn": "正在等候 Google 登入...", + "waitingForMicrosoftSignIn": "正在等候 Microsoft 登入...", + "microsoftSignInNotConfigured": "尚未設定 Microsoft 登入。請設定 MICROSOFT_OAUTH_CLIENT_ID。", + "cancel": "取消", + "close": "關閉", + "exit": "結束", + "options": "選項", + "hide": "隱藏", + "show": "顯示", + "export": "匯出", + "save": "儲存", + "settings": "設定", + "all": "全部", + "calendarEvents": "活動", + "calendarTasks": "待辦事項", + "calendar": "行事曆", + "calendars": "行事曆", + "newEvent": "新增活動", + "refreshCalendar": "重新整理行事曆", + "openInProvider": "在服務中開啟", + "hideFromSchedule": "從行程中隱藏", + "showInSchedule": "在行程中顯示", + "noCalendarsSynced": "尚未同步任何行事曆。", + "allDay": "全天", + "moreItems": "還有 {count} 項", + "noEventsOrTasks": "沒有活動或待辦事項", + "scheduleLoading": "正在載入行程...", + "scheduleUnavailable": "無法使用行程", + "scheduleNoSources": "沒有可見的行事曆或待辦清單", + "scheduleNoSourcesDescription": "請在設定中選擇要顯示的內容,然後重新整理。", + "scheduleSignInRequired": "連結帳戶", + "scheduleSignInDescription": "登入以同步行事曆和待辦事項。", + "scheduleNoSearchResults": "沒有相符的活動或待辦事項", + "scheduleNoSearchResultsDescription": "請嘗試其他搜尋內容或清除目前的篩選條件。", + "trayAgendaLoading": "正在載入行程...", + "trayAgendaSignInRequired": "請登入以顯示行程。", + "trayAgendaNoSources": "沒有可見的行事曆或待辦清單。", + "trayAgendaOpenBusyMax": "開啟應用程式", + "trayAgendaRefresh": "重新整理", + "trayAgendaError": "無法使用行程", + "compactAgendaTitle": "行程", + "compactAgendaSubtitle": "即將開始", + "compactAgendaOverdue": "已逾期", + "compactAgendaClear": "目前沒有安排", + "compactAgendaOpenBusyMax": "開啟 BusyMax", + "compactAgendaHide": "隱藏", + "compactAgendaNewTask": "新增待辦事項", + "compactAgendaRetry": "再試一次", + "compactAgendaRefresh": "重新整理", + "compactAgendaAllDay": "全天", + "compactAgendaDueToday": "今天到期", + "compactAgendaDueTomorrow": "明天到期", + "compactAgendaDueOn": "{date} 到期", + "compactAgendaMoreOverdue": "載入更多逾期待辦事項", + "agendaLoadMoreOverdue": "載入更多逾期待辦事項", + "agendaLoadMoreNoDate": "載入更多無日期待辦事項", + "viewDay": "日", + "viewWeek": "週", + "viewMonth": "月", + "viewYear": "年", + "viewAgenda": "行程", + "scheduleSettings": "行程", + "scheduleDisplaySettings": "行程顯示", + "scheduleDisplayHoursDescription": "日檢視和週檢視一開始會顯示此時間範圍。必要時,較早或較晚的項目會擴大此範圍。", + "scheduleDayStartsAt": "每日開始時間", + "scheduleDayEndsAt": "每日結束時間", + "sourceCalendar": "行事曆", + "sourceTaskList": "待辦清單", + "createChoiceTitle": "新增", + "createEventAtTime": "活動", + "createTaskAtDate": "待辦事項", + "editEvent": "編輯活動", + "eventTitle": "活動標題", + "location": "地點", + "timeSlot": "時段", + "startDateTime": "開始日期/時間", + "endDateTime": "結束日期/時間", + "doesNotRepeat": "不重複", + "defaultReminder": "預設提醒", + "guests": "參與者", + "noGuests": "沒有參與者", + "description": "說明", + "availabilityShowAs": "空閒狀態 / 顯示為", + "busy": "忙碌", + "visibility": "顯示設定", + "defaultVisibility": "預設顯示設定", + "conference": "會議", + "noConference": "無會議", + "providerCalendar": "服務行事曆", + "formatBoldShortLabel": "B", + "formatBoldTooltip": "粗體", + "formatItalicShortLabel": "I", + "formatItalicTooltip": "斜體", + "formatUnderlineShortLabel": "U", + "formatUnderlineTooltip": "底線", + "reminderMinutesBefore": "{minutes, plural, =1{1 分鐘前} other{{minutes} 分鐘前}}", + "reminderAtStart": "開始時", + "reminderHoursBefore": "{hours, plural, =1{1 小時前} other{{hours} 小時前}}", + "reminderDaysBefore": "{days, plural, =1{1 天前} other{{days} 天前}}", + "availabilityFree": "有空", + "availabilityTentative": "暫定", + "availabilityOutOfOffice": "不在辦公室", + "availabilityWorkingElsewhere": "在其他地點工作", + "visibilityDefault": "預設", + "visibilityPublic": "公開", + "visibilityPrivate": "私人", + "visibilityConfidential": "機密", + "sensitivityNormal": "一般", + "sensitivityPersonal": "個人", + "tasks": "待辦事項", + "allTasks": "所有待辦事項", + "tasksInList": "{title}中的待辦事項", + "taskLists": "待辦清單", + "navigation": "導覽", + "mainMenu": "主選單", + "keyboardShortcuts": "鍵盤快速鍵", + "shortcutGroupGeneral": "一般", + "shortcutKeyboardShortcutsDescription": "顯示此快速鍵參考", + "shortcutGroupNavigation": "導覽", + "shortcutNextPeriod": "下一時段", + "shortcutNextPeriodDescription": "在週檢視中前往下一週,在月檢視中前往下個月,依此類推", + "shortcutPreviousPeriod": "上一時段", + "shortcutPreviousPeriodDescription": "在週檢視中前往上一週,在月檢視中前往上個月,依此類推", + "shortcutJumpToToday": "跳至今天", + "shortcutGroupView": "檢視", + "shortcutDayView": "日檢視", + "shortcutWeekView": "週檢視", + "shortcutMonthView": "月檢視", + "shortcutYearView": "年檢視", + "shortcutAgendaView": "行程檢視", + "shortcutGroupCreateAndEdit": "新增和編輯", + "shortcutSaveItem": "儲存活動或待辦事項", + "shortcutDeleteItem": "刪除活動或待辦事項", + "shortcutGroupTaskEditing": "待辦事項編輯", + "shortcutCancelEditing": "取消編輯", + "shortcutCancelEditingDescription": "關閉待辦事項編輯或詳細資料", + "shortcutGroupCompactAgenda": "精簡行程", + "shortcutRefreshCompactAgendaDescription": "重新整理精簡行程視窗", + "shortcutHideCompactAgendaDescription": "隱藏精簡行程視窗", + "aboutBusyMax": "關於 BusyMax", + "aboutBusyMaxDescription": "待辦事項和行事曆", + "website": "網站", + "reportAnIssue": "回報問題", + "sendFeedback": "傳送意見", + "feedbackSubmit": "提交", + "feedbackCategory": "類別", + "feedbackSelectCategory": "選擇類別", + "feedbackCategoryProblem": "問題或錯誤", + "feedbackCategoryFeature": "功能要求", + "feedbackCategoryPrivacySecurity": "隱私權或安全性疑慮", + "feedbackCategoryUsability": "易用性疑慮", + "feedbackCategoryOther": "其他", + "feedbackSubject": "主旨", + "feedbackDetailedMessage": "詳細訊息", + "feedbackReplyEmail": "回覆用電子郵件地址(選填)", + "feedbackIncludeTechnicalDetails": "包含技術詳細資料", + "feedbackTechnicalDetailsDisclosure": "只會加入您的 Linux 作業系統版本和應用程式語系。不會包含記錄、帳戶資料、檔案名稱或其他診斷資訊。", + "feedbackCategoryRequired": "請選擇類別。", + "feedbackSubjectLengthError": "主旨必須介於 3 到 120 個字元之間。", + "feedbackMessageLengthError": "訊息必須介於 10 到 5,000 個字元之間。", + "feedbackInvalidEmail": "請輸入有效的電子郵件地址。", + "feedbackConnectionError": "無法連線至 BusyStack。請檢查連線,然後再試一次。", + "feedbackTimeoutError": "要求逾時。您的意見尚未清除,請再試一次。", + "feedbackRateLimitedError": "此網路已傳送太多意見。請稍候再試。", + "feedbackRejectedError": "伺服器拒絕了提交內容。請檢查各欄位,然後再試一次。", + "feedbackServerError": "BusyStack 目前無法接收您的意見。您的意見尚未清除,請再試一次。", + "feedbackSuccess": "意見已傳送。參考編號:{id}", + "toggleSidebar": "顯示或隱藏側邊欄", + "accounts": "帳戶", + "currentAccount": "目前帳戶", + "switchAccount": "切換帳戶", + "addGoogleAccount": "新增 Google 帳戶", + "addMicrosoftAccount": "新增 Microsoft 帳戶", + "googleProvider": "Google", + "microsoftProvider": "Microsoft", + "signedInAccount": "已登入", + "removeAccount": "移除帳戶…", + "removingAccount": "正在移除帳戶…", + "removeAccountDescription": "停止同步並從此裝置移除此帳戶的資料。", + "removeAccountTitle": "要從 BusyMax 移除 {account} 嗎?", + "removeAccountConfirmation": "這會從此裝置刪除快取的待辦事項、行事曆、活動、提醒和待處理的離線變更。未同步的變更將會遺失。不會從 Google 或 Microsoft 刪除任何內容。", + "revokeGoogleAccess": "同時撤銷 BusyMax 對此 Google 帳戶的存取權", + "revokeGoogleAccessDescription": "重新連結前,您必須再次授予存取權。", + "removeAccountAction": "移除帳戶", + "removeAccountFailed": "無法完成帳戶移除。請再試一次。", + "accountRemovedGoogleRevokeFailed": "已從此裝置移除該帳戶,但 BusyMax 無法撤銷 Google 存取權。您可以在 Google 帳戶中撤銷。", + "newList": "新增清單", + "signInToViewTaskLists": "登入以查看待辦清單。", + "noTaskListsSynced": "尚未同步任何待辦清單。", + "listActions": "清單動作", + "rename": "重新命名", + "delete": "刪除", + "renameList": "重新命名清單", + "deleteList": "刪除清單", + "builtInMicrosoftList": "內建", + "builtInMicrosoftListCannotRenameDelete": "無法重新命名或刪除 Microsoft To Do 內建清單。", + "deleteListConfirmation": "要從 Google Tasks 刪除「{title}」嗎?", + "deleteEvent": "刪除活動", + "title": "標題", + "create": "新增", + "newTask": "新增待辦事項", + "clearCompleted": "清除已完成項目", + "refreshList": "重新整理清單", + "refreshAll": "全部重新整理", + "listRefreshed": "清單已重新整理。", + "allTasksRefreshed": "所有帳戶都已重新整理。", + "exportedFile": "已匯出至 {path}", + "exportFailed": "匯出失敗:{error}", + "refreshFailed": "重新整理失敗:{error}", + "selectOrCreateTaskList": "請選擇或建立待辦清單以開始使用。", + "signInToViewTasks": "登入以查看待辦事項。", + "noTasks": "沒有待辦事項。", + "noTasksYet": "還沒有待辦事項", + "noTasksYetMessage": "建立待辦事項或重新整理帳戶以開始使用。", + "noTasksInList": "此清單中沒有待辦事項。", + "overdue": "已逾期", + "today": "今天", + "tomorrow": "明天", + "upcoming": "即將開始", + "noDate": "無日期", + "completed": "已完成", + "duePrefix": "{date} 到期", + "dateTimeDisplay": "{date} · {time}", + "taskDetails": "待辦事項詳細資料", + "editTask": "編輯待辦事項", + "noTaskSelected": "未選取待辦事項。", + "noTaskSelectedHelper": "選擇待辦事項以查看和編輯詳細資料。", + "taskUnavailable": "無法使用待辦事項。", + "signInToEditTasks": "登入以編輯待辦事項。", + "refreshTask": "重新整理待辦事項", + "primarySection": "主要資訊", + "statusSection": "狀態", + "openStatus": "未完成", + "doneStatus": "已完成", + "notes": "備註", + "dueDate": "到期日", + "clearDueDate": "清除到期日", + "dueTime": "到期時間", + "startDate": "開始日期", + "startTime": "開始時間", + "endDate": "結束日期", + "endTime": "結束時間", + "reminderDate": "提醒日期", + "reminderTime": "提醒時間", + "reminder": "提醒", + "addReminder": "新增提醒", + "addGuest": "新增參與者", + "addGuestEmail": "新增參與者電子郵件", + "removeReminder": "移除提醒", + "off": "關閉", + "repeat": "重複", + "repeatNone": "不重複", + "noneValue": "無", + "repeatDaily": "每天", + "repeatWeekly": "每週", + "repeatMonthly": "每月", + "repeatYearly": "每年", + "importance": "重要性", + "importanceLow": "低", + "importanceNormal": "一般", + "importanceHigh": "高", + "categories": "類別", + "scheduleSection": "行程", + "dueGroup": "到期", + "startGroup": "開始", + "reminderGroup": "提醒", + "organizationSection": "整理", + "actionsSection": "動作", + "advancedSection": "進階", + "addCategory": "新增類別", + "list": "清單", + "microsoftMoveUnsupported": "此版本不支援在 Microsoft To Do 帳戶的清單之間移動待辦事項。", + "createSubtask": "建立子待辦事項", + "moveToTop": "移至頂端", + "deleteTask": "刪除待辦事項", + "newSubtask": "新增子待辦事項", + "deleteTaskConfirmation": "要從 Google Tasks 刪除「{title}」嗎?", + "metadata": "中繼資料", + "id": "ID", + "etag": "ETag", + "updated": "更新時間", + "parent": "上層待辦事項", + "position": "位置", + "webLink": "網頁連結", + "assignment": "指派", + "localState": "本機狀態", + "pendingSync": "等候同步", + "synced": "已同步", + "account": "帳戶", + "sync": "同步", + "manualFullSync": "手動完整同步", + "runInBackgroundWhenClosed": "視窗關閉後繼續在背景執行", + "showTrayIcon": "顯示系統匣圖示", + "startMinimizedToTray": "啟動時最小化至系統匣", + "requiresTrayIcon": "需要系統匣圖示。", + "syncComplete": "同步完成。", + "syncFailed": "同步失敗:{error}", + "notifySyncFailures": "同步失敗通知", + "notifyConflicts": "衝突通知", + "notifyDueToday": "今天到期待辦事項通知", + "eventReminders": "活動提醒", + "taskReminders": "待辦事項提醒", + "notificationDetailLevel": "通知詳細程度", + "notificationDetailPrivate": "私人", + "notificationDetailNormal": "一般", + "quietHours": "勿擾時段", + "quietHoursDescription": "在此時段暫停通知。", + "quietHoursStart": "勿擾開始時間", + "quietHoursEnd": "勿擾結束時間", + "notifications": "通知", + "appearance": "外觀", + "theme": "主題", + "themeSystem": "系統", + "themeLight": "淺色", + "themeDark": "深色", + "themeFamily": "主題系列", + "themeFamilyYaru": "Ubuntu 原生主題(Yaru)", + "localization": "語言與地區", + "currentLocale": "目前語系", + "privacy": "隱私權", + "redactTaskContentInDiagnostics": "在診斷資訊中隱藏待辦事項內容", + "developerDiagnostics": "開發人員診斷", + "diagnostics": "診斷", + "apiInspectorDisabled": "顯示 API 檢查器", + "googleTasksApi": "Google Tasks API", + "discoveryRevision": "Discovery 修訂版本:{revision}", + "implementedMethods": "已實作的方法", + "supportsTasksScopes": "支援 tasks 和 tasks.readonly 權限範圍", + "requiresTasksScope": "需要 tasks 權限範圍", + "blockedPendingOperations": "遭封鎖的待處理作業", + "signInToInspectPendingOperations": "登入以檢查待處理作業。", + "noBlockedPendingOperations": "沒有遭封鎖的待處理作業。", + "operationActions": "作業動作", + "pendingOpListId": "清單={id}", + "pendingOpTaskId": "待辦事項={id}", + "pendingOpAttempts": "嘗試次數={count}", + "retry": "再試一次", + "discard": "捨棄", + "discardChanges": "要捨棄變更嗎?", + "discardChangesConfirmation": "這會捨棄此待辦事項中尚未儲存的編輯內容。", + "retryCompleted": "重試完成。", + "discardPendingOperation": "要捨棄待處理作業嗎?", + "discardPendingOperationConfirmation": "這會移除遭封鎖的本機作業。下次同步時將從 Google Tasks 重新整理資料。", + "pendingOperationDiscarded": "已捨棄待處理作業。", + "syncFailureNotificationTitle": "BusyMax 同步失敗", + "syncFailureNotificationBody": "背景同步失敗。{message}", + "conflictNotificationTitle": "BusyMax 同步衝突", + "conflictNotificationBody": "一項待處理的本機變更遭到封鎖。{summary}", + "dueTodayNotificationTitle": "今天到期的待辦事項", + "dueTodayNotificationBody": "{count, plural, =1{今天有 1 項待辦事項到期。} other{今天有 {count} 項待辦事項到期。}}", + "eventReminderNotificationTitle": "活動提醒", + "taskReminderNotificationTitle": "待辦事項提醒", + "eventReminderNotificationBody": "活動即將開始。", + "taskReminderNotificationBody": "待辦事項即將到期。", + "notificationOpenAction": "開啟", + "notificationDetailsHidden": "根據隱私權設定,詳細資料已隱藏。", + "previousMonth": "上個月", + "nextMonth": "下個月", + "openMonthView": "開啟月檢視", + "previousYear": "上一年", + "nextYear": "下一年", + "openYearView": "開啟年檢視", + "weekNumberTooltip": "第 {number} 週", + "resizeAllDayPanel": "調整全天面板大小", + "scheduleItemCount": "{count, plural, =1{1 項} other{{count} 項}}", + "readOnlyCalendar": "此行事曆為唯讀。", + "selectTimeZone": "選擇時區", + "searchLocations": "搜尋地點", + "noLocationsFound": "找不到地點", + "deleteCalendarConfirmation": "要刪除「{title}」嗎?" +} diff --git a/lib/l10n/generated/app_localizations.dart b/lib/l10n/generated/app_localizations.dart index f2d631d..f533ed4 100644 --- a/lib/l10n/generated/app_localizations.dart +++ b/lib/l10n/generated/app_localizations.dart @@ -8,7 +8,14 @@ import 'package:intl/intl.dart' as intl; import 'app_localizations_de.dart'; import 'app_localizations_en.dart'; import 'app_localizations_es.dart'; +import 'app_localizations_fi.dart'; import 'app_localizations_fr.dart'; +import 'app_localizations_hi.dart'; +import 'app_localizations_ja.dart'; +import 'app_localizations_ko.dart'; +import 'app_localizations_pt.dart'; +import 'app_localizations_ru.dart'; +import 'app_localizations_zh.dart'; // ignore_for_file: type=lint @@ -99,7 +106,16 @@ abstract class AppLocalizations { Locale('de'), Locale('en'), Locale('es'), + Locale('fi'), Locale('fr'), + Locale('hi'), + Locale('ja'), + Locale('ko'), + Locale('pt'), + Locale('ru'), + Locale('zh'), + Locale.fromSubtags(languageCode: 'zh', scriptCode: 'Hans'), + Locale.fromSubtags(languageCode: 'zh', scriptCode: 'Hant'), ]; /// No description provided for @appTitle. @@ -2298,6 +2314,36 @@ abstract class AppLocalizations { /// **'{count, plural, =1{One task is due today.} other{{count} tasks are due today.}}'** String dueTodayNotificationBody(int count); + /// No description provided for @eventReminderNotificationTitle. + /// + /// In en, this message translates to: + /// **'Event reminder'** + String get eventReminderNotificationTitle; + + /// No description provided for @taskReminderNotificationTitle. + /// + /// In en, this message translates to: + /// **'Task reminder'** + String get taskReminderNotificationTitle; + + /// No description provided for @eventReminderNotificationBody. + /// + /// In en, this message translates to: + /// **'Event starts soon.'** + String get eventReminderNotificationBody; + + /// No description provided for @taskReminderNotificationBody. + /// + /// In en, this message translates to: + /// **'Task is due soon.'** + String get taskReminderNotificationBody; + + /// No description provided for @notificationOpenAction. + /// + /// In en, this message translates to: + /// **'Open'** + String get notificationOpenAction; + /// No description provided for @notificationDetailsHidden. /// /// In en, this message translates to: @@ -2399,14 +2445,39 @@ class _AppLocalizationsDelegate } @override - bool isSupported(Locale locale) => - ['de', 'en', 'es', 'fr'].contains(locale.languageCode); + bool isSupported(Locale locale) => [ + 'de', + 'en', + 'es', + 'fi', + 'fr', + 'hi', + 'ja', + 'ko', + 'pt', + 'ru', + 'zh', + ].contains(locale.languageCode); @override bool shouldReload(_AppLocalizationsDelegate old) => false; } AppLocalizations lookupAppLocalizations(Locale locale) { + // Lookup logic when language+script codes are specified. + switch (locale.languageCode) { + case 'zh': + { + switch (locale.scriptCode) { + case 'Hans': + return AppLocalizationsZhHans(); + case 'Hant': + return AppLocalizationsZhHant(); + } + break; + } + } + // Lookup logic when only language code is specified. switch (locale.languageCode) { case 'de': @@ -2415,8 +2486,22 @@ AppLocalizations lookupAppLocalizations(Locale locale) { return AppLocalizationsEn(); case 'es': return AppLocalizationsEs(); + case 'fi': + return AppLocalizationsFi(); case 'fr': return AppLocalizationsFr(); + case 'hi': + return AppLocalizationsHi(); + case 'ja': + return AppLocalizationsJa(); + case 'ko': + return AppLocalizationsKo(); + case 'pt': + return AppLocalizationsPt(); + case 'ru': + return AppLocalizationsRu(); + case 'zh': + return AppLocalizationsZh(); } throw FlutterError( diff --git a/lib/l10n/generated/app_localizations_fr.dart b/lib/l10n/generated/app_localizations_fr.dart index 6122dfa..8ce6b34 100644 --- a/lib/l10n/generated/app_localizations_fr.dart +++ b/lib/l10n/generated/app_localizations_fr.dart @@ -44,7 +44,7 @@ class AppLocalizationsFr extends AppLocalizations { @override String get onboardingPreferencesStepDescription => - 'Réglez le comportement du bureau, les rappels, le détail des notifications et l’apparence avant d’ouvrir votre planning.'; + 'Réglez le comportement de l’application sur le bureau, les rappels, le niveau de détail des notifications et l’apparence avant d’ouvrir votre planning.'; @override String get signInWithGoogle => 'Se connecter avec Google'; @@ -121,7 +121,7 @@ class AppLocalizationsFr extends AppLocalizations { String get refreshCalendar => 'Actualiser le calendrier'; @override - String get openInProvider => 'Ouvrir chez le fournisseur'; + String get openInProvider => 'Ouvrir dans le service'; @override String get hideFromSchedule => 'Masquer du planning'; @@ -264,7 +264,7 @@ class AppLocalizationsFr extends AppLocalizations { @override String get scheduleDisplayHoursDescription => - 'Les vues Jour et Semaine s’ouvrent dans cette plage horaire. Les éléments tôt ou tardifs l’élargissent si nécessaire.'; + '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.'; @override String get scheduleDayStartsAt => 'La journée commence à'; @@ -473,7 +473,7 @@ class AppLocalizationsFr extends AppLocalizations { 'Semaine précédente en vue semaine, mois précédent en vue mois, etc.'; @override - String get shortcutJumpToToday => 'Aller à aujourd\'hui'; + String get shortcutJumpToToday => 'Aller à la date du jour'; @override String get shortcutGroupView => 'Affichage'; @@ -527,7 +527,7 @@ class AppLocalizationsFr extends AppLocalizations { String get aboutBusyMax => 'À propos de BusyMax'; @override - String get aboutBusyMaxDescription => 'ToDo et calendrier'; + String get aboutBusyMaxDescription => 'Tâches et calendrier'; @override String get website => 'Site web'; @@ -682,7 +682,7 @@ class AppLocalizationsFr extends AppLocalizations { @override String get accountRemovedGoogleRevokeFailed => - 'Le compte a été supprimé de cet appareil, mais BusyMax n’a pas pu révoquer l’accès Google. Vous pouvez le révoquer dans votre compte Google.'; + 'Le compte a été supprimé de cet appareil, mais BusyMax n’a pas pu révoquer son accès à votre compte Google. Vous pouvez révoquer cet accès depuis votre compte Google.'; @override String get newList => 'Nouvelle liste'; @@ -734,7 +734,7 @@ class AppLocalizationsFr extends AppLocalizations { String get newTask => 'Nouvelle tâche'; @override - String get clearCompleted => 'Effacer les terminées'; + String get clearCompleted => 'Effacer les tâches terminées'; @override String get refreshList => 'Actualiser la liste'; @@ -765,7 +765,7 @@ class AppLocalizationsFr extends AppLocalizations { @override String get selectOrCreateTaskList => - 'Sélectionnez ou créez une liste de tâches.'; + 'Sélectionnez ou créez une liste de tâches pour commencer.'; @override String get signInToViewTasks => 'Connectez-vous pour voir les tâches.'; @@ -964,7 +964,7 @@ class AppLocalizationsFr extends AppLocalizations { String get createSubtask => 'Créer une sous-tâche'; @override - String get moveToTop => 'Déplacer en haut'; + String get moveToTop => 'Déplacer tout en haut'; @override String get deleteTask => 'Supprimer la tâche'; @@ -990,7 +990,7 @@ class AppLocalizationsFr extends AppLocalizations { String get updated => 'Mis à jour'; @override - String get parent => 'Parent'; + String get parent => 'Tâche parente'; @override String get position => 'Position'; @@ -999,7 +999,7 @@ class AppLocalizationsFr extends AppLocalizations { String get webLink => 'Lien web'; @override - String get assignment => 'Assignation'; + String get assignment => 'Attribution'; @override String get localState => 'État local'; @@ -1067,17 +1067,17 @@ class AppLocalizationsFr extends AppLocalizations { String get notificationDetailNormal => 'Normal'; @override - String get quietHours => 'Plages horaires silencieuses'; + String get quietHours => 'Période de silence'; @override String get quietHoursDescription => 'Mettre les notifications en pause pendant cette période.'; @override - String get quietHoursStart => 'Début des plages silencieuses'; + String get quietHoursStart => 'Début de la période de silence'; @override - String get quietHoursEnd => 'Fin des plages silencieuses'; + String get quietHoursEnd => 'Fin de la période de silence'; @override String get notifications => 'Notifications'; @@ -1098,10 +1098,10 @@ class AppLocalizationsFr extends AppLocalizations { String get themeDark => 'Sombre'; @override - String get themeFamily => 'Famille de thème'; + String get themeFamily => 'Famille de thèmes'; @override - String get themeFamilyYaru => 'Ubuntu natif (Yaru)'; + String get themeFamilyYaru => 'Thème natif d’Ubuntu (Yaru)'; @override String get localization => 'Localisation'; @@ -1176,27 +1176,27 @@ class AppLocalizationsFr extends AppLocalizations { String get retry => 'Réessayer'; @override - String get discard => 'Ignorer'; + String get discard => 'Abandonner'; @override - String get discardChanges => 'Ignorer les modifications ?'; + String get discardChanges => 'Abandonner les modifications ?'; @override String get discardChangesConfirmation => - 'Cela ignore les modifications non enregistrées de cette tâche.'; + 'Les modifications non enregistrées apportées à cette tâche seront perdues.'; @override String get retryCompleted => 'Nouvelle tentative terminée.'; @override - String get discardPendingOperation => 'Ignorer l’opération en attente ?'; + String get discardPendingOperation => 'Abandonner l’opération en attente ?'; @override String get discardPendingOperationConfirmation => - 'Cela supprime l’opération locale bloquée. La prochaine synchronisation actualisera depuis Google Tasks.'; + 'Cette action supprime l’opération locale bloquée. Lors de la prochaine synchronisation, les données seront rechargées depuis Google Tasks.'; @override - String get pendingOperationDiscarded => 'Opération en attente ignorée.'; + String get pendingOperationDiscarded => 'Opération en attente abandonnée.'; @override String get syncFailureNotificationTitle => @@ -1229,6 +1229,22 @@ class AppLocalizationsFr extends AppLocalizations { return '$_temp0'; } + @override + String get eventReminderNotificationTitle => 'Rappel d’événement'; + + @override + String get taskReminderNotificationTitle => 'Rappel de tâche'; + + @override + String get eventReminderNotificationBody => 'L’événement commence bientôt.'; + + @override + String get taskReminderNotificationBody => + 'La tâche arrive bientôt à échéance.'; + + @override + String get notificationOpenAction => 'Ouvrir'; + @override String get notificationDetailsHidden => 'Les détails sont masqués par les paramètres de confidentialité.'; @@ -1285,6 +1301,6 @@ class AppLocalizationsFr extends AppLocalizations { @override String deleteCalendarConfirmation(String title) { - return 'Supprimer \"$title\" ?'; + return 'Supprimer « $title » ?'; } } diff --git a/lib/l10n/generated/app_localizations_hi.dart b/lib/l10n/generated/app_localizations_hi.dart new file mode 100644 index 0000000..399f88e --- /dev/null +++ b/lib/l10n/generated/app_localizations_hi.dart @@ -0,0 +1,1296 @@ +// ignore: unused_import +import 'package:intl/intl.dart' as intl; +import 'app_localizations.dart'; + +// ignore_for_file: type=lint + +/// The translations for Hindi (`hi`). +class AppLocalizationsHi extends AppLocalizations { + AppLocalizationsHi([String locale = 'hi']) : super(locale); + + @override + String get appTitle => 'BusyMax'; + + @override + String get connectGoogleAccount => + 'कैलेंडर और कार्य सिंक करने के लिए Google और Microsoft खाते कनेक्ट करें।'; + + @override + String get googlePermissionsConsentNotice => + 'Google अनुमति स्क्रीन पर, कैलेंडर और कार्य दोनों अनुमतियाँ चुनें।'; + + @override + String get googlePermissionsRequiredRetry => + 'Google Calendar और Google Tasks की अनुमतियाँ आवश्यक हैं। फिर से कोशिश करें और दोनों चेकबॉक्स चुनें।'; + + @override + String get finishSetup => 'सेटअप पूरा करें'; + + @override + String get continueSetup => 'जारी रखें'; + + @override + String get onboardingSetupTitle => 'BusyMax सेट अप करें'; + + @override + String get onboardingAccountsStepTitle => 'खाते कनेक्ट करें'; + + @override + String get onboardingAccountsStepDescription => + 'वे सभी Google और Microsoft खाते जोड़ें जिन्हें आप उपयोग करना चाहते हैं। BusyMax प्रत्येक खाते के कैलेंडर, ईवेंट, कार्य सूचियाँ और कार्य सिंक करता है।'; + + @override + String get onboardingPreferencesStepTitle => 'सिस्टम सेटिंग्स चुनें'; + + @override + String get onboardingPreferencesStepDescription => + 'अपना शेड्यूल खोलने से पहले डेस्कटॉप व्यवहार, रिमाइंडर, सूचना विवरण और दिखावट सेट करें।'; + + @override + String get signInWithGoogle => 'Google से साइन इन करें'; + + @override + String get signInWithMicrosoft => 'Microsoft से साइन इन करें'; + + @override + String get googleTasksProvider => 'Google Tasks'; + + @override + String get microsoftTodoProvider => 'Microsoft To Do'; + + @override + String get providerNotConfigured => 'यह प्रदाता कॉन्फ़िगर नहीं किया गया है।'; + + @override + String get waitingForGoogleSignIn => + 'Google साइन-इन की प्रतीक्षा हो रही है...'; + + @override + String get waitingForMicrosoftSignIn => + 'Microsoft साइन-इन की प्रतीक्षा हो रही है...'; + + @override + String get microsoftSignInNotConfigured => + 'Microsoft साइन-इन कॉन्फ़िगर नहीं है। MICROSOFT_OAUTH_CLIENT_ID सेट करें।'; + + @override + String get cancel => 'रद्द करें'; + + @override + String get close => 'बंद करें'; + + @override + String get exit => 'बाहर निकलें'; + + @override + String get options => 'विकल्प'; + + @override + String get hide => 'छिपाएँ'; + + @override + String get show => 'दिखाएँ'; + + @override + String get export => 'निर्यात करें'; + + @override + String get save => 'सहेजें'; + + @override + String get settings => 'सेटिंग्स'; + + @override + String get all => 'सभी'; + + @override + String get calendarEvents => 'ईवेंट'; + + @override + String get calendarTasks => 'कार्य'; + + @override + String get calendar => 'कैलेंडर'; + + @override + String get calendars => 'कैलेंडर'; + + @override + String get newEvent => 'नया ईवेंट'; + + @override + String get refreshCalendar => 'कैलेंडर रीफ़्रेश करें'; + + @override + String get openInProvider => 'प्रदाता में खोलें'; + + @override + String get hideFromSchedule => 'शेड्यूल से छिपाएँ'; + + @override + String get showInSchedule => 'शेड्यूल में दिखाएँ'; + + @override + String get noCalendarsSynced => 'अभी तक कोई कैलेंडर सिंक नहीं हुआ है।'; + + @override + String get allDay => 'पूरे दिन'; + + @override + String moreItems(int count) { + return '+$count और'; + } + + @override + String get noEventsOrTasks => 'कोई ईवेंट या कार्य नहीं'; + + @override + String get scheduleLoading => 'शेड्यूल लोड हो रहा है...'; + + @override + String get scheduleUnavailable => 'शेड्यूल उपलब्ध नहीं है'; + + @override + String get scheduleNoSources => + 'कोई दिखाई देने वाला कैलेंडर या कार्य सूची नहीं'; + + @override + String get scheduleNoSourcesDescription => + 'सेटिंग्स में चुनें कि क्या दिखाना है, फिर रीफ़्रेश करें।'; + + @override + String get scheduleSignInRequired => 'खाता कनेक्ट करें'; + + @override + String get scheduleSignInDescription => + 'कैलेंडर और कार्य सिंक करने के लिए साइन इन करें।'; + + @override + String get scheduleNoSearchResults => 'कोई मिलता-जुलता ईवेंट या कार्य नहीं'; + + @override + String get scheduleNoSearchResultsDescription => + 'कोई दूसरी खोज आज़माएँ या मौजूदा फ़िल्टर हटाएँ।'; + + @override + String get trayAgendaLoading => 'कार्यसूची लोड हो रही है...'; + + @override + String get trayAgendaSignInRequired => + 'कार्यसूची दिखाने के लिए साइन इन करें।'; + + @override + String get trayAgendaNoSources => + 'कोई दिखाई देने वाला कैलेंडर या कार्य सूची नहीं।'; + + @override + String get trayAgendaOpenBusyMax => 'ऐप खोलें'; + + @override + String get trayAgendaRefresh => 'रीफ़्रेश करें'; + + @override + String get trayAgendaError => 'कार्यसूची उपलब्ध नहीं है'; + + @override + String get compactAgendaTitle => 'कार्यसूची'; + + @override + String get compactAgendaSubtitle => 'आगामी'; + + @override + String get compactAgendaOverdue => 'समय सीमा बीत चुकी'; + + @override + String get compactAgendaClear => 'अभी कुछ नहीं'; + + @override + String get compactAgendaOpenBusyMax => 'BusyMax खोलें'; + + @override + String get compactAgendaHide => 'छिपाएँ'; + + @override + String get compactAgendaNewTask => 'नया कार्य'; + + @override + String get compactAgendaRetry => 'फिर से कोशिश करें'; + + @override + String get compactAgendaRefresh => 'रीफ़्रेश करें'; + + @override + String get compactAgendaAllDay => 'पूरे दिन'; + + @override + String get compactAgendaDueToday => 'आज देय'; + + @override + String get compactAgendaDueTomorrow => 'कल देय'; + + @override + String compactAgendaDueOn(String date) { + return '$date को देय'; + } + + @override + String get compactAgendaMoreOverdue => 'समय सीमा बीत चुके और कार्य लोड करें'; + + @override + String get agendaLoadMoreOverdue => 'समय सीमा बीत चुके और कार्य लोड करें'; + + @override + String get agendaLoadMoreNoDate => 'बिना तारीख वाले और कार्य लोड करें'; + + @override + String get viewDay => 'दिन'; + + @override + String get viewWeek => 'सप्ताह'; + + @override + String get viewMonth => 'महीना'; + + @override + String get viewYear => 'वर्ष'; + + @override + String get viewAgenda => 'कार्यसूची'; + + @override + String get scheduleSettings => 'शेड्यूल'; + + @override + String get scheduleDisplaySettings => 'शेड्यूल प्रदर्शन'; + + @override + String get scheduleDisplayHoursDescription => + 'दिन और सप्ताह दृश्य शुरू में यह समयावधि दिखाते हैं। आवश्यकता होने पर पहले या बाद के आइटम इस सीमा को बढ़ाते हैं।'; + + @override + String get scheduleDayStartsAt => 'दिन शुरू होता है'; + + @override + String get scheduleDayEndsAt => 'दिन समाप्त होता है'; + + @override + String get sourceCalendar => 'कैलेंडर'; + + @override + String get sourceTaskList => 'कार्य सूची'; + + @override + String get createChoiceTitle => 'बनाएँ'; + + @override + String get createEventAtTime => 'ईवेंट'; + + @override + String get createTaskAtDate => 'कार्य'; + + @override + String get editEvent => 'ईवेंट संपादित करें'; + + @override + String get eventTitle => 'ईवेंट का शीर्षक'; + + @override + String get location => 'स्थान'; + + @override + String get timeSlot => 'समयावधि'; + + @override + String get startDateTime => 'शुरू होने की तारीख/समय'; + + @override + String get endDateTime => 'समाप्त होने की तारीख/समय'; + + @override + String get doesNotRepeat => 'दोहराया नहीं जाता'; + + @override + String get defaultReminder => 'डिफ़ॉल्ट रिमाइंडर'; + + @override + String get guests => 'अतिथि'; + + @override + String get noGuests => 'कोई अतिथि नहीं'; + + @override + String get description => 'विवरण'; + + @override + String get availabilityShowAs => 'उपलब्धता / इस रूप में दिखाएँ'; + + @override + String get busy => 'व्यस्त'; + + @override + String get visibility => 'दृश्यता'; + + @override + String get defaultVisibility => 'डिफ़ॉल्ट दृश्यता'; + + @override + String get conference => 'कॉन्फ़्रेंस'; + + @override + String get noConference => 'कोई कॉन्फ़्रेंस नहीं'; + + @override + String get providerCalendar => 'प्रदाता कैलेंडर'; + + @override + String get formatBoldShortLabel => 'B'; + + @override + String get formatBoldTooltip => 'बोल्ड'; + + @override + String get formatItalicShortLabel => 'I'; + + @override + String get formatItalicTooltip => 'इटैलिक'; + + @override + String get formatUnderlineShortLabel => 'U'; + + @override + String get formatUnderlineTooltip => 'रेखांकित'; + + @override + String reminderMinutesBefore(int minutes) { + String _temp0 = intl.Intl.pluralLogic( + minutes, + locale: localeName, + other: '$minutes मिनट पहले', + one: '1 मिनट पहले', + ); + return '$_temp0'; + } + + @override + String get reminderAtStart => 'शुरू होने पर'; + + @override + String reminderHoursBefore(int hours) { + String _temp0 = intl.Intl.pluralLogic( + hours, + locale: localeName, + other: '$hours घंटे पहले', + one: '1 घंटा पहले', + ); + return '$_temp0'; + } + + @override + String reminderDaysBefore(int days) { + String _temp0 = intl.Intl.pluralLogic( + days, + locale: localeName, + other: '$days दिन पहले', + one: '1 दिन पहले', + ); + return '$_temp0'; + } + + @override + String get availabilityFree => 'खाली'; + + @override + String get availabilityTentative => 'अस्थायी'; + + @override + String get availabilityOutOfOffice => 'कार्यालय से बाहर'; + + @override + String get availabilityWorkingElsewhere => 'किसी अन्य स्थान पर कार्यरत'; + + @override + String get visibilityDefault => 'डिफ़ॉल्ट'; + + @override + String get visibilityPublic => 'सार्वजनिक'; + + @override + String get visibilityPrivate => 'निजी'; + + @override + String get visibilityConfidential => 'गोपनीय'; + + @override + String get sensitivityNormal => 'सामान्य'; + + @override + String get sensitivityPersonal => 'व्यक्तिगत'; + + @override + String get tasks => 'कार्य'; + + @override + String get allTasks => 'सभी कार्य'; + + @override + String tasksInList(String title) { + return '$title में कार्य'; + } + + @override + String get taskLists => 'कार्य सूचियाँ'; + + @override + String get navigation => 'नेविगेशन'; + + @override + String get mainMenu => 'मुख्य मेन्यू'; + + @override + String get keyboardShortcuts => 'कीबोर्ड शॉर्टकट'; + + @override + String get shortcutGroupGeneral => 'सामान्य'; + + @override + String get shortcutKeyboardShortcutsDescription => 'यह शॉर्टकट संदर्भ दिखाएँ'; + + @override + String get shortcutGroupNavigation => 'नेविगेशन'; + + @override + String get shortcutNextPeriod => 'अगली अवधि'; + + @override + String get shortcutNextPeriodDescription => + 'सप्ताह दृश्य में अगला सप्ताह, महीने के दृश्य में अगला महीना, इत्यादि'; + + @override + String get shortcutPreviousPeriod => 'पिछली अवधि'; + + @override + String get shortcutPreviousPeriodDescription => + 'सप्ताह दृश्य में पिछला सप्ताह, महीने के दृश्य में पिछला महीना, इत्यादि'; + + @override + String get shortcutJumpToToday => 'आज पर जाएँ'; + + @override + String get shortcutGroupView => 'दृश्य'; + + @override + String get shortcutDayView => 'दिन का दृश्य'; + + @override + String get shortcutWeekView => 'सप्ताह का दृश्य'; + + @override + String get shortcutMonthView => 'महीने का दृश्य'; + + @override + String get shortcutYearView => 'वर्ष का दृश्य'; + + @override + String get shortcutAgendaView => 'कार्यसूची दृश्य'; + + @override + String get shortcutGroupCreateAndEdit => 'बनाएँ और संपादित करें'; + + @override + String get shortcutSaveItem => 'ईवेंट या कार्य सहेजें'; + + @override + String get shortcutDeleteItem => 'ईवेंट या कार्य मिटाएँ'; + + @override + String get shortcutGroupTaskEditing => 'कार्य संपादन'; + + @override + String get shortcutCancelEditing => 'संपादन रद्द करें'; + + @override + String get shortcutCancelEditingDescription => + 'कार्य संपादन या कार्य विवरण बंद करें'; + + @override + String get shortcutGroupCompactAgenda => 'संक्षिप्त कार्यसूची'; + + @override + String get shortcutRefreshCompactAgendaDescription => + 'संक्षिप्त कार्यसूची विंडो रीफ़्रेश करें'; + + @override + String get shortcutHideCompactAgendaDescription => + 'संक्षिप्त कार्यसूची विंडो छिपाएँ'; + + @override + String get aboutBusyMax => 'BusyMax के बारे में'; + + @override + String get aboutBusyMaxDescription => 'कार्य और कैलेंडर'; + + @override + String get website => 'वेबसाइट'; + + @override + String get reportAnIssue => 'समस्या की रिपोर्ट करें'; + + @override + String get sendFeedback => 'प्रतिक्रिया भेजें'; + + @override + String get feedbackSubmit => 'सबमिट करें'; + + @override + String get feedbackCategory => 'श्रेणी'; + + @override + String get feedbackSelectCategory => 'श्रेणी चुनें'; + + @override + String get feedbackCategoryProblem => 'समस्या या बग'; + + @override + String get feedbackCategoryFeature => 'सुविधा का अनुरोध'; + + @override + String get feedbackCategoryPrivacySecurity => + 'गोपनीयता या सुरक्षा संबंधी चिंता'; + + @override + String get feedbackCategoryUsability => 'उपयोगिता संबंधी चिंता'; + + @override + String get feedbackCategoryOther => 'अन्य'; + + @override + String get feedbackSubject => 'विषय'; + + @override + String get feedbackDetailedMessage => 'विस्तृत संदेश'; + + @override + String get feedbackReplyEmail => 'जवाब के लिए ईमेल (वैकल्पिक)'; + + @override + String get feedbackIncludeTechnicalDetails => 'तकनीकी विवरण शामिल करें'; + + @override + String get feedbackTechnicalDetailsDisclosure => + 'केवल आपके Linux ऑपरेटिंग सिस्टम का संस्करण और ऐप का स्थान-भाषा जोड़ा जाता है। कोई लॉग, खाता डेटा, फ़ाइल नाम या अन्य निदान शामिल नहीं किया जाता।'; + + @override + String get feedbackCategoryRequired => 'श्रेणी चुनें।'; + + @override + String get feedbackSubjectLengthError => + 'विषय 3 से 120 वर्णों के बीच होना चाहिए।'; + + @override + String get feedbackMessageLengthError => + 'संदेश 10 से 5,000 वर्णों के बीच होना चाहिए।'; + + @override + String get feedbackInvalidEmail => 'मान्य ईमेल पता दर्ज करें।'; + + @override + String get feedbackConnectionError => + 'BusyStack से कनेक्ट नहीं हो सका। अपना कनेक्शन जाँचें और फिर कोशिश करें।'; + + @override + String get feedbackTimeoutError => + 'अनुरोध का समय समाप्त हो गया। आपकी प्रतिक्रिया हटाई नहीं गई है; फिर से कोशिश करें।'; + + @override + String get feedbackRateLimitedError => + 'इस नेटवर्क से बहुत अधिक प्रतिक्रियाएँ भेजी गई हैं। प्रतीक्षा करें और फिर कोशिश करें।'; + + @override + String get feedbackRejectedError => + 'सर्वर ने सबमिशन अस्वीकार कर दिया। फ़ील्ड की समीक्षा करें और फिर कोशिश करें।'; + + @override + String get feedbackServerError => + 'BusyStack अभी आपकी प्रतिक्रिया स्वीकार नहीं कर सका। आपकी प्रतिक्रिया हटाई नहीं गई है; फिर से कोशिश करें।'; + + @override + String feedbackSuccess(String id) { + return 'प्रतिक्रिया भेज दी गई। संदर्भ: $id'; + } + + @override + String get toggleSidebar => 'साइडबार दिखाएँ या छिपाएँ'; + + @override + String get accounts => 'खाते'; + + @override + String get currentAccount => 'मौजूदा खाता'; + + @override + String get switchAccount => 'खाता बदलें'; + + @override + String get addGoogleAccount => 'Google खाता जोड़ें'; + + @override + String get addMicrosoftAccount => 'Microsoft खाता जोड़ें'; + + @override + String get googleProvider => 'Google'; + + @override + String get microsoftProvider => 'Microsoft'; + + @override + String get signedInAccount => 'साइन इन है'; + + @override + String get removeAccount => 'खाता हटाएँ…'; + + @override + String get removingAccount => 'खाता हटाया जा रहा है…'; + + @override + String get removeAccountDescription => + 'सिंक करना बंद करें और इस डिवाइस से इस खाते का डेटा हटाएँ।'; + + @override + String removeAccountTitle(String account) { + return 'BusyMax से $account हटाएँ?'; + } + + @override + String get removeAccountConfirmation => + 'इससे कैश किए गए कार्य, कैलेंडर, ईवेंट, रिमाइंडर और लंबित ऑफ़लाइन बदलाव इस डिवाइस से मिट जाएँगे। सिंक न किए गए बदलाव खो जाएँगे। Google या Microsoft से कुछ भी नहीं मिटेगा।'; + + @override + String get revokeGoogleAccess => + 'इस Google खाते से BusyMax की पहुँच भी रद्द करें'; + + @override + String get revokeGoogleAccessDescription => + 'दोबारा कनेक्ट करने से पहले आपको फिर से पहुँच देनी होगी।'; + + @override + String get removeAccountAction => 'खाता हटाएँ'; + + @override + String get removeAccountFailed => + 'खाता हटाना पूरा नहीं हो सका। फिर से कोशिश करें।'; + + @override + String get accountRemovedGoogleRevokeFailed => + 'खाता इस डिवाइस से हटा दिया गया, लेकिन BusyMax Google की पहुँच रद्द नहीं कर सका। आप इसे अपने Google खाते से रद्द कर सकते हैं।'; + + @override + String get newList => 'नई सूची'; + + @override + String get signInToViewTaskLists => + 'कार्य सूचियाँ देखने के लिए साइन इन करें।'; + + @override + String get noTaskListsSynced => 'अभी तक कोई कार्य सूची सिंक नहीं हुई है।'; + + @override + String get listActions => 'सूची की कार्रवाइयाँ'; + + @override + String get rename => 'नाम बदलें'; + + @override + String get delete => 'मिटाएँ'; + + @override + String get renameList => 'सूची का नाम बदलें'; + + @override + String get deleteList => 'सूची मिटाएँ'; + + @override + String get builtInMicrosoftList => 'अंतर्निहित'; + + @override + String get builtInMicrosoftListCannotRenameDelete => + 'Microsoft To Do की अंतर्निहित सूचियों का नाम बदला या उन्हें मिटाया नहीं जा सकता।'; + + @override + String deleteListConfirmation(String title) { + return 'Google Tasks से “$title” मिटाएँ?'; + } + + @override + String get deleteEvent => 'ईवेंट मिटाएँ'; + + @override + String get title => 'शीर्षक'; + + @override + String get create => 'बनाएँ'; + + @override + String get newTask => 'नया कार्य'; + + @override + String get clearCompleted => 'पूरे हुए कार्य हटाएँ'; + + @override + String get refreshList => 'सूची रीफ़्रेश करें'; + + @override + String get refreshAll => 'सभी रीफ़्रेश करें'; + + @override + String get listRefreshed => 'सूची रीफ़्रेश हो गई।'; + + @override + String get allTasksRefreshed => 'सभी खाते रीफ़्रेश हो गए।'; + + @override + String exportedFile(String path) { + return '$path में निर्यात किया गया'; + } + + @override + String exportFailed(String error) { + return 'निर्यात विफल: $error'; + } + + @override + String refreshFailed(String error) { + return 'रीफ़्रेश विफल: $error'; + } + + @override + String get selectOrCreateTaskList => + 'शुरू करने के लिए कार्य सूची चुनें या बनाएँ।'; + + @override + String get signInToViewTasks => 'कार्य देखने के लिए साइन इन करें।'; + + @override + String get noTasks => 'कोई कार्य नहीं।'; + + @override + String get noTasksYet => 'अभी तक कोई कार्य नहीं'; + + @override + String get noTasksYetMessage => + 'शुरू करने के लिए कार्य बनाएँ या अपने खाते रीफ़्रेश करें।'; + + @override + String get noTasksInList => 'इस सूची में कोई कार्य नहीं है।'; + + @override + String get overdue => 'समय सीमा बीत चुकी'; + + @override + String get today => 'आज'; + + @override + String get tomorrow => 'कल'; + + @override + String get upcoming => 'आगामी'; + + @override + String get noDate => 'कोई तारीख नहीं'; + + @override + String get completed => 'पूर्ण'; + + @override + String duePrefix(String date) { + return '$date को देय'; + } + + @override + String dateTimeDisplay(String date, String time) { + return '$date · $time'; + } + + @override + String get taskDetails => 'कार्य का विवरण'; + + @override + String get editTask => 'कार्य संपादित करें'; + + @override + String get noTaskSelected => 'कोई कार्य नहीं चुना गया।'; + + @override + String get noTaskSelectedHelper => + 'विवरण देखने और संपादित करने के लिए कोई कार्य चुनें।'; + + @override + String get taskUnavailable => 'कार्य उपलब्ध नहीं है।'; + + @override + String get signInToEditTasks => 'कार्य संपादित करने के लिए साइन इन करें।'; + + @override + String get refreshTask => 'कार्य रीफ़्रेश करें'; + + @override + String get primarySection => 'मुख्य'; + + @override + String get statusSection => 'स्थिति'; + + @override + String get openStatus => 'खुला'; + + @override + String get doneStatus => 'पूर्ण'; + + @override + String get notes => 'नोट्स'; + + @override + String get dueDate => 'देय तारीख'; + + @override + String get clearDueDate => 'देय तारीख हटाएँ'; + + @override + String get dueTime => 'देय समय'; + + @override + String get startDate => 'शुरू होने की तारीख'; + + @override + String get startTime => 'शुरू होने का समय'; + + @override + String get endDate => 'समाप्ति तारीख'; + + @override + String get endTime => 'समाप्ति समय'; + + @override + String get reminderDate => 'रिमाइंडर की तारीख'; + + @override + String get reminderTime => 'रिमाइंडर का समय'; + + @override + String get reminder => 'रिमाइंडर'; + + @override + String get addReminder => 'रिमाइंडर जोड़ें'; + + @override + String get addGuest => 'अतिथि जोड़ें'; + + @override + String get addGuestEmail => 'अतिथि का ईमेल जोड़ें'; + + @override + String get removeReminder => 'रिमाइंडर हटाएँ'; + + @override + String get off => 'बंद'; + + @override + String get repeat => 'दोहराएँ'; + + @override + String get repeatNone => 'कभी नहीं'; + + @override + String get noneValue => 'कोई नहीं'; + + @override + String get repeatDaily => 'प्रतिदिन'; + + @override + String get repeatWeekly => 'हर सप्ताह'; + + @override + String get repeatMonthly => 'हर महीने'; + + @override + String get repeatYearly => 'हर वर्ष'; + + @override + String get importance => 'महत्त्व'; + + @override + String get importanceLow => 'कम'; + + @override + String get importanceNormal => 'सामान्य'; + + @override + String get importanceHigh => 'अधिक'; + + @override + String get categories => 'श्रेणियाँ'; + + @override + String get scheduleSection => 'शेड्यूल'; + + @override + String get dueGroup => 'देय'; + + @override + String get startGroup => 'शुरुआत'; + + @override + String get reminderGroup => 'रिमाइंडर'; + + @override + String get organizationSection => 'व्यवस्था'; + + @override + String get actionsSection => 'कार्रवाइयाँ'; + + @override + String get advancedSection => 'उन्नत'; + + @override + String get addCategory => 'श्रेणी जोड़ें'; + + @override + String get list => 'सूची'; + + @override + String get microsoftMoveUnsupported => + 'इस संस्करण में Microsoft To Do खातों के लिए सूचियों के बीच कार्य ले जाना समर्थित नहीं है।'; + + @override + String get createSubtask => 'उपकार्य बनाएँ'; + + @override + String get moveToTop => 'सबसे ऊपर ले जाएँ'; + + @override + String get deleteTask => 'कार्य मिटाएँ'; + + @override + String get newSubtask => 'नया उपकार्य'; + + @override + String deleteTaskConfirmation(String title) { + return 'Google Tasks से “$title” मिटाएँ?'; + } + + @override + String get metadata => 'मेटाडेटा'; + + @override + String get id => 'आईडी'; + + @override + String get etag => 'ETag'; + + @override + String get updated => 'अपडेट किया गया'; + + @override + String get parent => 'मूल कार्य'; + + @override + String get position => 'स्थान'; + + @override + String get webLink => 'वेब लिंक'; + + @override + String get assignment => 'असाइनमेंट'; + + @override + String get localState => 'स्थानीय स्थिति'; + + @override + String get pendingSync => 'सिंक लंबित'; + + @override + String get synced => 'सिंक किया गया'; + + @override + String get account => 'खाता'; + + @override + String get sync => 'सिंक'; + + @override + String get manualFullSync => 'मैन्युअल पूर्ण सिंक'; + + @override + String get runInBackgroundWhenClosed => 'विंडो बंद होने पर भी चलते रहें'; + + @override + String get showTrayIcon => 'ट्रे आइकन दिखाएँ'; + + @override + String get startMinimizedToTray => 'ट्रे में छोटा होकर शुरू करें'; + + @override + String get requiresTrayIcon => 'ट्रे आइकन आवश्यक है।'; + + @override + String get syncComplete => 'सिंक पूरा हुआ।'; + + @override + String syncFailed(String error) { + return 'सिंक विफल: $error'; + } + + @override + String get notifySyncFailures => 'सिंक विफल होने की सूचनाएँ'; + + @override + String get notifyConflicts => 'टकराव की सूचनाएँ'; + + @override + String get notifyDueToday => 'आज देय कार्यों की सूचनाएँ'; + + @override + String get eventReminders => 'ईवेंट रिमाइंडर'; + + @override + String get taskReminders => 'कार्य रिमाइंडर'; + + @override + String get notificationDetailLevel => 'सूचना विवरण का स्तर'; + + @override + String get notificationDetailPrivate => 'निजी'; + + @override + String get notificationDetailNormal => 'सामान्य'; + + @override + String get quietHours => 'शांत समय'; + + @override + String get quietHoursDescription => 'इस अवधि के दौरान सूचनाएँ रोकें।'; + + @override + String get quietHoursStart => 'शांत समय की शुरुआत'; + + @override + String get quietHoursEnd => 'शांत समय की समाप्ति'; + + @override + String get notifications => 'सूचनाएँ'; + + @override + String get appearance => 'दिखावट'; + + @override + String get theme => 'थीम'; + + @override + String get themeSystem => 'सिस्टम'; + + @override + String get themeLight => 'हल्की'; + + @override + String get themeDark => 'गहरी'; + + @override + String get themeFamily => 'थीम परिवार'; + + @override + String get themeFamilyYaru => 'मूल Ubuntu (Yaru)'; + + @override + String get localization => 'स्थानीयकरण'; + + @override + String get currentLocale => 'मौजूदा स्थान-भाषा'; + + @override + String get privacy => 'गोपनीयता'; + + @override + String get redactTaskContentInDiagnostics => 'निदान में कार्य सामग्री छिपाएँ'; + + @override + String get developerDiagnostics => 'डेवलपर निदान'; + + @override + String get diagnostics => 'निदान'; + + @override + String get apiInspectorDisabled => 'API इंस्पेक्टर दिखाएँ'; + + @override + String get googleTasksApi => 'Google Tasks API'; + + @override + String discoveryRevision(String revision) { + return 'डिस्कवरी संशोधन: $revision'; + } + + @override + String get implementedMethods => 'लागू की गई विधियाँ'; + + @override + String get supportsTasksScopes => 'tasks और tasks.readonly स्कोप समर्थित हैं'; + + @override + String get requiresTasksScope => 'tasks स्कोप आवश्यक है'; + + @override + String get blockedPendingOperations => 'अवरुद्ध लंबित कार्रवाइयाँ'; + + @override + String get signInToInspectPendingOperations => + 'लंबित कार्रवाइयाँ देखने के लिए साइन इन करें।'; + + @override + String get noBlockedPendingOperations => + 'कोई अवरुद्ध लंबित कार्रवाई नहीं है।'; + + @override + String get operationActions => 'कार्रवाई के विकल्प'; + + @override + String pendingOpListId(String id) { + return 'सूची=$id'; + } + + @override + String pendingOpTaskId(String id) { + return 'कार्य=$id'; + } + + @override + String pendingOpAttempts(int count) { + return 'प्रयास=$count'; + } + + @override + String get retry => 'फिर से कोशिश करें'; + + @override + String get discard => 'छोड़ें'; + + @override + String get discardChanges => 'बदलाव छोड़ें?'; + + @override + String get discardChangesConfirmation => + 'इससे इस कार्य के सहेजे न गए बदलाव छोड़ दिए जाएँगे।'; + + @override + String get retryCompleted => 'दोबारा प्रयास पूरा हुआ।'; + + @override + String get discardPendingOperation => 'लंबित कार्रवाई छोड़ें?'; + + @override + String get discardPendingOperationConfirmation => + 'इससे अवरुद्ध स्थानीय कार्रवाई हट जाती है। अगला सिंक Google Tasks से डेटा रीफ़्रेश करेगा।'; + + @override + String get pendingOperationDiscarded => 'लंबित कार्रवाई छोड़ दी गई।'; + + @override + String get syncFailureNotificationTitle => 'BusyMax सिंक विफल'; + + @override + String syncFailureNotificationBody(String message) { + return 'बैकग्राउंड सिंक विफल हुआ। $message'; + } + + @override + String get conflictNotificationTitle => 'BusyMax सिंक टकराव'; + + @override + String conflictNotificationBody(String summary) { + return 'एक लंबित स्थानीय बदलाव अवरुद्ध हो गया। $summary'; + } + + @override + String get dueTodayNotificationTitle => 'आज देय कार्य'; + + @override + String dueTodayNotificationBody(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'आज $count कार्य देय हैं।', + one: 'आज एक कार्य देय है।', + ); + return '$_temp0'; + } + + @override + String get eventReminderNotificationTitle => 'ईवेंट रिमाइंडर'; + + @override + String get taskReminderNotificationTitle => 'कार्य रिमाइंडर'; + + @override + String get eventReminderNotificationBody => 'ईवेंट जल्द शुरू होगा।'; + + @override + String get taskReminderNotificationBody => 'कार्य जल्द देय है।'; + + @override + String get notificationOpenAction => 'खोलें'; + + @override + String get notificationDetailsHidden => + 'गोपनीयता सेटिंग्स के कारण विवरण छिपे हुए हैं।'; + + @override + String get previousMonth => 'पिछला महीना'; + + @override + String get nextMonth => 'अगला महीना'; + + @override + String get openMonthView => 'महीने का दृश्य खोलें'; + + @override + String get previousYear => 'पिछला वर्ष'; + + @override + String get nextYear => 'अगला वर्ष'; + + @override + String get openYearView => 'वर्ष का दृश्य खोलें'; + + @override + String weekNumberTooltip(int number) { + return 'सप्ताह $number'; + } + + @override + String get resizeAllDayPanel => 'पूरे दिन वाले पैनल का आकार बदलें'; + + @override + String scheduleItemCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count आइटम', + one: '1 आइटम', + ); + return '$_temp0'; + } + + @override + String get readOnlyCalendar => 'यह कैलेंडर केवल पढ़ने योग्य है।'; + + @override + String get selectTimeZone => 'समय क्षेत्र चुनें'; + + @override + String get searchLocations => 'स्थान खोजें'; + + @override + String get noLocationsFound => 'कोई स्थान नहीं मिला'; + + @override + String deleteCalendarConfirmation(String title) { + return '“$title” मिटाएँ?'; + } +} diff --git a/lib/l10n/generated/app_localizations_ja.dart b/lib/l10n/generated/app_localizations_ja.dart new file mode 100644 index 0000000..4fc644a --- /dev/null +++ b/lib/l10n/generated/app_localizations_ja.dart @@ -0,0 +1,1269 @@ +// ignore: unused_import +import 'package:intl/intl.dart' as intl; +import 'app_localizations.dart'; + +// ignore_for_file: type=lint + +/// The translations for Japanese (`ja`). +class AppLocalizationsJa extends AppLocalizations { + AppLocalizationsJa([String locale = 'ja']) : super(locale); + + @override + String get appTitle => 'BusyMax'; + + @override + String get connectGoogleAccount => + 'Google と Microsoft のアカウントを接続して、カレンダーとタスクを同期します。'; + + @override + String get googlePermissionsConsentNotice => + 'Google の権限画面で、カレンダーとタスクの両方の権限を選択してください。'; + + @override + String get googlePermissionsRequiredRetry => + 'Google カレンダーと Google Tasks の権限が必要です。もう一度試して、両方のチェックボックスを選択してください。'; + + @override + String get finishSetup => 'セットアップを完了'; + + @override + String get continueSetup => '続行'; + + @override + String get onboardingSetupTitle => 'BusyMax をセットアップ'; + + @override + String get onboardingAccountsStepTitle => 'アカウントを接続'; + + @override + String get onboardingAccountsStepDescription => + '使用するすべての Google アカウントと Microsoft アカウントを追加してください。BusyMax は各アカウントのカレンダー、予定、タスクリスト、タスクを同期します。'; + + @override + String get onboardingPreferencesStepTitle => 'システム設定を選択'; + + @override + String get onboardingPreferencesStepDescription => + 'スケジュールを開く前に、デスクトップでの動作、リマインダー、通知の詳細度、外観を設定します。'; + + @override + String get signInWithGoogle => 'Google でサインイン'; + + @override + String get signInWithMicrosoft => 'Microsoft でサインイン'; + + @override + String get googleTasksProvider => 'Google Tasks'; + + @override + String get microsoftTodoProvider => 'Microsoft To Do'; + + @override + String get providerNotConfigured => 'このプロバイダーは設定されていません。'; + + @override + String get waitingForGoogleSignIn => 'Google のサインインを待機しています...'; + + @override + String get waitingForMicrosoftSignIn => 'Microsoft のサインインを待機しています...'; + + @override + String get microsoftSignInNotConfigured => + 'Microsoft のサインインが設定されていません。MICROSOFT_OAUTH_CLIENT_ID を設定してください。'; + + @override + String get cancel => 'キャンセル'; + + @override + String get close => '閉じる'; + + @override + String get exit => '終了'; + + @override + String get options => 'オプション'; + + @override + String get hide => '非表示'; + + @override + String get show => '表示'; + + @override + String get export => 'エクスポート'; + + @override + String get save => '保存'; + + @override + String get settings => '設定'; + + @override + String get all => 'すべて'; + + @override + String get calendarEvents => '予定'; + + @override + String get calendarTasks => 'タスク'; + + @override + String get calendar => 'カレンダー'; + + @override + String get calendars => 'カレンダー'; + + @override + String get newEvent => '新しい予定'; + + @override + String get refreshCalendar => 'カレンダーを更新'; + + @override + String get openInProvider => 'プロバイダーで開く'; + + @override + String get hideFromSchedule => 'スケジュールから非表示'; + + @override + String get showInSchedule => 'スケジュールに表示'; + + @override + String get noCalendarsSynced => '同期済みのカレンダーはまだありません。'; + + @override + String get allDay => '終日'; + + @override + String moreItems(int count) { + return '他 $count 件'; + } + + @override + String get noEventsOrTasks => '予定またはタスクはありません'; + + @override + String get scheduleLoading => 'スケジュールを読み込んでいます...'; + + @override + String get scheduleUnavailable => 'スケジュールを利用できません'; + + @override + String get scheduleNoSources => '表示できるカレンダーまたはタスクリストがありません'; + + @override + String get scheduleNoSourcesDescription => '設定で表示する項目を選択してから、更新してください。'; + + @override + String get scheduleSignInRequired => 'アカウントを接続'; + + @override + String get scheduleSignInDescription => 'カレンダーとタスクを同期するにはサインインしてください。'; + + @override + String get scheduleNoSearchResults => '一致する予定またはタスクはありません'; + + @override + String get scheduleNoSearchResultsDescription => + '別の条件で検索するか、現在のフィルターを解除してください。'; + + @override + String get trayAgendaLoading => '予定一覧を読み込んでいます...'; + + @override + String get trayAgendaSignInRequired => '予定一覧を表示するにはサインインしてください。'; + + @override + String get trayAgendaNoSources => '表示できるカレンダーまたはタスクリストがありません。'; + + @override + String get trayAgendaOpenBusyMax => 'アプリを開く'; + + @override + String get trayAgendaRefresh => '更新'; + + @override + String get trayAgendaError => '予定一覧を利用できません'; + + @override + String get compactAgendaTitle => '予定一覧'; + + @override + String get compactAgendaSubtitle => '今後の予定'; + + @override + String get compactAgendaOverdue => '期限超過'; + + @override + String get compactAgendaClear => '今のところ予定なし'; + + @override + String get compactAgendaOpenBusyMax => 'BusyMax を開く'; + + @override + String get compactAgendaHide => '非表示'; + + @override + String get compactAgendaNewTask => '新しいタスク'; + + @override + String get compactAgendaRetry => '再試行'; + + @override + String get compactAgendaRefresh => '更新'; + + @override + String get compactAgendaAllDay => '終日'; + + @override + String get compactAgendaDueToday => '今日が期限'; + + @override + String get compactAgendaDueTomorrow => '明日が期限'; + + @override + String compactAgendaDueOn(String date) { + return '期限: $date'; + } + + @override + String get compactAgendaMoreOverdue => '期限切れのタスクをさらに読み込む'; + + @override + String get agendaLoadMoreOverdue => '期限切れのタスクをさらに読み込む'; + + @override + String get agendaLoadMoreNoDate => '日付のないタスクをさらに読み込む'; + + @override + String get viewDay => '日'; + + @override + String get viewWeek => '週'; + + @override + String get viewMonth => '月'; + + @override + String get viewYear => '年'; + + @override + String get viewAgenda => '予定一覧'; + + @override + String get scheduleSettings => 'スケジュール'; + + @override + String get scheduleDisplaySettings => 'スケジュール表示'; + + @override + String get scheduleDisplayHoursDescription => + '日表示と週表示では、最初にこの時間範囲が表示されます。必要に応じて、範囲外の早い項目や遅い項目まで表示範囲が広がります。'; + + @override + String get scheduleDayStartsAt => '一日の開始時刻'; + + @override + String get scheduleDayEndsAt => '一日の終了時刻'; + + @override + String get sourceCalendar => 'カレンダー'; + + @override + String get sourceTaskList => 'タスクリスト'; + + @override + String get createChoiceTitle => '作成'; + + @override + String get createEventAtTime => '予定'; + + @override + String get createTaskAtDate => 'タスク'; + + @override + String get editEvent => '予定を編集'; + + @override + String get eventTitle => '予定のタイトル'; + + @override + String get location => '場所'; + + @override + String get timeSlot => '時間帯'; + + @override + String get startDateTime => '開始日時'; + + @override + String get endDateTime => '終了日時'; + + @override + String get doesNotRepeat => '繰り返さない'; + + @override + String get defaultReminder => 'デフォルトのリマインダー'; + + @override + String get guests => 'ゲスト'; + + @override + String get noGuests => 'ゲストなし'; + + @override + String get description => '説明'; + + @override + String get availabilityShowAs => '空き時間情報 / 表示方法'; + + @override + String get busy => '予定あり'; + + @override + String get visibility => '公開設定'; + + @override + String get defaultVisibility => 'デフォルトの公開設定'; + + @override + String get conference => '会議'; + + @override + String get noConference => '会議なし'; + + @override + String get providerCalendar => 'プロバイダーのカレンダー'; + + @override + String get formatBoldShortLabel => 'B'; + + @override + String get formatBoldTooltip => '太字'; + + @override + String get formatItalicShortLabel => 'I'; + + @override + String get formatItalicTooltip => '斜体'; + + @override + String get formatUnderlineShortLabel => 'U'; + + @override + String get formatUnderlineTooltip => '下線'; + + @override + String reminderMinutesBefore(int minutes) { + String _temp0 = intl.Intl.pluralLogic( + minutes, + locale: localeName, + other: '$minutes分前', + one: '1分前', + ); + return '$_temp0'; + } + + @override + String get reminderAtStart => '開始時刻'; + + @override + String reminderHoursBefore(int hours) { + String _temp0 = intl.Intl.pluralLogic( + hours, + locale: localeName, + other: '$hours時間前', + one: '1時間前', + ); + return '$_temp0'; + } + + @override + String reminderDaysBefore(int days) { + String _temp0 = intl.Intl.pluralLogic( + days, + locale: localeName, + other: '$days日前', + one: '1日前', + ); + return '$_temp0'; + } + + @override + String get availabilityFree => '空き時間'; + + @override + String get availabilityTentative => '仮の予定'; + + @override + String get availabilityOutOfOffice => '外出中'; + + @override + String get availabilityWorkingElsewhere => '別の場所で勤務'; + + @override + String get visibilityDefault => 'デフォルト'; + + @override + String get visibilityPublic => '一般公開'; + + @override + String get visibilityPrivate => '非公開'; + + @override + String get visibilityConfidential => '機密'; + + @override + String get sensitivityNormal => '標準'; + + @override + String get sensitivityPersonal => '個人用'; + + @override + String get tasks => 'タスク'; + + @override + String get allTasks => 'すべてのタスク'; + + @override + String tasksInList(String title) { + return '$title のタスク'; + } + + @override + String get taskLists => 'タスクリスト'; + + @override + String get navigation => 'ナビゲーション'; + + @override + String get mainMenu => 'メインメニュー'; + + @override + String get keyboardShortcuts => 'キーボードショートカット'; + + @override + String get shortcutGroupGeneral => '全般'; + + @override + String get shortcutKeyboardShortcutsDescription => 'このショートカット一覧を表示'; + + @override + String get shortcutGroupNavigation => 'ナビゲーション'; + + @override + String get shortcutNextPeriod => '次の期間'; + + @override + String get shortcutNextPeriodDescription => '週表示では次の週、月表示では次の月というように移動します'; + + @override + String get shortcutPreviousPeriod => '前の期間'; + + @override + String get shortcutPreviousPeriodDescription => + '週表示では前の週、月表示では前の月というように移動します'; + + @override + String get shortcutJumpToToday => '今日に移動'; + + @override + String get shortcutGroupView => '表示'; + + @override + String get shortcutDayView => '日表示'; + + @override + String get shortcutWeekView => '週表示'; + + @override + String get shortcutMonthView => '月表示'; + + @override + String get shortcutYearView => '年表示'; + + @override + String get shortcutAgendaView => '予定一覧表示'; + + @override + String get shortcutGroupCreateAndEdit => '作成と編集'; + + @override + String get shortcutSaveItem => '予定またはタスクを保存'; + + @override + String get shortcutDeleteItem => '予定またはタスクを削除'; + + @override + String get shortcutGroupTaskEditing => 'タスクの編集'; + + @override + String get shortcutCancelEditing => '編集をキャンセル'; + + @override + String get shortcutCancelEditingDescription => 'タスクの編集または詳細を閉じる'; + + @override + String get shortcutGroupCompactAgenda => 'コンパクト予定一覧'; + + @override + String get shortcutRefreshCompactAgendaDescription => 'コンパクト予定一覧ウィンドウを更新'; + + @override + String get shortcutHideCompactAgendaDescription => 'コンパクト予定一覧ウィンドウを非表示'; + + @override + String get aboutBusyMax => 'BusyMax について'; + + @override + String get aboutBusyMaxDescription => 'タスクとカレンダー'; + + @override + String get website => 'ウェブサイト'; + + @override + String get reportAnIssue => '問題を報告'; + + @override + String get sendFeedback => 'フィードバックを送信'; + + @override + String get feedbackSubmit => '送信'; + + @override + String get feedbackCategory => 'カテゴリー'; + + @override + String get feedbackSelectCategory => 'カテゴリーを選択'; + + @override + String get feedbackCategoryProblem => '問題またはバグ'; + + @override + String get feedbackCategoryFeature => '機能のリクエスト'; + + @override + String get feedbackCategoryPrivacySecurity => 'プライバシーまたはセキュリティに関する懸念'; + + @override + String get feedbackCategoryUsability => '使いやすさに関する懸念'; + + @override + String get feedbackCategoryOther => 'その他'; + + @override + String get feedbackSubject => '件名'; + + @override + String get feedbackDetailedMessage => '詳しい内容'; + + @override + String get feedbackReplyEmail => '返信先メールアドレス(任意)'; + + @override + String get feedbackIncludeTechnicalDetails => '技術情報を含める'; + + @override + String get feedbackTechnicalDetailsDisclosure => + 'Linux オペレーティングシステムのバージョンとアプリのロケールのみが追加されます。ログ、アカウントデータ、ファイル名、その他の診断情報は含まれません。'; + + @override + String get feedbackCategoryRequired => 'カテゴリーを選択してください。'; + + @override + String get feedbackSubjectLengthError => '件名は3文字以上120文字以下にしてください。'; + + @override + String get feedbackMessageLengthError => 'メッセージは10文字以上5,000文字以下にしてください。'; + + @override + String get feedbackInvalidEmail => '有効なメールアドレスを入力してください。'; + + @override + String get feedbackConnectionError => + 'BusyStack に接続できませんでした。接続を確認して、もう一度お試しください。'; + + @override + String get feedbackTimeoutError => + 'リクエストがタイムアウトしました。フィードバックは消去されていません。もう一度お試しください。'; + + @override + String get feedbackRateLimitedError => + 'このネットワークから送信されたフィードバックが多すぎます。しばらく待ってから、もう一度お試しください。'; + + @override + String get feedbackRejectedError => 'サーバーが送信を拒否しました。入力内容を確認して、もう一度お試しください。'; + + @override + String get feedbackServerError => + '現在、BusyStack はフィードバックを受け付けられません。フィードバックは消去されていません。もう一度お試しください。'; + + @override + String feedbackSuccess(String id) { + return 'フィードバックを送信しました。参照番号: $id'; + } + + @override + String get toggleSidebar => 'サイドバーの表示を切り替え'; + + @override + String get accounts => 'アカウント'; + + @override + String get currentAccount => '現在のアカウント'; + + @override + String get switchAccount => 'アカウントを切り替え'; + + @override + String get addGoogleAccount => 'Google アカウントを追加'; + + @override + String get addMicrosoftAccount => 'Microsoft アカウントを追加'; + + @override + String get googleProvider => 'Google'; + + @override + String get microsoftProvider => 'Microsoft'; + + @override + String get signedInAccount => 'サインイン済み'; + + @override + String get removeAccount => 'アカウントを削除…'; + + @override + String get removingAccount => 'アカウントを削除しています…'; + + @override + String get removeAccountDescription => '同期を停止し、このアカウントのデータをこのデバイスから削除します。'; + + @override + String removeAccountTitle(String account) { + return 'BusyMax から $account を削除しますか?'; + } + + @override + String get removeAccountConfirmation => + 'このデバイスにキャッシュされたタスク、カレンダー、予定、リマインダー、保留中のオフライン変更が削除されます。同期されていない変更は失われます。Google または Microsoft から削除されるデータはありません。'; + + @override + String get revokeGoogleAccess => 'この Google アカウントへの BusyMax のアクセス権も取り消す'; + + @override + String get revokeGoogleAccessDescription => '再接続する前に、もう一度アクセスを許可する必要があります。'; + + @override + String get removeAccountAction => 'アカウントを削除'; + + @override + String get removeAccountFailed => 'アカウントの削除を完了できませんでした。もう一度お試しください。'; + + @override + String get accountRemovedGoogleRevokeFailed => + 'アカウントはこのデバイスから削除されましたが、BusyMax は Google へのアクセス権を取り消せませんでした。Google アカウントから取り消すことができます。'; + + @override + String get newList => '新しいリスト'; + + @override + String get signInToViewTaskLists => 'タスクリストを表示するにはサインインしてください。'; + + @override + String get noTaskListsSynced => '同期済みのタスクリストはまだありません。'; + + @override + String get listActions => 'リストの操作'; + + @override + String get rename => '名前を変更'; + + @override + String get delete => '削除'; + + @override + String get renameList => 'リスト名を変更'; + + @override + String get deleteList => 'リストを削除'; + + @override + String get builtInMicrosoftList => '組み込み'; + + @override + String get builtInMicrosoftListCannotRenameDelete => + 'Microsoft To Do の組み込みリストは、名前の変更や削除ができません。'; + + @override + String deleteListConfirmation(String title) { + return 'Google Tasks から「$title」を削除しますか?'; + } + + @override + String get deleteEvent => '予定を削除'; + + @override + String get title => 'タイトル'; + + @override + String get create => '作成'; + + @override + String get newTask => '新しいタスク'; + + @override + String get clearCompleted => '完了済みを消去'; + + @override + String get refreshList => 'リストを更新'; + + @override + String get refreshAll => 'すべて更新'; + + @override + String get listRefreshed => 'リストを更新しました。'; + + @override + String get allTasksRefreshed => 'すべてのアカウントを更新しました。'; + + @override + String exportedFile(String path) { + return '$path にエクスポートしました'; + } + + @override + String exportFailed(String error) { + return 'エクスポートに失敗しました: $error'; + } + + @override + String refreshFailed(String error) { + return '更新に失敗しました: $error'; + } + + @override + String get selectOrCreateTaskList => '開始するには、タスクリストを選択または作成してください。'; + + @override + String get signInToViewTasks => 'タスクを表示するにはサインインしてください。'; + + @override + String get noTasks => 'タスクはありません。'; + + @override + String get noTasksYet => 'タスクはまだありません'; + + @override + String get noTasksYetMessage => 'タスクを作成するか、アカウントを更新して始めましょう。'; + + @override + String get noTasksInList => 'このリストにタスクはありません。'; + + @override + String get overdue => '期限超過'; + + @override + String get today => '今日'; + + @override + String get tomorrow => '明日'; + + @override + String get upcoming => '今後'; + + @override + String get noDate => '日付なし'; + + @override + String get completed => '完了'; + + @override + String duePrefix(String date) { + return '期限: $date'; + } + + @override + String dateTimeDisplay(String date, String time) { + return '$date · $time'; + } + + @override + String get taskDetails => 'タスクの詳細'; + + @override + String get editTask => 'タスクを編集'; + + @override + String get noTaskSelected => 'タスクが選択されていません。'; + + @override + String get noTaskSelectedHelper => '詳細を表示して編集するタスクを選択してください。'; + + @override + String get taskUnavailable => 'タスクを利用できません。'; + + @override + String get signInToEditTasks => 'タスクを編集するにはサインインしてください。'; + + @override + String get refreshTask => 'タスクを更新'; + + @override + String get primarySection => '基本情報'; + + @override + String get statusSection => 'ステータス'; + + @override + String get openStatus => '未完了'; + + @override + String get doneStatus => '完了'; + + @override + String get notes => 'メモ'; + + @override + String get dueDate => '期限日'; + + @override + String get clearDueDate => '期限日を消去'; + + @override + String get dueTime => '期限時刻'; + + @override + String get startDate => '開始日'; + + @override + String get startTime => '開始時刻'; + + @override + String get endDate => '終了日'; + + @override + String get endTime => '終了時刻'; + + @override + String get reminderDate => 'リマインダーの日付'; + + @override + String get reminderTime => 'リマインダーの時刻'; + + @override + String get reminder => 'リマインダー'; + + @override + String get addReminder => 'リマインダーを追加'; + + @override + String get addGuest => 'ゲストを追加'; + + @override + String get addGuestEmail => 'ゲストのメールアドレスを追加'; + + @override + String get removeReminder => 'リマインダーを削除'; + + @override + String get off => 'オフ'; + + @override + String get repeat => '繰り返し'; + + @override + String get repeatNone => 'なし'; + + @override + String get noneValue => 'なし'; + + @override + String get repeatDaily => '毎日'; + + @override + String get repeatWeekly => '毎週'; + + @override + String get repeatMonthly => '毎月'; + + @override + String get repeatYearly => '毎年'; + + @override + String get importance => '重要度'; + + @override + String get importanceLow => '低'; + + @override + String get importanceNormal => '標準'; + + @override + String get importanceHigh => '高'; + + @override + String get categories => 'カテゴリー'; + + @override + String get scheduleSection => 'スケジュール'; + + @override + String get dueGroup => '期限'; + + @override + String get startGroup => '開始'; + + @override + String get reminderGroup => 'リマインダー'; + + @override + String get organizationSection => '整理'; + + @override + String get actionsSection => '操作'; + + @override + String get advancedSection => '詳細設定'; + + @override + String get addCategory => 'カテゴリーを追加'; + + @override + String get list => 'リスト'; + + @override + String get microsoftMoveUnsupported => + 'このバージョンでは、Microsoft To Do アカウントのリスト間でタスクを移動できません。'; + + @override + String get createSubtask => 'サブタスクを作成'; + + @override + String get moveToTop => '一番上に移動'; + + @override + String get deleteTask => 'タスクを削除'; + + @override + String get newSubtask => '新しいサブタスク'; + + @override + String deleteTaskConfirmation(String title) { + return 'Google Tasks から「$title」を削除しますか?'; + } + + @override + String get metadata => 'メタデータ'; + + @override + String get id => 'ID'; + + @override + String get etag => 'ETag'; + + @override + String get updated => '更新日時'; + + @override + String get parent => '親タスク'; + + @override + String get position => '位置'; + + @override + String get webLink => 'ウェブリンク'; + + @override + String get assignment => '割り当て'; + + @override + String get localState => 'ローカル状態'; + + @override + String get pendingSync => '同期待ち'; + + @override + String get synced => '同期済み'; + + @override + String get account => 'アカウント'; + + @override + String get sync => '同期'; + + @override + String get manualFullSync => '手動ですべて同期'; + + @override + String get runInBackgroundWhenClosed => 'ウィンドウを閉じてもバックグラウンドで実行を続ける'; + + @override + String get showTrayIcon => 'トレイアイコンを表示'; + + @override + String get startMinimizedToTray => 'トレイに最小化して起動'; + + @override + String get requiresTrayIcon => 'トレイアイコンが必要です。'; + + @override + String get syncComplete => '同期が完了しました。'; + + @override + String syncFailed(String error) { + return '同期に失敗しました: $error'; + } + + @override + String get notifySyncFailures => '同期失敗時に通知'; + + @override + String get notifyConflicts => '競合時に通知'; + + @override + String get notifyDueToday => '今日が期限のタスクを通知'; + + @override + String get eventReminders => '予定のリマインダー'; + + @override + String get taskReminders => 'タスクのリマインダー'; + + @override + String get notificationDetailLevel => '通知の詳細度'; + + @override + String get notificationDetailPrivate => '非公開'; + + @override + String get notificationDetailNormal => '標準'; + + @override + String get quietHours => '通知を停止する時間'; + + @override + String get quietHoursDescription => 'この時間帯は通知を一時停止します。'; + + @override + String get quietHoursStart => '通知停止の開始時刻'; + + @override + String get quietHoursEnd => '通知停止の終了時刻'; + + @override + String get notifications => '通知'; + + @override + String get appearance => '外観'; + + @override + String get theme => 'テーマ'; + + @override + String get themeSystem => 'システム'; + + @override + String get themeLight => 'ライト'; + + @override + String get themeDark => 'ダーク'; + + @override + String get themeFamily => 'テーマファミリー'; + + @override + String get themeFamilyYaru => 'Ubuntu ネイティブ(Yaru)'; + + @override + String get localization => '言語と地域'; + + @override + String get currentLocale => '現在のロケール'; + + @override + String get privacy => 'プライバシー'; + + @override + String get redactTaskContentInDiagnostics => '診断情報でタスクの内容を伏せる'; + + @override + String get developerDiagnostics => '開発者向け診断'; + + @override + String get diagnostics => '診断'; + + @override + String get apiInspectorDisabled => 'API インスペクターを表示'; + + @override + String get googleTasksApi => 'Google Tasks API'; + + @override + String discoveryRevision(String revision) { + return 'Discovery リビジョン: $revision'; + } + + @override + String get implementedMethods => '実装済みメソッド'; + + @override + String get supportsTasksScopes => 'tasks および tasks.readonly スコープをサポート'; + + @override + String get requiresTasksScope => 'tasks スコープが必要'; + + @override + String get blockedPendingOperations => 'ブロックされた保留中の操作'; + + @override + String get signInToInspectPendingOperations => '保留中の操作を確認するにはサインインしてください。'; + + @override + String get noBlockedPendingOperations => 'ブロックされた保留中の操作はありません。'; + + @override + String get operationActions => '操作のアクション'; + + @override + String pendingOpListId(String id) { + return 'リスト=$id'; + } + + @override + String pendingOpTaskId(String id) { + return 'タスク=$id'; + } + + @override + String pendingOpAttempts(int count) { + return '試行回数=$count'; + } + + @override + String get retry => '再試行'; + + @override + String get discard => '破棄'; + + @override + String get discardChanges => '変更を破棄しますか?'; + + @override + String get discardChangesConfirmation => 'このタスクの未保存の編集内容を破棄します。'; + + @override + String get retryCompleted => '再試行が完了しました。'; + + @override + String get discardPendingOperation => '保留中の操作を破棄しますか?'; + + @override + String get discardPendingOperationConfirmation => + 'ブロックされたローカル操作を削除します。次回の同期時に Google Tasks からデータが再取得されます。'; + + @override + String get pendingOperationDiscarded => '保留中の操作を破棄しました。'; + + @override + String get syncFailureNotificationTitle => 'BusyMax の同期に失敗'; + + @override + String syncFailureNotificationBody(String message) { + return 'バックグラウンド同期に失敗しました。$message'; + } + + @override + String get conflictNotificationTitle => 'BusyMax の同期競合'; + + @override + String conflictNotificationBody(String summary) { + return '保留中のローカル変更がブロックされました。$summary'; + } + + @override + String get dueTodayNotificationTitle => '今日が期限のタスク'; + + @override + String dueTodayNotificationBody(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '今日が期限のタスクが$count件あります。', + one: '今日が期限のタスクが1件あります。', + ); + return '$_temp0'; + } + + @override + String get eventReminderNotificationTitle => '予定のリマインダー'; + + @override + String get taskReminderNotificationTitle => 'タスクのリマインダー'; + + @override + String get eventReminderNotificationBody => '予定がまもなく始まります。'; + + @override + String get taskReminderNotificationBody => 'タスクの期限が近づいています。'; + + @override + String get notificationOpenAction => '開く'; + + @override + String get notificationDetailsHidden => 'プライバシー設定により詳細は非表示です。'; + + @override + String get previousMonth => '前の月'; + + @override + String get nextMonth => '次の月'; + + @override + String get openMonthView => '月表示を開く'; + + @override + String get previousYear => '前の年'; + + @override + String get nextYear => '次の年'; + + @override + String get openYearView => '年表示を開く'; + + @override + String weekNumberTooltip(int number) { + return '第$number週'; + } + + @override + String get resizeAllDayPanel => '終日パネルのサイズを変更'; + + @override + String scheduleItemCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count件', + one: '1件', + ); + return '$_temp0'; + } + + @override + String get readOnlyCalendar => 'このカレンダーは読み取り専用です。'; + + @override + String get selectTimeZone => 'タイムゾーンを選択'; + + @override + String get searchLocations => '場所を検索'; + + @override + String get noLocationsFound => '場所が見つかりません'; + + @override + String deleteCalendarConfirmation(String title) { + return '「$title」を削除しますか?'; + } +} diff --git a/lib/l10n/generated/app_localizations_ko.dart b/lib/l10n/generated/app_localizations_ko.dart new file mode 100644 index 0000000..a9443b2 --- /dev/null +++ b/lib/l10n/generated/app_localizations_ko.dart @@ -0,0 +1,1269 @@ +// ignore: unused_import +import 'package:intl/intl.dart' as intl; +import 'app_localizations.dart'; + +// ignore_for_file: type=lint + +/// The translations for Korean (`ko`). +class AppLocalizationsKo extends AppLocalizations { + AppLocalizationsKo([String locale = 'ko']) : super(locale); + + @override + String get appTitle => 'BusyMax'; + + @override + String get connectGoogleAccount => + 'Google 및 Microsoft 계정을 연결하여 캘린더와 할 일을 동기화하세요.'; + + @override + String get googlePermissionsConsentNotice => + 'Google 권한 화면에서 캘린더와 할 일 권한을 모두 선택하세요.'; + + @override + String get googlePermissionsRequiredRetry => + 'Google Calendar 및 Google Tasks 권한이 필요합니다. 다시 시도하여 두 체크박스를 모두 선택하세요.'; + + @override + String get finishSetup => '설정 완료'; + + @override + String get continueSetup => '계속'; + + @override + String get onboardingSetupTitle => 'BusyMax 설정'; + + @override + String get onboardingAccountsStepTitle => '계정 연결'; + + @override + String get onboardingAccountsStepDescription => + '사용할 Google 및 Microsoft 계정을 모두 추가하세요. BusyMax는 각 계정의 캘린더, 일정, 할 일 목록 및 할 일을 동기화합니다.'; + + @override + String get onboardingPreferencesStepTitle => '시스템 설정 선택'; + + @override + String get onboardingPreferencesStepDescription => + '일정을 열기 전에 데스크톱 동작, 미리 알림, 알림 세부 수준 및 화면 모양을 설정하세요.'; + + @override + String get signInWithGoogle => 'Google로 로그인'; + + @override + String get signInWithMicrosoft => 'Microsoft로 로그인'; + + @override + String get googleTasksProvider => 'Google Tasks'; + + @override + String get microsoftTodoProvider => 'Microsoft To Do'; + + @override + String get providerNotConfigured => '이 공급자는 구성되지 않았습니다.'; + + @override + String get waitingForGoogleSignIn => 'Google 로그인을 기다리는 중...'; + + @override + String get waitingForMicrosoftSignIn => 'Microsoft 로그인을 기다리는 중...'; + + @override + String get microsoftSignInNotConfigured => + 'Microsoft 로그인이 구성되지 않았습니다. MICROSOFT_OAUTH_CLIENT_ID를 설정하세요.'; + + @override + String get cancel => '취소'; + + @override + String get close => '닫기'; + + @override + String get exit => '종료'; + + @override + String get options => '옵션'; + + @override + String get hide => '숨기기'; + + @override + String get show => '표시'; + + @override + String get export => '내보내기'; + + @override + String get save => '저장'; + + @override + String get settings => '설정'; + + @override + String get all => '모두'; + + @override + String get calendarEvents => '일정'; + + @override + String get calendarTasks => '할 일'; + + @override + String get calendar => '캘린더'; + + @override + String get calendars => '캘린더'; + + @override + String get newEvent => '새 일정'; + + @override + String get refreshCalendar => '캘린더 새로 고침'; + + @override + String get openInProvider => '공급자에서 열기'; + + @override + String get hideFromSchedule => '일정에서 숨기기'; + + @override + String get showInSchedule => '일정에 표시'; + + @override + String get noCalendarsSynced => '아직 동기화된 캘린더가 없습니다.'; + + @override + String get allDay => '하루 종일'; + + @override + String moreItems(int count) { + return '+$count개 더 보기'; + } + + @override + String get noEventsOrTasks => '일정 또는 할 일이 없습니다'; + + @override + String get scheduleLoading => '일정을 불러오는 중...'; + + @override + String get scheduleUnavailable => '일정을 사용할 수 없습니다'; + + @override + String get scheduleNoSources => '표시할 캘린더 또는 할 일 목록이 없습니다'; + + @override + String get scheduleNoSourcesDescription => '설정에서 표시할 항목을 선택한 다음 새로 고침하세요.'; + + @override + String get scheduleSignInRequired => '계정 연결'; + + @override + String get scheduleSignInDescription => '캘린더와 할 일을 동기화하려면 로그인하세요.'; + + @override + String get scheduleNoSearchResults => '일치하는 일정 또는 할 일이 없습니다'; + + @override + String get scheduleNoSearchResultsDescription => '다른 검색어를 사용하거나 현재 필터를 지우세요.'; + + @override + String get trayAgendaLoading => '일정 목록을 불러오는 중...'; + + @override + String get trayAgendaSignInRequired => '일정 목록을 표시하려면 로그인하세요.'; + + @override + String get trayAgendaNoSources => '표시할 캘린더 또는 할 일 목록이 없습니다.'; + + @override + String get trayAgendaOpenBusyMax => '앱 열기'; + + @override + String get trayAgendaRefresh => '새로 고침'; + + @override + String get trayAgendaError => '일정 목록을 사용할 수 없습니다'; + + @override + String get compactAgendaTitle => '일정 목록'; + + @override + String get compactAgendaSubtitle => '예정'; + + @override + String get compactAgendaOverdue => '기한 지남'; + + @override + String get compactAgendaClear => '현재 예정 없음'; + + @override + String get compactAgendaOpenBusyMax => 'BusyMax 열기'; + + @override + String get compactAgendaHide => '숨기기'; + + @override + String get compactAgendaNewTask => '새 할 일'; + + @override + String get compactAgendaRetry => '다시 시도'; + + @override + String get compactAgendaRefresh => '새로 고침'; + + @override + String get compactAgendaAllDay => '하루 종일'; + + @override + String get compactAgendaDueToday => '오늘 마감'; + + @override + String get compactAgendaDueTomorrow => '내일 마감'; + + @override + String compactAgendaDueOn(String date) { + return '$date 마감'; + } + + @override + String get compactAgendaMoreOverdue => '기한이 지난 할 일 더 불러오기'; + + @override + String get agendaLoadMoreOverdue => '기한이 지난 할 일 더 불러오기'; + + @override + String get agendaLoadMoreNoDate => '날짜 없는 할 일 더 불러오기'; + + @override + String get viewDay => '일'; + + @override + String get viewWeek => '주'; + + @override + String get viewMonth => '월'; + + @override + String get viewYear => '년'; + + @override + String get viewAgenda => '일정 목록'; + + @override + String get scheduleSettings => '일정'; + + @override + String get scheduleDisplaySettings => '일정 표시'; + + @override + String get scheduleDisplayHoursDescription => + '일간 및 주간 보기는 처음에 이 시간 범위를 표시합니다. 필요한 경우 더 이르거나 늦은 항목에 맞춰 범위가 확장됩니다.'; + + @override + String get scheduleDayStartsAt => '하루 시작 시간'; + + @override + String get scheduleDayEndsAt => '하루 종료 시간'; + + @override + String get sourceCalendar => '캘린더'; + + @override + String get sourceTaskList => '할 일 목록'; + + @override + String get createChoiceTitle => '만들기'; + + @override + String get createEventAtTime => '일정'; + + @override + String get createTaskAtDate => '할 일'; + + @override + String get editEvent => '일정 편집'; + + @override + String get eventTitle => '일정 제목'; + + @override + String get location => '위치'; + + @override + String get timeSlot => '시간대'; + + @override + String get startDateTime => '시작 날짜/시간'; + + @override + String get endDateTime => '종료 날짜/시간'; + + @override + String get doesNotRepeat => '반복 안 함'; + + @override + String get defaultReminder => '기본 미리 알림'; + + @override + String get guests => '참석자'; + + @override + String get noGuests => '참석자 없음'; + + @override + String get description => '설명'; + + @override + String get availabilityShowAs => '상태 / 다음으로 표시'; + + @override + String get busy => '바쁨'; + + @override + String get visibility => '공개 범위'; + + @override + String get defaultVisibility => '기본 공개 범위'; + + @override + String get conference => '회의'; + + @override + String get noConference => '회의 없음'; + + @override + String get providerCalendar => '공급자 캘린더'; + + @override + String get formatBoldShortLabel => 'B'; + + @override + String get formatBoldTooltip => '굵게'; + + @override + String get formatItalicShortLabel => 'I'; + + @override + String get formatItalicTooltip => '기울임꼴'; + + @override + String get formatUnderlineShortLabel => 'U'; + + @override + String get formatUnderlineTooltip => '밑줄'; + + @override + String reminderMinutesBefore(int minutes) { + String _temp0 = intl.Intl.pluralLogic( + minutes, + locale: localeName, + other: '$minutes분 전', + one: '1분 전', + ); + return '$_temp0'; + } + + @override + String get reminderAtStart => '시작 시간'; + + @override + String reminderHoursBefore(int hours) { + String _temp0 = intl.Intl.pluralLogic( + hours, + locale: localeName, + other: '$hours시간 전', + one: '1시간 전', + ); + return '$_temp0'; + } + + @override + String reminderDaysBefore(int days) { + String _temp0 = intl.Intl.pluralLogic( + days, + locale: localeName, + other: '$days일 전', + one: '1일 전', + ); + return '$_temp0'; + } + + @override + String get availabilityFree => '한가함'; + + @override + String get availabilityTentative => '미정'; + + @override + String get availabilityOutOfOffice => '부재중'; + + @override + String get availabilityWorkingElsewhere => '다른 장소에서 근무'; + + @override + String get visibilityDefault => '기본값'; + + @override + String get visibilityPublic => '공개'; + + @override + String get visibilityPrivate => '비공개'; + + @override + String get visibilityConfidential => '기밀'; + + @override + String get sensitivityNormal => '일반'; + + @override + String get sensitivityPersonal => '개인'; + + @override + String get tasks => '할 일'; + + @override + String get allTasks => '모든 할 일'; + + @override + String tasksInList(String title) { + return '$title의 할 일'; + } + + @override + String get taskLists => '할 일 목록'; + + @override + String get navigation => '탐색'; + + @override + String get mainMenu => '주 메뉴'; + + @override + String get keyboardShortcuts => '키보드 단축키'; + + @override + String get shortcutGroupGeneral => '일반'; + + @override + String get shortcutKeyboardShortcutsDescription => '이 단축키 도움말 표시'; + + @override + String get shortcutGroupNavigation => '탐색'; + + @override + String get shortcutNextPeriod => '다음 기간'; + + @override + String get shortcutNextPeriodDescription => + '주간 보기에서는 다음 주, 월간 보기에서는 다음 달로 이동하는 식입니다'; + + @override + String get shortcutPreviousPeriod => '이전 기간'; + + @override + String get shortcutPreviousPeriodDescription => + '주간 보기에서는 이전 주, 월간 보기에서는 이전 달로 이동하는 식입니다'; + + @override + String get shortcutJumpToToday => '오늘로 이동'; + + @override + String get shortcutGroupView => '보기'; + + @override + String get shortcutDayView => '일간 보기'; + + @override + String get shortcutWeekView => '주간 보기'; + + @override + String get shortcutMonthView => '월간 보기'; + + @override + String get shortcutYearView => '연간 보기'; + + @override + String get shortcutAgendaView => '일정 목록 보기'; + + @override + String get shortcutGroupCreateAndEdit => '만들기 및 편집'; + + @override + String get shortcutSaveItem => '일정 또는 할 일 저장'; + + @override + String get shortcutDeleteItem => '일정 또는 할 일 삭제'; + + @override + String get shortcutGroupTaskEditing => '할 일 편집'; + + @override + String get shortcutCancelEditing => '편집 취소'; + + @override + String get shortcutCancelEditingDescription => '할 일 편집 또는 할 일 세부 정보 닫기'; + + @override + String get shortcutGroupCompactAgenda => '간단 일정 목록'; + + @override + String get shortcutRefreshCompactAgendaDescription => '간단 일정 목록 창 새로 고침'; + + @override + String get shortcutHideCompactAgendaDescription => '간단 일정 목록 창 숨기기'; + + @override + String get aboutBusyMax => 'BusyMax 정보'; + + @override + String get aboutBusyMaxDescription => '할 일 및 캘린더'; + + @override + String get website => '웹사이트'; + + @override + String get reportAnIssue => '문제 신고'; + + @override + String get sendFeedback => '의견 보내기'; + + @override + String get feedbackSubmit => '제출'; + + @override + String get feedbackCategory => '범주'; + + @override + String get feedbackSelectCategory => '범주 선택'; + + @override + String get feedbackCategoryProblem => '문제 또는 버그'; + + @override + String get feedbackCategoryFeature => '기능 요청'; + + @override + String get feedbackCategoryPrivacySecurity => '개인정보 보호 또는 보안 우려'; + + @override + String get feedbackCategoryUsability => '사용성 관련 의견'; + + @override + String get feedbackCategoryOther => '기타'; + + @override + String get feedbackSubject => '제목'; + + @override + String get feedbackDetailedMessage => '자세한 내용'; + + @override + String get feedbackReplyEmail => '답변 받을 이메일 주소(선택 사항)'; + + @override + String get feedbackIncludeTechnicalDetails => '기술 세부 정보 포함'; + + @override + String get feedbackTechnicalDetailsDisclosure => + 'Linux 운영 체제 버전과 앱 로캘만 추가됩니다. 로그, 계정 데이터, 파일 이름 또는 기타 진단 정보는 포함되지 않습니다.'; + + @override + String get feedbackCategoryRequired => '범주를 선택하세요.'; + + @override + String get feedbackSubjectLengthError => '제목은 3~120자여야 합니다.'; + + @override + String get feedbackMessageLengthError => '메시지는 10~5,000자여야 합니다.'; + + @override + String get feedbackInvalidEmail => '올바른 이메일 주소를 입력하세요.'; + + @override + String get feedbackConnectionError => + 'BusyStack에 연결할 수 없습니다. 연결을 확인하고 다시 시도하세요.'; + + @override + String get feedbackTimeoutError => + '요청 시간이 초과되었습니다. 의견은 지워지지 않았습니다. 다시 시도하세요.'; + + @override + String get feedbackRateLimitedError => + '이 네트워크에서 너무 많은 의견이 제출되었습니다. 잠시 기다린 후 다시 시도하세요.'; + + @override + String get feedbackRejectedError => '서버가 제출을 거부했습니다. 입력란을 검토하고 다시 시도하세요.'; + + @override + String get feedbackServerError => + '현재 BusyStack에서 의견을 받을 수 없습니다. 의견은 지워지지 않았습니다. 다시 시도하세요.'; + + @override + String feedbackSuccess(String id) { + return '의견을 보냈습니다. 참조: $id'; + } + + @override + String get toggleSidebar => '사이드바 표시 전환'; + + @override + String get accounts => '계정'; + + @override + String get currentAccount => '현재 계정'; + + @override + String get switchAccount => '계정 전환'; + + @override + String get addGoogleAccount => 'Google 계정 추가'; + + @override + String get addMicrosoftAccount => 'Microsoft 계정 추가'; + + @override + String get googleProvider => 'Google'; + + @override + String get microsoftProvider => 'Microsoft'; + + @override + String get signedInAccount => '로그인됨'; + + @override + String get removeAccount => '계정 삭제…'; + + @override + String get removingAccount => '계정 삭제 중…'; + + @override + String get removeAccountDescription => '동기화를 중지하고 이 기기에서 이 계정의 데이터를 삭제합니다.'; + + @override + String removeAccountTitle(String account) { + return 'BusyMax에서 $account 계정을 삭제할까요?'; + } + + @override + String get removeAccountConfirmation => + '이 기기에서 캐시된 할 일, 캘린더, 일정, 미리 알림 및 보류 중인 오프라인 변경 사항이 삭제됩니다. 동기화되지 않은 변경 사항은 사라집니다. Google 또는 Microsoft에서는 아무것도 삭제되지 않습니다.'; + + @override + String get revokeGoogleAccess => '이 Google 계정에 대한 BusyMax의 액세스 권한도 취소'; + + @override + String get revokeGoogleAccessDescription => '다시 연결하기 전에 액세스 권한을 다시 부여해야 합니다.'; + + @override + String get removeAccountAction => '계정 삭제'; + + @override + String get removeAccountFailed => '계정 삭제를 완료할 수 없습니다. 다시 시도하세요.'; + + @override + String get accountRemovedGoogleRevokeFailed => + '이 기기에서 계정은 삭제되었지만 BusyMax가 Google 액세스 권한을 취소하지 못했습니다. Google 계정에서 직접 취소할 수 있습니다.'; + + @override + String get newList => '새 목록'; + + @override + String get signInToViewTaskLists => '할 일 목록을 보려면 로그인하세요.'; + + @override + String get noTaskListsSynced => '아직 동기화된 할 일 목록이 없습니다.'; + + @override + String get listActions => '목록 작업'; + + @override + String get rename => '이름 바꾸기'; + + @override + String get delete => '삭제'; + + @override + String get renameList => '목록 이름 바꾸기'; + + @override + String get deleteList => '목록 삭제'; + + @override + String get builtInMicrosoftList => '기본 제공'; + + @override + String get builtInMicrosoftListCannotRenameDelete => + 'Microsoft To Do의 기본 제공 목록은 이름을 바꾸거나 삭제할 수 없습니다.'; + + @override + String deleteListConfirmation(String title) { + return 'Google Tasks에서 “$title” 목록을 삭제할까요?'; + } + + @override + String get deleteEvent => '일정 삭제'; + + @override + String get title => '제목'; + + @override + String get create => '만들기'; + + @override + String get newTask => '새 할 일'; + + @override + String get clearCompleted => '완료된 항목 지우기'; + + @override + String get refreshList => '목록 새로 고침'; + + @override + String get refreshAll => '모두 새로 고침'; + + @override + String get listRefreshed => '목록을 새로 고쳤습니다.'; + + @override + String get allTasksRefreshed => '모든 계정을 새로 고쳤습니다.'; + + @override + String exportedFile(String path) { + return '$path(으)로 내보냈습니다'; + } + + @override + String exportFailed(String error) { + return '내보내기 실패: $error'; + } + + @override + String refreshFailed(String error) { + return '새로 고침 실패: $error'; + } + + @override + String get selectOrCreateTaskList => '시작하려면 할 일 목록을 선택하거나 만드세요.'; + + @override + String get signInToViewTasks => '할 일을 보려면 로그인하세요.'; + + @override + String get noTasks => '할 일이 없습니다.'; + + @override + String get noTasksYet => '아직 할 일이 없습니다'; + + @override + String get noTasksYetMessage => '할 일을 만들거나 계정을 새로 고쳐 시작하세요.'; + + @override + String get noTasksInList => '이 목록에 할 일이 없습니다.'; + + @override + String get overdue => '기한 지남'; + + @override + String get today => '오늘'; + + @override + String get tomorrow => '내일'; + + @override + String get upcoming => '예정'; + + @override + String get noDate => '날짜 없음'; + + @override + String get completed => '완료'; + + @override + String duePrefix(String date) { + return '$date 마감'; + } + + @override + String dateTimeDisplay(String date, String time) { + return '$date · $time'; + } + + @override + String get taskDetails => '할 일 세부 정보'; + + @override + String get editTask => '할 일 편집'; + + @override + String get noTaskSelected => '선택된 할 일이 없습니다.'; + + @override + String get noTaskSelectedHelper => '세부 정보를 보고 편집할 할 일을 선택하세요.'; + + @override + String get taskUnavailable => '할 일을 사용할 수 없습니다.'; + + @override + String get signInToEditTasks => '할 일을 편집하려면 로그인하세요.'; + + @override + String get refreshTask => '할 일 새로 고침'; + + @override + String get primarySection => '기본'; + + @override + String get statusSection => '상태'; + + @override + String get openStatus => '진행 중'; + + @override + String get doneStatus => '완료'; + + @override + String get notes => '메모'; + + @override + String get dueDate => '마감일'; + + @override + String get clearDueDate => '마감일 지우기'; + + @override + String get dueTime => '마감 시간'; + + @override + String get startDate => '시작일'; + + @override + String get startTime => '시작 시간'; + + @override + String get endDate => '종료일'; + + @override + String get endTime => '종료 시간'; + + @override + String get reminderDate => '미리 알림 날짜'; + + @override + String get reminderTime => '미리 알림 시간'; + + @override + String get reminder => '미리 알림'; + + @override + String get addReminder => '미리 알림 추가'; + + @override + String get addGuest => '참석자 추가'; + + @override + String get addGuestEmail => '참석자 이메일 추가'; + + @override + String get removeReminder => '미리 알림 삭제'; + + @override + String get off => '끔'; + + @override + String get repeat => '반복'; + + @override + String get repeatNone => '없음'; + + @override + String get noneValue => '없음'; + + @override + String get repeatDaily => '매일'; + + @override + String get repeatWeekly => '매주'; + + @override + String get repeatMonthly => '매월'; + + @override + String get repeatYearly => '매년'; + + @override + String get importance => '중요도'; + + @override + String get importanceLow => '낮음'; + + @override + String get importanceNormal => '보통'; + + @override + String get importanceHigh => '높음'; + + @override + String get categories => '범주'; + + @override + String get scheduleSection => '일정'; + + @override + String get dueGroup => '마감'; + + @override + String get startGroup => '시작'; + + @override + String get reminderGroup => '미리 알림'; + + @override + String get organizationSection => '구성'; + + @override + String get actionsSection => '작업'; + + @override + String get advancedSection => '고급'; + + @override + String get addCategory => '범주 추가'; + + @override + String get list => '목록'; + + @override + String get microsoftMoveUnsupported => + '이 버전에서는 Microsoft To Do 계정의 목록 간에 할 일을 이동할 수 없습니다.'; + + @override + String get createSubtask => '하위 할 일 만들기'; + + @override + String get moveToTop => '맨 위로 이동'; + + @override + String get deleteTask => '할 일 삭제'; + + @override + String get newSubtask => '새 하위 할 일'; + + @override + String deleteTaskConfirmation(String title) { + return 'Google Tasks에서 “$title” 항목을 삭제할까요?'; + } + + @override + String get metadata => '메타데이터'; + + @override + String get id => 'ID'; + + @override + String get etag => 'ETag'; + + @override + String get updated => '업데이트됨'; + + @override + String get parent => '상위 할 일'; + + @override + String get position => '위치'; + + @override + String get webLink => '웹 링크'; + + @override + String get assignment => '할당'; + + @override + String get localState => '로컬 상태'; + + @override + String get pendingSync => '동기화 보류 중'; + + @override + String get synced => '동기화됨'; + + @override + String get account => '계정'; + + @override + String get sync => '동기화'; + + @override + String get manualFullSync => '수동 전체 동기화'; + + @override + String get runInBackgroundWhenClosed => '창을 닫아도 계속 실행'; + + @override + String get showTrayIcon => '트레이 아이콘 표시'; + + @override + String get startMinimizedToTray => '트레이에 최소화하여 시작'; + + @override + String get requiresTrayIcon => '트레이 아이콘이 필요합니다.'; + + @override + String get syncComplete => '동기화가 완료되었습니다.'; + + @override + String syncFailed(String error) { + return '동기화 실패: $error'; + } + + @override + String get notifySyncFailures => '동기화 실패 알림'; + + @override + String get notifyConflicts => '충돌 알림'; + + @override + String get notifyDueToday => '오늘 마감인 할 일 알림'; + + @override + String get eventReminders => '일정 미리 알림'; + + @override + String get taskReminders => '할 일 미리 알림'; + + @override + String get notificationDetailLevel => '알림 세부 수준'; + + @override + String get notificationDetailPrivate => '비공개'; + + @override + String get notificationDetailNormal => '일반'; + + @override + String get quietHours => '방해 금지 시간'; + + @override + String get quietHoursDescription => '이 시간 동안 알림을 일시 중지합니다.'; + + @override + String get quietHoursStart => '방해 금지 시작 시간'; + + @override + String get quietHoursEnd => '방해 금지 종료 시간'; + + @override + String get notifications => '알림'; + + @override + String get appearance => '화면 모양'; + + @override + String get theme => '테마'; + + @override + String get themeSystem => '시스템'; + + @override + String get themeLight => '라이트'; + + @override + String get themeDark => '다크'; + + @override + String get themeFamily => '테마 계열'; + + @override + String get themeFamilyYaru => 'Ubuntu 기본 테마(Yaru)'; + + @override + String get localization => '언어 및 지역'; + + @override + String get currentLocale => '현재 로캘'; + + @override + String get privacy => '개인정보 보호'; + + @override + String get redactTaskContentInDiagnostics => '진단 정보에서 할 일 내용 숨기기'; + + @override + String get developerDiagnostics => '개발자 진단'; + + @override + String get diagnostics => '진단'; + + @override + String get apiInspectorDisabled => 'API 검사기 표시'; + + @override + String get googleTasksApi => 'Google Tasks API'; + + @override + String discoveryRevision(String revision) { + return '검색 버전: $revision'; + } + + @override + String get implementedMethods => '구현된 메서드'; + + @override + String get supportsTasksScopes => 'tasks 및 tasks.readonly 범위 지원'; + + @override + String get requiresTasksScope => 'tasks 범위 필요'; + + @override + String get blockedPendingOperations => '차단된 보류 작업'; + + @override + String get signInToInspectPendingOperations => '보류 작업을 확인하려면 로그인하세요.'; + + @override + String get noBlockedPendingOperations => '차단된 보류 작업이 없습니다.'; + + @override + String get operationActions => '작업 동작'; + + @override + String pendingOpListId(String id) { + return '목록=$id'; + } + + @override + String pendingOpTaskId(String id) { + return '할 일=$id'; + } + + @override + String pendingOpAttempts(int count) { + return '시도=$count'; + } + + @override + String get retry => '다시 시도'; + + @override + String get discard => '버리기'; + + @override + String get discardChanges => '변경 사항을 버릴까요?'; + + @override + String get discardChangesConfirmation => '이 할 일에서 저장하지 않은 편집 내용을 버립니다.'; + + @override + String get retryCompleted => '다시 시도했습니다.'; + + @override + String get discardPendingOperation => '보류 작업을 버릴까요?'; + + @override + String get discardPendingOperationConfirmation => + '차단된 로컬 작업을 삭제합니다. 다음 동기화에서 Google Tasks의 데이터를 새로 불러옵니다.'; + + @override + String get pendingOperationDiscarded => '보류 작업을 버렸습니다.'; + + @override + String get syncFailureNotificationTitle => 'BusyMax 동기화 실패'; + + @override + String syncFailureNotificationBody(String message) { + return '백그라운드 동기화에 실패했습니다. $message'; + } + + @override + String get conflictNotificationTitle => 'BusyMax 동기화 충돌'; + + @override + String conflictNotificationBody(String summary) { + return '보류 중인 로컬 변경 사항이 차단되었습니다. $summary'; + } + + @override + String get dueTodayNotificationTitle => '오늘 마감인 할 일'; + + @override + String dueTodayNotificationBody(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '오늘 마감인 할 일이 $count개 있습니다.', + one: '오늘 마감인 할 일이 1개 있습니다.', + ); + return '$_temp0'; + } + + @override + String get eventReminderNotificationTitle => '일정 미리 알림'; + + @override + String get taskReminderNotificationTitle => '할 일 미리 알림'; + + @override + String get eventReminderNotificationBody => '일정이 곧 시작됩니다.'; + + @override + String get taskReminderNotificationBody => '할 일 마감이 얼마 남지 않았습니다.'; + + @override + String get notificationOpenAction => '열기'; + + @override + String get notificationDetailsHidden => '개인정보 보호 설정에 따라 세부 정보가 숨겨졌습니다.'; + + @override + String get previousMonth => '이전 달'; + + @override + String get nextMonth => '다음 달'; + + @override + String get openMonthView => '월간 보기 열기'; + + @override + String get previousYear => '이전 해'; + + @override + String get nextYear => '다음 해'; + + @override + String get openYearView => '연간 보기 열기'; + + @override + String weekNumberTooltip(int number) { + return '$number주차'; + } + + @override + String get resizeAllDayPanel => '종일 패널 크기 조절'; + + @override + String scheduleItemCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '항목 $count개', + one: '항목 1개', + ); + return '$_temp0'; + } + + @override + String get readOnlyCalendar => '이 캘린더는 읽기 전용입니다.'; + + @override + String get selectTimeZone => '시간대 선택'; + + @override + String get searchLocations => '위치 검색'; + + @override + String get noLocationsFound => '위치를 찾을 수 없습니다'; + + @override + String deleteCalendarConfirmation(String title) { + return '“$title” 캘린더를 삭제할까요?'; + } +} diff --git a/lib/l10n/generated/app_localizations_pt.dart b/lib/l10n/generated/app_localizations_pt.dart new file mode 100644 index 0000000..e868a00 --- /dev/null +++ b/lib/l10n/generated/app_localizations_pt.dart @@ -0,0 +1,1306 @@ +// ignore: unused_import +import 'package:intl/intl.dart' as intl; +import 'app_localizations.dart'; + +// ignore_for_file: type=lint + +/// The translations for Portuguese (`pt`). +class AppLocalizationsPt extends AppLocalizations { + AppLocalizationsPt([String locale = 'pt']) : super(locale); + + @override + String get appTitle => 'BusyMax'; + + @override + String get connectGoogleAccount => + 'Ligue contas Google e Microsoft para sincronizar calendários e tarefas.'; + + @override + String get googlePermissionsConsentNotice => + 'No ecrã de autorizações da Google, selecione as autorizações do Calendário e das Tarefas.'; + + @override + String get googlePermissionsRequiredRetry => + 'As autorizações do Calendário Google e do Google Tasks são necessárias. Tente novamente e selecione ambas as caixas.'; + + @override + String get finishSetup => 'Concluir configuração'; + + @override + String get continueSetup => 'Continuar'; + + @override + String get onboardingSetupTitle => 'Configurar o BusyMax'; + + @override + String get onboardingAccountsStepTitle => 'Ligar contas'; + + @override + String get onboardingAccountsStepDescription => + 'Adicione todas as contas Google e Microsoft que pretende utilizar. O BusyMax sincroniza calendários, eventos, listas de tarefas e tarefas de cada conta.'; + + @override + String get onboardingPreferencesStepTitle => 'Escolher definições do sistema'; + + @override + String get onboardingPreferencesStepDescription => + 'Configure o comportamento da aplicação no ambiente de trabalho, os lembretes, o nível de detalhe das notificações e o aspeto antes de abrir a agenda.'; + + @override + String get signInWithGoogle => 'Iniciar sessão com a Google'; + + @override + String get signInWithMicrosoft => 'Iniciar sessão com a Microsoft'; + + @override + String get googleTasksProvider => 'Google Tasks'; + + @override + String get microsoftTodoProvider => 'Microsoft To Do'; + + @override + String get providerNotConfigured => 'Este fornecedor não está configurado.'; + + @override + String get waitingForGoogleSignIn => + 'A aguardar o início de sessão da Google...'; + + @override + String get waitingForMicrosoftSignIn => + 'A aguardar o início de sessão da Microsoft...'; + + @override + String get microsoftSignInNotConfigured => + 'O início de sessão da Microsoft não está configurado. Defina MICROSOFT_OAUTH_CLIENT_ID.'; + + @override + String get cancel => 'Cancelar'; + + @override + String get close => 'Fechar'; + + @override + String get exit => 'Sair'; + + @override + String get options => 'Opções'; + + @override + String get hide => 'Ocultar'; + + @override + String get show => 'Mostrar'; + + @override + String get export => 'Exportar'; + + @override + String get save => 'Guardar'; + + @override + String get settings => 'Definições'; + + @override + String get all => 'Tudo'; + + @override + String get calendarEvents => 'Eventos'; + + @override + String get calendarTasks => 'Tarefas'; + + @override + String get calendar => 'Calendário'; + + @override + String get calendars => 'Calendários'; + + @override + String get newEvent => 'Novo evento'; + + @override + String get refreshCalendar => 'Atualizar calendário'; + + @override + String get openInProvider => 'Abrir no serviço'; + + @override + String get hideFromSchedule => 'Ocultar da agenda'; + + @override + String get showInSchedule => 'Mostrar na agenda'; + + @override + String get noCalendarsSynced => 'Ainda não há calendários sincronizados.'; + + @override + String get allDay => 'Todo o dia'; + + @override + String moreItems(int count) { + return '+$count mais'; + } + + @override + String get noEventsOrTasks => 'Sem eventos ou tarefas'; + + @override + String get scheduleLoading => 'A carregar agenda...'; + + @override + String get scheduleUnavailable => 'Agenda indisponível'; + + @override + String get scheduleNoSources => + 'Sem calendários ou listas de tarefas visíveis'; + + @override + String get scheduleNoSourcesDescription => + 'Escolha o que pretende mostrar nas Definições e atualize a agenda.'; + + @override + String get scheduleSignInRequired => 'Ligar uma conta'; + + @override + String get scheduleSignInDescription => + 'Inicie sessão para sincronizar calendários e tarefas.'; + + @override + String get scheduleNoSearchResults => + 'Nenhum evento ou tarefa correspondente'; + + @override + String get scheduleNoSearchResultsDescription => + 'Experimente outra pesquisa ou limpe os filtros atuais.'; + + @override + String get trayAgendaLoading => 'A carregar agenda...'; + + @override + String get trayAgendaSignInRequired => 'Inicie sessão para ver a agenda.'; + + @override + String get trayAgendaNoSources => + 'Sem calendários ou listas de tarefas visíveis.'; + + @override + String get trayAgendaOpenBusyMax => 'Abrir aplicação'; + + @override + String get trayAgendaRefresh => 'Atualizar'; + + @override + String get trayAgendaError => 'Agenda indisponível'; + + @override + String get compactAgendaTitle => 'Agenda'; + + @override + String get compactAgendaSubtitle => 'Próximos'; + + @override + String get compactAgendaOverdue => 'Em atraso'; + + @override + String get compactAgendaClear => 'Livre por agora'; + + @override + String get compactAgendaOpenBusyMax => 'Abrir o BusyMax'; + + @override + String get compactAgendaHide => 'Ocultar'; + + @override + String get compactAgendaNewTask => 'Nova tarefa'; + + @override + String get compactAgendaRetry => 'Tentar novamente'; + + @override + String get compactAgendaRefresh => 'Atualizar'; + + @override + String get compactAgendaAllDay => 'Todo o dia'; + + @override + String get compactAgendaDueToday => 'Prazo: hoje'; + + @override + String get compactAgendaDueTomorrow => 'Prazo: amanhã'; + + @override + String compactAgendaDueOn(String date) { + return 'Prazo: $date'; + } + + @override + String get compactAgendaMoreOverdue => 'Carregar mais tarefas em atraso'; + + @override + String get agendaLoadMoreOverdue => 'Carregar mais tarefas em atraso'; + + @override + String get agendaLoadMoreNoDate => 'Carregar mais tarefas sem data'; + + @override + String get viewDay => 'Dia'; + + @override + String get viewWeek => 'Semana'; + + @override + String get viewMonth => 'Mês'; + + @override + String get viewYear => 'Ano'; + + @override + String get viewAgenda => 'Agenda'; + + @override + String get scheduleSettings => 'Agenda'; + + @override + String get scheduleDisplaySettings => 'Apresentação da agenda'; + + @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.'; + + @override + String get scheduleDayStartsAt => 'O dia começa às'; + + @override + String get scheduleDayEndsAt => 'O dia termina às'; + + @override + String get sourceCalendar => 'Calendário'; + + @override + String get sourceTaskList => 'Lista de tarefas'; + + @override + String get createChoiceTitle => 'Criar'; + + @override + String get createEventAtTime => 'Evento'; + + @override + String get createTaskAtDate => 'Tarefa'; + + @override + String get editEvent => 'Editar evento'; + + @override + String get eventTitle => 'Título do evento'; + + @override + String get location => 'Local'; + + @override + String get timeSlot => 'Intervalo de tempo'; + + @override + String get startDateTime => 'Data e hora de início'; + + @override + String get endDateTime => 'Data e hora de fim'; + + @override + String get doesNotRepeat => 'Não se repete'; + + @override + String get defaultReminder => 'Lembrete predefinido'; + + @override + String get guests => 'Convidados'; + + @override + String get noGuests => 'Sem convidados'; + + @override + String get description => 'Descrição'; + + @override + String get availabilityShowAs => 'Disponibilidade / Mostrar como'; + + @override + String get busy => 'Ocupado'; + + @override + String get visibility => 'Visibilidade'; + + @override + String get defaultVisibility => 'Visibilidade predefinida'; + + @override + String get conference => 'Conferência'; + + @override + String get noConference => 'Sem conferência'; + + @override + String get providerCalendar => 'Calendário do fornecedor'; + + @override + String get formatBoldShortLabel => 'N'; + + @override + String get formatBoldTooltip => 'Negrito'; + + @override + String get formatItalicShortLabel => 'I'; + + @override + String get formatItalicTooltip => 'Itálico'; + + @override + String get formatUnderlineShortLabel => 'S'; + + @override + String get formatUnderlineTooltip => 'Sublinhado'; + + @override + String reminderMinutesBefore(int minutes) { + String _temp0 = intl.Intl.pluralLogic( + minutes, + locale: localeName, + other: '$minutes minutos antes', + one: '1 minuto antes', + ); + return '$_temp0'; + } + + @override + String get reminderAtStart => 'À hora de início'; + + @override + String reminderHoursBefore(int hours) { + String _temp0 = intl.Intl.pluralLogic( + hours, + locale: localeName, + other: '$hours horas antes', + one: '1 hora antes', + ); + return '$_temp0'; + } + + @override + String reminderDaysBefore(int days) { + String _temp0 = intl.Intl.pluralLogic( + days, + locale: localeName, + other: '$days dias antes', + one: '1 dia antes', + ); + return '$_temp0'; + } + + @override + String get availabilityFree => 'Livre'; + + @override + String get availabilityTentative => 'Provisório'; + + @override + String get availabilityOutOfOffice => 'Fora do escritório'; + + @override + String get availabilityWorkingElsewhere => 'A trabalhar noutro local'; + + @override + String get visibilityDefault => 'Predefinida'; + + @override + String get visibilityPublic => 'Pública'; + + @override + String get visibilityPrivate => 'Privada'; + + @override + String get visibilityConfidential => 'Confidencial'; + + @override + String get sensitivityNormal => 'Normal'; + + @override + String get sensitivityPersonal => 'Pessoal'; + + @override + String get tasks => 'Tarefas'; + + @override + String get allTasks => 'Todas as tarefas'; + + @override + String tasksInList(String title) { + return 'Tarefas em $title'; + } + + @override + String get taskLists => 'Listas de tarefas'; + + @override + String get navigation => 'Navegação'; + + @override + String get mainMenu => 'Menu principal'; + + @override + String get keyboardShortcuts => 'Atalhos de teclado'; + + @override + String get shortcutGroupGeneral => 'Geral'; + + @override + String get shortcutKeyboardShortcutsDescription => + 'Mostrar esta referência de atalhos'; + + @override + String get shortcutGroupNavigation => 'Navegação'; + + @override + String get shortcutNextPeriod => 'Período seguinte'; + + @override + String get shortcutNextPeriodDescription => + 'Semana seguinte na vista semanal, mês seguinte na vista mensal e assim por diante'; + + @override + String get shortcutPreviousPeriod => 'Período anterior'; + + @override + String get shortcutPreviousPeriodDescription => + 'Semana anterior na vista semanal, mês anterior na vista mensal e assim por diante'; + + @override + String get shortcutJumpToToday => 'Ir para hoje'; + + @override + String get shortcutGroupView => 'Vista'; + + @override + String get shortcutDayView => 'Vista diária'; + + @override + String get shortcutWeekView => 'Vista semanal'; + + @override + String get shortcutMonthView => 'Vista mensal'; + + @override + String get shortcutYearView => 'Vista anual'; + + @override + String get shortcutAgendaView => 'Vista de agenda'; + + @override + String get shortcutGroupCreateAndEdit => 'Criar e editar'; + + @override + String get shortcutSaveItem => 'Guardar evento ou tarefa'; + + @override + String get shortcutDeleteItem => 'Eliminar evento ou tarefa'; + + @override + String get shortcutGroupTaskEditing => 'Edição de tarefas'; + + @override + String get shortcutCancelEditing => 'Cancelar edição'; + + @override + String get shortcutCancelEditingDescription => + 'Fechar a edição ou os detalhes da tarefa'; + + @override + String get shortcutGroupCompactAgenda => 'Agenda compacta'; + + @override + String get shortcutRefreshCompactAgendaDescription => + 'Atualizar a janela da agenda compacta'; + + @override + String get shortcutHideCompactAgendaDescription => + 'Ocultar a janela da agenda compacta'; + + @override + String get aboutBusyMax => 'Acerca do BusyMax'; + + @override + String get aboutBusyMaxDescription => 'Tarefas e calendário'; + + @override + String get website => 'Site'; + + @override + String get reportAnIssue => 'Comunicar um problema'; + + @override + String get sendFeedback => 'Enviar comentários'; + + @override + String get feedbackSubmit => 'Enviar'; + + @override + String get feedbackCategory => 'Categoria'; + + @override + String get feedbackSelectCategory => 'Selecione uma categoria'; + + @override + String get feedbackCategoryProblem => 'Problema ou erro'; + + @override + String get feedbackCategoryFeature => 'Pedido de funcionalidade'; + + @override + String get feedbackCategoryPrivacySecurity => + 'Questão de privacidade ou segurança'; + + @override + String get feedbackCategoryUsability => 'Problema de utilização'; + + @override + String get feedbackCategoryOther => 'Outro'; + + @override + String get feedbackSubject => 'Assunto'; + + @override + String get feedbackDetailedMessage => 'Mensagem detalhada'; + + @override + String get feedbackReplyEmail => + 'Endereço de e-mail para resposta (opcional)'; + + @override + String get feedbackIncludeTechnicalDetails => 'Incluir detalhes técnicos'; + + @override + String get feedbackTechnicalDetailsDisclosure => + 'Adiciona apenas a versão do sistema operativo Linux e a configuração regional da aplicação. Não são incluídos registos, dados de contas, nomes de ficheiros nem outros diagnósticos.'; + + @override + String get feedbackCategoryRequired => 'Selecione uma categoria.'; + + @override + String get feedbackSubjectLengthError => + 'O assunto deve ter entre 3 e 120 carateres.'; + + @override + String get feedbackMessageLengthError => + 'A mensagem deve ter entre 10 e 5 000 carateres.'; + + @override + String get feedbackInvalidEmail => 'Introduza um endereço de e-mail válido.'; + + @override + String get feedbackConnectionError => + 'Não foi possível ligar ao BusyStack. Verifique a ligação e tente novamente.'; + + @override + String get feedbackTimeoutError => + 'O pedido excedeu o tempo limite. Os seus comentários não foram apagados; tente novamente.'; + + @override + String get feedbackRateLimitedError => + 'Foram enviados demasiados comentários a partir desta rede. Aguarde e tente novamente.'; + + @override + String get feedbackRejectedError => + 'O servidor rejeitou o envio. Reveja os campos e tente novamente.'; + + @override + String get feedbackServerError => + 'O BusyStack não pode aceitar os seus comentários neste momento. Os seus comentários não foram apagados; tente novamente.'; + + @override + String feedbackSuccess(String id) { + return 'Comentários enviados. Referência: $id'; + } + + @override + String get toggleSidebar => 'Mostrar ou ocultar a barra lateral'; + + @override + String get accounts => 'Contas'; + + @override + String get currentAccount => 'Conta atual'; + + @override + String get switchAccount => 'Mudar de conta'; + + @override + String get addGoogleAccount => 'Adicionar conta Google'; + + @override + String get addMicrosoftAccount => 'Adicionar conta Microsoft'; + + @override + String get googleProvider => 'Google'; + + @override + String get microsoftProvider => 'Microsoft'; + + @override + String get signedInAccount => 'Sessão iniciada'; + + @override + String get removeAccount => 'Remover conta…'; + + @override + String get removingAccount => 'A remover conta…'; + + @override + String get removeAccountDescription => + 'Parar a sincronização e remover os dados desta conta deste dispositivo.'; + + @override + String removeAccountTitle(String account) { + return 'Remover $account do BusyMax?'; + } + + @override + String get removeAccountConfirmation => + 'Esta ação elimina deste dispositivo as tarefas, os calendários, os eventos, os lembretes e as alterações offline pendentes em cache. As alterações não sincronizadas serão perdidas. Nada será eliminado da Google ou da Microsoft.'; + + @override + String get revokeGoogleAccess => + 'Revogar também o acesso do BusyMax a esta conta Google'; + + @override + String get revokeGoogleAccessDescription => + 'Terá de conceder acesso novamente antes de voltar a ligar a conta.'; + + @override + String get removeAccountAction => 'Remover conta'; + + @override + String get removeAccountFailed => + 'Não foi possível concluir a remoção da conta. Tente novamente.'; + + @override + String get accountRemovedGoogleRevokeFailed => + 'A conta foi removida deste dispositivo, mas não foi possível revogar o acesso do BusyMax à sua conta Google. Pode revogar esse acesso na sua conta Google.'; + + @override + String get newList => 'Nova lista'; + + @override + String get signInToViewTaskLists => + 'Inicie sessão para ver as listas de tarefas.'; + + @override + String get noTaskListsSynced => + 'Ainda não há listas de tarefas sincronizadas.'; + + @override + String get listActions => 'Ações da lista'; + + @override + String get rename => 'Mudar o nome'; + + @override + String get delete => 'Eliminar'; + + @override + String get renameList => 'Mudar o nome da lista'; + + @override + String get deleteList => 'Eliminar lista'; + + @override + String get builtInMicrosoftList => 'Incorporada'; + + @override + String get builtInMicrosoftListCannotRenameDelete => + 'As listas incorporadas do Microsoft To Do não podem ser renomeadas nem eliminadas.'; + + @override + String deleteListConfirmation(String title) { + return 'Eliminar «$title» do Google Tasks?'; + } + + @override + String get deleteEvent => 'Eliminar evento'; + + @override + String get title => 'Título'; + + @override + String get create => 'Criar'; + + @override + String get newTask => 'Nova tarefa'; + + @override + String get clearCompleted => 'Limpar tarefas concluídas'; + + @override + String get refreshList => 'Atualizar lista'; + + @override + String get refreshAll => 'Atualizar tudo'; + + @override + String get listRefreshed => 'Lista atualizada.'; + + @override + String get allTasksRefreshed => 'Todas as contas foram atualizadas.'; + + @override + String exportedFile(String path) { + return 'Exportado para $path'; + } + + @override + String exportFailed(String error) { + return 'Falha ao exportar: $error'; + } + + @override + String refreshFailed(String error) { + return 'Falha ao atualizar: $error'; + } + + @override + String get selectOrCreateTaskList => + 'Selecione ou crie uma lista de tarefas para começar.'; + + @override + String get signInToViewTasks => 'Inicie sessão para ver as tarefas.'; + + @override + String get noTasks => 'Sem tarefas.'; + + @override + String get noTasksYet => 'Ainda não há tarefas'; + + @override + String get noTasksYetMessage => + 'Crie uma tarefa ou atualize as suas contas para começar.'; + + @override + String get noTasksInList => 'Não há tarefas nesta lista.'; + + @override + String get overdue => 'Em atraso'; + + @override + String get today => 'Hoje'; + + @override + String get tomorrow => 'Amanhã'; + + @override + String get upcoming => 'Próximas'; + + @override + String get noDate => 'Sem data'; + + @override + String get completed => 'Concluídas'; + + @override + String duePrefix(String date) { + return 'Prazo: $date'; + } + + @override + String dateTimeDisplay(String date, String time) { + return '$date, $time'; + } + + @override + String get taskDetails => 'Detalhes da tarefa'; + + @override + String get editTask => 'Editar tarefa'; + + @override + String get noTaskSelected => 'Nenhuma tarefa selecionada.'; + + @override + String get noTaskSelectedHelper => + 'Selecione uma tarefa para ver e editar os detalhes.'; + + @override + String get taskUnavailable => 'Tarefa indisponível.'; + + @override + String get signInToEditTasks => 'Inicie sessão para editar tarefas.'; + + @override + String get refreshTask => 'Atualizar tarefa'; + + @override + String get primarySection => 'Principal'; + + @override + String get statusSection => 'Estado'; + + @override + String get openStatus => 'Aberta'; + + @override + String get doneStatus => 'Concluída'; + + @override + String get notes => 'Notas'; + + @override + String get dueDate => 'Data limite'; + + @override + String get clearDueDate => 'Limpar data limite'; + + @override + String get dueTime => 'Hora limite'; + + @override + String get startDate => 'Data de início'; + + @override + String get startTime => 'Hora de início'; + + @override + String get endDate => 'Data de fim'; + + @override + String get endTime => 'Hora de fim'; + + @override + String get reminderDate => 'Data do lembrete'; + + @override + String get reminderTime => 'Hora do lembrete'; + + @override + String get reminder => 'Lembrete'; + + @override + String get addReminder => 'Adicionar lembrete'; + + @override + String get addGuest => 'Adicionar convidado'; + + @override + String get addGuestEmail => 'Adicionar e-mail do convidado'; + + @override + String get removeReminder => 'Remover lembrete'; + + @override + String get off => 'Desativado'; + + @override + String get repeat => 'Repetição'; + + @override + String get repeatNone => 'Não repetir'; + + @override + String get noneValue => 'Nenhum'; + + @override + String get repeatDaily => 'Diariamente'; + + @override + String get repeatWeekly => 'Semanalmente'; + + @override + String get repeatMonthly => 'Mensalmente'; + + @override + String get repeatYearly => 'Anualmente'; + + @override + String get importance => 'Importância'; + + @override + String get importanceLow => 'Baixa'; + + @override + String get importanceNormal => 'Normal'; + + @override + String get importanceHigh => 'Alta'; + + @override + String get categories => 'Categorias'; + + @override + String get scheduleSection => 'Agenda'; + + @override + String get dueGroup => 'Prazo'; + + @override + String get startGroup => 'Início'; + + @override + String get reminderGroup => 'Lembrete'; + + @override + String get organizationSection => 'Organização'; + + @override + String get actionsSection => 'Ações'; + + @override + String get advancedSection => 'Avançado'; + + @override + String get addCategory => 'Adicionar categoria'; + + @override + String get list => 'Lista'; + + @override + String get microsoftMoveUnsupported => + 'Nesta versão, não é possível mover tarefas entre listas em contas Microsoft To Do.'; + + @override + String get createSubtask => 'Criar subtarefa'; + + @override + String get moveToTop => 'Mover para o início'; + + @override + String get deleteTask => 'Eliminar tarefa'; + + @override + String get newSubtask => 'Nova subtarefa'; + + @override + String deleteTaskConfirmation(String title) { + return 'Eliminar «$title» do Google Tasks?'; + } + + @override + String get metadata => 'Metadados'; + + @override + String get id => 'ID'; + + @override + String get etag => 'ETag'; + + @override + String get updated => 'Atualizado'; + + @override + String get parent => 'Tarefa principal'; + + @override + String get position => 'Posição'; + + @override + String get webLink => 'Ligação Web'; + + @override + String get assignment => 'Atribuição'; + + @override + String get localState => 'Estado local'; + + @override + String get pendingSync => 'Sincronização pendente'; + + @override + String get synced => 'Sincronizado'; + + @override + String get account => 'Conta'; + + @override + String get sync => 'Sincronização'; + + @override + String get manualFullSync => 'Sincronização completa manual'; + + @override + String get runInBackgroundWhenClosed => + 'Continuar em execução quando a janela for fechada'; + + @override + String get showTrayIcon => 'Mostrar ícone na área de notificação'; + + @override + String get startMinimizedToTray => + 'Iniciar minimizado na área de notificação'; + + @override + String get requiresTrayIcon => 'Requer o ícone da área de notificação.'; + + @override + String get syncComplete => 'Sincronização concluída.'; + + @override + String syncFailed(String error) { + return 'Falha na sincronização: $error'; + } + + @override + String get notifySyncFailures => 'Notificações de falhas de sincronização'; + + @override + String get notifyConflicts => 'Notificações de conflitos'; + + @override + String get notifyDueToday => 'Notificações de tarefas com prazo para hoje'; + + @override + String get eventReminders => 'Lembretes de eventos'; + + @override + String get taskReminders => 'Lembretes de tarefas'; + + @override + String get notificationDetailLevel => 'Nível de detalhe das notificações'; + + @override + String get notificationDetailPrivate => 'Privado'; + + @override + String get notificationDetailNormal => 'Normal'; + + @override + String get quietHours => 'Período de silêncio'; + + @override + String get quietHoursDescription => + 'Pausar as notificações durante este período.'; + + @override + String get quietHoursStart => 'Início do período de silêncio'; + + @override + String get quietHoursEnd => 'Fim do período de silêncio'; + + @override + String get notifications => 'Notificações'; + + @override + String get appearance => 'Aspeto'; + + @override + String get theme => 'Tema'; + + @override + String get themeSystem => 'Sistema'; + + @override + String get themeLight => 'Claro'; + + @override + String get themeDark => 'Escuro'; + + @override + String get themeFamily => 'Família de temas'; + + @override + String get themeFamilyYaru => 'Tema nativo do Ubuntu (Yaru)'; + + @override + String get localization => 'Localização'; + + @override + String get currentLocale => 'Configuração regional atual'; + + @override + String get privacy => 'Privacidade'; + + @override + String get redactTaskContentInDiagnostics => + 'Ocultar o conteúdo das tarefas nos diagnósticos'; + + @override + String get developerDiagnostics => 'Diagnósticos de programador'; + + @override + String get diagnostics => 'Diagnósticos'; + + @override + String get apiInspectorDisabled => 'Mostrar inspetor da API'; + + @override + String get googleTasksApi => 'API do Google Tasks'; + + @override + String discoveryRevision(String revision) { + return 'Revisão de descoberta: $revision'; + } + + @override + String get implementedMethods => 'Métodos implementados'; + + @override + String get supportsTasksScopes => + 'Suporta os âmbitos de autorização tasks e tasks.readonly'; + + @override + String get requiresTasksScope => 'Requer o âmbito de autorização tasks'; + + @override + String get blockedPendingOperations => 'Operações pendentes bloqueadas'; + + @override + String get signInToInspectPendingOperations => + 'Inicie sessão para inspecionar as operações pendentes.'; + + @override + String get noBlockedPendingOperations => + 'Não há operações pendentes bloqueadas.'; + + @override + String get operationActions => 'Ações da operação'; + + @override + String pendingOpListId(String id) { + return 'lista=$id'; + } + + @override + String pendingOpTaskId(String id) { + return 'tarefa=$id'; + } + + @override + String pendingOpAttempts(int count) { + return 'tentativas=$count'; + } + + @override + String get retry => 'Tentar novamente'; + + @override + String get discard => 'Descartar'; + + @override + String get discardChanges => 'Descartar alterações?'; + + @override + String get discardChangesConfirmation => + 'Esta ação descarta as alterações não guardadas nesta tarefa.'; + + @override + String get retryCompleted => 'Nova tentativa concluída.'; + + @override + String get discardPendingOperation => 'Descartar operação pendente?'; + + @override + String get discardPendingOperationConfirmation => + 'Esta ação remove a operação local bloqueada. Na próxima sincronização, os dados serão novamente carregados do Google Tasks.'; + + @override + String get pendingOperationDiscarded => 'Operação pendente descartada.'; + + @override + String get syncFailureNotificationTitle => + 'Falha na sincronização do BusyMax'; + + @override + String syncFailureNotificationBody(String message) { + return 'A sincronização em segundo plano falhou. $message'; + } + + @override + String get conflictNotificationTitle => + 'Conflito de sincronização do BusyMax'; + + @override + String conflictNotificationBody(String summary) { + return 'Uma alteração local pendente foi bloqueada. $summary'; + } + + @override + String get dueTodayNotificationTitle => 'Tarefas com prazo para hoje'; + + @override + String dueTodayNotificationBody(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'Há $count tarefas com prazo para hoje.', + one: 'Há uma tarefa com prazo para hoje.', + ); + return '$_temp0'; + } + + @override + String get eventReminderNotificationTitle => 'Lembrete de evento'; + + @override + String get taskReminderNotificationTitle => 'Lembrete de tarefa'; + + @override + String get eventReminderNotificationBody => 'O evento começa em breve.'; + + @override + String get taskReminderNotificationBody => 'O prazo da tarefa aproxima-se.'; + + @override + String get notificationOpenAction => 'Abrir'; + + @override + String get notificationDetailsHidden => + 'Os detalhes estão ocultos pelas definições de privacidade.'; + + @override + String get previousMonth => 'Mês anterior'; + + @override + String get nextMonth => 'Mês seguinte'; + + @override + String get openMonthView => 'Abrir vista mensal'; + + @override + String get previousYear => 'Ano anterior'; + + @override + String get nextYear => 'Ano seguinte'; + + @override + String get openYearView => 'Abrir vista anual'; + + @override + String weekNumberTooltip(int number) { + return 'Semana $number'; + } + + @override + String get resizeAllDayPanel => 'Redimensionar o painel de dia inteiro'; + + @override + String scheduleItemCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count itens', + one: '1 item', + ); + return '$_temp0'; + } + + @override + String get readOnlyCalendar => 'Este calendário é só de leitura.'; + + @override + String get selectTimeZone => 'Selecionar fuso horário'; + + @override + String get searchLocations => 'Pesquisar locais'; + + @override + String get noLocationsFound => 'Nenhum local encontrado'; + + @override + String deleteCalendarConfirmation(String title) { + return 'Eliminar «$title»?'; + } +} diff --git a/lib/l10n/generated/app_localizations_ru.dart b/lib/l10n/generated/app_localizations_ru.dart index 0cb0373..f75bb32 100644 --- a/lib/l10n/generated/app_localizations_ru.dart +++ b/lib/l10n/generated/app_localizations_ru.dart @@ -44,7 +44,7 @@ class AppLocalizationsRu extends AppLocalizations { @override String get onboardingPreferencesStepDescription => - 'Настройте поведение на рабочем столе, напоминания, содержимое уведомлений и внешний вид, прежде чем открыть расписание.'; + 'Настройте поведение приложения на рабочем столе, напоминания, уровень детализации уведомлений и внешний вид, прежде чем открыть расписание.'; @override String get signInWithGoogle => 'Войти через Google'; @@ -120,7 +120,7 @@ class AppLocalizationsRu extends AppLocalizations { String get refreshCalendar => 'Обновить календарь'; @override - String get openInProvider => 'Открыть у поставщика'; + String get openInProvider => 'Открыть в сервисе'; @override String get hideFromSchedule => 'Скрыть из расписания'; @@ -475,7 +475,7 @@ class AppLocalizationsRu extends AppLocalizations { 'Предыдущая неделя в представлении недели, предыдущий месяц в представлении месяца и так далее'; @override - String get shortcutJumpToToday => 'Перейти к сегодняшнему дню'; + String get shortcutJumpToToday => 'Перейти к сегодняшней дате'; @override String get shortcutGroupView => 'Представление'; @@ -496,7 +496,7 @@ class AppLocalizationsRu extends AppLocalizations { String get shortcutAgendaView => 'Представление повестки'; @override - String get shortcutGroupCreateAndEdit => 'Создание и изменение'; + String get shortcutGroupCreateAndEdit => 'Создание и редактирование'; @override String get shortcutSaveItem => 'Сохранить событие или задачу'; @@ -505,14 +505,14 @@ class AppLocalizationsRu extends AppLocalizations { String get shortcutDeleteItem => 'Удалить событие или задачу'; @override - String get shortcutGroupTaskEditing => 'Изменение задач'; + String get shortcutGroupTaskEditing => 'Редактирование задач'; @override - String get shortcutCancelEditing => 'Отменить изменение'; + String get shortcutCancelEditing => 'Отменить редактирование'; @override String get shortcutCancelEditingDescription => - 'Закрыть изменение задачи или сведения о ней'; + 'Выйти из режима редактирования задачи или закрыть сведения о ней'; @override String get shortcutGroupCompactAgenda => 'Компактная повестка'; @@ -580,7 +580,7 @@ class AppLocalizationsRu extends AppLocalizations { @override String get feedbackTechnicalDetailsDisclosure => - 'Будут добавлены только версия операционной системы Linux и языковой стандарт приложения. Журналы, данные аккаунтов, имена файлов и другие диагностические сведения не включаются.'; + 'Будут добавлены только версия операционной системы Linux и локаль приложения. Журналы, данные аккаунтов, имена файлов и другие диагностические сведения не включаются.'; @override String get feedbackCategoryRequired => 'Выберите категорию.'; @@ -666,7 +666,7 @@ class AppLocalizationsRu extends AppLocalizations { @override String get removeAccountConfirmation => - 'С устройства будут удалены кэшированные задачи, календари, события, напоминания и ожидающие автономные изменения. Несинхронизированные изменения будут потеряны. Из Google и Microsoft ничего не удаляется.'; + 'С этого устройства будут удалены кэшированные задачи, календари, события, напоминания и локальные изменения, ожидающие синхронизации. Несинхронизированные изменения будут потеряны. В Google и Microsoft ничего не будет удалено.'; @override String get revokeGoogleAccess => @@ -685,7 +685,7 @@ class AppLocalizationsRu extends AppLocalizations { @override String get accountRemovedGoogleRevokeFailed => - 'Аккаунт удалён с устройства, но BusyMax не удалось отозвать доступ Google. Это можно сделать в аккаунте Google.'; + 'Аккаунт удалён с этого устройства, но отозвать доступ BusyMax к Google не удалось. Вы можете отозвать доступ в аккаунте Google.'; @override String get newList => 'Новый список'; @@ -855,7 +855,7 @@ class AppLocalizationsRu extends AppLocalizations { String get dueDate => 'Срок'; @override - String get clearDueDate => 'Очистить срок'; + String get clearDueDate => 'Удалить срок выполнения'; @override String get dueTime => 'Время выполнения'; @@ -1060,13 +1060,13 @@ class AppLocalizationsRu extends AppLocalizations { String get taskReminders => 'Напоминания о задачах'; @override - String get notificationDetailLevel => 'Подробность уведомлений'; + String get notificationDetailLevel => 'Уровень детализации уведомлений'; @override - String get notificationDetailPrivate => 'Конфиденциальные'; + String get notificationDetailPrivate => 'Конфиденциальный'; @override - String get notificationDetailNormal => 'Обычные'; + String get notificationDetailNormal => 'Обычный'; @override String get quietHours => 'Период тишины'; @@ -1103,13 +1103,13 @@ class AppLocalizationsRu extends AppLocalizations { String get themeFamily => 'Семейство тем'; @override - String get themeFamilyYaru => 'Родная тема Ubuntu (Yaru)'; + String get themeFamilyYaru => 'Нативная тема Ubuntu (Yaru)'; @override String get localization => 'Локализация'; @override - String get currentLocale => 'Текущий языковой стандарт'; + String get currentLocale => 'Текущая локаль'; @override String get privacy => 'Конфиденциальность'; diff --git a/lib/l10n/generated/app_localizations_zh.dart b/lib/l10n/generated/app_localizations_zh.dart new file mode 100644 index 0000000..48273a2 --- /dev/null +++ b/lib/l10n/generated/app_localizations_zh.dart @@ -0,0 +1,3771 @@ +// ignore: unused_import +import 'package:intl/intl.dart' as intl; +import 'app_localizations.dart'; + +// ignore_for_file: type=lint + +/// The translations for Chinese (`zh`). +class AppLocalizationsZh extends AppLocalizations { + AppLocalizationsZh([String locale = 'zh']) : super(locale); + + @override + String get appTitle => 'BusyMax'; + + @override + String get connectGoogleAccount => '连接 Google 和 Microsoft 帐户以同步日历和任务。'; + + @override + String get googlePermissionsConsentNotice => '在 Google 权限页面上,同时选择日历和任务权限。'; + + @override + String get googlePermissionsRequiredRetry => + '必须授予 Google 日历和 Google Tasks 权限。请重试并选中两个复选框。'; + + @override + String get finishSetup => '完成设置'; + + @override + String get continueSetup => '继续'; + + @override + String get onboardingSetupTitle => '设置 BusyMax'; + + @override + String get onboardingAccountsStepTitle => '连接帐户'; + + @override + String get onboardingAccountsStepDescription => + '添加您要使用的所有 Google 和 Microsoft 帐户。BusyMax 会同步每个帐户中的日历、日程、任务列表和任务。'; + + @override + String get onboardingPreferencesStepTitle => '选择系统设置'; + + @override + String get onboardingPreferencesStepDescription => + '打开日程前,请设置桌面行为、提醒、通知详细程度和外观。'; + + @override + String get signInWithGoogle => '使用 Google 登录'; + + @override + String get signInWithMicrosoft => '使用 Microsoft 登录'; + + @override + String get googleTasksProvider => 'Google Tasks'; + + @override + String get microsoftTodoProvider => 'Microsoft To Do'; + + @override + String get providerNotConfigured => '尚未配置此服务。'; + + @override + String get waitingForGoogleSignIn => '正在等待 Google 登录...'; + + @override + String get waitingForMicrosoftSignIn => '正在等待 Microsoft 登录...'; + + @override + String get microsoftSignInNotConfigured => + '尚未配置 Microsoft 登录。请设置 MICROSOFT_OAUTH_CLIENT_ID。'; + + @override + String get cancel => '取消'; + + @override + String get close => '关闭'; + + @override + String get exit => '退出'; + + @override + String get options => '选项'; + + @override + String get hide => '隐藏'; + + @override + String get show => '显示'; + + @override + String get export => '导出'; + + @override + String get save => '保存'; + + @override + String get settings => '设置'; + + @override + String get all => '全部'; + + @override + String get calendarEvents => '日程'; + + @override + String get calendarTasks => '任务'; + + @override + String get calendar => '日历'; + + @override + String get calendars => '日历'; + + @override + String get newEvent => '新建日程'; + + @override + String get refreshCalendar => '刷新日历'; + + @override + String get openInProvider => '在服务中打开'; + + @override + String get hideFromSchedule => '从日程中隐藏'; + + @override + String get showInSchedule => '在日程中显示'; + + @override + String get noCalendarsSynced => '尚未同步任何日历。'; + + @override + String get allDay => '全天'; + + @override + String moreItems(int count) { + return '还有 $count 项'; + } + + @override + String get noEventsOrTasks => '没有日程或任务'; + + @override + String get scheduleLoading => '正在加载日程...'; + + @override + String get scheduleUnavailable => '日程不可用'; + + @override + String get scheduleNoSources => '没有可见的日历或任务列表'; + + @override + String get scheduleNoSourcesDescription => '请在设置中选择要显示的内容,然后刷新。'; + + @override + String get scheduleSignInRequired => '连接帐户'; + + @override + String get scheduleSignInDescription => '登录以同步日历和任务。'; + + @override + String get scheduleNoSearchResults => '没有匹配的日程或任务'; + + @override + String get scheduleNoSearchResultsDescription => '请尝试其他搜索内容或清除当前筛选条件。'; + + @override + String get trayAgendaLoading => '正在加载日程...'; + + @override + String get trayAgendaSignInRequired => '请登录以显示日程。'; + + @override + String get trayAgendaNoSources => '没有可见的日历或任务列表。'; + + @override + String get trayAgendaOpenBusyMax => '打开应用'; + + @override + String get trayAgendaRefresh => '刷新'; + + @override + String get trayAgendaError => '日程不可用'; + + @override + String get compactAgendaTitle => '日程'; + + @override + String get compactAgendaSubtitle => '即将开始'; + + @override + String get compactAgendaOverdue => '已逾期'; + + @override + String get compactAgendaClear => '目前空闲'; + + @override + String get compactAgendaOpenBusyMax => '打开 BusyMax'; + + @override + String get compactAgendaHide => '隐藏'; + + @override + String get compactAgendaNewTask => '新建任务'; + + @override + String get compactAgendaRetry => '重试'; + + @override + String get compactAgendaRefresh => '刷新'; + + @override + String get compactAgendaAllDay => '全天'; + + @override + String get compactAgendaDueToday => '今天到期'; + + @override + String get compactAgendaDueTomorrow => '明天到期'; + + @override + String compactAgendaDueOn(String date) { + return '$date 到期'; + } + + @override + String get compactAgendaMoreOverdue => '加载更多逾期任务'; + + @override + String get agendaLoadMoreOverdue => '加载更多逾期任务'; + + @override + String get agendaLoadMoreNoDate => '加载更多无日期任务'; + + @override + String get viewDay => '日'; + + @override + String get viewWeek => '周'; + + @override + String get viewMonth => '月'; + + @override + String get viewYear => '年'; + + @override + String get viewAgenda => '日程'; + + @override + String get scheduleSettings => '日程'; + + @override + String get scheduleDisplaySettings => '日程显示'; + + @override + String get scheduleDisplayHoursDescription => + '日视图和周视图最初显示此时间范围。需要时,更早或更晚的项目会扩展该范围。'; + + @override + String get scheduleDayStartsAt => '每日开始时间'; + + @override + String get scheduleDayEndsAt => '每日结束时间'; + + @override + String get sourceCalendar => '日历'; + + @override + String get sourceTaskList => '任务列表'; + + @override + String get createChoiceTitle => '新建'; + + @override + String get createEventAtTime => '日程'; + + @override + String get createTaskAtDate => '任务'; + + @override + String get editEvent => '编辑日程'; + + @override + String get eventTitle => '日程标题'; + + @override + String get location => '地点'; + + @override + String get timeSlot => '时间段'; + + @override + String get startDateTime => '开始日期/时间'; + + @override + String get endDateTime => '结束日期/时间'; + + @override + String get doesNotRepeat => '不重复'; + + @override + String get defaultReminder => '默认提醒'; + + @override + String get guests => '参与者'; + + @override + String get noGuests => '没有参与者'; + + @override + String get description => '说明'; + + @override + String get availabilityShowAs => '空闲状态 / 显示为'; + + @override + String get busy => '忙碌'; + + @override + String get visibility => '可见性'; + + @override + String get defaultVisibility => '默认可见性'; + + @override + String get conference => '会议'; + + @override + String get noConference => '无会议'; + + @override + String get providerCalendar => '服务日历'; + + @override + String get formatBoldShortLabel => 'B'; + + @override + String get formatBoldTooltip => '粗体'; + + @override + String get formatItalicShortLabel => 'I'; + + @override + String get formatItalicTooltip => '斜体'; + + @override + String get formatUnderlineShortLabel => 'U'; + + @override + String get formatUnderlineTooltip => '下划线'; + + @override + String reminderMinutesBefore(int minutes) { + String _temp0 = intl.Intl.pluralLogic( + minutes, + locale: localeName, + other: '$minutes 分钟前', + one: '1 分钟前', + ); + return '$_temp0'; + } + + @override + String get reminderAtStart => '开始时'; + + @override + String reminderHoursBefore(int hours) { + String _temp0 = intl.Intl.pluralLogic( + hours, + locale: localeName, + other: '$hours 小时前', + one: '1 小时前', + ); + return '$_temp0'; + } + + @override + String reminderDaysBefore(int days) { + String _temp0 = intl.Intl.pluralLogic( + days, + locale: localeName, + other: '$days 天前', + one: '1 天前', + ); + return '$_temp0'; + } + + @override + String get availabilityFree => '空闲'; + + @override + String get availabilityTentative => '暂定'; + + @override + String get availabilityOutOfOffice => '不在办公室'; + + @override + String get availabilityWorkingElsewhere => '在其他地点办公'; + + @override + String get visibilityDefault => '默认'; + + @override + String get visibilityPublic => '公开'; + + @override + String get visibilityPrivate => '私密'; + + @override + String get visibilityConfidential => '机密'; + + @override + String get sensitivityNormal => '普通'; + + @override + String get sensitivityPersonal => '个人'; + + @override + String get tasks => '任务'; + + @override + String get allTasks => '所有任务'; + + @override + String tasksInList(String title) { + return '$title中的任务'; + } + + @override + String get taskLists => '任务列表'; + + @override + String get navigation => '导航'; + + @override + String get mainMenu => '主菜单'; + + @override + String get keyboardShortcuts => '键盘快捷键'; + + @override + String get shortcutGroupGeneral => '常规'; + + @override + String get shortcutKeyboardShortcutsDescription => '显示此快捷键参考'; + + @override + String get shortcutGroupNavigation => '导航'; + + @override + String get shortcutNextPeriod => '下一时段'; + + @override + String get shortcutNextPeriodDescription => '在周视图中前往下一周,在月视图中前往下个月,依此类推'; + + @override + String get shortcutPreviousPeriod => '上一时段'; + + @override + String get shortcutPreviousPeriodDescription => '在周视图中前往上一周,在月视图中前往上个月,依此类推'; + + @override + String get shortcutJumpToToday => '跳转到今天'; + + @override + String get shortcutGroupView => '视图'; + + @override + String get shortcutDayView => '日视图'; + + @override + String get shortcutWeekView => '周视图'; + + @override + String get shortcutMonthView => '月视图'; + + @override + String get shortcutYearView => '年视图'; + + @override + String get shortcutAgendaView => '日程视图'; + + @override + String get shortcutGroupCreateAndEdit => '新建和编辑'; + + @override + String get shortcutSaveItem => '保存日程或任务'; + + @override + String get shortcutDeleteItem => '删除日程或任务'; + + @override + String get shortcutGroupTaskEditing => '任务编辑'; + + @override + String get shortcutCancelEditing => '取消编辑'; + + @override + String get shortcutCancelEditingDescription => '关闭任务编辑或任务详情'; + + @override + String get shortcutGroupCompactAgenda => '紧凑日程'; + + @override + String get shortcutRefreshCompactAgendaDescription => '刷新紧凑日程窗口'; + + @override + String get shortcutHideCompactAgendaDescription => '隐藏紧凑日程窗口'; + + @override + String get aboutBusyMax => '关于 BusyMax'; + + @override + String get aboutBusyMaxDescription => '任务和日历'; + + @override + String get website => '网站'; + + @override + String get reportAnIssue => '报告问题'; + + @override + String get sendFeedback => '发送反馈'; + + @override + String get feedbackSubmit => '提交'; + + @override + String get feedbackCategory => '类别'; + + @override + String get feedbackSelectCategory => '选择类别'; + + @override + String get feedbackCategoryProblem => '问题或错误'; + + @override + String get feedbackCategoryFeature => '功能请求'; + + @override + String get feedbackCategoryPrivacySecurity => '隐私或安全问题'; + + @override + String get feedbackCategoryUsability => '易用性问题'; + + @override + String get feedbackCategoryOther => '其他'; + + @override + String get feedbackSubject => '主题'; + + @override + String get feedbackDetailedMessage => '详细信息'; + + @override + String get feedbackReplyEmail => '用于接收回复的电子邮件地址(可选)'; + + @override + String get feedbackIncludeTechnicalDetails => '包含技术详情'; + + @override + String get feedbackTechnicalDetailsDisclosure => + '仅添加您的 Linux 操作系统版本和应用区域设置。不包含日志、帐户数据、文件名或其他诊断信息。'; + + @override + String get feedbackCategoryRequired => '请选择类别。'; + + @override + String get feedbackSubjectLengthError => '主题必须为 3 至 120 个字符。'; + + @override + String get feedbackMessageLengthError => '消息必须为 10 至 5,000 个字符。'; + + @override + String get feedbackInvalidEmail => '请输入有效的电子邮件地址。'; + + @override + String get feedbackConnectionError => '无法连接到 BusyStack。请检查连接,然后重试。'; + + @override + String get feedbackTimeoutError => '请求超时。您的反馈尚未清除,请重试。'; + + @override + String get feedbackRateLimitedError => '从此网络发送的反馈过多。请稍后再试。'; + + @override + String get feedbackRejectedError => '服务器拒绝了提交。请检查各字段,然后重试。'; + + @override + String get feedbackServerError => 'BusyStack 目前无法接收您的反馈。您的反馈尚未清除,请重试。'; + + @override + String feedbackSuccess(String id) { + return '反馈已发送。参考编号:$id'; + } + + @override + String get toggleSidebar => '显示或隐藏侧边栏'; + + @override + String get accounts => '帐户'; + + @override + String get currentAccount => '当前帐户'; + + @override + String get switchAccount => '切换帐户'; + + @override + String get addGoogleAccount => '添加 Google 帐户'; + + @override + String get addMicrosoftAccount => '添加 Microsoft 帐户'; + + @override + String get googleProvider => 'Google'; + + @override + String get microsoftProvider => 'Microsoft'; + + @override + String get signedInAccount => '已登录'; + + @override + String get removeAccount => '移除帐户…'; + + @override + String get removingAccount => '正在移除帐户…'; + + @override + String get removeAccountDescription => '停止同步并从此设备移除此帐户的数据。'; + + @override + String removeAccountTitle(String account) { + return '从 BusyMax 中移除 $account?'; + } + + @override + String get removeAccountConfirmation => + '这会从此设备删除缓存的任务、日历、日程、提醒和待处理的离线更改。未同步的更改将丢失。不会从 Google 或 Microsoft 删除任何内容。'; + + @override + String get revokeGoogleAccess => '同时撤销 BusyMax 对此 Google 帐户的访问权限'; + + @override + String get revokeGoogleAccessDescription => '重新连接之前,您需要再次授予访问权限。'; + + @override + String get removeAccountAction => '移除帐户'; + + @override + String get removeAccountFailed => '无法完成帐户移除。请重试。'; + + @override + String get accountRemovedGoogleRevokeFailed => + '已从此设备移除该帐户,但 BusyMax 无法撤销 Google 访问权限。您可以在 Google 帐户中撤销。'; + + @override + String get newList => '新建列表'; + + @override + String get signInToViewTaskLists => '登录以查看任务列表。'; + + @override + String get noTaskListsSynced => '尚未同步任何任务列表。'; + + @override + String get listActions => '列表操作'; + + @override + String get rename => '重命名'; + + @override + String get delete => '删除'; + + @override + String get renameList => '重命名列表'; + + @override + String get deleteList => '删除列表'; + + @override + String get builtInMicrosoftList => '内置'; + + @override + String get builtInMicrosoftListCannotRenameDelete => + '无法重命名或删除 Microsoft To Do 内置列表。'; + + @override + String deleteListConfirmation(String title) { + return '从 Google Tasks 中删除“$title”?'; + } + + @override + String get deleteEvent => '删除日程'; + + @override + String get title => '标题'; + + @override + String get create => '新建'; + + @override + String get newTask => '新建任务'; + + @override + String get clearCompleted => '清除已完成项'; + + @override + String get refreshList => '刷新列表'; + + @override + String get refreshAll => '全部刷新'; + + @override + String get listRefreshed => '列表已刷新。'; + + @override + String get allTasksRefreshed => '所有帐户均已刷新。'; + + @override + String exportedFile(String path) { + return '已导出到 $path'; + } + + @override + String exportFailed(String error) { + return '导出失败:$error'; + } + + @override + String refreshFailed(String error) { + return '刷新失败:$error'; + } + + @override + String get selectOrCreateTaskList => '请选择或创建任务列表以开始使用。'; + + @override + String get signInToViewTasks => '登录以查看任务。'; + + @override + String get noTasks => '没有任务。'; + + @override + String get noTasksYet => '还没有任务'; + + @override + String get noTasksYetMessage => '创建任务或刷新帐户以开始使用。'; + + @override + String get noTasksInList => '此列表中没有任务。'; + + @override + String get overdue => '已逾期'; + + @override + String get today => '今天'; + + @override + String get tomorrow => '明天'; + + @override + String get upcoming => '即将开始'; + + @override + String get noDate => '无日期'; + + @override + String get completed => '已完成'; + + @override + String duePrefix(String date) { + return '$date 到期'; + } + + @override + String dateTimeDisplay(String date, String time) { + return '$date · $time'; + } + + @override + String get taskDetails => '任务详情'; + + @override + String get editTask => '编辑任务'; + + @override + String get noTaskSelected => '未选择任务。'; + + @override + String get noTaskSelectedHelper => '选择任务以查看和编辑详情。'; + + @override + String get taskUnavailable => '任务不可用。'; + + @override + String get signInToEditTasks => '登录以编辑任务。'; + + @override + String get refreshTask => '刷新任务'; + + @override + String get primarySection => '主要信息'; + + @override + String get statusSection => '状态'; + + @override + String get openStatus => '未完成'; + + @override + String get doneStatus => '已完成'; + + @override + String get notes => '备注'; + + @override + String get dueDate => '截止日期'; + + @override + String get clearDueDate => '清除截止日期'; + + @override + String get dueTime => '截止时间'; + + @override + String get startDate => '开始日期'; + + @override + String get startTime => '开始时间'; + + @override + String get endDate => '结束日期'; + + @override + String get endTime => '结束时间'; + + @override + String get reminderDate => '提醒日期'; + + @override + String get reminderTime => '提醒时间'; + + @override + String get reminder => '提醒'; + + @override + String get addReminder => '添加提醒'; + + @override + String get addGuest => '添加参与者'; + + @override + String get addGuestEmail => '添加参与者电子邮件'; + + @override + String get removeReminder => '移除提醒'; + + @override + String get off => '关闭'; + + @override + String get repeat => '重复'; + + @override + String get repeatNone => '不重复'; + + @override + String get noneValue => '无'; + + @override + String get repeatDaily => '每天'; + + @override + String get repeatWeekly => '每周'; + + @override + String get repeatMonthly => '每月'; + + @override + String get repeatYearly => '每年'; + + @override + String get importance => '重要性'; + + @override + String get importanceLow => '低'; + + @override + String get importanceNormal => '普通'; + + @override + String get importanceHigh => '高'; + + @override + String get categories => '类别'; + + @override + String get scheduleSection => '日程'; + + @override + String get dueGroup => '截止'; + + @override + String get startGroup => '开始'; + + @override + String get reminderGroup => '提醒'; + + @override + String get organizationSection => '整理'; + + @override + String get actionsSection => '操作'; + + @override + String get advancedSection => '高级'; + + @override + String get addCategory => '添加类别'; + + @override + String get list => '列表'; + + @override + String get microsoftMoveUnsupported => '此版本不支持在 Microsoft To Do 帐户的列表之间移动任务。'; + + @override + String get createSubtask => '创建子任务'; + + @override + String get moveToTop => '移到顶部'; + + @override + String get deleteTask => '删除任务'; + + @override + String get newSubtask => '新建子任务'; + + @override + String deleteTaskConfirmation(String title) { + return '从 Google Tasks 中删除“$title”?'; + } + + @override + String get metadata => '元数据'; + + @override + String get id => 'ID'; + + @override + String get etag => 'ETag'; + + @override + String get updated => '更新时间'; + + @override + String get parent => '父任务'; + + @override + String get position => '位置'; + + @override + String get webLink => '网页链接'; + + @override + String get assignment => '分配'; + + @override + String get localState => '本地状态'; + + @override + String get pendingSync => '等待同步'; + + @override + String get synced => '已同步'; + + @override + String get account => '帐户'; + + @override + String get sync => '同步'; + + @override + String get manualFullSync => '手动完整同步'; + + @override + String get runInBackgroundWhenClosed => '窗口关闭后继续在后台运行'; + + @override + String get showTrayIcon => '显示托盘图标'; + + @override + String get startMinimizedToTray => '启动时最小化到托盘'; + + @override + String get requiresTrayIcon => '需要托盘图标。'; + + @override + String get syncComplete => '同步完成。'; + + @override + String syncFailed(String error) { + return '同步失败:$error'; + } + + @override + String get notifySyncFailures => '同步失败通知'; + + @override + String get notifyConflicts => '冲突通知'; + + @override + String get notifyDueToday => '今天到期任务通知'; + + @override + String get eventReminders => '日程提醒'; + + @override + String get taskReminders => '任务提醒'; + + @override + String get notificationDetailLevel => '通知详细程度'; + + @override + String get notificationDetailPrivate => '私密'; + + @override + String get notificationDetailNormal => '普通'; + + @override + String get quietHours => '免打扰时段'; + + @override + String get quietHoursDescription => '在此时段暂停通知。'; + + @override + String get quietHoursStart => '免打扰开始时间'; + + @override + String get quietHoursEnd => '免打扰结束时间'; + + @override + String get notifications => '通知'; + + @override + String get appearance => '外观'; + + @override + String get theme => '主题'; + + @override + String get themeSystem => '系统'; + + @override + String get themeLight => '浅色'; + + @override + String get themeDark => '深色'; + + @override + String get themeFamily => '主题系列'; + + @override + String get themeFamilyYaru => 'Ubuntu 原生主题(Yaru)'; + + @override + String get localization => '语言和区域'; + + @override + String get currentLocale => '当前区域设置'; + + @override + String get privacy => '隐私'; + + @override + String get redactTaskContentInDiagnostics => '在诊断信息中隐藏任务内容'; + + @override + String get developerDiagnostics => '开发者诊断'; + + @override + String get diagnostics => '诊断'; + + @override + String get apiInspectorDisabled => '显示 API 检查器'; + + @override + String get googleTasksApi => 'Google Tasks API'; + + @override + String discoveryRevision(String revision) { + return 'Discovery 修订版:$revision'; + } + + @override + String get implementedMethods => '已实现的方法'; + + @override + String get supportsTasksScopes => '支持 tasks 和 tasks.readonly 权限范围'; + + @override + String get requiresTasksScope => '需要 tasks 权限范围'; + + @override + String get blockedPendingOperations => '被阻止的待处理操作'; + + @override + String get signInToInspectPendingOperations => '登录以检查待处理操作。'; + + @override + String get noBlockedPendingOperations => '没有被阻止的待处理操作。'; + + @override + String get operationActions => '操作选项'; + + @override + String pendingOpListId(String id) { + return '列表=$id'; + } + + @override + String pendingOpTaskId(String id) { + return '任务=$id'; + } + + @override + String pendingOpAttempts(int count) { + return '尝试次数=$count'; + } + + @override + String get retry => '重试'; + + @override + String get discard => '舍弃'; + + @override + String get discardChanges => '舍弃更改?'; + + @override + String get discardChangesConfirmation => '这将舍弃对此任务所做的未保存编辑。'; + + @override + String get retryCompleted => '重试完成。'; + + @override + String get discardPendingOperation => '舍弃待处理操作?'; + + @override + String get discardPendingOperationConfirmation => + '这将移除被阻止的本地操作。下次同步时将从 Google Tasks 刷新数据。'; + + @override + String get pendingOperationDiscarded => '已舍弃待处理操作。'; + + @override + String get syncFailureNotificationTitle => 'BusyMax 同步失败'; + + @override + String syncFailureNotificationBody(String message) { + return '后台同步失败。$message'; + } + + @override + String get conflictNotificationTitle => 'BusyMax 同步冲突'; + + @override + String conflictNotificationBody(String summary) { + return '一项待处理的本地更改被阻止。$summary'; + } + + @override + String get dueTodayNotificationTitle => '今天到期的任务'; + + @override + String dueTodayNotificationBody(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '今天有 $count 项任务到期。', + one: '今天有 1 项任务到期。', + ); + return '$_temp0'; + } + + @override + String get eventReminderNotificationTitle => '日程提醒'; + + @override + String get taskReminderNotificationTitle => '任务提醒'; + + @override + String get eventReminderNotificationBody => '日程即将开始。'; + + @override + String get taskReminderNotificationBody => '任务即将到期。'; + + @override + String get notificationOpenAction => '打开'; + + @override + String get notificationDetailsHidden => '根据隐私设置,详细信息已隐藏。'; + + @override + String get previousMonth => '上个月'; + + @override + String get nextMonth => '下个月'; + + @override + String get openMonthView => '打开月视图'; + + @override + String get previousYear => '上一年'; + + @override + String get nextYear => '下一年'; + + @override + String get openYearView => '打开年视图'; + + @override + String weekNumberTooltip(int number) { + return '第 $number 周'; + } + + @override + String get resizeAllDayPanel => '调整全天面板的大小'; + + @override + String scheduleItemCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count 项', + one: '1 项', + ); + return '$_temp0'; + } + + @override + String get readOnlyCalendar => '此日历为只读。'; + + @override + String get selectTimeZone => '选择时区'; + + @override + String get searchLocations => '搜索地点'; + + @override + String get noLocationsFound => '未找到地点'; + + @override + String deleteCalendarConfirmation(String title) { + return '删除“$title”?'; + } +} + +/// The translations for Chinese, using the Han script (`zh_Hans`). +class AppLocalizationsZhHans extends AppLocalizationsZh { + AppLocalizationsZhHans() : super('zh_Hans'); + + @override + String get appTitle => 'BusyMax'; + + @override + String get connectGoogleAccount => '连接 Google 和 Microsoft 帐户以同步日历和任务。'; + + @override + String get googlePermissionsConsentNotice => '在 Google 权限页面上,同时选择日历和任务权限。'; + + @override + String get googlePermissionsRequiredRetry => + '必须授予 Google 日历和 Google Tasks 权限。请重试并选中两个复选框。'; + + @override + String get finishSetup => '完成设置'; + + @override + String get continueSetup => '继续'; + + @override + String get onboardingSetupTitle => '设置 BusyMax'; + + @override + String get onboardingAccountsStepTitle => '连接帐户'; + + @override + String get onboardingAccountsStepDescription => + '添加您要使用的所有 Google 和 Microsoft 帐户。BusyMax 会同步每个帐户中的日历、日程、任务列表和任务。'; + + @override + String get onboardingPreferencesStepTitle => '选择系统设置'; + + @override + String get onboardingPreferencesStepDescription => + '打开日程前,请设置桌面行为、提醒、通知详细程度和外观。'; + + @override + String get signInWithGoogle => '使用 Google 登录'; + + @override + String get signInWithMicrosoft => '使用 Microsoft 登录'; + + @override + String get googleTasksProvider => 'Google Tasks'; + + @override + String get microsoftTodoProvider => 'Microsoft To Do'; + + @override + String get providerNotConfigured => '尚未配置此服务。'; + + @override + String get waitingForGoogleSignIn => '正在等待 Google 登录...'; + + @override + String get waitingForMicrosoftSignIn => '正在等待 Microsoft 登录...'; + + @override + String get microsoftSignInNotConfigured => + '尚未配置 Microsoft 登录。请设置 MICROSOFT_OAUTH_CLIENT_ID。'; + + @override + String get cancel => '取消'; + + @override + String get close => '关闭'; + + @override + String get exit => '退出'; + + @override + String get options => '选项'; + + @override + String get hide => '隐藏'; + + @override + String get show => '显示'; + + @override + String get export => '导出'; + + @override + String get save => '保存'; + + @override + String get settings => '设置'; + + @override + String get all => '全部'; + + @override + String get calendarEvents => '日程'; + + @override + String get calendarTasks => '任务'; + + @override + String get calendar => '日历'; + + @override + String get calendars => '日历'; + + @override + String get newEvent => '新建日程'; + + @override + String get refreshCalendar => '刷新日历'; + + @override + String get openInProvider => '在服务中打开'; + + @override + String get hideFromSchedule => '从日程中隐藏'; + + @override + String get showInSchedule => '在日程中显示'; + + @override + String get noCalendarsSynced => '尚未同步任何日历。'; + + @override + String get allDay => '全天'; + + @override + String moreItems(int count) { + return '还有 $count 项'; + } + + @override + String get noEventsOrTasks => '没有日程或任务'; + + @override + String get scheduleLoading => '正在加载日程...'; + + @override + String get scheduleUnavailable => '日程不可用'; + + @override + String get scheduleNoSources => '没有可见的日历或任务列表'; + + @override + String get scheduleNoSourcesDescription => '请在设置中选择要显示的内容,然后刷新。'; + + @override + String get scheduleSignInRequired => '连接帐户'; + + @override + String get scheduleSignInDescription => '登录以同步日历和任务。'; + + @override + String get scheduleNoSearchResults => '没有匹配的日程或任务'; + + @override + String get scheduleNoSearchResultsDescription => '请尝试其他搜索内容或清除当前筛选条件。'; + + @override + String get trayAgendaLoading => '正在加载日程...'; + + @override + String get trayAgendaSignInRequired => '请登录以显示日程。'; + + @override + String get trayAgendaNoSources => '没有可见的日历或任务列表。'; + + @override + String get trayAgendaOpenBusyMax => '打开应用'; + + @override + String get trayAgendaRefresh => '刷新'; + + @override + String get trayAgendaError => '日程不可用'; + + @override + String get compactAgendaTitle => '日程'; + + @override + String get compactAgendaSubtitle => '即将开始'; + + @override + String get compactAgendaOverdue => '已逾期'; + + @override + String get compactAgendaClear => '目前空闲'; + + @override + String get compactAgendaOpenBusyMax => '打开 BusyMax'; + + @override + String get compactAgendaHide => '隐藏'; + + @override + String get compactAgendaNewTask => '新建任务'; + + @override + String get compactAgendaRetry => '重试'; + + @override + String get compactAgendaRefresh => '刷新'; + + @override + String get compactAgendaAllDay => '全天'; + + @override + String get compactAgendaDueToday => '今天到期'; + + @override + String get compactAgendaDueTomorrow => '明天到期'; + + @override + String compactAgendaDueOn(String date) { + return '$date 到期'; + } + + @override + String get compactAgendaMoreOverdue => '加载更多逾期任务'; + + @override + String get agendaLoadMoreOverdue => '加载更多逾期任务'; + + @override + String get agendaLoadMoreNoDate => '加载更多无日期任务'; + + @override + String get viewDay => '日'; + + @override + String get viewWeek => '周'; + + @override + String get viewMonth => '月'; + + @override + String get viewYear => '年'; + + @override + String get viewAgenda => '日程'; + + @override + String get scheduleSettings => '日程'; + + @override + String get scheduleDisplaySettings => '日程显示'; + + @override + String get scheduleDisplayHoursDescription => + '日视图和周视图最初显示此时间范围。需要时,更早或更晚的项目会扩展该范围。'; + + @override + String get scheduleDayStartsAt => '每日开始时间'; + + @override + String get scheduleDayEndsAt => '每日结束时间'; + + @override + String get sourceCalendar => '日历'; + + @override + String get sourceTaskList => '任务列表'; + + @override + String get createChoiceTitle => '新建'; + + @override + String get createEventAtTime => '日程'; + + @override + String get createTaskAtDate => '任务'; + + @override + String get editEvent => '编辑日程'; + + @override + String get eventTitle => '日程标题'; + + @override + String get location => '地点'; + + @override + String get timeSlot => '时间段'; + + @override + String get startDateTime => '开始日期/时间'; + + @override + String get endDateTime => '结束日期/时间'; + + @override + String get doesNotRepeat => '不重复'; + + @override + String get defaultReminder => '默认提醒'; + + @override + String get guests => '参与者'; + + @override + String get noGuests => '没有参与者'; + + @override + String get description => '说明'; + + @override + String get availabilityShowAs => '空闲状态 / 显示为'; + + @override + String get busy => '忙碌'; + + @override + String get visibility => '可见性'; + + @override + String get defaultVisibility => '默认可见性'; + + @override + String get conference => '会议'; + + @override + String get noConference => '无会议'; + + @override + String get providerCalendar => '服务日历'; + + @override + String get formatBoldShortLabel => 'B'; + + @override + String get formatBoldTooltip => '粗体'; + + @override + String get formatItalicShortLabel => 'I'; + + @override + String get formatItalicTooltip => '斜体'; + + @override + String get formatUnderlineShortLabel => 'U'; + + @override + String get formatUnderlineTooltip => '下划线'; + + @override + String reminderMinutesBefore(int minutes) { + String _temp0 = intl.Intl.pluralLogic( + minutes, + locale: localeName, + other: '$minutes 分钟前', + one: '1 分钟前', + ); + return '$_temp0'; + } + + @override + String get reminderAtStart => '开始时'; + + @override + String reminderHoursBefore(int hours) { + String _temp0 = intl.Intl.pluralLogic( + hours, + locale: localeName, + other: '$hours 小时前', + one: '1 小时前', + ); + return '$_temp0'; + } + + @override + String reminderDaysBefore(int days) { + String _temp0 = intl.Intl.pluralLogic( + days, + locale: localeName, + other: '$days 天前', + one: '1 天前', + ); + return '$_temp0'; + } + + @override + String get availabilityFree => '空闲'; + + @override + String get availabilityTentative => '暂定'; + + @override + String get availabilityOutOfOffice => '不在办公室'; + + @override + String get availabilityWorkingElsewhere => '在其他地点办公'; + + @override + String get visibilityDefault => '默认'; + + @override + String get visibilityPublic => '公开'; + + @override + String get visibilityPrivate => '私密'; + + @override + String get visibilityConfidential => '机密'; + + @override + String get sensitivityNormal => '普通'; + + @override + String get sensitivityPersonal => '个人'; + + @override + String get tasks => '任务'; + + @override + String get allTasks => '所有任务'; + + @override + String tasksInList(String title) { + return '$title中的任务'; + } + + @override + String get taskLists => '任务列表'; + + @override + String get navigation => '导航'; + + @override + String get mainMenu => '主菜单'; + + @override + String get keyboardShortcuts => '键盘快捷键'; + + @override + String get shortcutGroupGeneral => '常规'; + + @override + String get shortcutKeyboardShortcutsDescription => '显示此快捷键参考'; + + @override + String get shortcutGroupNavigation => '导航'; + + @override + String get shortcutNextPeriod => '下一时段'; + + @override + String get shortcutNextPeriodDescription => '在周视图中前往下一周,在月视图中前往下个月,依此类推'; + + @override + String get shortcutPreviousPeriod => '上一时段'; + + @override + String get shortcutPreviousPeriodDescription => '在周视图中前往上一周,在月视图中前往上个月,依此类推'; + + @override + String get shortcutJumpToToday => '跳转到今天'; + + @override + String get shortcutGroupView => '视图'; + + @override + String get shortcutDayView => '日视图'; + + @override + String get shortcutWeekView => '周视图'; + + @override + String get shortcutMonthView => '月视图'; + + @override + String get shortcutYearView => '年视图'; + + @override + String get shortcutAgendaView => '日程视图'; + + @override + String get shortcutGroupCreateAndEdit => '新建和编辑'; + + @override + String get shortcutSaveItem => '保存日程或任务'; + + @override + String get shortcutDeleteItem => '删除日程或任务'; + + @override + String get shortcutGroupTaskEditing => '任务编辑'; + + @override + String get shortcutCancelEditing => '取消编辑'; + + @override + String get shortcutCancelEditingDescription => '关闭任务编辑或任务详情'; + + @override + String get shortcutGroupCompactAgenda => '紧凑日程'; + + @override + String get shortcutRefreshCompactAgendaDescription => '刷新紧凑日程窗口'; + + @override + String get shortcutHideCompactAgendaDescription => '隐藏紧凑日程窗口'; + + @override + String get aboutBusyMax => '关于 BusyMax'; + + @override + String get aboutBusyMaxDescription => '任务和日历'; + + @override + String get website => '网站'; + + @override + String get reportAnIssue => '报告问题'; + + @override + String get sendFeedback => '发送反馈'; + + @override + String get feedbackSubmit => '提交'; + + @override + String get feedbackCategory => '类别'; + + @override + String get feedbackSelectCategory => '选择类别'; + + @override + String get feedbackCategoryProblem => '问题或错误'; + + @override + String get feedbackCategoryFeature => '功能请求'; + + @override + String get feedbackCategoryPrivacySecurity => '隐私或安全问题'; + + @override + String get feedbackCategoryUsability => '易用性问题'; + + @override + String get feedbackCategoryOther => '其他'; + + @override + String get feedbackSubject => '主题'; + + @override + String get feedbackDetailedMessage => '详细信息'; + + @override + String get feedbackReplyEmail => '用于接收回复的电子邮件地址(可选)'; + + @override + String get feedbackIncludeTechnicalDetails => '包含技术详情'; + + @override + String get feedbackTechnicalDetailsDisclosure => + '仅添加您的 Linux 操作系统版本和应用区域设置。不包含日志、帐户数据、文件名或其他诊断信息。'; + + @override + String get feedbackCategoryRequired => '请选择类别。'; + + @override + String get feedbackSubjectLengthError => '主题必须为 3 至 120 个字符。'; + + @override + String get feedbackMessageLengthError => '消息必须为 10 至 5,000 个字符。'; + + @override + String get feedbackInvalidEmail => '请输入有效的电子邮件地址。'; + + @override + String get feedbackConnectionError => '无法连接到 BusyStack。请检查连接,然后重试。'; + + @override + String get feedbackTimeoutError => '请求超时。您的反馈尚未清除,请重试。'; + + @override + String get feedbackRateLimitedError => '从此网络发送的反馈过多。请稍后再试。'; + + @override + String get feedbackRejectedError => '服务器拒绝了提交。请检查各字段,然后重试。'; + + @override + String get feedbackServerError => 'BusyStack 目前无法接收您的反馈。您的反馈尚未清除,请重试。'; + + @override + String feedbackSuccess(String id) { + return '反馈已发送。参考编号:$id'; + } + + @override + String get toggleSidebar => '显示或隐藏侧边栏'; + + @override + String get accounts => '帐户'; + + @override + String get currentAccount => '当前帐户'; + + @override + String get switchAccount => '切换帐户'; + + @override + String get addGoogleAccount => '添加 Google 帐户'; + + @override + String get addMicrosoftAccount => '添加 Microsoft 帐户'; + + @override + String get googleProvider => 'Google'; + + @override + String get microsoftProvider => 'Microsoft'; + + @override + String get signedInAccount => '已登录'; + + @override + String get removeAccount => '移除帐户…'; + + @override + String get removingAccount => '正在移除帐户…'; + + @override + String get removeAccountDescription => '停止同步并从此设备移除此帐户的数据。'; + + @override + String removeAccountTitle(String account) { + return '从 BusyMax 中移除 $account?'; + } + + @override + String get removeAccountConfirmation => + '这会从此设备删除缓存的任务、日历、日程、提醒和待处理的离线更改。未同步的更改将丢失。不会从 Google 或 Microsoft 删除任何内容。'; + + @override + String get revokeGoogleAccess => '同时撤销 BusyMax 对此 Google 帐户的访问权限'; + + @override + String get revokeGoogleAccessDescription => '重新连接之前,您需要再次授予访问权限。'; + + @override + String get removeAccountAction => '移除帐户'; + + @override + String get removeAccountFailed => '无法完成帐户移除。请重试。'; + + @override + String get accountRemovedGoogleRevokeFailed => + '已从此设备移除该帐户,但 BusyMax 无法撤销 Google 访问权限。您可以在 Google 帐户中撤销。'; + + @override + String get newList => '新建列表'; + + @override + String get signInToViewTaskLists => '登录以查看任务列表。'; + + @override + String get noTaskListsSynced => '尚未同步任何任务列表。'; + + @override + String get listActions => '列表操作'; + + @override + String get rename => '重命名'; + + @override + String get delete => '删除'; + + @override + String get renameList => '重命名列表'; + + @override + String get deleteList => '删除列表'; + + @override + String get builtInMicrosoftList => '内置'; + + @override + String get builtInMicrosoftListCannotRenameDelete => + '无法重命名或删除 Microsoft To Do 内置列表。'; + + @override + String deleteListConfirmation(String title) { + return '从 Google Tasks 中删除“$title”?'; + } + + @override + String get deleteEvent => '删除日程'; + + @override + String get title => '标题'; + + @override + String get create => '新建'; + + @override + String get newTask => '新建任务'; + + @override + String get clearCompleted => '清除已完成项'; + + @override + String get refreshList => '刷新列表'; + + @override + String get refreshAll => '全部刷新'; + + @override + String get listRefreshed => '列表已刷新。'; + + @override + String get allTasksRefreshed => '所有帐户均已刷新。'; + + @override + String exportedFile(String path) { + return '已导出到 $path'; + } + + @override + String exportFailed(String error) { + return '导出失败:$error'; + } + + @override + String refreshFailed(String error) { + return '刷新失败:$error'; + } + + @override + String get selectOrCreateTaskList => '请选择或创建任务列表以开始使用。'; + + @override + String get signInToViewTasks => '登录以查看任务。'; + + @override + String get noTasks => '没有任务。'; + + @override + String get noTasksYet => '还没有任务'; + + @override + String get noTasksYetMessage => '创建任务或刷新帐户以开始使用。'; + + @override + String get noTasksInList => '此列表中没有任务。'; + + @override + String get overdue => '已逾期'; + + @override + String get today => '今天'; + + @override + String get tomorrow => '明天'; + + @override + String get upcoming => '即将开始'; + + @override + String get noDate => '无日期'; + + @override + String get completed => '已完成'; + + @override + String duePrefix(String date) { + return '$date 到期'; + } + + @override + String dateTimeDisplay(String date, String time) { + return '$date · $time'; + } + + @override + String get taskDetails => '任务详情'; + + @override + String get editTask => '编辑任务'; + + @override + String get noTaskSelected => '未选择任务。'; + + @override + String get noTaskSelectedHelper => '选择任务以查看和编辑详情。'; + + @override + String get taskUnavailable => '任务不可用。'; + + @override + String get signInToEditTasks => '登录以编辑任务。'; + + @override + String get refreshTask => '刷新任务'; + + @override + String get primarySection => '主要信息'; + + @override + String get statusSection => '状态'; + + @override + String get openStatus => '未完成'; + + @override + String get doneStatus => '已完成'; + + @override + String get notes => '备注'; + + @override + String get dueDate => '截止日期'; + + @override + String get clearDueDate => '清除截止日期'; + + @override + String get dueTime => '截止时间'; + + @override + String get startDate => '开始日期'; + + @override + String get startTime => '开始时间'; + + @override + String get endDate => '结束日期'; + + @override + String get endTime => '结束时间'; + + @override + String get reminderDate => '提醒日期'; + + @override + String get reminderTime => '提醒时间'; + + @override + String get reminder => '提醒'; + + @override + String get addReminder => '添加提醒'; + + @override + String get addGuest => '添加参与者'; + + @override + String get addGuestEmail => '添加参与者电子邮件'; + + @override + String get removeReminder => '移除提醒'; + + @override + String get off => '关闭'; + + @override + String get repeat => '重复'; + + @override + String get repeatNone => '不重复'; + + @override + String get noneValue => '无'; + + @override + String get repeatDaily => '每天'; + + @override + String get repeatWeekly => '每周'; + + @override + String get repeatMonthly => '每月'; + + @override + String get repeatYearly => '每年'; + + @override + String get importance => '重要性'; + + @override + String get importanceLow => '低'; + + @override + String get importanceNormal => '普通'; + + @override + String get importanceHigh => '高'; + + @override + String get categories => '类别'; + + @override + String get scheduleSection => '日程'; + + @override + String get dueGroup => '截止'; + + @override + String get startGroup => '开始'; + + @override + String get reminderGroup => '提醒'; + + @override + String get organizationSection => '整理'; + + @override + String get actionsSection => '操作'; + + @override + String get advancedSection => '高级'; + + @override + String get addCategory => '添加类别'; + + @override + String get list => '列表'; + + @override + String get microsoftMoveUnsupported => '此版本不支持在 Microsoft To Do 帐户的列表之间移动任务。'; + + @override + String get createSubtask => '创建子任务'; + + @override + String get moveToTop => '移到顶部'; + + @override + String get deleteTask => '删除任务'; + + @override + String get newSubtask => '新建子任务'; + + @override + String deleteTaskConfirmation(String title) { + return '从 Google Tasks 中删除“$title”?'; + } + + @override + String get metadata => '元数据'; + + @override + String get id => 'ID'; + + @override + String get etag => 'ETag'; + + @override + String get updated => '更新时间'; + + @override + String get parent => '父任务'; + + @override + String get position => '位置'; + + @override + String get webLink => '网页链接'; + + @override + String get assignment => '分配'; + + @override + String get localState => '本地状态'; + + @override + String get pendingSync => '等待同步'; + + @override + String get synced => '已同步'; + + @override + String get account => '帐户'; + + @override + String get sync => '同步'; + + @override + String get manualFullSync => '手动完整同步'; + + @override + String get runInBackgroundWhenClosed => '窗口关闭后继续在后台运行'; + + @override + String get showTrayIcon => '显示托盘图标'; + + @override + String get startMinimizedToTray => '启动时最小化到托盘'; + + @override + String get requiresTrayIcon => '需要托盘图标。'; + + @override + String get syncComplete => '同步完成。'; + + @override + String syncFailed(String error) { + return '同步失败:$error'; + } + + @override + String get notifySyncFailures => '同步失败通知'; + + @override + String get notifyConflicts => '冲突通知'; + + @override + String get notifyDueToday => '今天到期任务通知'; + + @override + String get eventReminders => '日程提醒'; + + @override + String get taskReminders => '任务提醒'; + + @override + String get notificationDetailLevel => '通知详细程度'; + + @override + String get notificationDetailPrivate => '私密'; + + @override + String get notificationDetailNormal => '普通'; + + @override + String get quietHours => '免打扰时段'; + + @override + String get quietHoursDescription => '在此时段暂停通知。'; + + @override + String get quietHoursStart => '免打扰开始时间'; + + @override + String get quietHoursEnd => '免打扰结束时间'; + + @override + String get notifications => '通知'; + + @override + String get appearance => '外观'; + + @override + String get theme => '主题'; + + @override + String get themeSystem => '系统'; + + @override + String get themeLight => '浅色'; + + @override + String get themeDark => '深色'; + + @override + String get themeFamily => '主题系列'; + + @override + String get themeFamilyYaru => 'Ubuntu 原生主题(Yaru)'; + + @override + String get localization => '语言和区域'; + + @override + String get currentLocale => '当前区域设置'; + + @override + String get privacy => '隐私'; + + @override + String get redactTaskContentInDiagnostics => '在诊断信息中隐藏任务内容'; + + @override + String get developerDiagnostics => '开发者诊断'; + + @override + String get diagnostics => '诊断'; + + @override + String get apiInspectorDisabled => '显示 API 检查器'; + + @override + String get googleTasksApi => 'Google Tasks API'; + + @override + String discoveryRevision(String revision) { + return 'Discovery 修订版:$revision'; + } + + @override + String get implementedMethods => '已实现的方法'; + + @override + String get supportsTasksScopes => '支持 tasks 和 tasks.readonly 权限范围'; + + @override + String get requiresTasksScope => '需要 tasks 权限范围'; + + @override + String get blockedPendingOperations => '被阻止的待处理操作'; + + @override + String get signInToInspectPendingOperations => '登录以检查待处理操作。'; + + @override + String get noBlockedPendingOperations => '没有被阻止的待处理操作。'; + + @override + String get operationActions => '操作选项'; + + @override + String pendingOpListId(String id) { + return '列表=$id'; + } + + @override + String pendingOpTaskId(String id) { + return '任务=$id'; + } + + @override + String pendingOpAttempts(int count) { + return '尝试次数=$count'; + } + + @override + String get retry => '重试'; + + @override + String get discard => '舍弃'; + + @override + String get discardChanges => '舍弃更改?'; + + @override + String get discardChangesConfirmation => '这将舍弃对此任务所做的未保存编辑。'; + + @override + String get retryCompleted => '重试完成。'; + + @override + String get discardPendingOperation => '舍弃待处理操作?'; + + @override + String get discardPendingOperationConfirmation => + '这将移除被阻止的本地操作。下次同步时将从 Google Tasks 刷新数据。'; + + @override + String get pendingOperationDiscarded => '已舍弃待处理操作。'; + + @override + String get syncFailureNotificationTitle => 'BusyMax 同步失败'; + + @override + String syncFailureNotificationBody(String message) { + return '后台同步失败。$message'; + } + + @override + String get conflictNotificationTitle => 'BusyMax 同步冲突'; + + @override + String conflictNotificationBody(String summary) { + return '一项待处理的本地更改被阻止。$summary'; + } + + @override + String get dueTodayNotificationTitle => '今天到期的任务'; + + @override + String dueTodayNotificationBody(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '今天有 $count 项任务到期。', + one: '今天有 1 项任务到期。', + ); + return '$_temp0'; + } + + @override + String get eventReminderNotificationTitle => '日程提醒'; + + @override + String get taskReminderNotificationTitle => '任务提醒'; + + @override + String get eventReminderNotificationBody => '日程即将开始。'; + + @override + String get taskReminderNotificationBody => '任务即将到期。'; + + @override + String get notificationOpenAction => '打开'; + + @override + String get notificationDetailsHidden => '根据隐私设置,详细信息已隐藏。'; + + @override + String get previousMonth => '上个月'; + + @override + String get nextMonth => '下个月'; + + @override + String get openMonthView => '打开月视图'; + + @override + String get previousYear => '上一年'; + + @override + String get nextYear => '下一年'; + + @override + String get openYearView => '打开年视图'; + + @override + String weekNumberTooltip(int number) { + return '第 $number 周'; + } + + @override + String get resizeAllDayPanel => '调整全天面板的大小'; + + @override + String scheduleItemCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count 项', + one: '1 项', + ); + return '$_temp0'; + } + + @override + String get readOnlyCalendar => '此日历为只读。'; + + @override + String get selectTimeZone => '选择时区'; + + @override + String get searchLocations => '搜索地点'; + + @override + String get noLocationsFound => '未找到地点'; + + @override + String deleteCalendarConfirmation(String title) { + return '删除“$title”?'; + } +} + +/// The translations for Chinese, using the Han script (`zh_Hant`). +class AppLocalizationsZhHant extends AppLocalizationsZh { + AppLocalizationsZhHant() : super('zh_Hant'); + + @override + String get appTitle => 'BusyMax'; + + @override + String get connectGoogleAccount => '連結 Google 和 Microsoft 帳戶以同步行事曆和待辦事項。'; + + @override + String get googlePermissionsConsentNotice => '在 Google 權限畫面中,同時選取行事曆和待辦事項權限。'; + + @override + String get googlePermissionsRequiredRetry => + '必須授予 Google 日曆和 Google Tasks 權限。請再試一次並勾選兩個核取方塊。'; + + @override + String get finishSetup => '完成設定'; + + @override + String get continueSetup => '繼續'; + + @override + String get onboardingSetupTitle => '設定 BusyMax'; + + @override + String get onboardingAccountsStepTitle => '連結帳戶'; + + @override + String get onboardingAccountsStepDescription => + '新增您要使用的所有 Google 和 Microsoft 帳戶。BusyMax 會同步每個帳戶中的行事曆、活動、待辦清單和待辦事項。'; + + @override + String get onboardingPreferencesStepTitle => '選擇系統設定'; + + @override + String get onboardingPreferencesStepDescription => + '開啟行程前,請設定桌面行為、提醒、通知詳細程度和外觀。'; + + @override + String get signInWithGoogle => '使用 Google 登入'; + + @override + String get signInWithMicrosoft => '使用 Microsoft 登入'; + + @override + String get googleTasksProvider => 'Google Tasks'; + + @override + String get microsoftTodoProvider => 'Microsoft To Do'; + + @override + String get providerNotConfigured => '尚未設定此服務。'; + + @override + String get waitingForGoogleSignIn => '正在等候 Google 登入...'; + + @override + String get waitingForMicrosoftSignIn => '正在等候 Microsoft 登入...'; + + @override + String get microsoftSignInNotConfigured => + '尚未設定 Microsoft 登入。請設定 MICROSOFT_OAUTH_CLIENT_ID。'; + + @override + String get cancel => '取消'; + + @override + String get close => '關閉'; + + @override + String get exit => '結束'; + + @override + String get options => '選項'; + + @override + String get hide => '隱藏'; + + @override + String get show => '顯示'; + + @override + String get export => '匯出'; + + @override + String get save => '儲存'; + + @override + String get settings => '設定'; + + @override + String get all => '全部'; + + @override + String get calendarEvents => '活動'; + + @override + String get calendarTasks => '待辦事項'; + + @override + String get calendar => '行事曆'; + + @override + String get calendars => '行事曆'; + + @override + String get newEvent => '新增活動'; + + @override + String get refreshCalendar => '重新整理行事曆'; + + @override + String get openInProvider => '在服務中開啟'; + + @override + String get hideFromSchedule => '從行程中隱藏'; + + @override + String get showInSchedule => '在行程中顯示'; + + @override + String get noCalendarsSynced => '尚未同步任何行事曆。'; + + @override + String get allDay => '全天'; + + @override + String moreItems(int count) { + return '還有 $count 項'; + } + + @override + String get noEventsOrTasks => '沒有活動或待辦事項'; + + @override + String get scheduleLoading => '正在載入行程...'; + + @override + String get scheduleUnavailable => '無法使用行程'; + + @override + String get scheduleNoSources => '沒有可見的行事曆或待辦清單'; + + @override + String get scheduleNoSourcesDescription => '請在設定中選擇要顯示的內容,然後重新整理。'; + + @override + String get scheduleSignInRequired => '連結帳戶'; + + @override + String get scheduleSignInDescription => '登入以同步行事曆和待辦事項。'; + + @override + String get scheduleNoSearchResults => '沒有相符的活動或待辦事項'; + + @override + String get scheduleNoSearchResultsDescription => '請嘗試其他搜尋內容或清除目前的篩選條件。'; + + @override + String get trayAgendaLoading => '正在載入行程...'; + + @override + String get trayAgendaSignInRequired => '請登入以顯示行程。'; + + @override + String get trayAgendaNoSources => '沒有可見的行事曆或待辦清單。'; + + @override + String get trayAgendaOpenBusyMax => '開啟應用程式'; + + @override + String get trayAgendaRefresh => '重新整理'; + + @override + String get trayAgendaError => '無法使用行程'; + + @override + String get compactAgendaTitle => '行程'; + + @override + String get compactAgendaSubtitle => '即將開始'; + + @override + String get compactAgendaOverdue => '已逾期'; + + @override + String get compactAgendaClear => '目前沒有安排'; + + @override + String get compactAgendaOpenBusyMax => '開啟 BusyMax'; + + @override + String get compactAgendaHide => '隱藏'; + + @override + String get compactAgendaNewTask => '新增待辦事項'; + + @override + String get compactAgendaRetry => '再試一次'; + + @override + String get compactAgendaRefresh => '重新整理'; + + @override + String get compactAgendaAllDay => '全天'; + + @override + String get compactAgendaDueToday => '今天到期'; + + @override + String get compactAgendaDueTomorrow => '明天到期'; + + @override + String compactAgendaDueOn(String date) { + return '$date 到期'; + } + + @override + String get compactAgendaMoreOverdue => '載入更多逾期待辦事項'; + + @override + String get agendaLoadMoreOverdue => '載入更多逾期待辦事項'; + + @override + String get agendaLoadMoreNoDate => '載入更多無日期待辦事項'; + + @override + String get viewDay => '日'; + + @override + String get viewWeek => '週'; + + @override + String get viewMonth => '月'; + + @override + String get viewYear => '年'; + + @override + String get viewAgenda => '行程'; + + @override + String get scheduleSettings => '行程'; + + @override + String get scheduleDisplaySettings => '行程顯示'; + + @override + String get scheduleDisplayHoursDescription => + '日檢視和週檢視一開始會顯示此時間範圍。必要時,較早或較晚的項目會擴大此範圍。'; + + @override + String get scheduleDayStartsAt => '每日開始時間'; + + @override + String get scheduleDayEndsAt => '每日結束時間'; + + @override + String get sourceCalendar => '行事曆'; + + @override + String get sourceTaskList => '待辦清單'; + + @override + String get createChoiceTitle => '新增'; + + @override + String get createEventAtTime => '活動'; + + @override + String get createTaskAtDate => '待辦事項'; + + @override + String get editEvent => '編輯活動'; + + @override + String get eventTitle => '活動標題'; + + @override + String get location => '地點'; + + @override + String get timeSlot => '時段'; + + @override + String get startDateTime => '開始日期/時間'; + + @override + String get endDateTime => '結束日期/時間'; + + @override + String get doesNotRepeat => '不重複'; + + @override + String get defaultReminder => '預設提醒'; + + @override + String get guests => '參與者'; + + @override + String get noGuests => '沒有參與者'; + + @override + String get description => '說明'; + + @override + String get availabilityShowAs => '空閒狀態 / 顯示為'; + + @override + String get busy => '忙碌'; + + @override + String get visibility => '顯示設定'; + + @override + String get defaultVisibility => '預設顯示設定'; + + @override + String get conference => '會議'; + + @override + String get noConference => '無會議'; + + @override + String get providerCalendar => '服務行事曆'; + + @override + String get formatBoldShortLabel => 'B'; + + @override + String get formatBoldTooltip => '粗體'; + + @override + String get formatItalicShortLabel => 'I'; + + @override + String get formatItalicTooltip => '斜體'; + + @override + String get formatUnderlineShortLabel => 'U'; + + @override + String get formatUnderlineTooltip => '底線'; + + @override + String reminderMinutesBefore(int minutes) { + String _temp0 = intl.Intl.pluralLogic( + minutes, + locale: localeName, + other: '$minutes 分鐘前', + one: '1 分鐘前', + ); + return '$_temp0'; + } + + @override + String get reminderAtStart => '開始時'; + + @override + String reminderHoursBefore(int hours) { + String _temp0 = intl.Intl.pluralLogic( + hours, + locale: localeName, + other: '$hours 小時前', + one: '1 小時前', + ); + return '$_temp0'; + } + + @override + String reminderDaysBefore(int days) { + String _temp0 = intl.Intl.pluralLogic( + days, + locale: localeName, + other: '$days 天前', + one: '1 天前', + ); + return '$_temp0'; + } + + @override + String get availabilityFree => '有空'; + + @override + String get availabilityTentative => '暫定'; + + @override + String get availabilityOutOfOffice => '不在辦公室'; + + @override + String get availabilityWorkingElsewhere => '在其他地點工作'; + + @override + String get visibilityDefault => '預設'; + + @override + String get visibilityPublic => '公開'; + + @override + String get visibilityPrivate => '私人'; + + @override + String get visibilityConfidential => '機密'; + + @override + String get sensitivityNormal => '一般'; + + @override + String get sensitivityPersonal => '個人'; + + @override + String get tasks => '待辦事項'; + + @override + String get allTasks => '所有待辦事項'; + + @override + String tasksInList(String title) { + return '$title中的待辦事項'; + } + + @override + String get taskLists => '待辦清單'; + + @override + String get navigation => '導覽'; + + @override + String get mainMenu => '主選單'; + + @override + String get keyboardShortcuts => '鍵盤快速鍵'; + + @override + String get shortcutGroupGeneral => '一般'; + + @override + String get shortcutKeyboardShortcutsDescription => '顯示此快速鍵參考'; + + @override + String get shortcutGroupNavigation => '導覽'; + + @override + String get shortcutNextPeriod => '下一時段'; + + @override + String get shortcutNextPeriodDescription => '在週檢視中前往下一週,在月檢視中前往下個月,依此類推'; + + @override + String get shortcutPreviousPeriod => '上一時段'; + + @override + String get shortcutPreviousPeriodDescription => '在週檢視中前往上一週,在月檢視中前往上個月,依此類推'; + + @override + String get shortcutJumpToToday => '跳至今天'; + + @override + String get shortcutGroupView => '檢視'; + + @override + String get shortcutDayView => '日檢視'; + + @override + String get shortcutWeekView => '週檢視'; + + @override + String get shortcutMonthView => '月檢視'; + + @override + String get shortcutYearView => '年檢視'; + + @override + String get shortcutAgendaView => '行程檢視'; + + @override + String get shortcutGroupCreateAndEdit => '新增和編輯'; + + @override + String get shortcutSaveItem => '儲存活動或待辦事項'; + + @override + String get shortcutDeleteItem => '刪除活動或待辦事項'; + + @override + String get shortcutGroupTaskEditing => '待辦事項編輯'; + + @override + String get shortcutCancelEditing => '取消編輯'; + + @override + String get shortcutCancelEditingDescription => '關閉待辦事項編輯或詳細資料'; + + @override + String get shortcutGroupCompactAgenda => '精簡行程'; + + @override + String get shortcutRefreshCompactAgendaDescription => '重新整理精簡行程視窗'; + + @override + String get shortcutHideCompactAgendaDescription => '隱藏精簡行程視窗'; + + @override + String get aboutBusyMax => '關於 BusyMax'; + + @override + String get aboutBusyMaxDescription => '待辦事項和行事曆'; + + @override + String get website => '網站'; + + @override + String get reportAnIssue => '回報問題'; + + @override + String get sendFeedback => '傳送意見'; + + @override + String get feedbackSubmit => '提交'; + + @override + String get feedbackCategory => '類別'; + + @override + String get feedbackSelectCategory => '選擇類別'; + + @override + String get feedbackCategoryProblem => '問題或錯誤'; + + @override + String get feedbackCategoryFeature => '功能要求'; + + @override + String get feedbackCategoryPrivacySecurity => '隱私權或安全性疑慮'; + + @override + String get feedbackCategoryUsability => '易用性疑慮'; + + @override + String get feedbackCategoryOther => '其他'; + + @override + String get feedbackSubject => '主旨'; + + @override + String get feedbackDetailedMessage => '詳細訊息'; + + @override + String get feedbackReplyEmail => '回覆用電子郵件地址(選填)'; + + @override + String get feedbackIncludeTechnicalDetails => '包含技術詳細資料'; + + @override + String get feedbackTechnicalDetailsDisclosure => + '只會加入您的 Linux 作業系統版本和應用程式語系。不會包含記錄、帳戶資料、檔案名稱或其他診斷資訊。'; + + @override + String get feedbackCategoryRequired => '請選擇類別。'; + + @override + String get feedbackSubjectLengthError => '主旨必須介於 3 到 120 個字元之間。'; + + @override + String get feedbackMessageLengthError => '訊息必須介於 10 到 5,000 個字元之間。'; + + @override + String get feedbackInvalidEmail => '請輸入有效的電子郵件地址。'; + + @override + String get feedbackConnectionError => '無法連線至 BusyStack。請檢查連線,然後再試一次。'; + + @override + String get feedbackTimeoutError => '要求逾時。您的意見尚未清除,請再試一次。'; + + @override + String get feedbackRateLimitedError => '此網路已傳送太多意見。請稍候再試。'; + + @override + String get feedbackRejectedError => '伺服器拒絕了提交內容。請檢查各欄位,然後再試一次。'; + + @override + String get feedbackServerError => 'BusyStack 目前無法接收您的意見。您的意見尚未清除,請再試一次。'; + + @override + String feedbackSuccess(String id) { + return '意見已傳送。參考編號:$id'; + } + + @override + String get toggleSidebar => '顯示或隱藏側邊欄'; + + @override + String get accounts => '帳戶'; + + @override + String get currentAccount => '目前帳戶'; + + @override + String get switchAccount => '切換帳戶'; + + @override + String get addGoogleAccount => '新增 Google 帳戶'; + + @override + String get addMicrosoftAccount => '新增 Microsoft 帳戶'; + + @override + String get googleProvider => 'Google'; + + @override + String get microsoftProvider => 'Microsoft'; + + @override + String get signedInAccount => '已登入'; + + @override + String get removeAccount => '移除帳戶…'; + + @override + String get removingAccount => '正在移除帳戶…'; + + @override + String get removeAccountDescription => '停止同步並從此裝置移除此帳戶的資料。'; + + @override + String removeAccountTitle(String account) { + return '要從 BusyMax 移除 $account 嗎?'; + } + + @override + String get removeAccountConfirmation => + '這會從此裝置刪除快取的待辦事項、行事曆、活動、提醒和待處理的離線變更。未同步的變更將會遺失。不會從 Google 或 Microsoft 刪除任何內容。'; + + @override + String get revokeGoogleAccess => '同時撤銷 BusyMax 對此 Google 帳戶的存取權'; + + @override + String get revokeGoogleAccessDescription => '重新連結前,您必須再次授予存取權。'; + + @override + String get removeAccountAction => '移除帳戶'; + + @override + String get removeAccountFailed => '無法完成帳戶移除。請再試一次。'; + + @override + String get accountRemovedGoogleRevokeFailed => + '已從此裝置移除該帳戶,但 BusyMax 無法撤銷 Google 存取權。您可以在 Google 帳戶中撤銷。'; + + @override + String get newList => '新增清單'; + + @override + String get signInToViewTaskLists => '登入以查看待辦清單。'; + + @override + String get noTaskListsSynced => '尚未同步任何待辦清單。'; + + @override + String get listActions => '清單動作'; + + @override + String get rename => '重新命名'; + + @override + String get delete => '刪除'; + + @override + String get renameList => '重新命名清單'; + + @override + String get deleteList => '刪除清單'; + + @override + String get builtInMicrosoftList => '內建'; + + @override + String get builtInMicrosoftListCannotRenameDelete => + '無法重新命名或刪除 Microsoft To Do 內建清單。'; + + @override + String deleteListConfirmation(String title) { + return '要從 Google Tasks 刪除「$title」嗎?'; + } + + @override + String get deleteEvent => '刪除活動'; + + @override + String get title => '標題'; + + @override + String get create => '新增'; + + @override + String get newTask => '新增待辦事項'; + + @override + String get clearCompleted => '清除已完成項目'; + + @override + String get refreshList => '重新整理清單'; + + @override + String get refreshAll => '全部重新整理'; + + @override + String get listRefreshed => '清單已重新整理。'; + + @override + String get allTasksRefreshed => '所有帳戶都已重新整理。'; + + @override + String exportedFile(String path) { + return '已匯出至 $path'; + } + + @override + String exportFailed(String error) { + return '匯出失敗:$error'; + } + + @override + String refreshFailed(String error) { + return '重新整理失敗:$error'; + } + + @override + String get selectOrCreateTaskList => '請選擇或建立待辦清單以開始使用。'; + + @override + String get signInToViewTasks => '登入以查看待辦事項。'; + + @override + String get noTasks => '沒有待辦事項。'; + + @override + String get noTasksYet => '還沒有待辦事項'; + + @override + String get noTasksYetMessage => '建立待辦事項或重新整理帳戶以開始使用。'; + + @override + String get noTasksInList => '此清單中沒有待辦事項。'; + + @override + String get overdue => '已逾期'; + + @override + String get today => '今天'; + + @override + String get tomorrow => '明天'; + + @override + String get upcoming => '即將開始'; + + @override + String get noDate => '無日期'; + + @override + String get completed => '已完成'; + + @override + String duePrefix(String date) { + return '$date 到期'; + } + + @override + String dateTimeDisplay(String date, String time) { + return '$date · $time'; + } + + @override + String get taskDetails => '待辦事項詳細資料'; + + @override + String get editTask => '編輯待辦事項'; + + @override + String get noTaskSelected => '未選取待辦事項。'; + + @override + String get noTaskSelectedHelper => '選擇待辦事項以查看和編輯詳細資料。'; + + @override + String get taskUnavailable => '無法使用待辦事項。'; + + @override + String get signInToEditTasks => '登入以編輯待辦事項。'; + + @override + String get refreshTask => '重新整理待辦事項'; + + @override + String get primarySection => '主要資訊'; + + @override + String get statusSection => '狀態'; + + @override + String get openStatus => '未完成'; + + @override + String get doneStatus => '已完成'; + + @override + String get notes => '備註'; + + @override + String get dueDate => '到期日'; + + @override + String get clearDueDate => '清除到期日'; + + @override + String get dueTime => '到期時間'; + + @override + String get startDate => '開始日期'; + + @override + String get startTime => '開始時間'; + + @override + String get endDate => '結束日期'; + + @override + String get endTime => '結束時間'; + + @override + String get reminderDate => '提醒日期'; + + @override + String get reminderTime => '提醒時間'; + + @override + String get reminder => '提醒'; + + @override + String get addReminder => '新增提醒'; + + @override + String get addGuest => '新增參與者'; + + @override + String get addGuestEmail => '新增參與者電子郵件'; + + @override + String get removeReminder => '移除提醒'; + + @override + String get off => '關閉'; + + @override + String get repeat => '重複'; + + @override + String get repeatNone => '不重複'; + + @override + String get noneValue => '無'; + + @override + String get repeatDaily => '每天'; + + @override + String get repeatWeekly => '每週'; + + @override + String get repeatMonthly => '每月'; + + @override + String get repeatYearly => '每年'; + + @override + String get importance => '重要性'; + + @override + String get importanceLow => '低'; + + @override + String get importanceNormal => '一般'; + + @override + String get importanceHigh => '高'; + + @override + String get categories => '類別'; + + @override + String get scheduleSection => '行程'; + + @override + String get dueGroup => '到期'; + + @override + String get startGroup => '開始'; + + @override + String get reminderGroup => '提醒'; + + @override + String get organizationSection => '整理'; + + @override + String get actionsSection => '動作'; + + @override + String get advancedSection => '進階'; + + @override + String get addCategory => '新增類別'; + + @override + String get list => '清單'; + + @override + String get microsoftMoveUnsupported => + '此版本不支援在 Microsoft To Do 帳戶的清單之間移動待辦事項。'; + + @override + String get createSubtask => '建立子待辦事項'; + + @override + String get moveToTop => '移至頂端'; + + @override + String get deleteTask => '刪除待辦事項'; + + @override + String get newSubtask => '新增子待辦事項'; + + @override + String deleteTaskConfirmation(String title) { + return '要從 Google Tasks 刪除「$title」嗎?'; + } + + @override + String get metadata => '中繼資料'; + + @override + String get id => 'ID'; + + @override + String get etag => 'ETag'; + + @override + String get updated => '更新時間'; + + @override + String get parent => '上層待辦事項'; + + @override + String get position => '位置'; + + @override + String get webLink => '網頁連結'; + + @override + String get assignment => '指派'; + + @override + String get localState => '本機狀態'; + + @override + String get pendingSync => '等候同步'; + + @override + String get synced => '已同步'; + + @override + String get account => '帳戶'; + + @override + String get sync => '同步'; + + @override + String get manualFullSync => '手動完整同步'; + + @override + String get runInBackgroundWhenClosed => '視窗關閉後繼續在背景執行'; + + @override + String get showTrayIcon => '顯示系統匣圖示'; + + @override + String get startMinimizedToTray => '啟動時最小化至系統匣'; + + @override + String get requiresTrayIcon => '需要系統匣圖示。'; + + @override + String get syncComplete => '同步完成。'; + + @override + String syncFailed(String error) { + return '同步失敗:$error'; + } + + @override + String get notifySyncFailures => '同步失敗通知'; + + @override + String get notifyConflicts => '衝突通知'; + + @override + String get notifyDueToday => '今天到期待辦事項通知'; + + @override + String get eventReminders => '活動提醒'; + + @override + String get taskReminders => '待辦事項提醒'; + + @override + String get notificationDetailLevel => '通知詳細程度'; + + @override + String get notificationDetailPrivate => '私人'; + + @override + String get notificationDetailNormal => '一般'; + + @override + String get quietHours => '勿擾時段'; + + @override + String get quietHoursDescription => '在此時段暫停通知。'; + + @override + String get quietHoursStart => '勿擾開始時間'; + + @override + String get quietHoursEnd => '勿擾結束時間'; + + @override + String get notifications => '通知'; + + @override + String get appearance => '外觀'; + + @override + String get theme => '主題'; + + @override + String get themeSystem => '系統'; + + @override + String get themeLight => '淺色'; + + @override + String get themeDark => '深色'; + + @override + String get themeFamily => '主題系列'; + + @override + String get themeFamilyYaru => 'Ubuntu 原生主題(Yaru)'; + + @override + String get localization => '語言與地區'; + + @override + String get currentLocale => '目前語系'; + + @override + String get privacy => '隱私權'; + + @override + String get redactTaskContentInDiagnostics => '在診斷資訊中隱藏待辦事項內容'; + + @override + String get developerDiagnostics => '開發人員診斷'; + + @override + String get diagnostics => '診斷'; + + @override + String get apiInspectorDisabled => '顯示 API 檢查器'; + + @override + String get googleTasksApi => 'Google Tasks API'; + + @override + String discoveryRevision(String revision) { + return 'Discovery 修訂版本:$revision'; + } + + @override + String get implementedMethods => '已實作的方法'; + + @override + String get supportsTasksScopes => '支援 tasks 和 tasks.readonly 權限範圍'; + + @override + String get requiresTasksScope => '需要 tasks 權限範圍'; + + @override + String get blockedPendingOperations => '遭封鎖的待處理作業'; + + @override + String get signInToInspectPendingOperations => '登入以檢查待處理作業。'; + + @override + String get noBlockedPendingOperations => '沒有遭封鎖的待處理作業。'; + + @override + String get operationActions => '作業動作'; + + @override + String pendingOpListId(String id) { + return '清單=$id'; + } + + @override + String pendingOpTaskId(String id) { + return '待辦事項=$id'; + } + + @override + String pendingOpAttempts(int count) { + return '嘗試次數=$count'; + } + + @override + String get retry => '再試一次'; + + @override + String get discard => '捨棄'; + + @override + String get discardChanges => '要捨棄變更嗎?'; + + @override + String get discardChangesConfirmation => '這會捨棄此待辦事項中尚未儲存的編輯內容。'; + + @override + String get retryCompleted => '重試完成。'; + + @override + String get discardPendingOperation => '要捨棄待處理作業嗎?'; + + @override + String get discardPendingOperationConfirmation => + '這會移除遭封鎖的本機作業。下次同步時將從 Google Tasks 重新整理資料。'; + + @override + String get pendingOperationDiscarded => '已捨棄待處理作業。'; + + @override + String get syncFailureNotificationTitle => 'BusyMax 同步失敗'; + + @override + String syncFailureNotificationBody(String message) { + return '背景同步失敗。$message'; + } + + @override + String get conflictNotificationTitle => 'BusyMax 同步衝突'; + + @override + String conflictNotificationBody(String summary) { + return '一項待處理的本機變更遭到封鎖。$summary'; + } + + @override + String get dueTodayNotificationTitle => '今天到期的待辦事項'; + + @override + String dueTodayNotificationBody(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '今天有 $count 項待辦事項到期。', + one: '今天有 1 項待辦事項到期。', + ); + return '$_temp0'; + } + + @override + String get eventReminderNotificationTitle => '活動提醒'; + + @override + String get taskReminderNotificationTitle => '待辦事項提醒'; + + @override + String get eventReminderNotificationBody => '活動即將開始。'; + + @override + String get taskReminderNotificationBody => '待辦事項即將到期。'; + + @override + String get notificationOpenAction => '開啟'; + + @override + String get notificationDetailsHidden => '根據隱私權設定,詳細資料已隱藏。'; + + @override + String get previousMonth => '上個月'; + + @override + String get nextMonth => '下個月'; + + @override + String get openMonthView => '開啟月檢視'; + + @override + String get previousYear => '上一年'; + + @override + String get nextYear => '下一年'; + + @override + String get openYearView => '開啟年檢視'; + + @override + String weekNumberTooltip(int number) { + return '第 $number 週'; + } + + @override + String get resizeAllDayPanel => '調整全天面板大小'; + + @override + String scheduleItemCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count 項', + one: '1 項', + ); + return '$_temp0'; + } + + @override + String get readOnlyCalendar => '此行事曆為唯讀。'; + + @override + String get selectTimeZone => '選擇時區'; + + @override + String get searchLocations => '搜尋地點'; + + @override + String get noLocationsFound => '找不到地點'; + + @override + String deleteCalendarConfirmation(String title) { + return '要刪除「$title」嗎?'; + } +} From c8bb226d5eee1b966a391828d4f6474f70250c90 Mon Sep 17 00:00:00 2001 From: albert Date: Wed, 29 Jul 2026 16:55:25 -0700 Subject: [PATCH 38/73] Update localizations --- lib/l10n/app_ar.arb | 389 ++++++ lib/l10n/app_fa.arb | 389 ++++++ lib/l10n/app_hi.arb | 36 +- lib/l10n/generated/app_localizations.dart | 10 + lib/l10n/generated/app_localizations_ar.dart | 1308 ++++++++++++++++++ lib/l10n/generated/app_localizations_fa.dart | 1299 +++++++++++++++++ lib/l10n/generated/app_localizations_hi.dart | 38 +- 7 files changed, 3433 insertions(+), 36 deletions(-) create mode 100644 lib/l10n/app_ar.arb create mode 100644 lib/l10n/app_fa.arb create mode 100644 lib/l10n/generated/app_localizations_ar.dart create mode 100644 lib/l10n/generated/app_localizations_fa.dart diff --git a/lib/l10n/app_ar.arb b/lib/l10n/app_ar.arb new file mode 100644 index 0000000..7c98990 --- /dev/null +++ b/lib/l10n/app_ar.arb @@ -0,0 +1,389 @@ +{ + "@@locale": "ar", + "appTitle": "BusyMax", + "connectGoogleAccount": "اربط حسابات Google وMicrosoft لمزامنة التقويمات والمهام.", + "googlePermissionsConsentNotice": "في شاشة أذونات Google، حدّد أذونات التقويم والمهام معًا.", + "googlePermissionsRequiredRetry": "أذونات تقويم Google وGoogle Tasks مطلوبة. حاول مرة أخرى وحدّد مربعي الاختيار.", + "finishSetup": "إنهاء الإعداد", + "continueSetup": "متابعة", + "onboardingSetupTitle": "إعداد BusyMax", + "onboardingAccountsStepTitle": "ربط الحسابات", + "onboardingAccountsStepDescription": "أضف جميع حسابات Google وMicrosoft التي تريد استخدامها. يزامن BusyMax التقويمات والأحداث وقوائم المهام والمهام من كل حساب.", + "onboardingPreferencesStepTitle": "اختيار إعدادات النظام", + "onboardingPreferencesStepDescription": "اضبط سلوك التطبيق على سطح المكتب والتذكيرات ومستوى تفاصيل الإشعارات والمظهر قبل فتح جدولك.", + "signInWithGoogle": "تسجيل الدخول باستخدام Google", + "signInWithMicrosoft": "تسجيل الدخول باستخدام Microsoft", + "googleTasksProvider": "Google Tasks", + "microsoftTodoProvider": "Microsoft To Do", + "providerNotConfigured": "هذه الخدمة غير مهيأة.", + "waitingForGoogleSignIn": "في انتظار تسجيل الدخول إلى Google...", + "waitingForMicrosoftSignIn": "في انتظار تسجيل الدخول إلى Microsoft...", + "microsoftSignInNotConfigured": "تسجيل الدخول إلى Microsoft غير مهيأ. اضبط MICROSOFT_OAUTH_CLIENT_ID.", + "cancel": "إلغاء", + "close": "إغلاق", + "exit": "خروج", + "options": "خيارات", + "hide": "إخفاء", + "show": "إظهار", + "export": "تصدير", + "save": "حفظ", + "settings": "الإعدادات", + "all": "الكل", + "calendarEvents": "الأحداث", + "calendarTasks": "المهام", + "calendar": "التقويم", + "calendars": "التقويمات", + "newEvent": "حدث جديد", + "refreshCalendar": "تحديث التقويم", + "openInProvider": "فتح في الخدمة", + "hideFromSchedule": "إخفاء من الجدول", + "showInSchedule": "إظهار في الجدول", + "noCalendarsSynced": "لم تتم مزامنة أي تقويمات بعد.", + "allDay": "طوال اليوم", + "moreItems": "+{count} عناصر أخرى", + "noEventsOrTasks": "لا توجد أحداث أو مهام", + "scheduleLoading": "جارٍ تحميل الجدول...", + "scheduleUnavailable": "الجدول غير متاح", + "scheduleNoSources": "لا توجد تقويمات أو قوائم مهام ظاهرة", + "scheduleNoSourcesDescription": "اختر ما تريد إظهاره في الإعدادات، ثم حدّث الجدول.", + "scheduleSignInRequired": "ربط حساب", + "scheduleSignInDescription": "سجّل الدخول لمزامنة التقويمات والمهام.", + "scheduleNoSearchResults": "لا توجد أحداث أو مهام مطابقة", + "scheduleNoSearchResultsDescription": "جرّب بحثًا مختلفًا أو امسح عوامل التصفية الحالية.", + "trayAgendaLoading": "جارٍ تحميل جدول الأعمال...", + "trayAgendaSignInRequired": "سجّل الدخول لإظهار جدول الأعمال.", + "trayAgendaNoSources": "لا توجد تقويمات أو قوائم مهام ظاهرة.", + "trayAgendaOpenBusyMax": "فتح التطبيق", + "trayAgendaRefresh": "تحديث", + "trayAgendaError": "جدول الأعمال غير متاح", + "compactAgendaTitle": "جدول الأعمال", + "compactAgendaSubtitle": "القادم", + "compactAgendaOverdue": "متأخرة", + "compactAgendaClear": "لا شيء حاليًا", + "compactAgendaOpenBusyMax": "فتح BusyMax", + "compactAgendaHide": "إخفاء", + "compactAgendaNewTask": "مهمة جديدة", + "compactAgendaRetry": "إعادة المحاولة", + "compactAgendaRefresh": "تحديث", + "compactAgendaAllDay": "طوال اليوم", + "compactAgendaDueToday": "مستحقة اليوم", + "compactAgendaDueTomorrow": "مستحقة غدًا", + "compactAgendaDueOn": "مستحقة في {date}", + "compactAgendaMoreOverdue": "تحميل المزيد من المهام المتأخرة", + "agendaLoadMoreOverdue": "تحميل المزيد من المهام المتأخرة", + "agendaLoadMoreNoDate": "تحميل المزيد من المهام بلا تاريخ", + "viewDay": "يوم", + "viewWeek": "أسبوع", + "viewMonth": "شهر", + "viewYear": "سنة", + "viewAgenda": "جدول الأعمال", + "scheduleSettings": "الجدول", + "scheduleDisplaySettings": "عرض الجدول", + "scheduleDisplayHoursDescription": "تفتح طريقتا عرض اليوم والأسبوع ضمن هذه الساعات. توسّع العناصر المبكرة والمتأخرة النطاق عند الحاجة.", + "scheduleDayStartsAt": "يبدأ اليوم في", + "scheduleDayEndsAt": "ينتهي اليوم في", + "sourceCalendar": "التقويم", + "sourceTaskList": "قائمة المهام", + "createChoiceTitle": "إنشاء", + "createEventAtTime": "حدث", + "createTaskAtDate": "مهمة", + "editEvent": "تعديل الحدث", + "eventTitle": "عنوان الحدث", + "location": "الموقع", + "timeSlot": "الفترة الزمنية", + "startDateTime": "تاريخ/وقت البدء", + "endDateTime": "تاريخ/وقت الانتهاء", + "doesNotRepeat": "لا يتكرر", + "defaultReminder": "التذكير الافتراضي", + "guests": "المدعوون", + "noGuests": "لا يوجد مدعوون", + "description": "الوصف", + "availabilityShowAs": "التوفر / إظهار كـ", + "busy": "مشغول", + "visibility": "إمكانية العرض", + "defaultVisibility": "إمكانية العرض الافتراضية", + "conference": "اجتماع", + "noConference": "لا يوجد اجتماع", + "providerCalendar": "تقويم الخدمة", + "formatBoldShortLabel": "B", + "formatBoldTooltip": "عريض", + "formatItalicShortLabel": "I", + "formatItalicTooltip": "مائل", + "formatUnderlineShortLabel": "U", + "formatUnderlineTooltip": "تحته خط", + "reminderMinutesBefore": "{minutes, plural, =0{عند البدء} =1{قبل دقيقة واحدة} =2{قبل دقيقتين} few{قبل {minutes} دقائق} many{قبل {minutes} دقيقة} other{قبل {minutes} دقيقة}}", + "reminderAtStart": "عند البدء", + "reminderHoursBefore": "{hours, plural, =0{عند البدء} =1{قبل ساعة واحدة} =2{قبل ساعتين} few{قبل {hours} ساعات} many{قبل {hours} ساعة} other{قبل {hours} ساعة}}", + "reminderDaysBefore": "{days, plural, =0{في اليوم نفسه} =1{قبل يوم واحد} =2{قبل يومين} few{قبل {days} أيام} many{قبل {days} يومًا} other{قبل {days} يوم}}", + "availabilityFree": "متاح", + "availabilityTentative": "مبدئي", + "availabilityOutOfOffice": "خارج المكتب", + "availabilityWorkingElsewhere": "العمل من مكان آخر", + "visibilityDefault": "افتراضي", + "visibilityPublic": "عام", + "visibilityPrivate": "خاص", + "visibilityConfidential": "سري", + "sensitivityNormal": "عادي", + "sensitivityPersonal": "شخصي", + "tasks": "المهام", + "allTasks": "كل المهام", + "tasksInList": "المهام في {title}", + "taskLists": "قوائم المهام", + "navigation": "التنقل", + "mainMenu": "القائمة الرئيسية", + "keyboardShortcuts": "اختصارات لوحة المفاتيح", + "shortcutGroupGeneral": "عام", + "shortcutKeyboardShortcutsDescription": "إظهار مرجع الاختصارات هذا", + "shortcutGroupNavigation": "التنقل", + "shortcutNextPeriod": "الفترة التالية", + "shortcutNextPeriodDescription": "الأسبوع التالي في عرض الأسبوع، والشهر التالي في عرض الشهر، وهكذا", + "shortcutPreviousPeriod": "الفترة السابقة", + "shortcutPreviousPeriodDescription": "الأسبوع السابق في عرض الأسبوع، والشهر السابق في عرض الشهر، وهكذا", + "shortcutJumpToToday": "الانتقال إلى اليوم", + "shortcutGroupView": "العرض", + "shortcutDayView": "عرض اليوم", + "shortcutWeekView": "عرض الأسبوع", + "shortcutMonthView": "عرض الشهر", + "shortcutYearView": "عرض السنة", + "shortcutAgendaView": "عرض جدول الأعمال", + "shortcutGroupCreateAndEdit": "الإنشاء والتعديل", + "shortcutSaveItem": "حفظ الحدث أو المهمة", + "shortcutDeleteItem": "حذف الحدث أو المهمة", + "shortcutGroupTaskEditing": "تعديل المهام", + "shortcutCancelEditing": "إلغاء التعديل", + "shortcutCancelEditingDescription": "إغلاق تعديل المهمة أو تفاصيلها", + "shortcutGroupCompactAgenda": "جدول الأعمال المصغّر", + "shortcutRefreshCompactAgendaDescription": "تحديث نافذة جدول الأعمال المصغّر", + "shortcutHideCompactAgendaDescription": "إخفاء نافذة جدول الأعمال المصغّر", + "aboutBusyMax": "حول BusyMax", + "aboutBusyMaxDescription": "المهام والتقويم", + "website": "الموقع الإلكتروني", + "reportAnIssue": "الإبلاغ عن مشكلة", + "sendFeedback": "إرسال الملاحظات", + "feedbackSubmit": "إرسال", + "feedbackCategory": "الفئة", + "feedbackSelectCategory": "اختر فئة", + "feedbackCategoryProblem": "مشكلة أو خلل", + "feedbackCategoryFeature": "طلب ميزة", + "feedbackCategoryPrivacySecurity": "مشكلة تتعلق بالخصوصية أو الأمان", + "feedbackCategoryUsability": "مشكلة في سهولة الاستخدام", + "feedbackCategoryOther": "أخرى", + "feedbackSubject": "الموضوع", + "feedbackDetailedMessage": "رسالة مفصّلة", + "feedbackReplyEmail": "البريد الإلكتروني للرد (اختياري)", + "feedbackIncludeTechnicalDetails": "تضمين التفاصيل التقنية", + "feedbackTechnicalDetailsDisclosure": "يضيف فقط إصدار نظام التشغيل Linux ولغة التطبيق ومنطقته. لا يتم تضمين أي سجلات أو بيانات حسابات أو أسماء ملفات أو معلومات تشخيصية أخرى.", + "feedbackCategoryRequired": "اختر فئة.", + "feedbackSubjectLengthError": "يجب أن يتراوح الموضوع بين 3 و120 حرفًا.", + "feedbackMessageLengthError": "يجب أن تتراوح الرسالة بين 10 و5,000 حرف.", + "feedbackInvalidEmail": "أدخل عنوان بريد إلكتروني صالحًا.", + "feedbackConnectionError": "تعذر الاتصال بـ BusyStack. تحقق من اتصالك وحاول مرة أخرى.", + "feedbackTimeoutError": "انتهت مهلة الطلب. لم تُمسح ملاحظاتك؛ حاول مرة أخرى.", + "feedbackRateLimitedError": "أُرسلت ملاحظات كثيرة جدًا من هذه الشبكة. انتظر وحاول مرة أخرى.", + "feedbackRejectedError": "رفض الخادم الإرسال. راجع الحقول وحاول مرة أخرى.", + "feedbackServerError": "يتعذر على BusyStack قبول ملاحظاتك الآن. لم تُمسح ملاحظاتك؛ حاول مرة أخرى.", + "feedbackSuccess": "تم إرسال الملاحظات. المرجع: {id}", + "toggleSidebar": "إظهار الشريط الجانبي أو إخفاؤه", + "accounts": "الحسابات", + "currentAccount": "الحساب الحالي", + "switchAccount": "تبديل الحساب", + "addGoogleAccount": "إضافة حساب Google", + "addMicrosoftAccount": "إضافة حساب Microsoft", + "googleProvider": "Google", + "microsoftProvider": "Microsoft", + "signedInAccount": "تم تسجيل الدخول", + "removeAccount": "إزالة الحساب…", + "removingAccount": "جارٍ إزالة الحساب…", + "removeAccountDescription": "إيقاف المزامنة وإزالة بيانات هذا الحساب من هذا الجهاز.", + "removeAccountTitle": "إزالة {account} من BusyMax؟", + "removeAccountConfirmation": "سيؤدي ذلك إلى حذف المهام والتقويمات والأحداث والتذكيرات والتغييرات غير المتصلة المعلّقة المخزّنة مؤقتًا من هذا الجهاز. ستُفقد التغييرات غير المتزامنة. لن يُحذف أي شيء من Google أو Microsoft.", + "revokeGoogleAccess": "إلغاء وصول BusyMax إلى حساب Google هذا أيضًا", + "revokeGoogleAccessDescription": "ستحتاج إلى منح الوصول مرة أخرى قبل إعادة الاتصال.", + "removeAccountAction": "إزالة الحساب", + "removeAccountFailed": "تعذر إكمال إزالة الحساب. حاول مرة أخرى.", + "accountRemovedGoogleRevokeFailed": "تمت إزالة الحساب من هذا الجهاز، لكن تعذر على BusyMax إلغاء الوصول إلى Google. يمكنك إلغاء الوصول من حسابك على Google.", + "newList": "قائمة جديدة", + "signInToViewTaskLists": "سجّل الدخول لعرض قوائم المهام.", + "noTaskListsSynced": "لم تتم مزامنة أي قوائم مهام بعد.", + "listActions": "إجراءات القائمة", + "rename": "إعادة تسمية", + "delete": "حذف", + "renameList": "إعادة تسمية القائمة", + "deleteList": "حذف القائمة", + "builtInMicrosoftList": "مدمجة", + "builtInMicrosoftListCannotRenameDelete": "لا يمكن إعادة تسمية قوائم Microsoft To Do المدمجة أو حذفها.", + "deleteListConfirmation": "حذف «{title}» من Google Tasks؟", + "deleteEvent": "حذف الحدث", + "title": "العنوان", + "create": "إنشاء", + "newTask": "مهمة جديدة", + "clearCompleted": "مسح المهام المكتملة", + "refreshList": "تحديث القائمة", + "refreshAll": "تحديث الكل", + "listRefreshed": "تم تحديث القائمة.", + "allTasksRefreshed": "تم تحديث جميع الحسابات.", + "exportedFile": "تم التصدير إلى {path}", + "exportFailed": "فشل التصدير: {error}", + "refreshFailed": "فشل التحديث: {error}", + "selectOrCreateTaskList": "اختر قائمة مهام أو أنشئ واحدة للبدء.", + "signInToViewTasks": "سجّل الدخول لعرض المهام.", + "noTasks": "لا توجد مهام.", + "noTasksYet": "لا توجد مهام بعد", + "noTasksYetMessage": "أنشئ مهمة أو حدّث حساباتك للبدء.", + "noTasksInList": "لا توجد مهام في هذه القائمة.", + "overdue": "متأخرة", + "today": "اليوم", + "tomorrow": "غدًا", + "upcoming": "القادمة", + "noDate": "بلا تاريخ", + "completed": "مكتملة", + "duePrefix": "مستحقة في {date}", + "dateTimeDisplay": "{date} · {time}", + "taskDetails": "تفاصيل المهمة", + "editTask": "تعديل المهمة", + "noTaskSelected": "لم يتم تحديد مهمة.", + "noTaskSelectedHelper": "حدّد مهمة لعرض تفاصيلها وتعديلها.", + "taskUnavailable": "المهمة غير متاحة.", + "signInToEditTasks": "سجّل الدخول لتعديل المهام.", + "refreshTask": "تحديث المهمة", + "primarySection": "أساسي", + "statusSection": "الحالة", + "openStatus": "مفتوحة", + "doneStatus": "منجزة", + "notes": "ملاحظات", + "dueDate": "تاريخ الاستحقاق", + "clearDueDate": "مسح تاريخ الاستحقاق", + "dueTime": "وقت الاستحقاق", + "startDate": "تاريخ البدء", + "startTime": "وقت البدء", + "endDate": "تاريخ الانتهاء", + "endTime": "وقت الانتهاء", + "reminderDate": "تاريخ التذكير", + "reminderTime": "وقت التذكير", + "reminder": "تذكير", + "addReminder": "إضافة تذكير", + "addGuest": "إضافة مدعو", + "addGuestEmail": "إضافة بريد المدعو الإلكتروني", + "removeReminder": "إزالة التذكير", + "off": "إيقاف", + "repeat": "التكرار", + "repeatNone": "بلا تكرار", + "noneValue": "لا شيء", + "repeatDaily": "يوميًا", + "repeatWeekly": "أسبوعيًا", + "repeatMonthly": "شهريًا", + "repeatYearly": "سنويًا", + "importance": "الأهمية", + "importanceLow": "منخفضة", + "importanceNormal": "عادية", + "importanceHigh": "مرتفعة", + "categories": "الفئات", + "scheduleSection": "الجدول", + "dueGroup": "الاستحقاق", + "startGroup": "البدء", + "reminderGroup": "التذكير", + "organizationSection": "التنظيم", + "actionsSection": "الإجراءات", + "advancedSection": "متقدم", + "addCategory": "إضافة فئة", + "list": "القائمة", + "microsoftMoveUnsupported": "نقل المهام بين القوائم غير مدعوم لحسابات Microsoft To Do في هذا الإصدار.", + "createSubtask": "إنشاء مهمة فرعية", + "moveToTop": "نقل إلى الأعلى", + "deleteTask": "حذف المهمة", + "newSubtask": "مهمة فرعية جديدة", + "deleteTaskConfirmation": "حذف «{title}» من Google Tasks؟", + "metadata": "البيانات الوصفية", + "id": "المعرّف", + "etag": "ETag", + "updated": "آخر تحديث", + "parent": "المهمة الأصلية", + "position": "الموضع", + "webLink": "رابط الويب", + "assignment": "التعيين", + "localState": "الحالة المحلية", + "pendingSync": "في انتظار المزامنة", + "synced": "تمت المزامنة", + "account": "الحساب", + "sync": "المزامنة", + "manualFullSync": "مزامنة كاملة يدويًا", + "runInBackgroundWhenClosed": "متابعة التشغيل عند إغلاق النافذة", + "showTrayIcon": "إظهار أيقونة شريط النظام", + "startMinimizedToTray": "البدء مصغّرًا في شريط النظام", + "requiresTrayIcon": "يتطلب أيقونة شريط النظام.", + "syncComplete": "اكتملت المزامنة.", + "syncFailed": "فشلت المزامنة: {error}", + "notifySyncFailures": "إشعارات عند فشل المزامنة", + "notifyConflicts": "إشعارات عند حدوث تعارضات", + "notifyDueToday": "إشعارات المهام المستحقة اليوم", + "eventReminders": "تذكيرات الأحداث", + "taskReminders": "تذكيرات المهام", + "notificationDetailLevel": "مستوى تفاصيل الإشعارات", + "notificationDetailPrivate": "خاص", + "notificationDetailNormal": "عادي", + "quietHours": "ساعات الهدوء", + "quietHoursDescription": "إيقاف الإشعارات مؤقتًا خلال هذه الفترة.", + "quietHoursStart": "بداية ساعات الهدوء", + "quietHoursEnd": "نهاية ساعات الهدوء", + "notifications": "الإشعارات", + "appearance": "المظهر", + "theme": "السمة", + "themeSystem": "النظام", + "themeLight": "فاتحة", + "themeDark": "داكنة", + "themeFamily": "عائلة السمات", + "themeFamilyYaru": "سمة Ubuntu الأصلية (Yaru)", + "localization": "اللغة والمنطقة", + "currentLocale": "اللغة والمنطقة الحالية", + "privacy": "الخصوصية", + "redactTaskContentInDiagnostics": "إخفاء محتوى المهام في معلومات التشخيص", + "developerDiagnostics": "تشخيصات المطور", + "diagnostics": "التشخيصات", + "apiInspectorDisabled": "إظهار فاحص API", + "googleTasksApi": "واجهة Google Tasks API", + "discoveryRevision": "مراجعة Discovery: ‏{revision}", + "implementedMethods": "الطرق المنفذة", + "supportsTasksScopes": "يدعم نطاقَي tasks وtasks.readonly", + "requiresTasksScope": "يتطلب نطاق tasks", + "blockedPendingOperations": "العمليات المعلّقة المحظورة", + "signInToInspectPendingOperations": "سجّل الدخول لفحص العمليات المعلّقة.", + "noBlockedPendingOperations": "لا توجد عمليات معلّقة محظورة.", + "operationActions": "إجراءات العملية", + "pendingOpListId": "القائمة={id}", + "pendingOpTaskId": "المهمة={id}", + "pendingOpAttempts": "المحاولات={count}", + "retry": "إعادة المحاولة", + "discard": "تجاهل", + "discardChanges": "تجاهل التغييرات؟", + "discardChangesConfirmation": "سيؤدي ذلك إلى تجاهل التعديلات غير المحفوظة على هذه المهمة.", + "retryCompleted": "اكتملت إعادة المحاولة.", + "discardPendingOperation": "تجاهل العملية المعلّقة؟", + "discardPendingOperationConfirmation": "سيؤدي ذلك إلى إزالة العملية المحلية المحظورة. ستُحدّث البيانات من Google Tasks في المزامنة التالية.", + "pendingOperationDiscarded": "تم تجاهل العملية المعلّقة.", + "syncFailureNotificationTitle": "فشلت مزامنة BusyMax", + "syncFailureNotificationBody": "فشلت المزامنة في الخلفية. {message}", + "conflictNotificationTitle": "تعارض في مزامنة BusyMax", + "conflictNotificationBody": "تم حظر تغيير محلي معلّق. {summary}", + "dueTodayNotificationTitle": "المهام المستحقة اليوم", + "dueTodayNotificationBody": "{count, plural, =0{لا توجد مهام مستحقة اليوم.} =1{هناك مهمة واحدة مستحقة اليوم.} =2{هناك مهمتان مستحقتان اليوم.} few{هناك {count} مهام مستحقة اليوم.} many{هناك {count} مهمة مستحقة اليوم.} other{هناك {count} مهمة مستحقة اليوم.}}", + "eventReminderNotificationTitle": "تذكير بحدث", + "taskReminderNotificationTitle": "تذكير بمهمة", + "eventReminderNotificationBody": "سيبدأ الحدث قريبًا.", + "taskReminderNotificationBody": "ستحلّ مهلة المهمة قريبًا.", + "notificationOpenAction": "فتح", + "notificationDetailsHidden": "التفاصيل مخفية وفقًا لإعدادات الخصوصية.", + "previousMonth": "الشهر السابق", + "nextMonth": "الشهر التالي", + "openMonthView": "فتح عرض الشهر", + "previousYear": "السنة السابقة", + "nextYear": "السنة التالية", + "openYearView": "فتح عرض السنة", + "weekNumberTooltip": "الأسبوع {number}", + "resizeAllDayPanel": "تغيير حجم لوحة اليوم الكامل", + "scheduleItemCount": "{count, plural, =0{لا عناصر} =1{عنصر واحد} =2{عنصران} few{{count} عناصر} many{{count} عنصرًا} other{{count} عنصر}}", + "readOnlyCalendar": "هذا التقويم للقراءة فقط.", + "selectTimeZone": "اختيار المنطقة الزمنية", + "searchLocations": "البحث عن مواقع", + "noLocationsFound": "لم يتم العثور على مواقع", + "deleteCalendarConfirmation": "حذف «{title}»؟" +} diff --git a/lib/l10n/app_fa.arb b/lib/l10n/app_fa.arb new file mode 100644 index 0000000..89fb630 --- /dev/null +++ b/lib/l10n/app_fa.arb @@ -0,0 +1,389 @@ +{ + "@@locale": "fa", + "appTitle": "BusyMax", + "connectGoogleAccount": "حساب‌های Google و Microsoft را متصل کنید تا تقویم‌ها و کارها همگام شوند.", + "googlePermissionsConsentNotice": "در صفحهٔ مجوزهای Google، مجوزهای تقویم و کارها را هر دو انتخاب کنید.", + "googlePermissionsRequiredRetry": "مجوزهای Google Calendar و Google Tasks لازم هستند. دوباره تلاش کنید و هر دو کادر را علامت بزنید.", + "finishSetup": "پایان راه‌اندازی", + "continueSetup": "ادامه", + "onboardingSetupTitle": "راه‌اندازی BusyMax", + "onboardingAccountsStepTitle": "اتصال حساب‌ها", + "onboardingAccountsStepDescription": "همهٔ حساب‌های Google و Microsoft موردنظرتان را اضافه کنید. BusyMax تقویم‌ها، رویدادها، فهرست‌های کار و کارهای هر حساب را همگام می‌کند.", + "onboardingPreferencesStepTitle": "انتخاب تنظیمات سیستم", + "onboardingPreferencesStepDescription": "پیش از باز کردن برنامه، رفتار برنامه روی میزکار، یادآورها، سطح جزئیات اعلان‌ها و ظاهر را تنظیم کنید.", + "signInWithGoogle": "ورود با Google", + "signInWithMicrosoft": "ورود با Microsoft", + "googleTasksProvider": "Google Tasks", + "microsoftTodoProvider": "Microsoft To Do", + "providerNotConfigured": "این سرویس پیکربندی نشده است.", + "waitingForGoogleSignIn": "در انتظار ورود به Google...", + "waitingForMicrosoftSignIn": "در انتظار ورود به Microsoft...", + "microsoftSignInNotConfigured": "ورود به Microsoft پیکربندی نشده است. MICROSOFT_OAUTH_CLIENT_ID را تنظیم کنید.", + "cancel": "لغو", + "close": "بستن", + "exit": "خروج", + "options": "گزینه‌ها", + "hide": "پنهان کردن", + "show": "نمایش", + "export": "خروجی گرفتن", + "save": "ذخیره", + "settings": "تنظیمات", + "all": "همه", + "calendarEvents": "رویدادها", + "calendarTasks": "کارها", + "calendar": "تقویم", + "calendars": "تقویم‌ها", + "newEvent": "رویداد جدید", + "refreshCalendar": "تازه‌سازی تقویم", + "openInProvider": "باز کردن در سرویس", + "hideFromSchedule": "پنهان کردن از برنامه", + "showInSchedule": "نمایش در برنامه", + "noCalendarsSynced": "هنوز هیچ تقویمی همگام نشده است.", + "allDay": "تمام روز", + "moreItems": "+{count} مورد دیگر", + "noEventsOrTasks": "هیچ رویداد یا کاری وجود ندارد", + "scheduleLoading": "در حال بارگیری برنامه...", + "scheduleUnavailable": "برنامه در دسترس نیست", + "scheduleNoSources": "هیچ تقویم یا فهرست کار قابل نمایشی وجود ندارد", + "scheduleNoSourcesDescription": "در تنظیمات انتخاب کنید چه چیزهایی نمایش داده شوند، سپس برنامه را تازه‌سازی کنید.", + "scheduleSignInRequired": "اتصال حساب", + "scheduleSignInDescription": "برای همگام‌سازی تقویم‌ها و کارها وارد شوید.", + "scheduleNoSearchResults": "هیچ رویداد یا کار منطبقی وجود ندارد", + "scheduleNoSearchResultsDescription": "جست‌وجوی دیگری را امتحان کنید یا پالایه‌های فعلی را پاک کنید.", + "trayAgendaLoading": "در حال بارگیری برنامه...", + "trayAgendaSignInRequired": "برای نمایش برنامه وارد شوید.", + "trayAgendaNoSources": "هیچ تقویم یا فهرست کار قابل نمایشی وجود ندارد.", + "trayAgendaOpenBusyMax": "باز کردن برنامه", + "trayAgendaRefresh": "تازه‌سازی", + "trayAgendaError": "برنامه در دسترس نیست", + "compactAgendaTitle": "برنامه", + "compactAgendaSubtitle": "پیش رو", + "compactAgendaOverdue": "گذشته از موعد", + "compactAgendaClear": "فعلاً موردی نیست", + "compactAgendaOpenBusyMax": "باز کردن BusyMax", + "compactAgendaHide": "پنهان کردن", + "compactAgendaNewTask": "کار جدید", + "compactAgendaRetry": "تلاش دوباره", + "compactAgendaRefresh": "تازه‌سازی", + "compactAgendaAllDay": "تمام روز", + "compactAgendaDueToday": "سررسید امروز", + "compactAgendaDueTomorrow": "سررسید فردا", + "compactAgendaDueOn": "سررسید: {date}", + "compactAgendaMoreOverdue": "بارگیری کارهای عقب‌افتادهٔ بیشتر", + "agendaLoadMoreOverdue": "بارگیری کارهای عقب‌افتادهٔ بیشتر", + "agendaLoadMoreNoDate": "بارگیری کارهای بدون تاریخ بیشتر", + "viewDay": "روز", + "viewWeek": "هفته", + "viewMonth": "ماه", + "viewYear": "سال", + "viewAgenda": "برنامه", + "scheduleSettings": "برنامه", + "scheduleDisplaySettings": "نمایش برنامه", + "scheduleDisplayHoursDescription": "نماهای روز و هفته ابتدا این بازهٔ زمانی را نشان می‌دهند. موارد زودتر یا دیرتر در صورت نیاز این بازه را گسترش می‌دهند.", + "scheduleDayStartsAt": "شروع روز از", + "scheduleDayEndsAt": "پایان روز در", + "sourceCalendar": "تقویم", + "sourceTaskList": "فهرست کار", + "createChoiceTitle": "ایجاد", + "createEventAtTime": "رویداد", + "createTaskAtDate": "کار", + "editEvent": "ویرایش رویداد", + "eventTitle": "عنوان رویداد", + "location": "مکان", + "timeSlot": "بازهٔ زمانی", + "startDateTime": "تاریخ/زمان شروع", + "endDateTime": "تاریخ/زمان پایان", + "doesNotRepeat": "تکرار نمی‌شود", + "defaultReminder": "یادآور پیش‌فرض", + "guests": "مهمانان", + "noGuests": "بدون مهمان", + "description": "توضیحات", + "availabilityShowAs": "وضعیت دسترسی / نمایش به‌عنوان", + "busy": "مشغول", + "visibility": "قابلیت مشاهده", + "defaultVisibility": "قابلیت مشاهدهٔ پیش‌فرض", + "conference": "جلسه", + "noConference": "بدون جلسه", + "providerCalendar": "تقویم سرویس", + "formatBoldShortLabel": "B", + "formatBoldTooltip": "پررنگ", + "formatItalicShortLabel": "I", + "formatItalicTooltip": "مورب", + "formatUnderlineShortLabel": "U", + "formatUnderlineTooltip": "زیرخط", + "reminderMinutesBefore": "{minutes, plural, =0{هنگام شروع} =1{یک دقیقه قبل} other{{minutes} دقیقه قبل}}", + "reminderAtStart": "هنگام شروع", + "reminderHoursBefore": "{hours, plural, =0{هنگام شروع} =1{یک ساعت قبل} other{{hours} ساعت قبل}}", + "reminderDaysBefore": "{days, plural, =0{همان روز} =1{یک روز قبل} other{{days} روز قبل}}", + "availabilityFree": "آزاد", + "availabilityTentative": "احتمالی", + "availabilityOutOfOffice": "خارج از دفتر", + "availabilityWorkingElsewhere": "مشغول به کار در مکانی دیگر", + "visibilityDefault": "پیش‌فرض", + "visibilityPublic": "عمومی", + "visibilityPrivate": "خصوصی", + "visibilityConfidential": "محرمانه", + "sensitivityNormal": "عادی", + "sensitivityPersonal": "شخصی", + "tasks": "کارها", + "allTasks": "همهٔ کارها", + "tasksInList": "کارهای {title}", + "taskLists": "فهرست‌های کار", + "navigation": "پیمایش", + "mainMenu": "منوی اصلی", + "keyboardShortcuts": "میان‌برهای صفحه‌کلید", + "shortcutGroupGeneral": "عمومی", + "shortcutKeyboardShortcutsDescription": "نمایش این راهنمای میان‌برها", + "shortcutGroupNavigation": "پیمایش", + "shortcutNextPeriod": "بازهٔ بعدی", + "shortcutNextPeriodDescription": "هفتهٔ بعد در نمای هفته، ماه بعد در نمای ماه و به همین ترتیب", + "shortcutPreviousPeriod": "بازهٔ قبلی", + "shortcutPreviousPeriodDescription": "هفتهٔ قبل در نمای هفته، ماه قبل در نمای ماه و به همین ترتیب", + "shortcutJumpToToday": "رفتن به امروز", + "shortcutGroupView": "نما", + "shortcutDayView": "نمای روز", + "shortcutWeekView": "نمای هفته", + "shortcutMonthView": "نمای ماه", + "shortcutYearView": "نمای سال", + "shortcutAgendaView": "نمای برنامه", + "shortcutGroupCreateAndEdit": "ایجاد و ویرایش", + "shortcutSaveItem": "ذخیرهٔ رویداد یا کار", + "shortcutDeleteItem": "حذف رویداد یا کار", + "shortcutGroupTaskEditing": "ویرایش کار", + "shortcutCancelEditing": "لغو ویرایش", + "shortcutCancelEditingDescription": "بستن ویرایش یا جزئیات کار", + "shortcutGroupCompactAgenda": "برنامهٔ فشرده", + "shortcutRefreshCompactAgendaDescription": "تازه‌سازی پنجرهٔ برنامهٔ فشرده", + "shortcutHideCompactAgendaDescription": "پنهان کردن پنجرهٔ برنامهٔ فشرده", + "aboutBusyMax": "دربارهٔ BusyMax", + "aboutBusyMaxDescription": "کارها و تقویم", + "website": "وب‌سایت", + "reportAnIssue": "گزارش مشکل", + "sendFeedback": "ارسال بازخورد", + "feedbackSubmit": "ارسال", + "feedbackCategory": "دسته‌بندی", + "feedbackSelectCategory": "یک دسته‌بندی انتخاب کنید", + "feedbackCategoryProblem": "مشکل یا اشکال", + "feedbackCategoryFeature": "درخواست قابلیت", + "feedbackCategoryPrivacySecurity": "نگرانی دربارهٔ حریم خصوصی یا امنیت", + "feedbackCategoryUsability": "نگرانی دربارهٔ کاربردپذیری", + "feedbackCategoryOther": "سایر", + "feedbackSubject": "موضوع", + "feedbackDetailedMessage": "پیام با جزئیات", + "feedbackReplyEmail": "نشانی ایمیل برای پاسخ (اختیاری)", + "feedbackIncludeTechnicalDetails": "افزودن جزئیات فنی", + "feedbackTechnicalDetailsDisclosure": "فقط نسخهٔ سیستم‌عامل Linux و تنظیمات منطقه‌ای برنامه افزوده می‌شود. هیچ گزارش، دادهٔ حساب، نام پرونده یا اطلاعات تشخیصی دیگری افزوده نمی‌شود.", + "feedbackCategoryRequired": "یک دسته‌بندی انتخاب کنید.", + "feedbackSubjectLengthError": "موضوع باید بین ۳ تا ۱۲۰ نویسه باشد.", + "feedbackMessageLengthError": "پیام باید بین ۱۰ تا ۵٬۰۰۰ نویسه باشد.", + "feedbackInvalidEmail": "یک نشانی ایمیل معتبر وارد کنید.", + "feedbackConnectionError": "اتصال به BusyStack ممکن نشد. اتصال خود را بررسی و دوباره تلاش کنید.", + "feedbackTimeoutError": "مهلت درخواست پایان یافت. بازخورد شما پاک نشده است؛ دوباره تلاش کنید.", + "feedbackRateLimitedError": "بازخوردهای بیش از حدی از این شبکه ارسال شده است. کمی صبر کنید و دوباره تلاش کنید.", + "feedbackRejectedError": "سرور ارسال را رد کرد. فیلدها را بررسی و دوباره تلاش کنید.", + "feedbackServerError": "BusyStack اکنون نمی‌تواند بازخورد شما را بپذیرد. بازخورد شما پاک نشده است؛ دوباره تلاش کنید.", + "feedbackSuccess": "بازخورد ارسال شد. شناسهٔ پیگیری: {id}", + "toggleSidebar": "نمایش یا پنهان کردن نوار کناری", + "accounts": "حساب‌ها", + "currentAccount": "حساب فعلی", + "switchAccount": "تعویض حساب", + "addGoogleAccount": "افزودن حساب Google", + "addMicrosoftAccount": "افزودن حساب Microsoft", + "googleProvider": "Google", + "microsoftProvider": "Microsoft", + "signedInAccount": "وارد شده", + "removeAccount": "حذف حساب…", + "removingAccount": "در حال حذف حساب…", + "removeAccountDescription": "همگام‌سازی را متوقف و داده‌های این حساب را از این دستگاه حذف کنید.", + "removeAccountTitle": "حذف {account} از BusyMax؟", + "removeAccountConfirmation": "با این کار، کارها، تقویم‌ها، رویدادها، یادآورها و تغییرات آفلاین در انتظار از حافظهٔ نهان این دستگاه حذف می‌شوند. تغییرات همگام‌نشده از دست می‌روند. هیچ چیزی از Google یا Microsoft حذف نمی‌شود.", + "revokeGoogleAccess": "دسترسی BusyMax به این حساب Google نیز لغو شود", + "revokeGoogleAccessDescription": "پیش از اتصال دوباره باید دسترسی را دوباره اعطا کنید.", + "removeAccountAction": "حذف حساب", + "removeAccountFailed": "حذف حساب کامل نشد. دوباره تلاش کنید.", + "accountRemovedGoogleRevokeFailed": "حساب از این دستگاه حذف شد، اما BusyMax نتوانست دسترسی Google را لغو کند. می‌توانید آن را از حساب Google خود لغو کنید.", + "newList": "فهرست جدید", + "signInToViewTaskLists": "برای دیدن فهرست‌های کار وارد شوید.", + "noTaskListsSynced": "هنوز هیچ فهرست کاری همگام نشده است.", + "listActions": "عملیات فهرست", + "rename": "تغییر نام", + "delete": "حذف", + "renameList": "تغییر نام فهرست", + "deleteList": "حذف فهرست", + "builtInMicrosoftList": "داخلی", + "builtInMicrosoftListCannotRenameDelete": "فهرست‌های داخلی Microsoft To Do را نمی‌توان تغییر نام داد یا حذف کرد.", + "deleteListConfirmation": "«{title}» از Google Tasks حذف شود؟", + "deleteEvent": "حذف رویداد", + "title": "عنوان", + "create": "ایجاد", + "newTask": "کار جدید", + "clearCompleted": "پاک کردن کارهای انجام‌شده", + "refreshList": "تازه‌سازی فهرست", + "refreshAll": "تازه‌سازی همه", + "listRefreshed": "فهرست تازه‌سازی شد.", + "allTasksRefreshed": "همهٔ حساب‌ها تازه‌سازی شدند.", + "exportedFile": "در {path} خروجی گرفته شد", + "exportFailed": "خروجی گرفتن ناموفق بود: {error}", + "refreshFailed": "تازه‌سازی ناموفق بود: {error}", + "selectOrCreateTaskList": "برای شروع، یک فهرست کار انتخاب یا ایجاد کنید.", + "signInToViewTasks": "برای دیدن کارها وارد شوید.", + "noTasks": "هیچ کاری وجود ندارد.", + "noTasksYet": "هنوز کاری وجود ندارد", + "noTasksYetMessage": "برای شروع یک کار ایجاد کنید یا حساب‌هایتان را تازه‌سازی کنید.", + "noTasksInList": "هیچ کاری در این فهرست وجود ندارد.", + "overdue": "گذشته از موعد", + "today": "امروز", + "tomorrow": "فردا", + "upcoming": "پیش رو", + "noDate": "بدون تاریخ", + "completed": "انجام‌شده", + "duePrefix": "سررسید: {date}", + "dateTimeDisplay": "{date} · {time}", + "taskDetails": "جزئیات کار", + "editTask": "ویرایش کار", + "noTaskSelected": "هیچ کاری انتخاب نشده است.", + "noTaskSelectedHelper": "برای دیدن و ویرایش جزئیات، کاری را انتخاب کنید.", + "taskUnavailable": "کار در دسترس نیست.", + "signInToEditTasks": "برای ویرایش کارها وارد شوید.", + "refreshTask": "تازه‌سازی کار", + "primarySection": "اصلی", + "statusSection": "وضعیت", + "openStatus": "باز", + "doneStatus": "انجام‌شده", + "notes": "یادداشت‌ها", + "dueDate": "تاریخ سررسید", + "clearDueDate": "پاک کردن تاریخ سررسید", + "dueTime": "زمان سررسید", + "startDate": "تاریخ شروع", + "startTime": "زمان شروع", + "endDate": "تاریخ پایان", + "endTime": "زمان پایان", + "reminderDate": "تاریخ یادآور", + "reminderTime": "زمان یادآور", + "reminder": "یادآور", + "addReminder": "افزودن یادآور", + "addGuest": "افزودن مهمان", + "addGuestEmail": "افزودن ایمیل مهمان", + "removeReminder": "حذف یادآور", + "off": "خاموش", + "repeat": "تکرار", + "repeatNone": "بدون تکرار", + "noneValue": "هیچ‌کدام", + "repeatDaily": "روزانه", + "repeatWeekly": "هفتگی", + "repeatMonthly": "ماهانه", + "repeatYearly": "سالانه", + "importance": "اهمیت", + "importanceLow": "کم", + "importanceNormal": "عادی", + "importanceHigh": "زیاد", + "categories": "دسته‌ها", + "scheduleSection": "برنامه", + "dueGroup": "سررسید", + "startGroup": "شروع", + "reminderGroup": "یادآور", + "organizationSection": "سازمان‌دهی", + "actionsSection": "عملیات", + "advancedSection": "پیشرفته", + "addCategory": "افزودن دسته", + "list": "فهرست", + "microsoftMoveUnsupported": "در این نسخه، جابه‌جایی کارها بین فهرست‌های حساب Microsoft To Do پشتیبانی نمی‌شود.", + "createSubtask": "ایجاد زیرکار", + "moveToTop": "انتقال به بالاترین جایگاه", + "deleteTask": "حذف کار", + "newSubtask": "زیرکار جدید", + "deleteTaskConfirmation": "«{title}» از Google Tasks حذف شود؟", + "metadata": "فراداده", + "id": "شناسه", + "etag": "ETag", + "updated": "به‌روزشده", + "parent": "کار والد", + "position": "جایگاه", + "webLink": "پیوند وب", + "assignment": "واگذاری", + "localState": "وضعیت محلی", + "pendingSync": "در انتظار همگام‌سازی", + "synced": "همگام‌شده", + "account": "حساب", + "sync": "همگام‌سازی", + "manualFullSync": "همگام‌سازی کامل دستی", + "runInBackgroundWhenClosed": "ادامهٔ اجرا پس از بسته شدن پنجره", + "showTrayIcon": "نمایش نماد سینی سیستم", + "startMinimizedToTray": "شروع به‌صورت کوچک‌شده در سینی سیستم", + "requiresTrayIcon": "به نماد سینی سیستم نیاز دارد.", + "syncComplete": "همگام‌سازی کامل شد.", + "syncFailed": "همگام‌سازی ناموفق بود: {error}", + "notifySyncFailures": "اعلان هنگام شکست همگام‌سازی", + "notifyConflicts": "اعلان هنگام تداخل", + "notifyDueToday": "اعلان کارهای دارای سررسید امروز", + "eventReminders": "یادآورهای رویداد", + "taskReminders": "یادآورهای کار", + "notificationDetailLevel": "سطح جزئیات اعلان", + "notificationDetailPrivate": "خصوصی", + "notificationDetailNormal": "عادی", + "quietHours": "ساعات سکوت", + "quietHoursDescription": "اعلان‌ها را در این بازه موقتاً متوقف کنید.", + "quietHoursStart": "شروع ساعات سکوت", + "quietHoursEnd": "پایان ساعات سکوت", + "notifications": "اعلان‌ها", + "appearance": "ظاهر", + "theme": "پوسته", + "themeSystem": "سیستم", + "themeLight": "روشن", + "themeDark": "تیره", + "themeFamily": "خانوادهٔ پوسته", + "themeFamilyYaru": "پوستهٔ بومی Ubuntu ‏(Yaru)", + "localization": "زبان و منطقه", + "currentLocale": "زبان و منطقهٔ فعلی", + "privacy": "حریم خصوصی", + "redactTaskContentInDiagnostics": "پنهان کردن محتوای کارها در اطلاعات تشخیصی", + "developerDiagnostics": "تشخیص‌های توسعه‌دهنده", + "diagnostics": "اطلاعات تشخیصی", + "apiInspectorDisabled": "نمایش بازرس API", + "googleTasksApi": "رابط Google Tasks API", + "discoveryRevision": "بازبینی Discovery: ‏{revision}", + "implementedMethods": "روش‌های پیاده‌سازی‌شده", + "supportsTasksScopes": "از محدوده‌های tasks و tasks.readonly پشتیبانی می‌کند", + "requiresTasksScope": "به محدودهٔ tasks نیاز دارد", + "blockedPendingOperations": "عملیات در انتظار مسدودشده", + "signInToInspectPendingOperations": "برای بررسی عملیات در انتظار وارد شوید.", + "noBlockedPendingOperations": "هیچ عملیات در انتظار مسدودشده‌ای وجود ندارد.", + "operationActions": "اقدامات عملیات", + "pendingOpListId": "فهرست={id}", + "pendingOpTaskId": "کار={id}", + "pendingOpAttempts": "تلاش‌ها={count}", + "retry": "تلاش دوباره", + "discard": "کنار گذاشتن", + "discardChanges": "تغییرات کنار گذاشته شوند؟", + "discardChangesConfirmation": "با این کار ویرایش‌های ذخیره‌نشدهٔ این کار کنار گذاشته می‌شوند.", + "retryCompleted": "تلاش دوباره کامل شد.", + "discardPendingOperation": "عملیات در انتظار کنار گذاشته شود؟", + "discardPendingOperationConfirmation": "با این کار عملیات محلی مسدودشده حذف می‌شود. در همگام‌سازی بعدی، داده‌ها از Google Tasks تازه‌سازی می‌شوند.", + "pendingOperationDiscarded": "عملیات در انتظار کنار گذاشته شد.", + "syncFailureNotificationTitle": "همگام‌سازی BusyMax ناموفق بود", + "syncFailureNotificationBody": "همگام‌سازی پس‌زمینه ناموفق بود. {message}", + "conflictNotificationTitle": "تداخل همگام‌سازی BusyMax", + "conflictNotificationBody": "یک تغییر محلی در انتظار مسدود شد. {summary}", + "dueTodayNotificationTitle": "کارهای دارای سررسید امروز", + "dueTodayNotificationBody": "{count, plural, =0{امروز هیچ کاری سررسید ندارد.} =1{امروز یک کار سررسید دارد.} other{امروز {count} کار سررسید دارند.}}", + "eventReminderNotificationTitle": "یادآور رویداد", + "taskReminderNotificationTitle": "یادآور کار", + "eventReminderNotificationBody": "رویداد به‌زودی شروع می‌شود.", + "taskReminderNotificationBody": "سررسید کار نزدیک است.", + "notificationOpenAction": "باز کردن", + "notificationDetailsHidden": "جزئیات به‌دلیل تنظیمات حریم خصوصی پنهان شده‌اند.", + "previousMonth": "ماه قبل", + "nextMonth": "ماه بعد", + "openMonthView": "باز کردن نمای ماه", + "previousYear": "سال قبل", + "nextYear": "سال بعد", + "openYearView": "باز کردن نمای سال", + "weekNumberTooltip": "هفتهٔ {number}", + "resizeAllDayPanel": "تغییر اندازهٔ پنل تمام‌روز", + "scheduleItemCount": "{count, plural, =0{هیچ موردی} =1{یک مورد} other{{count} مورد}}", + "readOnlyCalendar": "این تقویم فقط‌خواندنی است.", + "selectTimeZone": "انتخاب منطقهٔ زمانی", + "searchLocations": "جست‌وجوی مکان‌ها", + "noLocationsFound": "مکانی پیدا نشد", + "deleteCalendarConfirmation": "«{title}» حذف شود؟" +} diff --git a/lib/l10n/app_hi.arb b/lib/l10n/app_hi.arb index 0210ee9..9acf645 100644 --- a/lib/l10n/app_hi.arb +++ b/lib/l10n/app_hi.arb @@ -10,7 +10,7 @@ "onboardingAccountsStepTitle": "खाते कनेक्ट करें", "onboardingAccountsStepDescription": "वे सभी Google और Microsoft खाते जोड़ें जिन्हें आप उपयोग करना चाहते हैं। BusyMax प्रत्येक खाते के कैलेंडर, ईवेंट, कार्य सूचियाँ और कार्य सिंक करता है।", "onboardingPreferencesStepTitle": "सिस्टम सेटिंग्स चुनें", - "onboardingPreferencesStepDescription": "अपना शेड्यूल खोलने से पहले डेस्कटॉप व्यवहार, रिमाइंडर, सूचना विवरण और दिखावट सेट करें।", + "onboardingPreferencesStepDescription": "अपना शेड्यूल खोलने से पहले डेस्कटॉप व्यवहार, रिमाइंडर, सूचनाओं के विवरण का स्तर और दिखावट सेट करें।", "signInWithGoogle": "Google से साइन इन करें", "signInWithMicrosoft": "Microsoft से साइन इन करें", "googleTasksProvider": "Google Tasks", @@ -35,7 +35,7 @@ "calendars": "कैलेंडर", "newEvent": "नया ईवेंट", "refreshCalendar": "कैलेंडर रीफ़्रेश करें", - "openInProvider": "प्रदाता में खोलें", + "openInProvider": "सेवा में खोलें", "hideFromSchedule": "शेड्यूल से छिपाएँ", "showInSchedule": "शेड्यूल में दिखाएँ", "noCalendarsSynced": "अभी तक कोई कैलेंडर सिंक नहीं हुआ है।", @@ -69,8 +69,8 @@ "compactAgendaDueToday": "आज देय", "compactAgendaDueTomorrow": "कल देय", "compactAgendaDueOn": "{date} को देय", - "compactAgendaMoreOverdue": "समय सीमा बीत चुके और कार्य लोड करें", - "agendaLoadMoreOverdue": "समय सीमा बीत चुके और कार्य लोड करें", + "compactAgendaMoreOverdue": "समय-सीमा पार कर चुके अतिरिक्त कार्य लोड करें", + "agendaLoadMoreOverdue": "समय-सीमा पार कर चुके अतिरिक्त कार्य लोड करें", "agendaLoadMoreNoDate": "बिना तारीख वाले और कार्य लोड करें", "viewDay": "दिन", "viewWeek": "सप्ताह", @@ -139,7 +139,7 @@ "shortcutNextPeriodDescription": "सप्ताह दृश्य में अगला सप्ताह, महीने के दृश्य में अगला महीना, इत्यादि", "shortcutPreviousPeriod": "पिछली अवधि", "shortcutPreviousPeriodDescription": "सप्ताह दृश्य में पिछला सप्ताह, महीने के दृश्य में पिछला महीना, इत्यादि", - "shortcutJumpToToday": "आज पर जाएँ", + "shortcutJumpToToday": "आज की तारीख पर जाएँ", "shortcutGroupView": "दृश्य", "shortcutDayView": "दिन का दृश्य", "shortcutWeekView": "सप्ताह का दृश्य", @@ -172,7 +172,7 @@ "feedbackDetailedMessage": "विस्तृत संदेश", "feedbackReplyEmail": "जवाब के लिए ईमेल (वैकल्पिक)", "feedbackIncludeTechnicalDetails": "तकनीकी विवरण शामिल करें", - "feedbackTechnicalDetailsDisclosure": "केवल आपके Linux ऑपरेटिंग सिस्टम का संस्करण और ऐप का स्थान-भाषा जोड़ा जाता है। कोई लॉग, खाता डेटा, फ़ाइल नाम या अन्य निदान शामिल नहीं किया जाता।", + "feedbackTechnicalDetailsDisclosure": "केवल आपके Linux ऑपरेटिंग सिस्टम का संस्करण और ऐप की भाषा व क्षेत्रीय सेटिंग जोड़ी जाती है। लॉग, खाता डेटा, फ़ाइल नाम या अन्य निदान जानकारी शामिल नहीं की जाती।", "feedbackCategoryRequired": "श्रेणी चुनें।", "feedbackSubjectLengthError": "विषय 3 से 120 वर्णों के बीच होना चाहिए।", "feedbackMessageLengthError": "संदेश 10 से 5,000 वर्णों के बीच होना चाहिए।", @@ -201,7 +201,7 @@ "revokeGoogleAccessDescription": "दोबारा कनेक्ट करने से पहले आपको फिर से पहुँच देनी होगी।", "removeAccountAction": "खाता हटाएँ", "removeAccountFailed": "खाता हटाना पूरा नहीं हो सका। फिर से कोशिश करें।", - "accountRemovedGoogleRevokeFailed": "खाता इस डिवाइस से हटा दिया गया, लेकिन BusyMax Google की पहुँच रद्द नहीं कर सका। आप इसे अपने Google खाते से रद्द कर सकते हैं।", + "accountRemovedGoogleRevokeFailed": "खाता इस डिवाइस से हटा दिया गया, लेकिन BusyMax की Google खाते तक पहुँच रद्द नहीं की जा सकी। आप यह पहुँच अपने Google खाते से रद्द कर सकते हैं।", "newList": "नई सूची", "signInToViewTaskLists": "कार्य सूचियाँ देखने के लिए साइन इन करें।", "noTaskListsSynced": "अभी तक कोई कार्य सूची सिंक नहीं हुई है।", @@ -276,7 +276,7 @@ "importance": "महत्त्व", "importanceLow": "कम", "importanceNormal": "सामान्य", - "importanceHigh": "अधिक", + "importanceHigh": "उच्च", "categories": "श्रेणियाँ", "scheduleSection": "शेड्यूल", "dueGroup": "देय", @@ -309,7 +309,7 @@ "manualFullSync": "मैन्युअल पूर्ण सिंक", "runInBackgroundWhenClosed": "विंडो बंद होने पर भी चलते रहें", "showTrayIcon": "ट्रे आइकन दिखाएँ", - "startMinimizedToTray": "ट्रे में छोटा होकर शुरू करें", + "startMinimizedToTray": "ट्रे में मिनिमाइज़ होकर शुरू करें", "requiresTrayIcon": "ट्रे आइकन आवश्यक है।", "syncComplete": "सिंक पूरा हुआ।", "syncFailed": "सिंक विफल: {error}", @@ -318,7 +318,7 @@ "notifyDueToday": "आज देय कार्यों की सूचनाएँ", "eventReminders": "ईवेंट रिमाइंडर", "taskReminders": "कार्य रिमाइंडर", - "notificationDetailLevel": "सूचना विवरण का स्तर", + "notificationDetailLevel": "सूचनाओं के विवरण का स्तर", "notificationDetailPrivate": "निजी", "notificationDetailNormal": "सामान्य", "quietHours": "शांत समय", @@ -332,9 +332,9 @@ "themeLight": "हल्की", "themeDark": "गहरी", "themeFamily": "थीम परिवार", - "themeFamilyYaru": "मूल Ubuntu (Yaru)", + "themeFamilyYaru": "Ubuntu की मूल थीम (Yaru)", "localization": "स्थानीयकरण", - "currentLocale": "मौजूदा स्थान-भाषा", + "currentLocale": "मौजूदा भाषा और क्षेत्रीय सेटिंग", "privacy": "गोपनीयता", "redactTaskContentInDiagnostics": "निदान में कार्य सामग्री छिपाएँ", "developerDiagnostics": "डेवलपर निदान", @@ -353,16 +353,16 @@ "pendingOpTaskId": "कार्य={id}", "pendingOpAttempts": "प्रयास={count}", "retry": "फिर से कोशिश करें", - "discard": "छोड़ें", - "discardChanges": "बदलाव छोड़ें?", - "discardChangesConfirmation": "इससे इस कार्य के सहेजे न गए बदलाव छोड़ दिए जाएँगे।", + "discard": "खारिज करें", + "discardChanges": "बदलाव खारिज करें?", + "discardChangesConfirmation": "इससे इस कार्य में किए गए सहेजे न गए बदलाव खारिज हो जाएँगे।", "retryCompleted": "दोबारा प्रयास पूरा हुआ।", - "discardPendingOperation": "लंबित कार्रवाई छोड़ें?", + "discardPendingOperation": "लंबित कार्रवाई खारिज करें?", "discardPendingOperationConfirmation": "इससे अवरुद्ध स्थानीय कार्रवाई हट जाती है। अगला सिंक Google Tasks से डेटा रीफ़्रेश करेगा।", - "pendingOperationDiscarded": "लंबित कार्रवाई छोड़ दी गई।", + "pendingOperationDiscarded": "लंबित कार्रवाई खारिज कर दी गई।", "syncFailureNotificationTitle": "BusyMax सिंक विफल", "syncFailureNotificationBody": "बैकग्राउंड सिंक विफल हुआ। {message}", - "conflictNotificationTitle": "BusyMax सिंक टकराव", + "conflictNotificationTitle": "BusyMax सिंक में टकराव", "conflictNotificationBody": "एक लंबित स्थानीय बदलाव अवरुद्ध हो गया। {summary}", "dueTodayNotificationTitle": "आज देय कार्य", "dueTodayNotificationBody": "{count, plural, =1{आज एक कार्य देय है।} other{आज {count} कार्य देय हैं।}}", diff --git a/lib/l10n/generated/app_localizations.dart b/lib/l10n/generated/app_localizations.dart index f533ed4..71e9643 100644 --- a/lib/l10n/generated/app_localizations.dart +++ b/lib/l10n/generated/app_localizations.dart @@ -5,9 +5,11 @@ import 'package:flutter/widgets.dart'; import 'package:flutter_localizations/flutter_localizations.dart'; import 'package:intl/intl.dart' as intl; +import 'app_localizations_ar.dart'; import 'app_localizations_de.dart'; import 'app_localizations_en.dart'; import 'app_localizations_es.dart'; +import 'app_localizations_fa.dart'; import 'app_localizations_fi.dart'; import 'app_localizations_fr.dart'; import 'app_localizations_hi.dart'; @@ -103,9 +105,11 @@ abstract class AppLocalizations { /// A list of this localizations delegate's supported locales. static const List supportedLocales = [ + Locale('ar'), Locale('de'), Locale('en'), Locale('es'), + Locale('fa'), Locale('fi'), Locale('fr'), Locale('hi'), @@ -2446,9 +2450,11 @@ class _AppLocalizationsDelegate @override bool isSupported(Locale locale) => [ + 'ar', 'de', 'en', 'es', + 'fa', 'fi', 'fr', 'hi', @@ -2480,12 +2486,16 @@ AppLocalizations lookupAppLocalizations(Locale locale) { // Lookup logic when only language code is specified. switch (locale.languageCode) { + case 'ar': + return AppLocalizationsAr(); case 'de': return AppLocalizationsDe(); case 'en': return AppLocalizationsEn(); case 'es': return AppLocalizationsEs(); + case 'fa': + return AppLocalizationsFa(); case 'fi': return AppLocalizationsFi(); case 'fr': diff --git a/lib/l10n/generated/app_localizations_ar.dart b/lib/l10n/generated/app_localizations_ar.dart new file mode 100644 index 0000000..04e00a1 --- /dev/null +++ b/lib/l10n/generated/app_localizations_ar.dart @@ -0,0 +1,1308 @@ +// ignore: unused_import +import 'package:intl/intl.dart' as intl; +import 'app_localizations.dart'; + +// ignore_for_file: type=lint + +/// The translations for Arabic (`ar`). +class AppLocalizationsAr extends AppLocalizations { + AppLocalizationsAr([String locale = 'ar']) : super(locale); + + @override + String get appTitle => 'BusyMax'; + + @override + String get connectGoogleAccount => + 'اربط حسابات Google وMicrosoft لمزامنة التقويمات والمهام.'; + + @override + String get googlePermissionsConsentNotice => + 'في شاشة أذونات Google، حدّد أذونات التقويم والمهام معًا.'; + + @override + String get googlePermissionsRequiredRetry => + 'أذونات تقويم Google وGoogle Tasks مطلوبة. حاول مرة أخرى وحدّد مربعي الاختيار.'; + + @override + String get finishSetup => 'إنهاء الإعداد'; + + @override + String get continueSetup => 'متابعة'; + + @override + String get onboardingSetupTitle => 'إعداد BusyMax'; + + @override + String get onboardingAccountsStepTitle => 'ربط الحسابات'; + + @override + String get onboardingAccountsStepDescription => + 'أضف جميع حسابات Google وMicrosoft التي تريد استخدامها. يزامن BusyMax التقويمات والأحداث وقوائم المهام والمهام من كل حساب.'; + + @override + String get onboardingPreferencesStepTitle => 'اختيار إعدادات النظام'; + + @override + String get onboardingPreferencesStepDescription => + 'اضبط سلوك التطبيق على سطح المكتب والتذكيرات ومستوى تفاصيل الإشعارات والمظهر قبل فتح جدولك.'; + + @override + String get signInWithGoogle => 'تسجيل الدخول باستخدام Google'; + + @override + String get signInWithMicrosoft => 'تسجيل الدخول باستخدام Microsoft'; + + @override + String get googleTasksProvider => 'Google Tasks'; + + @override + String get microsoftTodoProvider => 'Microsoft To Do'; + + @override + String get providerNotConfigured => 'هذه الخدمة غير مهيأة.'; + + @override + String get waitingForGoogleSignIn => 'في انتظار تسجيل الدخول إلى Google...'; + + @override + String get waitingForMicrosoftSignIn => + 'في انتظار تسجيل الدخول إلى Microsoft...'; + + @override + String get microsoftSignInNotConfigured => + 'تسجيل الدخول إلى Microsoft غير مهيأ. اضبط MICROSOFT_OAUTH_CLIENT_ID.'; + + @override + String get cancel => 'إلغاء'; + + @override + String get close => 'إغلاق'; + + @override + String get exit => 'خروج'; + + @override + String get options => 'خيارات'; + + @override + String get hide => 'إخفاء'; + + @override + String get show => 'إظهار'; + + @override + String get export => 'تصدير'; + + @override + String get save => 'حفظ'; + + @override + String get settings => 'الإعدادات'; + + @override + String get all => 'الكل'; + + @override + String get calendarEvents => 'الأحداث'; + + @override + String get calendarTasks => 'المهام'; + + @override + String get calendar => 'التقويم'; + + @override + String get calendars => 'التقويمات'; + + @override + String get newEvent => 'حدث جديد'; + + @override + String get refreshCalendar => 'تحديث التقويم'; + + @override + String get openInProvider => 'فتح في الخدمة'; + + @override + String get hideFromSchedule => 'إخفاء من الجدول'; + + @override + String get showInSchedule => 'إظهار في الجدول'; + + @override + String get noCalendarsSynced => 'لم تتم مزامنة أي تقويمات بعد.'; + + @override + String get allDay => 'طوال اليوم'; + + @override + String moreItems(int count) { + return '+$count عناصر أخرى'; + } + + @override + String get noEventsOrTasks => 'لا توجد أحداث أو مهام'; + + @override + String get scheduleLoading => 'جارٍ تحميل الجدول...'; + + @override + String get scheduleUnavailable => 'الجدول غير متاح'; + + @override + String get scheduleNoSources => 'لا توجد تقويمات أو قوائم مهام ظاهرة'; + + @override + String get scheduleNoSourcesDescription => + 'اختر ما تريد إظهاره في الإعدادات، ثم حدّث الجدول.'; + + @override + String get scheduleSignInRequired => 'ربط حساب'; + + @override + String get scheduleSignInDescription => + 'سجّل الدخول لمزامنة التقويمات والمهام.'; + + @override + String get scheduleNoSearchResults => 'لا توجد أحداث أو مهام مطابقة'; + + @override + String get scheduleNoSearchResultsDescription => + 'جرّب بحثًا مختلفًا أو امسح عوامل التصفية الحالية.'; + + @override + String get trayAgendaLoading => 'جارٍ تحميل جدول الأعمال...'; + + @override + String get trayAgendaSignInRequired => 'سجّل الدخول لإظهار جدول الأعمال.'; + + @override + String get trayAgendaNoSources => 'لا توجد تقويمات أو قوائم مهام ظاهرة.'; + + @override + String get trayAgendaOpenBusyMax => 'فتح التطبيق'; + + @override + String get trayAgendaRefresh => 'تحديث'; + + @override + String get trayAgendaError => 'جدول الأعمال غير متاح'; + + @override + String get compactAgendaTitle => 'جدول الأعمال'; + + @override + String get compactAgendaSubtitle => 'القادم'; + + @override + String get compactAgendaOverdue => 'متأخرة'; + + @override + String get compactAgendaClear => 'لا شيء حاليًا'; + + @override + String get compactAgendaOpenBusyMax => 'فتح BusyMax'; + + @override + String get compactAgendaHide => 'إخفاء'; + + @override + String get compactAgendaNewTask => 'مهمة جديدة'; + + @override + String get compactAgendaRetry => 'إعادة المحاولة'; + + @override + String get compactAgendaRefresh => 'تحديث'; + + @override + String get compactAgendaAllDay => 'طوال اليوم'; + + @override + String get compactAgendaDueToday => 'مستحقة اليوم'; + + @override + String get compactAgendaDueTomorrow => 'مستحقة غدًا'; + + @override + String compactAgendaDueOn(String date) { + return 'مستحقة في $date'; + } + + @override + String get compactAgendaMoreOverdue => 'تحميل المزيد من المهام المتأخرة'; + + @override + String get agendaLoadMoreOverdue => 'تحميل المزيد من المهام المتأخرة'; + + @override + String get agendaLoadMoreNoDate => 'تحميل المزيد من المهام بلا تاريخ'; + + @override + String get viewDay => 'يوم'; + + @override + String get viewWeek => 'أسبوع'; + + @override + String get viewMonth => 'شهر'; + + @override + String get viewYear => 'سنة'; + + @override + String get viewAgenda => 'جدول الأعمال'; + + @override + String get scheduleSettings => 'الجدول'; + + @override + String get scheduleDisplaySettings => 'عرض الجدول'; + + @override + String get scheduleDisplayHoursDescription => + 'تفتح طريقتا عرض اليوم والأسبوع ضمن هذه الساعات. توسّع العناصر المبكرة والمتأخرة النطاق عند الحاجة.'; + + @override + String get scheduleDayStartsAt => 'يبدأ اليوم في'; + + @override + String get scheduleDayEndsAt => 'ينتهي اليوم في'; + + @override + String get sourceCalendar => 'التقويم'; + + @override + String get sourceTaskList => 'قائمة المهام'; + + @override + String get createChoiceTitle => 'إنشاء'; + + @override + String get createEventAtTime => 'حدث'; + + @override + String get createTaskAtDate => 'مهمة'; + + @override + String get editEvent => 'تعديل الحدث'; + + @override + String get eventTitle => 'عنوان الحدث'; + + @override + String get location => 'الموقع'; + + @override + String get timeSlot => 'الفترة الزمنية'; + + @override + String get startDateTime => 'تاريخ/وقت البدء'; + + @override + String get endDateTime => 'تاريخ/وقت الانتهاء'; + + @override + String get doesNotRepeat => 'لا يتكرر'; + + @override + String get defaultReminder => 'التذكير الافتراضي'; + + @override + String get guests => 'المدعوون'; + + @override + String get noGuests => 'لا يوجد مدعوون'; + + @override + String get description => 'الوصف'; + + @override + String get availabilityShowAs => 'التوفر / إظهار كـ'; + + @override + String get busy => 'مشغول'; + + @override + String get visibility => 'إمكانية العرض'; + + @override + String get defaultVisibility => 'إمكانية العرض الافتراضية'; + + @override + String get conference => 'اجتماع'; + + @override + String get noConference => 'لا يوجد اجتماع'; + + @override + String get providerCalendar => 'تقويم الخدمة'; + + @override + String get formatBoldShortLabel => 'B'; + + @override + String get formatBoldTooltip => 'عريض'; + + @override + String get formatItalicShortLabel => 'I'; + + @override + String get formatItalicTooltip => 'مائل'; + + @override + String get formatUnderlineShortLabel => 'U'; + + @override + String get formatUnderlineTooltip => 'تحته خط'; + + @override + String reminderMinutesBefore(int minutes) { + String _temp0 = intl.Intl.pluralLogic( + minutes, + locale: localeName, + other: 'قبل $minutes دقيقة', + many: 'قبل $minutes دقيقة', + few: 'قبل $minutes دقائق', + two: 'قبل دقيقتين', + one: 'قبل دقيقة واحدة', + zero: 'عند البدء', + ); + return '$_temp0'; + } + + @override + String get reminderAtStart => 'عند البدء'; + + @override + String reminderHoursBefore(int hours) { + String _temp0 = intl.Intl.pluralLogic( + hours, + locale: localeName, + other: 'قبل $hours ساعة', + many: 'قبل $hours ساعة', + few: 'قبل $hours ساعات', + two: 'قبل ساعتين', + one: 'قبل ساعة واحدة', + zero: 'عند البدء', + ); + return '$_temp0'; + } + + @override + String reminderDaysBefore(int days) { + String _temp0 = intl.Intl.pluralLogic( + days, + locale: localeName, + other: 'قبل $days يوم', + many: 'قبل $days يومًا', + few: 'قبل $days أيام', + two: 'قبل يومين', + one: 'قبل يوم واحد', + zero: 'في اليوم نفسه', + ); + return '$_temp0'; + } + + @override + String get availabilityFree => 'متاح'; + + @override + String get availabilityTentative => 'مبدئي'; + + @override + String get availabilityOutOfOffice => 'خارج المكتب'; + + @override + String get availabilityWorkingElsewhere => 'العمل من مكان آخر'; + + @override + String get visibilityDefault => 'افتراضي'; + + @override + String get visibilityPublic => 'عام'; + + @override + String get visibilityPrivate => 'خاص'; + + @override + String get visibilityConfidential => 'سري'; + + @override + String get sensitivityNormal => 'عادي'; + + @override + String get sensitivityPersonal => 'شخصي'; + + @override + String get tasks => 'المهام'; + + @override + String get allTasks => 'كل المهام'; + + @override + String tasksInList(String title) { + return 'المهام في $title'; + } + + @override + String get taskLists => 'قوائم المهام'; + + @override + String get navigation => 'التنقل'; + + @override + String get mainMenu => 'القائمة الرئيسية'; + + @override + String get keyboardShortcuts => 'اختصارات لوحة المفاتيح'; + + @override + String get shortcutGroupGeneral => 'عام'; + + @override + String get shortcutKeyboardShortcutsDescription => + 'إظهار مرجع الاختصارات هذا'; + + @override + String get shortcutGroupNavigation => 'التنقل'; + + @override + String get shortcutNextPeriod => 'الفترة التالية'; + + @override + String get shortcutNextPeriodDescription => + 'الأسبوع التالي في عرض الأسبوع، والشهر التالي في عرض الشهر، وهكذا'; + + @override + String get shortcutPreviousPeriod => 'الفترة السابقة'; + + @override + String get shortcutPreviousPeriodDescription => + 'الأسبوع السابق في عرض الأسبوع، والشهر السابق في عرض الشهر، وهكذا'; + + @override + String get shortcutJumpToToday => 'الانتقال إلى اليوم'; + + @override + String get shortcutGroupView => 'العرض'; + + @override + String get shortcutDayView => 'عرض اليوم'; + + @override + String get shortcutWeekView => 'عرض الأسبوع'; + + @override + String get shortcutMonthView => 'عرض الشهر'; + + @override + String get shortcutYearView => 'عرض السنة'; + + @override + String get shortcutAgendaView => 'عرض جدول الأعمال'; + + @override + String get shortcutGroupCreateAndEdit => 'الإنشاء والتعديل'; + + @override + String get shortcutSaveItem => 'حفظ الحدث أو المهمة'; + + @override + String get shortcutDeleteItem => 'حذف الحدث أو المهمة'; + + @override + String get shortcutGroupTaskEditing => 'تعديل المهام'; + + @override + String get shortcutCancelEditing => 'إلغاء التعديل'; + + @override + String get shortcutCancelEditingDescription => + 'إغلاق تعديل المهمة أو تفاصيلها'; + + @override + String get shortcutGroupCompactAgenda => 'جدول الأعمال المصغّر'; + + @override + String get shortcutRefreshCompactAgendaDescription => + 'تحديث نافذة جدول الأعمال المصغّر'; + + @override + String get shortcutHideCompactAgendaDescription => + 'إخفاء نافذة جدول الأعمال المصغّر'; + + @override + String get aboutBusyMax => 'حول BusyMax'; + + @override + String get aboutBusyMaxDescription => 'المهام والتقويم'; + + @override + String get website => 'الموقع الإلكتروني'; + + @override + String get reportAnIssue => 'الإبلاغ عن مشكلة'; + + @override + String get sendFeedback => 'إرسال الملاحظات'; + + @override + String get feedbackSubmit => 'إرسال'; + + @override + String get feedbackCategory => 'الفئة'; + + @override + String get feedbackSelectCategory => 'اختر فئة'; + + @override + String get feedbackCategoryProblem => 'مشكلة أو خلل'; + + @override + String get feedbackCategoryFeature => 'طلب ميزة'; + + @override + String get feedbackCategoryPrivacySecurity => + 'مشكلة تتعلق بالخصوصية أو الأمان'; + + @override + String get feedbackCategoryUsability => 'مشكلة في سهولة الاستخدام'; + + @override + String get feedbackCategoryOther => 'أخرى'; + + @override + String get feedbackSubject => 'الموضوع'; + + @override + String get feedbackDetailedMessage => 'رسالة مفصّلة'; + + @override + String get feedbackReplyEmail => 'البريد الإلكتروني للرد (اختياري)'; + + @override + String get feedbackIncludeTechnicalDetails => 'تضمين التفاصيل التقنية'; + + @override + String get feedbackTechnicalDetailsDisclosure => + 'يضيف فقط إصدار نظام التشغيل Linux ولغة التطبيق ومنطقته. لا يتم تضمين أي سجلات أو بيانات حسابات أو أسماء ملفات أو معلومات تشخيصية أخرى.'; + + @override + String get feedbackCategoryRequired => 'اختر فئة.'; + + @override + String get feedbackSubjectLengthError => + 'يجب أن يتراوح الموضوع بين 3 و120 حرفًا.'; + + @override + String get feedbackMessageLengthError => + 'يجب أن تتراوح الرسالة بين 10 و5,000 حرف.'; + + @override + String get feedbackInvalidEmail => 'أدخل عنوان بريد إلكتروني صالحًا.'; + + @override + String get feedbackConnectionError => + 'تعذر الاتصال بـ BusyStack. تحقق من اتصالك وحاول مرة أخرى.'; + + @override + String get feedbackTimeoutError => + 'انتهت مهلة الطلب. لم تُمسح ملاحظاتك؛ حاول مرة أخرى.'; + + @override + String get feedbackRateLimitedError => + 'أُرسلت ملاحظات كثيرة جدًا من هذه الشبكة. انتظر وحاول مرة أخرى.'; + + @override + String get feedbackRejectedError => + 'رفض الخادم الإرسال. راجع الحقول وحاول مرة أخرى.'; + + @override + String get feedbackServerError => + 'يتعذر على BusyStack قبول ملاحظاتك الآن. لم تُمسح ملاحظاتك؛ حاول مرة أخرى.'; + + @override + String feedbackSuccess(String id) { + return 'تم إرسال الملاحظات. المرجع: $id'; + } + + @override + String get toggleSidebar => 'إظهار الشريط الجانبي أو إخفاؤه'; + + @override + String get accounts => 'الحسابات'; + + @override + String get currentAccount => 'الحساب الحالي'; + + @override + String get switchAccount => 'تبديل الحساب'; + + @override + String get addGoogleAccount => 'إضافة حساب Google'; + + @override + String get addMicrosoftAccount => 'إضافة حساب Microsoft'; + + @override + String get googleProvider => 'Google'; + + @override + String get microsoftProvider => 'Microsoft'; + + @override + String get signedInAccount => 'تم تسجيل الدخول'; + + @override + String get removeAccount => 'إزالة الحساب…'; + + @override + String get removingAccount => 'جارٍ إزالة الحساب…'; + + @override + String get removeAccountDescription => + 'إيقاف المزامنة وإزالة بيانات هذا الحساب من هذا الجهاز.'; + + @override + String removeAccountTitle(String account) { + return 'إزالة $account من BusyMax؟'; + } + + @override + String get removeAccountConfirmation => + 'سيؤدي ذلك إلى حذف المهام والتقويمات والأحداث والتذكيرات والتغييرات غير المتصلة المعلّقة المخزّنة مؤقتًا من هذا الجهاز. ستُفقد التغييرات غير المتزامنة. لن يُحذف أي شيء من Google أو Microsoft.'; + + @override + String get revokeGoogleAccess => + 'إلغاء وصول BusyMax إلى حساب Google هذا أيضًا'; + + @override + String get revokeGoogleAccessDescription => + 'ستحتاج إلى منح الوصول مرة أخرى قبل إعادة الاتصال.'; + + @override + String get removeAccountAction => 'إزالة الحساب'; + + @override + String get removeAccountFailed => 'تعذر إكمال إزالة الحساب. حاول مرة أخرى.'; + + @override + String get accountRemovedGoogleRevokeFailed => + 'تمت إزالة الحساب من هذا الجهاز، لكن تعذر على BusyMax إلغاء الوصول إلى Google. يمكنك إلغاء الوصول من حسابك على Google.'; + + @override + String get newList => 'قائمة جديدة'; + + @override + String get signInToViewTaskLists => 'سجّل الدخول لعرض قوائم المهام.'; + + @override + String get noTaskListsSynced => 'لم تتم مزامنة أي قوائم مهام بعد.'; + + @override + String get listActions => 'إجراءات القائمة'; + + @override + String get rename => 'إعادة تسمية'; + + @override + String get delete => 'حذف'; + + @override + String get renameList => 'إعادة تسمية القائمة'; + + @override + String get deleteList => 'حذف القائمة'; + + @override + String get builtInMicrosoftList => 'مدمجة'; + + @override + String get builtInMicrosoftListCannotRenameDelete => + 'لا يمكن إعادة تسمية قوائم Microsoft To Do المدمجة أو حذفها.'; + + @override + String deleteListConfirmation(String title) { + return 'حذف «$title» من Google Tasks؟'; + } + + @override + String get deleteEvent => 'حذف الحدث'; + + @override + String get title => 'العنوان'; + + @override + String get create => 'إنشاء'; + + @override + String get newTask => 'مهمة جديدة'; + + @override + String get clearCompleted => 'مسح المهام المكتملة'; + + @override + String get refreshList => 'تحديث القائمة'; + + @override + String get refreshAll => 'تحديث الكل'; + + @override + String get listRefreshed => 'تم تحديث القائمة.'; + + @override + String get allTasksRefreshed => 'تم تحديث جميع الحسابات.'; + + @override + String exportedFile(String path) { + return 'تم التصدير إلى $path'; + } + + @override + String exportFailed(String error) { + return 'فشل التصدير: $error'; + } + + @override + String refreshFailed(String error) { + return 'فشل التحديث: $error'; + } + + @override + String get selectOrCreateTaskList => 'اختر قائمة مهام أو أنشئ واحدة للبدء.'; + + @override + String get signInToViewTasks => 'سجّل الدخول لعرض المهام.'; + + @override + String get noTasks => 'لا توجد مهام.'; + + @override + String get noTasksYet => 'لا توجد مهام بعد'; + + @override + String get noTasksYetMessage => 'أنشئ مهمة أو حدّث حساباتك للبدء.'; + + @override + String get noTasksInList => 'لا توجد مهام في هذه القائمة.'; + + @override + String get overdue => 'متأخرة'; + + @override + String get today => 'اليوم'; + + @override + String get tomorrow => 'غدًا'; + + @override + String get upcoming => 'القادمة'; + + @override + String get noDate => 'بلا تاريخ'; + + @override + String get completed => 'مكتملة'; + + @override + String duePrefix(String date) { + return 'مستحقة في $date'; + } + + @override + String dateTimeDisplay(String date, String time) { + return '$date · $time'; + } + + @override + String get taskDetails => 'تفاصيل المهمة'; + + @override + String get editTask => 'تعديل المهمة'; + + @override + String get noTaskSelected => 'لم يتم تحديد مهمة.'; + + @override + String get noTaskSelectedHelper => 'حدّد مهمة لعرض تفاصيلها وتعديلها.'; + + @override + String get taskUnavailable => 'المهمة غير متاحة.'; + + @override + String get signInToEditTasks => 'سجّل الدخول لتعديل المهام.'; + + @override + String get refreshTask => 'تحديث المهمة'; + + @override + String get primarySection => 'أساسي'; + + @override + String get statusSection => 'الحالة'; + + @override + String get openStatus => 'مفتوحة'; + + @override + String get doneStatus => 'منجزة'; + + @override + String get notes => 'ملاحظات'; + + @override + String get dueDate => 'تاريخ الاستحقاق'; + + @override + String get clearDueDate => 'مسح تاريخ الاستحقاق'; + + @override + String get dueTime => 'وقت الاستحقاق'; + + @override + String get startDate => 'تاريخ البدء'; + + @override + String get startTime => 'وقت البدء'; + + @override + String get endDate => 'تاريخ الانتهاء'; + + @override + String get endTime => 'وقت الانتهاء'; + + @override + String get reminderDate => 'تاريخ التذكير'; + + @override + String get reminderTime => 'وقت التذكير'; + + @override + String get reminder => 'تذكير'; + + @override + String get addReminder => 'إضافة تذكير'; + + @override + String get addGuest => 'إضافة مدعو'; + + @override + String get addGuestEmail => 'إضافة بريد المدعو الإلكتروني'; + + @override + String get removeReminder => 'إزالة التذكير'; + + @override + String get off => 'إيقاف'; + + @override + String get repeat => 'التكرار'; + + @override + String get repeatNone => 'بلا تكرار'; + + @override + String get noneValue => 'لا شيء'; + + @override + String get repeatDaily => 'يوميًا'; + + @override + String get repeatWeekly => 'أسبوعيًا'; + + @override + String get repeatMonthly => 'شهريًا'; + + @override + String get repeatYearly => 'سنويًا'; + + @override + String get importance => 'الأهمية'; + + @override + String get importanceLow => 'منخفضة'; + + @override + String get importanceNormal => 'عادية'; + + @override + String get importanceHigh => 'مرتفعة'; + + @override + String get categories => 'الفئات'; + + @override + String get scheduleSection => 'الجدول'; + + @override + String get dueGroup => 'الاستحقاق'; + + @override + String get startGroup => 'البدء'; + + @override + String get reminderGroup => 'التذكير'; + + @override + String get organizationSection => 'التنظيم'; + + @override + String get actionsSection => 'الإجراءات'; + + @override + String get advancedSection => 'متقدم'; + + @override + String get addCategory => 'إضافة فئة'; + + @override + String get list => 'القائمة'; + + @override + String get microsoftMoveUnsupported => + 'نقل المهام بين القوائم غير مدعوم لحسابات Microsoft To Do في هذا الإصدار.'; + + @override + String get createSubtask => 'إنشاء مهمة فرعية'; + + @override + String get moveToTop => 'نقل إلى الأعلى'; + + @override + String get deleteTask => 'حذف المهمة'; + + @override + String get newSubtask => 'مهمة فرعية جديدة'; + + @override + String deleteTaskConfirmation(String title) { + return 'حذف «$title» من Google Tasks؟'; + } + + @override + String get metadata => 'البيانات الوصفية'; + + @override + String get id => 'المعرّف'; + + @override + String get etag => 'ETag'; + + @override + String get updated => 'آخر تحديث'; + + @override + String get parent => 'المهمة الأصلية'; + + @override + String get position => 'الموضع'; + + @override + String get webLink => 'رابط الويب'; + + @override + String get assignment => 'التعيين'; + + @override + String get localState => 'الحالة المحلية'; + + @override + String get pendingSync => 'في انتظار المزامنة'; + + @override + String get synced => 'تمت المزامنة'; + + @override + String get account => 'الحساب'; + + @override + String get sync => 'المزامنة'; + + @override + String get manualFullSync => 'مزامنة كاملة يدويًا'; + + @override + String get runInBackgroundWhenClosed => 'متابعة التشغيل عند إغلاق النافذة'; + + @override + String get showTrayIcon => 'إظهار أيقونة شريط النظام'; + + @override + String get startMinimizedToTray => 'البدء مصغّرًا في شريط النظام'; + + @override + String get requiresTrayIcon => 'يتطلب أيقونة شريط النظام.'; + + @override + String get syncComplete => 'اكتملت المزامنة.'; + + @override + String syncFailed(String error) { + return 'فشلت المزامنة: $error'; + } + + @override + String get notifySyncFailures => 'إشعارات عند فشل المزامنة'; + + @override + String get notifyConflicts => 'إشعارات عند حدوث تعارضات'; + + @override + String get notifyDueToday => 'إشعارات المهام المستحقة اليوم'; + + @override + String get eventReminders => 'تذكيرات الأحداث'; + + @override + String get taskReminders => 'تذكيرات المهام'; + + @override + String get notificationDetailLevel => 'مستوى تفاصيل الإشعارات'; + + @override + String get notificationDetailPrivate => 'خاص'; + + @override + String get notificationDetailNormal => 'عادي'; + + @override + String get quietHours => 'ساعات الهدوء'; + + @override + String get quietHoursDescription => 'إيقاف الإشعارات مؤقتًا خلال هذه الفترة.'; + + @override + String get quietHoursStart => 'بداية ساعات الهدوء'; + + @override + String get quietHoursEnd => 'نهاية ساعات الهدوء'; + + @override + String get notifications => 'الإشعارات'; + + @override + String get appearance => 'المظهر'; + + @override + String get theme => 'السمة'; + + @override + String get themeSystem => 'النظام'; + + @override + String get themeLight => 'فاتحة'; + + @override + String get themeDark => 'داكنة'; + + @override + String get themeFamily => 'عائلة السمات'; + + @override + String get themeFamilyYaru => 'سمة Ubuntu الأصلية (Yaru)'; + + @override + String get localization => 'اللغة والمنطقة'; + + @override + String get currentLocale => 'اللغة والمنطقة الحالية'; + + @override + String get privacy => 'الخصوصية'; + + @override + String get redactTaskContentInDiagnostics => + 'إخفاء محتوى المهام في معلومات التشخيص'; + + @override + String get developerDiagnostics => 'تشخيصات المطور'; + + @override + String get diagnostics => 'التشخيصات'; + + @override + String get apiInspectorDisabled => 'إظهار فاحص API'; + + @override + String get googleTasksApi => 'واجهة Google Tasks API'; + + @override + String discoveryRevision(String revision) { + return 'مراجعة Discovery: ‏$revision'; + } + + @override + String get implementedMethods => 'الطرق المنفذة'; + + @override + String get supportsTasksScopes => 'يدعم نطاقَي tasks وtasks.readonly'; + + @override + String get requiresTasksScope => 'يتطلب نطاق tasks'; + + @override + String get blockedPendingOperations => 'العمليات المعلّقة المحظورة'; + + @override + String get signInToInspectPendingOperations => + 'سجّل الدخول لفحص العمليات المعلّقة.'; + + @override + String get noBlockedPendingOperations => 'لا توجد عمليات معلّقة محظورة.'; + + @override + String get operationActions => 'إجراءات العملية'; + + @override + String pendingOpListId(String id) { + return 'القائمة=$id'; + } + + @override + String pendingOpTaskId(String id) { + return 'المهمة=$id'; + } + + @override + String pendingOpAttempts(int count) { + return 'المحاولات=$count'; + } + + @override + String get retry => 'إعادة المحاولة'; + + @override + String get discard => 'تجاهل'; + + @override + String get discardChanges => 'تجاهل التغييرات؟'; + + @override + String get discardChangesConfirmation => + 'سيؤدي ذلك إلى تجاهل التعديلات غير المحفوظة على هذه المهمة.'; + + @override + String get retryCompleted => 'اكتملت إعادة المحاولة.'; + + @override + String get discardPendingOperation => 'تجاهل العملية المعلّقة؟'; + + @override + String get discardPendingOperationConfirmation => + 'سيؤدي ذلك إلى إزالة العملية المحلية المحظورة. ستُحدّث البيانات من Google Tasks في المزامنة التالية.'; + + @override + String get pendingOperationDiscarded => 'تم تجاهل العملية المعلّقة.'; + + @override + String get syncFailureNotificationTitle => 'فشلت مزامنة BusyMax'; + + @override + String syncFailureNotificationBody(String message) { + return 'فشلت المزامنة في الخلفية. $message'; + } + + @override + String get conflictNotificationTitle => 'تعارض في مزامنة BusyMax'; + + @override + String conflictNotificationBody(String summary) { + return 'تم حظر تغيير محلي معلّق. $summary'; + } + + @override + String get dueTodayNotificationTitle => 'المهام المستحقة اليوم'; + + @override + String dueTodayNotificationBody(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'هناك $count مهمة مستحقة اليوم.', + many: 'هناك $count مهمة مستحقة اليوم.', + few: 'هناك $count مهام مستحقة اليوم.', + two: 'هناك مهمتان مستحقتان اليوم.', + one: 'هناك مهمة واحدة مستحقة اليوم.', + zero: 'لا توجد مهام مستحقة اليوم.', + ); + return '$_temp0'; + } + + @override + String get eventReminderNotificationTitle => 'تذكير بحدث'; + + @override + String get taskReminderNotificationTitle => 'تذكير بمهمة'; + + @override + String get eventReminderNotificationBody => 'سيبدأ الحدث قريبًا.'; + + @override + String get taskReminderNotificationBody => 'ستحلّ مهلة المهمة قريبًا.'; + + @override + String get notificationOpenAction => 'فتح'; + + @override + String get notificationDetailsHidden => + 'التفاصيل مخفية وفقًا لإعدادات الخصوصية.'; + + @override + String get previousMonth => 'الشهر السابق'; + + @override + String get nextMonth => 'الشهر التالي'; + + @override + String get openMonthView => 'فتح عرض الشهر'; + + @override + String get previousYear => 'السنة السابقة'; + + @override + String get nextYear => 'السنة التالية'; + + @override + String get openYearView => 'فتح عرض السنة'; + + @override + String weekNumberTooltip(int number) { + return 'الأسبوع $number'; + } + + @override + String get resizeAllDayPanel => 'تغيير حجم لوحة اليوم الكامل'; + + @override + String scheduleItemCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count عنصر', + many: '$count عنصرًا', + few: '$count عناصر', + two: 'عنصران', + one: 'عنصر واحد', + zero: 'لا عناصر', + ); + return '$_temp0'; + } + + @override + String get readOnlyCalendar => 'هذا التقويم للقراءة فقط.'; + + @override + String get selectTimeZone => 'اختيار المنطقة الزمنية'; + + @override + String get searchLocations => 'البحث عن مواقع'; + + @override + String get noLocationsFound => 'لم يتم العثور على مواقع'; + + @override + String deleteCalendarConfirmation(String title) { + return 'حذف «$title»؟'; + } +} diff --git a/lib/l10n/generated/app_localizations_fa.dart b/lib/l10n/generated/app_localizations_fa.dart new file mode 100644 index 0000000..f52e880 --- /dev/null +++ b/lib/l10n/generated/app_localizations_fa.dart @@ -0,0 +1,1299 @@ +// ignore: unused_import +import 'package:intl/intl.dart' as intl; +import 'app_localizations.dart'; + +// ignore_for_file: type=lint + +/// The translations for Persian (`fa`). +class AppLocalizationsFa extends AppLocalizations { + AppLocalizationsFa([String locale = 'fa']) : super(locale); + + @override + String get appTitle => 'BusyMax'; + + @override + String get connectGoogleAccount => + 'حساب‌های Google و Microsoft را متصل کنید تا تقویم‌ها و کارها همگام شوند.'; + + @override + String get googlePermissionsConsentNotice => + 'در صفحهٔ مجوزهای Google، مجوزهای تقویم و کارها را هر دو انتخاب کنید.'; + + @override + String get googlePermissionsRequiredRetry => + 'مجوزهای Google Calendar و Google Tasks لازم هستند. دوباره تلاش کنید و هر دو کادر را علامت بزنید.'; + + @override + String get finishSetup => 'پایان راه‌اندازی'; + + @override + String get continueSetup => 'ادامه'; + + @override + String get onboardingSetupTitle => 'راه‌اندازی BusyMax'; + + @override + String get onboardingAccountsStepTitle => 'اتصال حساب‌ها'; + + @override + String get onboardingAccountsStepDescription => + 'همهٔ حساب‌های Google و Microsoft موردنظرتان را اضافه کنید. BusyMax تقویم‌ها، رویدادها، فهرست‌های کار و کارهای هر حساب را همگام می‌کند.'; + + @override + String get onboardingPreferencesStepTitle => 'انتخاب تنظیمات سیستم'; + + @override + String get onboardingPreferencesStepDescription => + 'پیش از باز کردن برنامه، رفتار برنامه روی میزکار، یادآورها، سطح جزئیات اعلان‌ها و ظاهر را تنظیم کنید.'; + + @override + String get signInWithGoogle => 'ورود با Google'; + + @override + String get signInWithMicrosoft => 'ورود با Microsoft'; + + @override + String get googleTasksProvider => 'Google Tasks'; + + @override + String get microsoftTodoProvider => 'Microsoft To Do'; + + @override + String get providerNotConfigured => 'این سرویس پیکربندی نشده است.'; + + @override + String get waitingForGoogleSignIn => 'در انتظار ورود به Google...'; + + @override + String get waitingForMicrosoftSignIn => 'در انتظار ورود به Microsoft...'; + + @override + String get microsoftSignInNotConfigured => + 'ورود به Microsoft پیکربندی نشده است. MICROSOFT_OAUTH_CLIENT_ID را تنظیم کنید.'; + + @override + String get cancel => 'لغو'; + + @override + String get close => 'بستن'; + + @override + String get exit => 'خروج'; + + @override + String get options => 'گزینه‌ها'; + + @override + String get hide => 'پنهان کردن'; + + @override + String get show => 'نمایش'; + + @override + String get export => 'خروجی گرفتن'; + + @override + String get save => 'ذخیره'; + + @override + String get settings => 'تنظیمات'; + + @override + String get all => 'همه'; + + @override + String get calendarEvents => 'رویدادها'; + + @override + String get calendarTasks => 'کارها'; + + @override + String get calendar => 'تقویم'; + + @override + String get calendars => 'تقویم‌ها'; + + @override + String get newEvent => 'رویداد جدید'; + + @override + String get refreshCalendar => 'تازه‌سازی تقویم'; + + @override + String get openInProvider => 'باز کردن در سرویس'; + + @override + String get hideFromSchedule => 'پنهان کردن از برنامه'; + + @override + String get showInSchedule => 'نمایش در برنامه'; + + @override + String get noCalendarsSynced => 'هنوز هیچ تقویمی همگام نشده است.'; + + @override + String get allDay => 'تمام روز'; + + @override + String moreItems(int count) { + return '+$count مورد دیگر'; + } + + @override + String get noEventsOrTasks => 'هیچ رویداد یا کاری وجود ندارد'; + + @override + String get scheduleLoading => 'در حال بارگیری برنامه...'; + + @override + String get scheduleUnavailable => 'برنامه در دسترس نیست'; + + @override + String get scheduleNoSources => + 'هیچ تقویم یا فهرست کار قابل نمایشی وجود ندارد'; + + @override + String get scheduleNoSourcesDescription => + 'در تنظیمات انتخاب کنید چه چیزهایی نمایش داده شوند، سپس برنامه را تازه‌سازی کنید.'; + + @override + String get scheduleSignInRequired => 'اتصال حساب'; + + @override + String get scheduleSignInDescription => + 'برای همگام‌سازی تقویم‌ها و کارها وارد شوید.'; + + @override + String get scheduleNoSearchResults => 'هیچ رویداد یا کار منطبقی وجود ندارد'; + + @override + String get scheduleNoSearchResultsDescription => + 'جست‌وجوی دیگری را امتحان کنید یا پالایه‌های فعلی را پاک کنید.'; + + @override + String get trayAgendaLoading => 'در حال بارگیری برنامه...'; + + @override + String get trayAgendaSignInRequired => 'برای نمایش برنامه وارد شوید.'; + + @override + String get trayAgendaNoSources => + 'هیچ تقویم یا فهرست کار قابل نمایشی وجود ندارد.'; + + @override + String get trayAgendaOpenBusyMax => 'باز کردن برنامه'; + + @override + String get trayAgendaRefresh => 'تازه‌سازی'; + + @override + String get trayAgendaError => 'برنامه در دسترس نیست'; + + @override + String get compactAgendaTitle => 'برنامه'; + + @override + String get compactAgendaSubtitle => 'پیش رو'; + + @override + String get compactAgendaOverdue => 'گذشته از موعد'; + + @override + String get compactAgendaClear => 'فعلاً موردی نیست'; + + @override + String get compactAgendaOpenBusyMax => 'باز کردن BusyMax'; + + @override + String get compactAgendaHide => 'پنهان کردن'; + + @override + String get compactAgendaNewTask => 'کار جدید'; + + @override + String get compactAgendaRetry => 'تلاش دوباره'; + + @override + String get compactAgendaRefresh => 'تازه‌سازی'; + + @override + String get compactAgendaAllDay => 'تمام روز'; + + @override + String get compactAgendaDueToday => 'سررسید امروز'; + + @override + String get compactAgendaDueTomorrow => 'سررسید فردا'; + + @override + String compactAgendaDueOn(String date) { + return 'سررسید: $date'; + } + + @override + String get compactAgendaMoreOverdue => 'بارگیری کارهای عقب‌افتادهٔ بیشتر'; + + @override + String get agendaLoadMoreOverdue => 'بارگیری کارهای عقب‌افتادهٔ بیشتر'; + + @override + String get agendaLoadMoreNoDate => 'بارگیری کارهای بدون تاریخ بیشتر'; + + @override + String get viewDay => 'روز'; + + @override + String get viewWeek => 'هفته'; + + @override + String get viewMonth => 'ماه'; + + @override + String get viewYear => 'سال'; + + @override + String get viewAgenda => 'برنامه'; + + @override + String get scheduleSettings => 'برنامه'; + + @override + String get scheduleDisplaySettings => 'نمایش برنامه'; + + @override + String get scheduleDisplayHoursDescription => + 'نماهای روز و هفته ابتدا این بازهٔ زمانی را نشان می‌دهند. موارد زودتر یا دیرتر در صورت نیاز این بازه را گسترش می‌دهند.'; + + @override + String get scheduleDayStartsAt => 'شروع روز از'; + + @override + String get scheduleDayEndsAt => 'پایان روز در'; + + @override + String get sourceCalendar => 'تقویم'; + + @override + String get sourceTaskList => 'فهرست کار'; + + @override + String get createChoiceTitle => 'ایجاد'; + + @override + String get createEventAtTime => 'رویداد'; + + @override + String get createTaskAtDate => 'کار'; + + @override + String get editEvent => 'ویرایش رویداد'; + + @override + String get eventTitle => 'عنوان رویداد'; + + @override + String get location => 'مکان'; + + @override + String get timeSlot => 'بازهٔ زمانی'; + + @override + String get startDateTime => 'تاریخ/زمان شروع'; + + @override + String get endDateTime => 'تاریخ/زمان پایان'; + + @override + String get doesNotRepeat => 'تکرار نمی‌شود'; + + @override + String get defaultReminder => 'یادآور پیش‌فرض'; + + @override + String get guests => 'مهمانان'; + + @override + String get noGuests => 'بدون مهمان'; + + @override + String get description => 'توضیحات'; + + @override + String get availabilityShowAs => 'وضعیت دسترسی / نمایش به‌عنوان'; + + @override + String get busy => 'مشغول'; + + @override + String get visibility => 'قابلیت مشاهده'; + + @override + String get defaultVisibility => 'قابلیت مشاهدهٔ پیش‌فرض'; + + @override + String get conference => 'جلسه'; + + @override + String get noConference => 'بدون جلسه'; + + @override + String get providerCalendar => 'تقویم سرویس'; + + @override + String get formatBoldShortLabel => 'B'; + + @override + String get formatBoldTooltip => 'پررنگ'; + + @override + String get formatItalicShortLabel => 'I'; + + @override + String get formatItalicTooltip => 'مورب'; + + @override + String get formatUnderlineShortLabel => 'U'; + + @override + String get formatUnderlineTooltip => 'زیرخط'; + + @override + String reminderMinutesBefore(int minutes) { + String _temp0 = intl.Intl.pluralLogic( + minutes, + locale: localeName, + other: '$minutes دقیقه قبل', + one: 'یک دقیقه قبل', + zero: 'هنگام شروع', + ); + return '$_temp0'; + } + + @override + String get reminderAtStart => 'هنگام شروع'; + + @override + String reminderHoursBefore(int hours) { + String _temp0 = intl.Intl.pluralLogic( + hours, + locale: localeName, + other: '$hours ساعت قبل', + one: 'یک ساعت قبل', + zero: 'هنگام شروع', + ); + return '$_temp0'; + } + + @override + String reminderDaysBefore(int days) { + String _temp0 = intl.Intl.pluralLogic( + days, + locale: localeName, + other: '$days روز قبل', + one: 'یک روز قبل', + zero: 'همان روز', + ); + return '$_temp0'; + } + + @override + String get availabilityFree => 'آزاد'; + + @override + String get availabilityTentative => 'احتمالی'; + + @override + String get availabilityOutOfOffice => 'خارج از دفتر'; + + @override + String get availabilityWorkingElsewhere => 'مشغول به کار در مکانی دیگر'; + + @override + String get visibilityDefault => 'پیش‌فرض'; + + @override + String get visibilityPublic => 'عمومی'; + + @override + String get visibilityPrivate => 'خصوصی'; + + @override + String get visibilityConfidential => 'محرمانه'; + + @override + String get sensitivityNormal => 'عادی'; + + @override + String get sensitivityPersonal => 'شخصی'; + + @override + String get tasks => 'کارها'; + + @override + String get allTasks => 'همهٔ کارها'; + + @override + String tasksInList(String title) { + return 'کارهای $title'; + } + + @override + String get taskLists => 'فهرست‌های کار'; + + @override + String get navigation => 'پیمایش'; + + @override + String get mainMenu => 'منوی اصلی'; + + @override + String get keyboardShortcuts => 'میان‌برهای صفحه‌کلید'; + + @override + String get shortcutGroupGeneral => 'عمومی'; + + @override + String get shortcutKeyboardShortcutsDescription => + 'نمایش این راهنمای میان‌برها'; + + @override + String get shortcutGroupNavigation => 'پیمایش'; + + @override + String get shortcutNextPeriod => 'بازهٔ بعدی'; + + @override + String get shortcutNextPeriodDescription => + 'هفتهٔ بعد در نمای هفته، ماه بعد در نمای ماه و به همین ترتیب'; + + @override + String get shortcutPreviousPeriod => 'بازهٔ قبلی'; + + @override + String get shortcutPreviousPeriodDescription => + 'هفتهٔ قبل در نمای هفته، ماه قبل در نمای ماه و به همین ترتیب'; + + @override + String get shortcutJumpToToday => 'رفتن به امروز'; + + @override + String get shortcutGroupView => 'نما'; + + @override + String get shortcutDayView => 'نمای روز'; + + @override + String get shortcutWeekView => 'نمای هفته'; + + @override + String get shortcutMonthView => 'نمای ماه'; + + @override + String get shortcutYearView => 'نمای سال'; + + @override + String get shortcutAgendaView => 'نمای برنامه'; + + @override + String get shortcutGroupCreateAndEdit => 'ایجاد و ویرایش'; + + @override + String get shortcutSaveItem => 'ذخیرهٔ رویداد یا کار'; + + @override + String get shortcutDeleteItem => 'حذف رویداد یا کار'; + + @override + String get shortcutGroupTaskEditing => 'ویرایش کار'; + + @override + String get shortcutCancelEditing => 'لغو ویرایش'; + + @override + String get shortcutCancelEditingDescription => 'بستن ویرایش یا جزئیات کار'; + + @override + String get shortcutGroupCompactAgenda => 'برنامهٔ فشرده'; + + @override + String get shortcutRefreshCompactAgendaDescription => + 'تازه‌سازی پنجرهٔ برنامهٔ فشرده'; + + @override + String get shortcutHideCompactAgendaDescription => + 'پنهان کردن پنجرهٔ برنامهٔ فشرده'; + + @override + String get aboutBusyMax => 'دربارهٔ BusyMax'; + + @override + String get aboutBusyMaxDescription => 'کارها و تقویم'; + + @override + String get website => 'وب‌سایت'; + + @override + String get reportAnIssue => 'گزارش مشکل'; + + @override + String get sendFeedback => 'ارسال بازخورد'; + + @override + String get feedbackSubmit => 'ارسال'; + + @override + String get feedbackCategory => 'دسته‌بندی'; + + @override + String get feedbackSelectCategory => 'یک دسته‌بندی انتخاب کنید'; + + @override + String get feedbackCategoryProblem => 'مشکل یا اشکال'; + + @override + String get feedbackCategoryFeature => 'درخواست قابلیت'; + + @override + String get feedbackCategoryPrivacySecurity => + 'نگرانی دربارهٔ حریم خصوصی یا امنیت'; + + @override + String get feedbackCategoryUsability => 'نگرانی دربارهٔ کاربردپذیری'; + + @override + String get feedbackCategoryOther => 'سایر'; + + @override + String get feedbackSubject => 'موضوع'; + + @override + String get feedbackDetailedMessage => 'پیام با جزئیات'; + + @override + String get feedbackReplyEmail => 'نشانی ایمیل برای پاسخ (اختیاری)'; + + @override + String get feedbackIncludeTechnicalDetails => 'افزودن جزئیات فنی'; + + @override + String get feedbackTechnicalDetailsDisclosure => + 'فقط نسخهٔ سیستم‌عامل Linux و تنظیمات منطقه‌ای برنامه افزوده می‌شود. هیچ گزارش، دادهٔ حساب، نام پرونده یا اطلاعات تشخیصی دیگری افزوده نمی‌شود.'; + + @override + String get feedbackCategoryRequired => 'یک دسته‌بندی انتخاب کنید.'; + + @override + String get feedbackSubjectLengthError => + 'موضوع باید بین ۳ تا ۱۲۰ نویسه باشد.'; + + @override + String get feedbackMessageLengthError => + 'پیام باید بین ۱۰ تا ۵٬۰۰۰ نویسه باشد.'; + + @override + String get feedbackInvalidEmail => 'یک نشانی ایمیل معتبر وارد کنید.'; + + @override + String get feedbackConnectionError => + 'اتصال به BusyStack ممکن نشد. اتصال خود را بررسی و دوباره تلاش کنید.'; + + @override + String get feedbackTimeoutError => + 'مهلت درخواست پایان یافت. بازخورد شما پاک نشده است؛ دوباره تلاش کنید.'; + + @override + String get feedbackRateLimitedError => + 'بازخوردهای بیش از حدی از این شبکه ارسال شده است. کمی صبر کنید و دوباره تلاش کنید.'; + + @override + String get feedbackRejectedError => + 'سرور ارسال را رد کرد. فیلدها را بررسی و دوباره تلاش کنید.'; + + @override + String get feedbackServerError => + 'BusyStack اکنون نمی‌تواند بازخورد شما را بپذیرد. بازخورد شما پاک نشده است؛ دوباره تلاش کنید.'; + + @override + String feedbackSuccess(String id) { + return 'بازخورد ارسال شد. شناسهٔ پیگیری: $id'; + } + + @override + String get toggleSidebar => 'نمایش یا پنهان کردن نوار کناری'; + + @override + String get accounts => 'حساب‌ها'; + + @override + String get currentAccount => 'حساب فعلی'; + + @override + String get switchAccount => 'تعویض حساب'; + + @override + String get addGoogleAccount => 'افزودن حساب Google'; + + @override + String get addMicrosoftAccount => 'افزودن حساب Microsoft'; + + @override + String get googleProvider => 'Google'; + + @override + String get microsoftProvider => 'Microsoft'; + + @override + String get signedInAccount => 'وارد شده'; + + @override + String get removeAccount => 'حذف حساب…'; + + @override + String get removingAccount => 'در حال حذف حساب…'; + + @override + String get removeAccountDescription => + 'همگام‌سازی را متوقف و داده‌های این حساب را از این دستگاه حذف کنید.'; + + @override + String removeAccountTitle(String account) { + return 'حذف $account از BusyMax؟'; + } + + @override + String get removeAccountConfirmation => + 'با این کار، کارها، تقویم‌ها، رویدادها، یادآورها و تغییرات آفلاین در انتظار از حافظهٔ نهان این دستگاه حذف می‌شوند. تغییرات همگام‌نشده از دست می‌روند. هیچ چیزی از Google یا Microsoft حذف نمی‌شود.'; + + @override + String get revokeGoogleAccess => + 'دسترسی BusyMax به این حساب Google نیز لغو شود'; + + @override + String get revokeGoogleAccessDescription => + 'پیش از اتصال دوباره باید دسترسی را دوباره اعطا کنید.'; + + @override + String get removeAccountAction => 'حذف حساب'; + + @override + String get removeAccountFailed => 'حذف حساب کامل نشد. دوباره تلاش کنید.'; + + @override + String get accountRemovedGoogleRevokeFailed => + 'حساب از این دستگاه حذف شد، اما BusyMax نتوانست دسترسی Google را لغو کند. می‌توانید آن را از حساب Google خود لغو کنید.'; + + @override + String get newList => 'فهرست جدید'; + + @override + String get signInToViewTaskLists => 'برای دیدن فهرست‌های کار وارد شوید.'; + + @override + String get noTaskListsSynced => 'هنوز هیچ فهرست کاری همگام نشده است.'; + + @override + String get listActions => 'عملیات فهرست'; + + @override + String get rename => 'تغییر نام'; + + @override + String get delete => 'حذف'; + + @override + String get renameList => 'تغییر نام فهرست'; + + @override + String get deleteList => 'حذف فهرست'; + + @override + String get builtInMicrosoftList => 'داخلی'; + + @override + String get builtInMicrosoftListCannotRenameDelete => + 'فهرست‌های داخلی Microsoft To Do را نمی‌توان تغییر نام داد یا حذف کرد.'; + + @override + String deleteListConfirmation(String title) { + return '«$title» از Google Tasks حذف شود؟'; + } + + @override + String get deleteEvent => 'حذف رویداد'; + + @override + String get title => 'عنوان'; + + @override + String get create => 'ایجاد'; + + @override + String get newTask => 'کار جدید'; + + @override + String get clearCompleted => 'پاک کردن کارهای انجام‌شده'; + + @override + String get refreshList => 'تازه‌سازی فهرست'; + + @override + String get refreshAll => 'تازه‌سازی همه'; + + @override + String get listRefreshed => 'فهرست تازه‌سازی شد.'; + + @override + String get allTasksRefreshed => 'همهٔ حساب‌ها تازه‌سازی شدند.'; + + @override + String exportedFile(String path) { + return 'در $path خروجی گرفته شد'; + } + + @override + String exportFailed(String error) { + return 'خروجی گرفتن ناموفق بود: $error'; + } + + @override + String refreshFailed(String error) { + return 'تازه‌سازی ناموفق بود: $error'; + } + + @override + String get selectOrCreateTaskList => + 'برای شروع، یک فهرست کار انتخاب یا ایجاد کنید.'; + + @override + String get signInToViewTasks => 'برای دیدن کارها وارد شوید.'; + + @override + String get noTasks => 'هیچ کاری وجود ندارد.'; + + @override + String get noTasksYet => 'هنوز کاری وجود ندارد'; + + @override + String get noTasksYetMessage => + 'برای شروع یک کار ایجاد کنید یا حساب‌هایتان را تازه‌سازی کنید.'; + + @override + String get noTasksInList => 'هیچ کاری در این فهرست وجود ندارد.'; + + @override + String get overdue => 'گذشته از موعد'; + + @override + String get today => 'امروز'; + + @override + String get tomorrow => 'فردا'; + + @override + String get upcoming => 'پیش رو'; + + @override + String get noDate => 'بدون تاریخ'; + + @override + String get completed => 'انجام‌شده'; + + @override + String duePrefix(String date) { + return 'سررسید: $date'; + } + + @override + String dateTimeDisplay(String date, String time) { + return '$date · $time'; + } + + @override + String get taskDetails => 'جزئیات کار'; + + @override + String get editTask => 'ویرایش کار'; + + @override + String get noTaskSelected => 'هیچ کاری انتخاب نشده است.'; + + @override + String get noTaskSelectedHelper => + 'برای دیدن و ویرایش جزئیات، کاری را انتخاب کنید.'; + + @override + String get taskUnavailable => 'کار در دسترس نیست.'; + + @override + String get signInToEditTasks => 'برای ویرایش کارها وارد شوید.'; + + @override + String get refreshTask => 'تازه‌سازی کار'; + + @override + String get primarySection => 'اصلی'; + + @override + String get statusSection => 'وضعیت'; + + @override + String get openStatus => 'باز'; + + @override + String get doneStatus => 'انجام‌شده'; + + @override + String get notes => 'یادداشت‌ها'; + + @override + String get dueDate => 'تاریخ سررسید'; + + @override + String get clearDueDate => 'پاک کردن تاریخ سررسید'; + + @override + String get dueTime => 'زمان سررسید'; + + @override + String get startDate => 'تاریخ شروع'; + + @override + String get startTime => 'زمان شروع'; + + @override + String get endDate => 'تاریخ پایان'; + + @override + String get endTime => 'زمان پایان'; + + @override + String get reminderDate => 'تاریخ یادآور'; + + @override + String get reminderTime => 'زمان یادآور'; + + @override + String get reminder => 'یادآور'; + + @override + String get addReminder => 'افزودن یادآور'; + + @override + String get addGuest => 'افزودن مهمان'; + + @override + String get addGuestEmail => 'افزودن ایمیل مهمان'; + + @override + String get removeReminder => 'حذف یادآور'; + + @override + String get off => 'خاموش'; + + @override + String get repeat => 'تکرار'; + + @override + String get repeatNone => 'بدون تکرار'; + + @override + String get noneValue => 'هیچ‌کدام'; + + @override + String get repeatDaily => 'روزانه'; + + @override + String get repeatWeekly => 'هفتگی'; + + @override + String get repeatMonthly => 'ماهانه'; + + @override + String get repeatYearly => 'سالانه'; + + @override + String get importance => 'اهمیت'; + + @override + String get importanceLow => 'کم'; + + @override + String get importanceNormal => 'عادی'; + + @override + String get importanceHigh => 'زیاد'; + + @override + String get categories => 'دسته‌ها'; + + @override + String get scheduleSection => 'برنامه'; + + @override + String get dueGroup => 'سررسید'; + + @override + String get startGroup => 'شروع'; + + @override + String get reminderGroup => 'یادآور'; + + @override + String get organizationSection => 'سازمان‌دهی'; + + @override + String get actionsSection => 'عملیات'; + + @override + String get advancedSection => 'پیشرفته'; + + @override + String get addCategory => 'افزودن دسته'; + + @override + String get list => 'فهرست'; + + @override + String get microsoftMoveUnsupported => + 'در این نسخه، جابه‌جایی کارها بین فهرست‌های حساب Microsoft To Do پشتیبانی نمی‌شود.'; + + @override + String get createSubtask => 'ایجاد زیرکار'; + + @override + String get moveToTop => 'انتقال به بالاترین جایگاه'; + + @override + String get deleteTask => 'حذف کار'; + + @override + String get newSubtask => 'زیرکار جدید'; + + @override + String deleteTaskConfirmation(String title) { + return '«$title» از Google Tasks حذف شود؟'; + } + + @override + String get metadata => 'فراداده'; + + @override + String get id => 'شناسه'; + + @override + String get etag => 'ETag'; + + @override + String get updated => 'به‌روزشده'; + + @override + String get parent => 'کار والد'; + + @override + String get position => 'جایگاه'; + + @override + String get webLink => 'پیوند وب'; + + @override + String get assignment => 'واگذاری'; + + @override + String get localState => 'وضعیت محلی'; + + @override + String get pendingSync => 'در انتظار همگام‌سازی'; + + @override + String get synced => 'همگام‌شده'; + + @override + String get account => 'حساب'; + + @override + String get sync => 'همگام‌سازی'; + + @override + String get manualFullSync => 'همگام‌سازی کامل دستی'; + + @override + String get runInBackgroundWhenClosed => 'ادامهٔ اجرا پس از بسته شدن پنجره'; + + @override + String get showTrayIcon => 'نمایش نماد سینی سیستم'; + + @override + String get startMinimizedToTray => 'شروع به‌صورت کوچک‌شده در سینی سیستم'; + + @override + String get requiresTrayIcon => 'به نماد سینی سیستم نیاز دارد.'; + + @override + String get syncComplete => 'همگام‌سازی کامل شد.'; + + @override + String syncFailed(String error) { + return 'همگام‌سازی ناموفق بود: $error'; + } + + @override + String get notifySyncFailures => 'اعلان هنگام شکست همگام‌سازی'; + + @override + String get notifyConflicts => 'اعلان هنگام تداخل'; + + @override + String get notifyDueToday => 'اعلان کارهای دارای سررسید امروز'; + + @override + String get eventReminders => 'یادآورهای رویداد'; + + @override + String get taskReminders => 'یادآورهای کار'; + + @override + String get notificationDetailLevel => 'سطح جزئیات اعلان'; + + @override + String get notificationDetailPrivate => 'خصوصی'; + + @override + String get notificationDetailNormal => 'عادی'; + + @override + String get quietHours => 'ساعات سکوت'; + + @override + String get quietHoursDescription => + 'اعلان‌ها را در این بازه موقتاً متوقف کنید.'; + + @override + String get quietHoursStart => 'شروع ساعات سکوت'; + + @override + String get quietHoursEnd => 'پایان ساعات سکوت'; + + @override + String get notifications => 'اعلان‌ها'; + + @override + String get appearance => 'ظاهر'; + + @override + String get theme => 'پوسته'; + + @override + String get themeSystem => 'سیستم'; + + @override + String get themeLight => 'روشن'; + + @override + String get themeDark => 'تیره'; + + @override + String get themeFamily => 'خانوادهٔ پوسته'; + + @override + String get themeFamilyYaru => 'پوستهٔ بومی Ubuntu ‏(Yaru)'; + + @override + String get localization => 'زبان و منطقه'; + + @override + String get currentLocale => 'زبان و منطقهٔ فعلی'; + + @override + String get privacy => 'حریم خصوصی'; + + @override + String get redactTaskContentInDiagnostics => + 'پنهان کردن محتوای کارها در اطلاعات تشخیصی'; + + @override + String get developerDiagnostics => 'تشخیص‌های توسعه‌دهنده'; + + @override + String get diagnostics => 'اطلاعات تشخیصی'; + + @override + String get apiInspectorDisabled => 'نمایش بازرس API'; + + @override + String get googleTasksApi => 'رابط Google Tasks API'; + + @override + String discoveryRevision(String revision) { + return 'بازبینی Discovery: ‏$revision'; + } + + @override + String get implementedMethods => 'روش‌های پیاده‌سازی‌شده'; + + @override + String get supportsTasksScopes => + 'از محدوده‌های tasks و tasks.readonly پشتیبانی می‌کند'; + + @override + String get requiresTasksScope => 'به محدودهٔ tasks نیاز دارد'; + + @override + String get blockedPendingOperations => 'عملیات در انتظار مسدودشده'; + + @override + String get signInToInspectPendingOperations => + 'برای بررسی عملیات در انتظار وارد شوید.'; + + @override + String get noBlockedPendingOperations => + 'هیچ عملیات در انتظار مسدودشده‌ای وجود ندارد.'; + + @override + String get operationActions => 'اقدامات عملیات'; + + @override + String pendingOpListId(String id) { + return 'فهرست=$id'; + } + + @override + String pendingOpTaskId(String id) { + return 'کار=$id'; + } + + @override + String pendingOpAttempts(int count) { + return 'تلاش‌ها=$count'; + } + + @override + String get retry => 'تلاش دوباره'; + + @override + String get discard => 'کنار گذاشتن'; + + @override + String get discardChanges => 'تغییرات کنار گذاشته شوند؟'; + + @override + String get discardChangesConfirmation => + 'با این کار ویرایش‌های ذخیره‌نشدهٔ این کار کنار گذاشته می‌شوند.'; + + @override + String get retryCompleted => 'تلاش دوباره کامل شد.'; + + @override + String get discardPendingOperation => 'عملیات در انتظار کنار گذاشته شود؟'; + + @override + String get discardPendingOperationConfirmation => + 'با این کار عملیات محلی مسدودشده حذف می‌شود. در همگام‌سازی بعدی، داده‌ها از Google Tasks تازه‌سازی می‌شوند.'; + + @override + String get pendingOperationDiscarded => 'عملیات در انتظار کنار گذاشته شد.'; + + @override + String get syncFailureNotificationTitle => 'همگام‌سازی BusyMax ناموفق بود'; + + @override + String syncFailureNotificationBody(String message) { + return 'همگام‌سازی پس‌زمینه ناموفق بود. $message'; + } + + @override + String get conflictNotificationTitle => 'تداخل همگام‌سازی BusyMax'; + + @override + String conflictNotificationBody(String summary) { + return 'یک تغییر محلی در انتظار مسدود شد. $summary'; + } + + @override + String get dueTodayNotificationTitle => 'کارهای دارای سررسید امروز'; + + @override + String dueTodayNotificationBody(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'امروز $count کار سررسید دارند.', + one: 'امروز یک کار سررسید دارد.', + zero: 'امروز هیچ کاری سررسید ندارد.', + ); + return '$_temp0'; + } + + @override + String get eventReminderNotificationTitle => 'یادآور رویداد'; + + @override + String get taskReminderNotificationTitle => 'یادآور کار'; + + @override + String get eventReminderNotificationBody => 'رویداد به‌زودی شروع می‌شود.'; + + @override + String get taskReminderNotificationBody => 'سررسید کار نزدیک است.'; + + @override + String get notificationOpenAction => 'باز کردن'; + + @override + String get notificationDetailsHidden => + 'جزئیات به‌دلیل تنظیمات حریم خصوصی پنهان شده‌اند.'; + + @override + String get previousMonth => 'ماه قبل'; + + @override + String get nextMonth => 'ماه بعد'; + + @override + String get openMonthView => 'باز کردن نمای ماه'; + + @override + String get previousYear => 'سال قبل'; + + @override + String get nextYear => 'سال بعد'; + + @override + String get openYearView => 'باز کردن نمای سال'; + + @override + String weekNumberTooltip(int number) { + return 'هفتهٔ $number'; + } + + @override + String get resizeAllDayPanel => 'تغییر اندازهٔ پنل تمام‌روز'; + + @override + String scheduleItemCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count مورد', + one: 'یک مورد', + zero: 'هیچ موردی', + ); + return '$_temp0'; + } + + @override + String get readOnlyCalendar => 'این تقویم فقط‌خواندنی است.'; + + @override + String get selectTimeZone => 'انتخاب منطقهٔ زمانی'; + + @override + String get searchLocations => 'جست‌وجوی مکان‌ها'; + + @override + String get noLocationsFound => 'مکانی پیدا نشد'; + + @override + String deleteCalendarConfirmation(String title) { + return '«$title» حذف شود؟'; + } +} diff --git a/lib/l10n/generated/app_localizations_hi.dart b/lib/l10n/generated/app_localizations_hi.dart index 399f88e..4aa1894 100644 --- a/lib/l10n/generated/app_localizations_hi.dart +++ b/lib/l10n/generated/app_localizations_hi.dart @@ -44,7 +44,7 @@ class AppLocalizationsHi extends AppLocalizations { @override String get onboardingPreferencesStepDescription => - 'अपना शेड्यूल खोलने से पहले डेस्कटॉप व्यवहार, रिमाइंडर, सूचना विवरण और दिखावट सेट करें।'; + 'अपना शेड्यूल खोलने से पहले डेस्कटॉप व्यवहार, रिमाइंडर, सूचनाओं के विवरण का स्तर और दिखावट सेट करें।'; @override String get signInWithGoogle => 'Google से साइन इन करें'; @@ -122,7 +122,7 @@ class AppLocalizationsHi extends AppLocalizations { String get refreshCalendar => 'कैलेंडर रीफ़्रेश करें'; @override - String get openInProvider => 'प्रदाता में खोलें'; + String get openInProvider => 'सेवा में खोलें'; @override String get hideFromSchedule => 'शेड्यूल से छिपाएँ'; @@ -234,10 +234,12 @@ class AppLocalizationsHi extends AppLocalizations { } @override - String get compactAgendaMoreOverdue => 'समय सीमा बीत चुके और कार्य लोड करें'; + String get compactAgendaMoreOverdue => + 'समय-सीमा पार कर चुके अतिरिक्त कार्य लोड करें'; @override - String get agendaLoadMoreOverdue => 'समय सीमा बीत चुके और कार्य लोड करें'; + String get agendaLoadMoreOverdue => + 'समय-सीमा पार कर चुके अतिरिक्त कार्य लोड करें'; @override String get agendaLoadMoreNoDate => 'बिना तारीख वाले और कार्य लोड करें'; @@ -473,7 +475,7 @@ class AppLocalizationsHi extends AppLocalizations { 'सप्ताह दृश्य में पिछला सप्ताह, महीने के दृश्य में पिछला महीना, इत्यादि'; @override - String get shortcutJumpToToday => 'आज पर जाएँ'; + String get shortcutJumpToToday => 'आज की तारीख पर जाएँ'; @override String get shortcutGroupView => 'दृश्य'; @@ -577,7 +579,7 @@ class AppLocalizationsHi extends AppLocalizations { @override String get feedbackTechnicalDetailsDisclosure => - 'केवल आपके Linux ऑपरेटिंग सिस्टम का संस्करण और ऐप का स्थान-भाषा जोड़ा जाता है। कोई लॉग, खाता डेटा, फ़ाइल नाम या अन्य निदान शामिल नहीं किया जाता।'; + 'केवल आपके Linux ऑपरेटिंग सिस्टम का संस्करण और ऐप की भाषा व क्षेत्रीय सेटिंग जोड़ी जाती है। लॉग, खाता डेटा, फ़ाइल नाम या अन्य निदान जानकारी शामिल नहीं की जाती।'; @override String get feedbackCategoryRequired => 'श्रेणी चुनें।'; @@ -681,7 +683,7 @@ class AppLocalizationsHi extends AppLocalizations { @override String get accountRemovedGoogleRevokeFailed => - 'खाता इस डिवाइस से हटा दिया गया, लेकिन BusyMax Google की पहुँच रद्द नहीं कर सका। आप इसे अपने Google खाते से रद्द कर सकते हैं।'; + 'खाता इस डिवाइस से हटा दिया गया, लेकिन BusyMax की Google खाते तक पहुँच रद्द नहीं की जा सकी। आप यह पहुँच अपने Google खाते से रद्द कर सकते हैं।'; @override String get newList => 'नई सूची'; @@ -923,7 +925,7 @@ class AppLocalizationsHi extends AppLocalizations { String get importanceNormal => 'सामान्य'; @override - String get importanceHigh => 'अधिक'; + String get importanceHigh => 'उच्च'; @override String get categories => 'श्रेणियाँ'; @@ -1025,7 +1027,7 @@ class AppLocalizationsHi extends AppLocalizations { String get showTrayIcon => 'ट्रे आइकन दिखाएँ'; @override - String get startMinimizedToTray => 'ट्रे में छोटा होकर शुरू करें'; + String get startMinimizedToTray => 'ट्रे में मिनिमाइज़ होकर शुरू करें'; @override String get requiresTrayIcon => 'ट्रे आइकन आवश्यक है।'; @@ -1054,7 +1056,7 @@ class AppLocalizationsHi extends AppLocalizations { String get taskReminders => 'कार्य रिमाइंडर'; @override - String get notificationDetailLevel => 'सूचना विवरण का स्तर'; + String get notificationDetailLevel => 'सूचनाओं के विवरण का स्तर'; @override String get notificationDetailPrivate => 'निजी'; @@ -1096,13 +1098,13 @@ class AppLocalizationsHi extends AppLocalizations { String get themeFamily => 'थीम परिवार'; @override - String get themeFamilyYaru => 'मूल Ubuntu (Yaru)'; + String get themeFamilyYaru => 'Ubuntu की मूल थीम (Yaru)'; @override String get localization => 'स्थानीयकरण'; @override - String get currentLocale => 'मौजूदा स्थान-भाषा'; + String get currentLocale => 'मौजूदा भाषा और क्षेत्रीय सेटिंग'; @override String get privacy => 'गोपनीयता'; @@ -1169,27 +1171,27 @@ class AppLocalizationsHi extends AppLocalizations { String get retry => 'फिर से कोशिश करें'; @override - String get discard => 'छोड़ें'; + String get discard => 'खारिज करें'; @override - String get discardChanges => 'बदलाव छोड़ें?'; + String get discardChanges => 'बदलाव खारिज करें?'; @override String get discardChangesConfirmation => - 'इससे इस कार्य के सहेजे न गए बदलाव छोड़ दिए जाएँगे।'; + 'इससे इस कार्य में किए गए सहेजे न गए बदलाव खारिज हो जाएँगे।'; @override String get retryCompleted => 'दोबारा प्रयास पूरा हुआ।'; @override - String get discardPendingOperation => 'लंबित कार्रवाई छोड़ें?'; + String get discardPendingOperation => 'लंबित कार्रवाई खारिज करें?'; @override String get discardPendingOperationConfirmation => 'इससे अवरुद्ध स्थानीय कार्रवाई हट जाती है। अगला सिंक Google Tasks से डेटा रीफ़्रेश करेगा।'; @override - String get pendingOperationDiscarded => 'लंबित कार्रवाई छोड़ दी गई।'; + String get pendingOperationDiscarded => 'लंबित कार्रवाई खारिज कर दी गई।'; @override String get syncFailureNotificationTitle => 'BusyMax सिंक विफल'; @@ -1200,7 +1202,7 @@ class AppLocalizationsHi extends AppLocalizations { } @override - String get conflictNotificationTitle => 'BusyMax सिंक टकराव'; + String get conflictNotificationTitle => 'BusyMax सिंक में टकराव'; @override String conflictNotificationBody(String summary) { From 5de090e68da75c056db4cc3284e5c12d1a41a3c8 Mon Sep 17 00:00:00 2001 From: albert Date: Wed, 29 Jul 2026 16:55:40 -0700 Subject: [PATCH 39/73] Refactor MiniCalendar and related components for improved layout and functionality --- .../schedule/presentation/mini_calendar.dart | 415 ++++++++++-------- .../presentation/schedule_sidebar.dart | 1 - .../presentation/schedule_year_view.dart | 11 +- .../desktop_date_time_fields.dart | 84 ++-- test/app/busymax_grouped_surface_test.dart | 2 +- test/app/localization_audit_test.dart | 66 ++- test/app/native_ui_audit_test.dart | 6 +- .../desktop_notification_service_test.dart | 42 ++ .../presentation/schedule_views_test.dart | 143 +----- .../desktop_date_time_fields_test.dart | 15 +- 10 files changed, 428 insertions(+), 357 deletions(-) diff --git a/lib/src/features/schedule/presentation/mini_calendar.dart b/lib/src/features/schedule/presentation/mini_calendar.dart index 3d6c591..9f7fedc 100644 --- a/lib/src/features/schedule/presentation/mini_calendar.dart +++ b/lib/src/features/schedule/presentation/mini_calendar.dart @@ -11,43 +11,30 @@ import '../../../schedule/schedule_item.dart'; import '../../../schedule/schedule_projection.dart'; import 'calendar_day_semantics.dart'; -enum MiniCalendarHeaderStyle { navigation, monthLabel } - const _miniCalendarHeaderControlExtent = 28.0; const _miniCalendarMonthControlFlex = 3; const _miniCalendarYearControlFlex = 2; +/// Sidebar calendar with local month paging and schedule-view shortcuts. class MiniCalendar extends StatefulWidget { const MiniCalendar({ super.key, required this.selectedDate, - this.displayedMonth, required this.firstWeekday, this.items = const [], required this.onSelected, - this.showHeader = true, - this.headerStyle = MiniCalendarHeaderStyle.navigation, - this.showDayHover = false, - this.weekNumbersInteractive = true, - this.onMonthSelected, - this.onYearSelected, - this.onWeekSelected, - this.onDayDoubleTap, - }) : assert(!weekNumbersInteractive || onWeekSelected != null); + required this.onMonthSelected, + required this.onYearSelected, + required this.onWeekSelected, + }); final DateTime selectedDate; - final DateTime? displayedMonth; final int firstWeekday; final List items; final ValueChanged onSelected; - final bool showHeader; - final MiniCalendarHeaderStyle headerStyle; - final bool showDayHover; - final bool weekNumbersInteractive; - final ValueChanged? onMonthSelected; - final ValueChanged? onYearSelected; - final ValueChanged? onWeekSelected; - final ValueChanged? onDayDoubleTap; + final ValueChanged onMonthSelected; + final ValueChanged onYearSelected; + final ValueChanged onWeekSelected; @override State createState() => _MiniCalendarState(); @@ -59,35 +46,26 @@ class _MiniCalendarState extends State { @override void initState() { super.initState(); - _displayedMonth = _monthOf(widget.displayedMonth ?? widget.selectedDate); + _displayedMonth = _monthOf(widget.selectedDate); } @override void didUpdateWidget(covariant MiniCalendar oldWidget) { super.didUpdateWidget(oldWidget); - if (widget.displayedMonth != null) { - _displayedMonth = _monthOf(widget.displayedMonth!); - return; - } if (!_sameMonth(oldWidget.selectedDate, widget.selectedDate)) { _displayedMonth = _monthOf(widget.selectedDate); } } void _showMonth(DateTime month) { - if (widget.displayedMonth != null) { - return; - } setState(() => _displayedMonth = _monthOf(month)); } @override Widget build(BuildContext context) { final l10n = context.l10n; - final visibleMonth = widget.displayedMonth ?? _displayedMonth; + final visibleMonth = _displayedMonth; final first = DateTime(visibleMonth.year, visibleMonth.month); - final start = _calendarStartForMonth(first, widget.firstWeekday); - final groupedItems = ScheduleProjection.groupByDay(widget.items); final locale = Localizations.localeOf(context).toLanguageTag(); return Padding( padding: const EdgeInsetsDirectional.fromSTEB( @@ -99,138 +77,229 @@ class _MiniCalendarState extends State { child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - if (widget.showHeader) ...[ - if (widget.headerStyle == MiniCalendarHeaderStyle.navigation) - Row( - children: [ - Expanded( - flex: _miniCalendarMonthControlFlex, - child: _MiniCalendarStepper( - label: DateFormat.MMMM(locale).format(visibleMonth), - previousTooltip: l10n.previousMonth, - nextTooltip: l10n.nextMonth, - onPrevious: () => _showMonth( - DateTime(visibleMonth.year, visibleMonth.month - 1), - ), - onNext: () => _showMonth( - DateTime(visibleMonth.year, visibleMonth.month + 1), - ), - labelTooltip: l10n.openMonthView, - onLabelPressed: widget.onMonthSelected == null - ? null - : () => widget.onMonthSelected!(first), - ), + Row( + children: [ + Expanded( + flex: _miniCalendarMonthControlFlex, + child: _MiniCalendarStepper( + label: DateFormat.MMMM(locale).format(visibleMonth), + previousTooltip: l10n.previousMonth, + nextTooltip: l10n.nextMonth, + onPrevious: () => _showMonth( + DateTime(visibleMonth.year, visibleMonth.month - 1), ), - const SizedBox(width: BusyMaxSpacing.sm), - Expanded( - flex: _miniCalendarYearControlFlex, - child: _MiniCalendarStepper( - label: '${visibleMonth.year}', - previousTooltip: l10n.previousYear, - nextTooltip: l10n.nextYear, - onPrevious: () => _showMonth( - DateTime(visibleMonth.year - 1, visibleMonth.month), - ), - onNext: () => _showMonth( - DateTime(visibleMonth.year + 1, visibleMonth.month), - ), - labelTooltip: l10n.openYearView, - onLabelPressed: widget.onYearSelected == null - ? null - : () => widget.onYearSelected!( - DateTime(visibleMonth.year), - ), - ), + onNext: () => _showMonth( + DateTime(visibleMonth.year, visibleMonth.month + 1), ), - ], - ) - else - _MiniCalendarHeaderLabel( - label: DateFormat.yMMMM(locale).format(first), - tooltip: l10n.openMonthView, - onPressed: widget.onMonthSelected == null - ? null - : () => widget.onMonthSelected!(first), + labelTooltip: l10n.openMonthView, + onLabelPressed: () => widget.onMonthSelected(first), + ), ), - const SizedBox(height: BusyMaxSpacing.sm), - ], - LayoutBuilder( - builder: (context, constraints) { - final maximumWeekNumberExtent = widget.weekNumbersInteractive - ? _miniCalendarHeaderControlExtent - : _miniCalendarHeaderControlExtent - - BusyMaxSpacing.headerInset; - final weekNumberExtent = math.min( - maximumWeekNumberExtent, - constraints.maxWidth / (DateTime.daysPerWeek + 1), - ); - final dayExtent = - math.max(0.0, constraints.maxWidth - weekNumberExtent) / - DateTime.daysPerWeek; - const weekdayHeaderHeight = 18.0; - return SizedBox( - width: double.infinity, - height: weekdayHeaderHeight + BusyMaxSpacing.xs + dayExtent * 6, - child: Column( + const SizedBox(width: BusyMaxSpacing.sm), + Expanded( + flex: _miniCalendarYearControlFlex, + child: _MiniCalendarStepper( + label: '${visibleMonth.year}', + previousTooltip: l10n.previousYear, + nextTooltip: l10n.nextYear, + onPrevious: () => _showMonth( + DateTime(visibleMonth.year - 1, visibleMonth.month), + ), + onNext: () => _showMonth( + DateTime(visibleMonth.year + 1, visibleMonth.month), + ), + labelTooltip: l10n.openYearView, + onLabelPressed: () => + widget.onYearSelected(DateTime(visibleMonth.year)), + ), + ), + ], + ), + const SizedBox(height: BusyMaxSpacing.sm), + MiniCalendarGrid( + displayedMonth: first, + selectedDate: widget.selectedDate, + firstWeekday: widget.firstWeekday, + markerColorsByDay: miniCalendarMarkerColorsForItems( + context, + widget.items, + ), + onDaySelected: widget.onSelected, + onWeekSelected: widget.onWeekSelected, + ), + ], + ), + ); + } +} + +/// One fixed month used by each card in the year view. +class YearMonthMiniCalendar extends StatelessWidget { + const YearMonthMiniCalendar({ + super.key, + required this.displayedMonth, + required this.selectedDate, + required this.firstWeekday, + required this.markerColorsByDay, + required this.onDaySelected, + required this.onMonthSelected, + required this.onWeekSelected, + required this.onDayDoubleTap, + }); + + final DateTime displayedMonth; + final DateTime selectedDate; + final int firstWeekday; + final Map> markerColorsByDay; + final ValueChanged onDaySelected; + final ValueChanged onMonthSelected; + final ValueChanged onWeekSelected; + final ValueChanged onDayDoubleTap; + + @override + Widget build(BuildContext context) { + final month = _monthOf(displayedMonth); + final locale = Localizations.localeOf(context).toLanguageTag(); + return Padding( + padding: const EdgeInsetsDirectional.fromSTEB( + BusyMaxSpacing.headerInset, + BusyMaxSpacing.headerInset, + BusyMaxSpacing.headerInset, + BusyMaxSpacing.md, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _MiniCalendarHeaderLabel( + label: DateFormat.yMMMM(locale).format(month), + tooltip: context.l10n.openMonthView, + onPressed: () => onMonthSelected(month), + ), + const SizedBox(height: BusyMaxSpacing.sm), + MiniCalendarGrid( + displayedMonth: month, + selectedDate: selectedDate, + firstWeekday: firstWeekday, + markerColorsByDay: markerColorsByDay, + onDaySelected: onDaySelected, + onWeekSelected: onWeekSelected, + onDayDoubleTap: onDayDoubleTap, + ), + ], + ), + ); + } +} + +/// Shared stateless weekday, week-number, and day-cell matrix. +class MiniCalendarGrid extends StatelessWidget { + const MiniCalendarGrid({ + super.key, + required this.displayedMonth, + required this.selectedDate, + required this.firstWeekday, + required this.onDaySelected, + this.markerColorsByDay = const {}, + this.onWeekSelected, + this.onDayDoubleTap, + }); + + final DateTime displayedMonth; + final DateTime selectedDate; + final int firstWeekday; + final Map> markerColorsByDay; + final ValueChanged onDaySelected; + final ValueChanged? onWeekSelected; + final ValueChanged? onDayDoubleTap; + + @override + Widget build(BuildContext context) { + final month = _monthOf(displayedMonth); + final start = _calendarStartForMonth(month, firstWeekday); + final locale = Localizations.localeOf(context).toLanguageTag(); + return LayoutBuilder( + builder: (context, constraints) { + final maximumWeekNumberExtent = onWeekSelected == null + ? _miniCalendarHeaderControlExtent - BusyMaxSpacing.headerInset + : _miniCalendarHeaderControlExtent; + final weekNumberExtent = math.min( + maximumWeekNumberExtent, + constraints.maxWidth / (DateTime.daysPerWeek + 1), + ); + final dayExtent = + math.max(0.0, constraints.maxWidth - weekNumberExtent) / + DateTime.daysPerWeek; + const weekdayHeaderHeight = 18.0; + return SizedBox( + width: double.infinity, + height: weekdayHeaderHeight + BusyMaxSpacing.xs + dayExtent * 6, + child: Column( + children: [ + SizedBox( + height: weekdayHeaderHeight, + child: Row( children: [ - SizedBox( - height: weekdayHeaderHeight, - child: Row( - children: [ - SizedBox(width: weekNumberExtent), - for (final weekday in _weekdays(widget.firstWeekday)) - Expanded( - child: Center( - child: Text( - DateFormat.E( - locale, - ).format(_weekdayDate(weekday)), - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.labelSmall - ?.copyWith( - color: Theme.of( - context, - ).colorScheme.onSurfaceVariant, - ), + SizedBox(width: weekNumberExtent), + for (final weekday in _weekdays(firstWeekday)) + Expanded( + child: Center( + child: Text( + DateFormat.E(locale).format(_weekdayDate(weekday)), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.labelSmall + ?.copyWith( + color: Theme.of( + context, + ).colorScheme.onSurfaceVariant, ), - ), - ), - ], - ), - ), - const SizedBox(height: BusyMaxSpacing.xs), - for (var row = 0; row < 6; row++) - SizedBox( - height: dayExtent, - child: _MiniCalendarWeekRow( - weekStart: _addCalendarDays( - start, - row * DateTime.daysPerWeek, ), - weekNumberExtent: weekNumberExtent, - onWeekSelected: widget.weekNumbersInteractive - ? widget.onWeekSelected - : null, - selectedDate: widget.selectedDate, - displayedMonth: first, - groupedItems: groupedItems, - showDayHover: widget.showDayHover, - onDaySelected: widget.onSelected, - onDayDoubleTap: widget.onDayDoubleTap, ), ), ], ), - ); - }, + ), + const SizedBox(height: BusyMaxSpacing.xs), + for (var row = 0; row < 6; row++) + SizedBox( + height: dayExtent, + child: _MiniCalendarWeekRow( + weekStart: _addCalendarDays( + start, + row * DateTime.daysPerWeek, + ), + weekNumberExtent: weekNumberExtent, + onWeekSelected: onWeekSelected, + selectedDate: selectedDate, + displayedMonth: month, + markerColorsByDay: markerColorsByDay, + onDaySelected: onDaySelected, + onDayDoubleTap: onDayDoubleTap, + ), + ), + ], ), - ], - ), + ); + }, ); } } +Map> miniCalendarMarkerColorsForItems( + BuildContext context, + List items, +) { + final brightness = Theme.of(context).brightness; + final groupedItems = ScheduleProjection.groupByDay(items); + return { + for (final entry in groupedItems.entries) + entry.key: [ + for (final item in entry.value.take(3)) + ScheduleProjection.colorForItem(item, brightness), + ], + }; +} + class _MiniCalendarWeekRow extends StatelessWidget { const _MiniCalendarWeekRow({ required this.weekStart, @@ -238,8 +307,7 @@ class _MiniCalendarWeekRow extends StatelessWidget { required this.onWeekSelected, required this.selectedDate, required this.displayedMonth, - required this.groupedItems, - required this.showDayHover, + required this.markerColorsByDay, required this.onDaySelected, required this.onDayDoubleTap, }); @@ -249,8 +317,7 @@ class _MiniCalendarWeekRow extends StatelessWidget { final ValueChanged? onWeekSelected; final DateTime selectedDate; final DateTime displayedMonth; - final Map> groupedItems; - final bool showDayHover; + final Map> markerColorsByDay; final ValueChanged onDaySelected; final ValueChanged? onDayDoubleTap; @@ -273,8 +340,7 @@ class _MiniCalendarWeekRow extends StatelessWidget { day: _addCalendarDays(weekStart, column), selectedDate: selectedDate, displayedMonth: displayedMonth, - groupedItems: groupedItems, - showHoverBackground: showDayHover, + markerColorsByDay: markerColorsByDay, onSelected: onDaySelected, onDoubleTap: onDayDoubleTap, ), @@ -346,8 +412,7 @@ class _MiniCalendarDayButton extends StatefulWidget { required this.day, required this.selectedDate, required this.displayedMonth, - required this.groupedItems, - required this.showHoverBackground, + required this.markerColorsByDay, required this.onSelected, required this.onDoubleTap, }); @@ -355,8 +420,7 @@ class _MiniCalendarDayButton extends StatefulWidget { final DateTime day; final DateTime selectedDate; final DateTime displayedMonth; - final Map> groupedItems; - final bool showHoverBackground; + final Map> markerColorsByDay; final ValueChanged onSelected; final ValueChanged? onDoubleTap; @@ -371,7 +435,6 @@ class _MiniCalendarDayButtonState extends State<_MiniCalendarDayButton> { Widget build(BuildContext context) { final day = widget.day; final selectedDate = widget.selectedDate; - final groupedItems = widget.groupedItems; final onSelected = widget.onSelected; final colorScheme = Theme.of(context).colorScheme; final surfaceColors = BusyMaxSurfaceColors.of(context); @@ -384,8 +447,8 @@ class _MiniCalendarDayButtonState extends State<_MiniCalendarDayButton> { widget.displayedMonth.year == DateTime.now().year && widget.displayedMonth.month == DateTime.now().month; final highlightToday = today && displayingCurrentMonth; - final items = - groupedItems[ScheduleProjection.day(day)] ?? const []; + final markerColors = + widget.markerColorsByDay[_dayOf(day)] ?? const []; return BusyMaxCalendarDaySemantics( day: day, @@ -394,7 +457,7 @@ class _MiniCalendarDayButtonState extends State<_MiniCalendarDayButton> { child: LayoutBuilder( builder: (context, constraints) { final canShowIndicators = - items.isNotEmpty && constraints.maxHeight >= 28; + markerColors.isNotEmpty && constraints.maxHeight >= 28; final indicatorHeight = canShowIndicators ? 4.0 : 0.0; final availableMarkerExtent = math.min( constraints.maxWidth, @@ -418,15 +481,14 @@ class _MiniCalendarDayButtonState extends State<_MiniCalendarDayButton> { 32.0, math.max(0.0, availableMarkerExtent), ); - final currentMarkerSize = - _isHovering && widget.showHoverBackground && !selected + final currentMarkerSize = _isHovering && !selected ? hoveredMarkerSize : markerSize; final backgroundColor = selected ? colorScheme.primary : highlightToday ? surfaceColors.controlActive - : _isHovering && widget.showHoverBackground + : _isHovering ? hoverColor : Colors.transparent; @@ -478,7 +540,7 @@ class _MiniCalendarDayButtonState extends State<_MiniCalendarDayButton> { if (canShowIndicators) ...[ const SizedBox(height: BusyMaxSpacing.xxs), _MiniCalendarDayIndicators( - items: items, + colors: markerColors, height: indicatorHeight, ), ], @@ -493,30 +555,29 @@ class _MiniCalendarDayButtonState extends State<_MiniCalendarDayButton> { } class _MiniCalendarDayIndicators extends StatelessWidget { - const _MiniCalendarDayIndicators({required this.items, required this.height}); + const _MiniCalendarDayIndicators({ + required this.colors, + required this.height, + }); - final List items; + final List colors; final double height; @override Widget build(BuildContext context) { - if (items.isEmpty) { + if (colors.isEmpty) { return SizedBox(height: height); } - final brightness = Theme.of(context).brightness; return SizedBox( height: height, child: Row( mainAxisAlignment: MainAxisAlignment.center, children: [ - for (final item in items.take(3)) + for (final color in colors.take(3)) Padding( padding: const EdgeInsets.symmetric(horizontal: 1), child: DecoratedBox( - decoration: BoxDecoration( - color: ScheduleProjection.colorForItem(item, brightness), - shape: BoxShape.circle, - ), + decoration: BoxDecoration(color: color, shape: BoxShape.circle), child: const SizedBox.square(dimension: 4), ), ), @@ -710,6 +771,8 @@ bool _sameMonth(DateTime a, DateTime b) { DateTime _monthOf(DateTime date) => DateTime(date.year, date.month); +DateTime _dayOf(DateTime date) => DateTime(date.year, date.month, date.day); + DateTime _calendarStartForMonth(DateTime first, int firstWeekday) { final monthWeekdayFromMonday = first.weekday - DateTime.monday; final firstWeekdayFromMonday = firstWeekday - DateTime.monday; diff --git a/lib/src/features/schedule/presentation/schedule_sidebar.dart b/lib/src/features/schedule/presentation/schedule_sidebar.dart index e3002a8..e1219ca 100644 --- a/lib/src/features/schedule/presentation/schedule_sidebar.dart +++ b/lib/src/features/schedule/presentation/schedule_sidebar.dart @@ -50,7 +50,6 @@ class ScheduleSidebar extends ConsumerWidget { selectedDate: selectedDate, firstWeekday: firstWeekday, items: items, - showDayHover: true, onSelected: onDateSelected, onMonthSelected: onMonthSelected, onYearSelected: onYearSelected, diff --git a/lib/src/features/schedule/presentation/schedule_year_view.dart b/lib/src/features/schedule/presentation/schedule_year_view.dart index 0411b62..9f24c42 100644 --- a/lib/src/features/schedule/presentation/schedule_year_view.dart +++ b/lib/src/features/schedule/presentation/schedule_year_view.dart @@ -31,6 +31,7 @@ class ScheduleYearView extends StatelessWidget { @override Widget build(BuildContext context) { + final markerColorsByDay = miniCalendarMarkerColorsForItems(context, items); return LayoutBuilder( builder: (context, constraints) { final columns = _columnCount(constraints.maxWidth, compact: compact); @@ -52,20 +53,16 @@ class ScheduleYearView extends StatelessWidget { SizedBox( width: monthWidth, child: BusyMaxGroupedSurface( - child: MiniCalendar( + child: YearMonthMiniCalendar( displayedMonth: DateTime( selectedDate.year, index + 1, ), selectedDate: selectedDate, firstWeekday: firstWeekday, - items: items, - headerStyle: MiniCalendarHeaderStyle.monthLabel, - showDayHover: true, - weekNumbersInteractive: true, - onSelected: onDaySelected, + markerColorsByDay: markerColorsByDay, + onDaySelected: onDaySelected, onMonthSelected: onMonthSelected, - onYearSelected: null, onWeekSelected: onWeekSelected, onDayDoubleTap: onCreateAtDay, ), diff --git a/lib/src/features/tasks/presentation/desktop_date_time_fields.dart b/lib/src/features/tasks/presentation/desktop_date_time_fields.dart index dd648a9..9125323 100644 --- a/lib/src/features/tasks/presentation/desktop_date_time_fields.dart +++ b/lib/src/features/tasks/presentation/desktop_date_time_fields.dart @@ -8,7 +8,6 @@ import 'package:busymax/src/core/time/time_zone_catalog.dart'; import 'package:busymax/src/l10n/l10n.dart'; import 'package:busymax/src/features/schedule/presentation/mini_calendar.dart'; import 'package:busymax/src/features/schedule/presentation/schedule_anchored_popover.dart'; -import 'package:busymax/src/schedule/schedule_item.dart'; import 'package:busymax/src/features/tasks/presentation/time_zone_selection_dialog.dart'; import 'package:yaru/yaru.dart'; @@ -352,11 +351,13 @@ class _DesktopDateValueDialogState extends State<_DesktopDateValueDialog> { static final _firstDate = DateTime(1900); static final _lastDate = DateTime(2100, 12, 31); late DateTime _selected; + late DateTime _displayedMonth; @override void initState() { super.initState(); _selected = _supportedInitialDate(widget.initialDate); + _displayedMonth = DateTime(_selected.year, _selected.month); } @override @@ -379,23 +380,24 @@ class _DesktopDateValueDialogState extends State<_DesktopDateValueDialog> { mainAxisSize: MainAxisSize.min, children: [ _buildDateModeHeader(context), - MiniCalendar( - selectedDate: _selected, - firstWeekday: _firstWeekday(context), - items: const [], - showHeader: false, - showDayHover: true, - weekNumbersInteractive: false, - onSelected: (date) => _setSelectedDate( - date, - submit: - date.year == _selected.year && - date.month == _selected.month, + Padding( + padding: const EdgeInsetsDirectional.fromSTEB( + BusyMaxSpacing.headerInset, + BusyMaxSpacing.headerInset, + BusyMaxSpacing.headerInset, + BusyMaxSpacing.md, + ), + child: MiniCalendarGrid( + displayedMonth: _displayedMonth, + selectedDate: _selected, + firstWeekday: _firstWeekday(context), + onDaySelected: (date) => _setSelectedDate( + date, + submit: + date.year == _displayedMonth.year && + date.month == _displayedMonth.month, + ), ), - onMonthSelected: null, - onYearSelected: null, - onWeekSelected: (week) => - _setSelectedDate(week, submit: true), ), ], ), @@ -408,17 +410,10 @@ class _DesktopDateValueDialogState extends State<_DesktopDateValueDialog> { } void _setSelectedDate(DateTime value, {bool submit = false}) { - final preserveDay = - value.day == 1 && - (value.year != _selected.year || value.month != _selected.month); - final nextDay = preserveDay ? _selected.day : value.day; - final clamped = _clampMonthAndDay( - nextDay, - DateTime(value.year, value.month), - ); - final adjusted = _coerceSupportedRange(clamped); + final adjusted = _coerceSupportedRange(value); setState(() { _selected = adjusted; + _displayedMonth = DateTime(adjusted.year, adjusted.month); }); if (submit) { _submit(); @@ -431,7 +426,7 @@ class _DesktopDateValueDialogState extends State<_DesktopDateValueDialog> { Widget _buildDateModeHeader(BuildContext context) { final locale = Localizations.localeOf(context).toLanguageTag(); - final monthLabel = DateFormat.MMMM(locale).format(_selected); + final monthLabel = DateFormat.MMMM(locale).format(_displayedMonth); return Padding( padding: const EdgeInsetsDirectional.fromSTEB( @@ -448,11 +443,11 @@ class _DesktopDateValueDialogState extends State<_DesktopDateValueDialog> { label: monthLabel, previousTooltip: context.l10n.previousMonth, nextTooltip: context.l10n.nextMonth, - onPrevious: () => _setSelectedDate( - DateTime(_selected.year, _selected.month - 1), + onPrevious: () => _showMonth( + DateTime(_displayedMonth.year, _displayedMonth.month - 1), ), - onNext: () => _setSelectedDate( - DateTime(_selected.year, _selected.month + 1), + onNext: () => _showMonth( + DateTime(_displayedMonth.year, _displayedMonth.month + 1), ), onLabelPressed: null, ), @@ -461,14 +456,14 @@ class _DesktopDateValueDialogState extends State<_DesktopDateValueDialog> { Expanded( child: _buildDateModeStepper( context: context, - label: '${_selected.year}', + label: '${_displayedMonth.year}', previousTooltip: context.l10n.previousYear, nextTooltip: context.l10n.nextYear, - onPrevious: () => _setSelectedDate( - DateTime(_selected.year - 1, _selected.month), + onPrevious: () => _showMonth( + DateTime(_displayedMonth.year - 1, _displayedMonth.month), ), - onNext: () => _setSelectedDate( - DateTime(_selected.year + 1, _selected.month), + onNext: () => _showMonth( + DateTime(_displayedMonth.year + 1, _displayedMonth.month), ), onLabelPressed: null, ), @@ -478,6 +473,18 @@ class _DesktopDateValueDialogState extends State<_DesktopDateValueDialog> { ); } + void _showMonth(DateTime value) { + final firstMonth = DateTime(_firstDate.year, _firstDate.month); + final lastMonth = DateTime(_lastDate.year, _lastDate.month); + final month = DateTime(value.year, value.month); + final adjusted = month.isBefore(firstMonth) + ? firstMonth + : month.isAfter(lastMonth) + ? lastMonth + : month; + setState(() => _displayedMonth = adjusted); + } + Widget _buildDateModeStepper({ required BuildContext context, required String label, @@ -570,11 +577,6 @@ class _DesktopDateValueDialogState extends State<_DesktopDateValueDialog> { ); } - DateTime _clampMonthAndDay(int day, DateTime month) { - final maxDay = DateUtils.getDaysInMonth(month.year, month.month); - return DateTime(month.year, month.month, day.clamp(1, maxDay)); - } - DateTime _coerceSupportedRange(DateTime date) { if (date.isBefore(_firstDate)) { return _firstDate; diff --git a/test/app/busymax_grouped_surface_test.dart b/test/app/busymax_grouped_surface_test.dart index 8511c91..8342dc9 100644 --- a/test/app/busymax_grouped_surface_test.dart +++ b/test/app/busymax_grouped_surface_test.dart @@ -183,7 +183,7 @@ void main() { ).readAsStringSync(); expect(settings, contains('BusyMaxGroupedList(')); - expect(yearView, contains('MiniCalendar(')); + expect(yearView, contains('YearMonthMiniCalendar(')); expect(yearView, contains('BusyMaxGroupedSurface(')); for (final source in [settings, yearView]) { expect(source, isNot(contains('BoxShadow('))); diff --git a/test/app/localization_audit_test.dart b/test/app/localization_audit_test.dart index 9779339..7d96502 100644 --- a/test/app/localization_audit_test.dart +++ b/test/app/localization_audit_test.dart @@ -3,7 +3,7 @@ import 'dart:io'; import 'package:busymax/l10n/generated/app_localizations.dart'; import 'package:busymax/src/l10n/locale_resolution.dart'; -import 'package:flutter/widgets.dart'; +import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; void main() { @@ -114,6 +114,68 @@ void main() { expect(localizations.today, '오늘'); }); + test('Arabic is generated and exposed as a supported locale', () { + const locale = Locale('ar'); + final localizations = lookupAppLocalizations(locale); + + expect(AppLocalizations.supportedLocales, contains(locale)); + expect(localizations.settings, 'الإعدادات'); + expect(localizations.today, 'اليوم'); + }); + + testWidgets('Arabic locale renders right to left', (tester) async { + TextDirection? direction; + + await tester.pumpWidget( + MaterialApp( + locale: const Locale('ar'), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Builder( + builder: (context) { + direction = Directionality.of(context); + return const SizedBox(); + }, + ), + ), + ); + + expect(direction, TextDirection.rtl); + }); + + test('Persian is generated and exposed as a supported locale', () { + const locale = Locale('fa'); + final localizations = lookupAppLocalizations(locale); + + expect(AppLocalizations.supportedLocales, contains(locale)); + expect(localizations.settings, 'تنظیمات'); + expect(localizations.today, 'امروز'); + expect( + localizations.dueTodayNotificationBody(0), + 'امروز هیچ کاری سررسید ندارد.', + ); + }); + + testWidgets('Persian locale renders right to left', (tester) async { + TextDirection? direction; + + await tester.pumpWidget( + MaterialApp( + locale: const Locale('fa'), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Builder( + builder: (context) { + direction = Directionality.of(context); + return const SizedBox(); + }, + ), + ), + ); + + expect(direction, TextDirection.rtl); + }); + test('both Chinese scripts are generated and supported', () { const simplified = Locale.fromSubtags( languageCode: 'zh', @@ -168,8 +230,10 @@ const _auditedUiPaths = [ ]; const _translatedArbPaths = [ + 'lib/l10n/app_ar.arb', 'lib/l10n/app_de.arb', 'lib/l10n/app_es.arb', + 'lib/l10n/app_fa.arb', 'lib/l10n/app_fi.arb', 'lib/l10n/app_fr.arb', 'lib/l10n/app_hi.arb', diff --git a/test/app/native_ui_audit_test.dart b/test/app/native_ui_audit_test.dart index 504c21e..1d75018 100644 --- a/test/app/native_ui_audit_test.dart +++ b/test/app/native_ui_audit_test.dart @@ -275,11 +275,11 @@ void main() { expect(compactAgenda, contains('ScheduleProjection.colorForItem')); expect(compactAgenda, contains('leading: _CompactAgendaRowMarker')); - expect(dateTimeFields, contains('MiniCalendar(')); - expect(dateTimeFields, contains('items: const []')); + expect(dateTimeFields, contains('MiniCalendarGrid(')); + expect(dateTimeFields, isNot(contains('ScheduleItem'))); expect( dateTimeFields, - contains('onSelected: (date) => _setSelectedDate'), + contains('onDaySelected: (date) => _setSelectedDate'), ); expect( 'busyMaxGroupedTextFieldDecoration'.allMatches(dateTimeFields).length, diff --git a/test/features/notifications/desktop_notification_service_test.dart b/test/features/notifications/desktop_notification_service_test.dart index e56ee19..69b38a1 100644 --- a/test/features/notifications/desktop_notification_service_test.dart +++ b/test/features/notifications/desktop_notification_service_test.dart @@ -144,6 +144,48 @@ void main() { } }); + test('notification strings use Arabic plural rules', () async { + final backend = _FakeNotificationBackend(); + final service = DesktopNotificationService( + backend: backend, + settings: AppSettings.defaults().copyWith(notifyDueToday: true), + locale: const Locale('ar'), + ); + + for (final count in [1, 2, 3, 11]) { + await service.notifyDueToday(count); + } + + expect(backend.notifications.map((notification) => notification.body), [ + 'هناك مهمة واحدة مستحقة اليوم.', + 'هناك مهمتان مستحقتان اليوم.', + 'هناك 3 مهام مستحقة اليوم.', + 'هناك 11 مهمة مستحقة اليوم.', + ]); + }); + + test('notification strings use Persian plural rules', () async { + final backend = _FakeNotificationBackend(); + final service = DesktopNotificationService( + backend: backend, + settings: AppSettings.defaults().copyWith(notifyDueToday: true), + locale: const Locale('fa'), + ); + + for (final count in [1, 2]) { + await service.notifyDueToday(count); + } + + expect( + backend.notifications.map((notification) => notification.summary), + everyElement('کارهای دارای سررسید امروز'), + ); + expect(backend.notifications.map((notification) => notification.body), [ + 'امروز یک کار سررسید دارد.', + 'امروز 2 کار سررسید دارند.', + ]); + }); + test('reminder notification details are visible by default', () async { final backend = _FakeNotificationBackend(); final service = DesktopNotificationService( diff --git a/test/features/schedule/presentation/schedule_views_test.dart b/test/features/schedule/presentation/schedule_views_test.dart index bbc88c8..0f298e7 100644 --- a/test/features/schedule/presentation/schedule_views_test.dart +++ b/test/features/schedule/presentation/schedule_views_test.dart @@ -265,7 +265,7 @@ void main() { expect( find.descendant( of: find.byType(ScheduleYearView), - matching: find.byType(MiniCalendar), + matching: find.byType(YearMonthMiniCalendar), ), findsNWidgets(DateTime.monthsPerYear), ); @@ -2475,117 +2475,26 @@ void main() { expect(sidebar, contains('AnimatedRotation')); expect(sidebar, contains('YaruIcons.pan_end')); expect(sidebar, contains('if (_expanded)')); - expect(sidebar, contains('showDayHover: true')); + expect(sidebar, contains('MiniCalendar(')); expect(sidebar, isNot(contains('BusyMaxGroupedList('))); expect(sidebar, isNot(contains('hoverColor: Colors.transparent'))); }); - test('mini calendar has separate month and year steppers', () { + test('mini calendar keeps surface behavior outside the shared grid', () { final source = File( 'lib/src/features/schedule/presentation/mini_calendar.dart', ).readAsStringSync(); + expect(source, contains('class MiniCalendar extends StatefulWidget')); + expect(source, contains('class YearMonthMiniCalendar')); + expect(source, contains('class MiniCalendarGrid extends StatelessWidget')); expect(source, contains('class _MiniCalendarStepper')); - expect(source, contains('this.onMonthSelected')); - expect(source, contains('this.onYearSelected')); - expect(source, contains('this.onWeekSelected')); - expect(source, contains('this.weekNumbersInteractive = true')); - expect(source, contains('required this.firstWeekday')); + expect(source, contains('miniCalendarMarkerColorsForItems')); expect( source, - contains('_calendarStartForMonth(first, widget.firstWeekday)'), - ); - expect(source, contains('monthWeekdayFromMonday')); - expect(source, contains('firstWeekdayFromMonday')); - expect(source, contains('_addCalendarDays(')); - expect(source, contains('row * DateTime.daysPerWeek')); - expect(source, contains('_addCalendarDays(weekStart, column)')); - expect(source, isNot(contains('weekStart.add(Duration(days: column))'))); - expect(source, contains('final weekNumberExtent = math.min')); - expect(source, contains('constraints.maxWidth - weekNumberExtent')); - expect(source, isNot(contains('final calendarWidth ='))); - expect(source, contains('class _MiniCalendarWeekRow')); - expect(source, contains('class _MiniCalendarWeekNumberButton')); - expect(source, contains('class _MiniCalendarDayButton')); - expect(source, contains('class _MiniCalendarDayIndicators')); - expect( - source, - contains( - 'final groupedItems = ScheduleProjection.groupByDay(widget.items)', - ), - ); - expect(source, contains('DateFormat.E(')); - expect(source, contains('_weekdays(widget.firstWeekday)')); - expect(source, contains('ScheduleProjection.colorForItem')); - expect(source, contains('height: dayExtent')); - expect(source, contains('width: double.infinity')); - expect(source, contains('crossAxisAlignment: CrossAxisAlignment.stretch')); - expect(source, contains('SizedBox(width: weekNumberExtent)')); - expect( - source, - contains('for (var column = 0; column < DateTime.daysPerWeek; column++)'), - ); - expect(source, isNot(contains('GridView.builder'))); - expect(source, contains('const SizedBox(width: BusyMaxSpacing.xs)')); - expect( - source, - contains('label: DateFormat.MMMM(locale).format(visibleMonth)'), - ); - expect(source, contains('BusyMaxSpacing.headerInset')); - expect( - source, - isNot(contains('padding: const EdgeInsets.all(BusyMaxSpacing.md)')), - ); - expect(source, contains('labelTooltip: l10n.openMonthView')); - expect(source, contains('onMonthSelected!(first)')); - expect(source, contains('busyMaxHeaderTextButtonStyle')); - expect(source, contains("label: '\${visibleMonth.year}'")); - expect(source, contains('labelTooltip: l10n.openYearView')); - expect(source, contains('onYearSelected!(')); - expect(source, isNot(contains('String _monthName(DateTime date)'))); - expect( - source, - isNot(contains("return '\${months[date.month - 1]} \${date.year}';")), - ); - expect(source, contains('previousTooltip: l10n.previousMonth')); - expect(source, contains('nextTooltip: l10n.nextMonth')); - expect(source, contains('previousTooltip: l10n.previousYear')); - expect(source, contains('nextTooltip: l10n.nextYear')); - expect(source, contains('visibleMonth.year - 1')); - expect(source, contains('visibleMonth.year + 1')); - expect(source, contains('busyMaxHeaderIconButtonStyle')); - expect(source, contains('? _miniCalendarHeaderControlExtent')); - expect(source, contains('flex: _miniCalendarMonthControlFlex')); - expect(source, contains('flex: _miniCalendarYearControlFlex')); - expect(source, contains('busyMaxSubtleButtonBackground(context)')); - expect(source, contains('fixedSize: const Size.square(')); - expect(source, contains('shape: const CircleBorder()')); - expect(source, contains('fontWeight: FontWeight.w600')); - expect(source, contains('_isoWeekNumber')); - expect(source, contains('DateTime.daysPerWeek')); - expect(source, contains('TextButton(')); - expect(source, contains('context.l10n.weekNumberTooltip(weekNumber)')); - expect(source, contains('onSelected!(weekStart)')); - expect(source, contains('BoxShape.circle')); - expect(source, contains('customBorder: const CircleBorder()')); - expect(source, contains('final markerSize = math.min')); - expect( - source, - contains('final highlightToday = today && displayingCurrentMonth'), - ); - expect(source, contains('color: selected')); - expect( - source, - contains('widget.displayedMonth.year == DateTime.now().year'), + contains('final groupedItems = ScheduleProjection.groupByDay'), ); - expect( - source, - contains('widget.displayedMonth.month == DateTime.now().month'), - ); - expect(source, contains('final selected = _sameDay(day, selectedDate)')); - expect(source, isNot(contains('YaruIcons.arrow_left'))); - expect(source, isNot(contains('YaruIcons.arrow_right'))); - expect(source, isNot(contains('BorderRadius.circular(BusyMaxRadius.sm)'))); + expect(source, contains('onWeekSelected == null')); }); testWidgets('mini calendar exposes and activates the selected day', ( @@ -2638,14 +2547,11 @@ void main() { child: Scaffold( body: SizedBox( width: 300, - child: MiniCalendar( + child: MiniCalendarGrid( + displayedMonth: DateTime(2026, 1), selectedDate: DateTime(2026, 1, 15), firstWeekday: DateTime.monday, - showDayHover: true, - onSelected: (_) {}, - onMonthSelected: null, - onYearSelected: null, - onWeekSelected: (_) {}, + onDaySelected: (_) {}, ), ), ), @@ -2844,7 +2750,7 @@ void main() { ), ); - final january = find.byType(MiniCalendar).first; + final january = find.byType(YearMonthMiniCalendar).first; await tester.tap( find.descendant(of: january, matching: find.byTooltip('Week 3')), ); @@ -2916,21 +2822,16 @@ void main() { testWidgets('mini calendar can render week numbers as labels', ( tester, ) async { - DateTime? selectedWeek; - await tester.pumpWidget( localizedTestApp( child: Scaffold( body: SizedBox( width: 300, - child: MiniCalendar( + child: MiniCalendarGrid( + displayedMonth: DateTime(2026, 1), selectedDate: DateTime(2026, 1, 15), firstWeekday: DateTime.monday, - weekNumbersInteractive: false, - onSelected: (_) {}, - onMonthSelected: null, - onYearSelected: null, - onWeekSelected: (weekStart) => selectedWeek = weekStart, + onDaySelected: (_) {}, ), ), ), @@ -2946,7 +2847,6 @@ void main() { findsNothing, ); await tester.tap(find.byTooltip('Week 3')); - expect(selectedWeek, isNull); }); testWidgets('mini calendar week number honors first weekday', (tester) async { @@ -3676,17 +3576,16 @@ void main() { contains('backgroundColor ?? BusyMaxSurfaceColors.of(context).window'), ); expect(yearView, contains('BusyMaxGroupedSurface(')); - expect(yearView, contains('MiniCalendar(')); - expect( - yearView, - contains('headerStyle: MiniCalendarHeaderStyle.monthLabel'), - ); + expect(yearView, contains('YearMonthMiniCalendar(')); expect(yearView, contains('displayedMonth: DateTime(')); expect(yearView, contains('onDayDoubleTap: onCreateAtDay')); expect(yearView, contains('firstWeekday')); expect(yearView, contains('onMonthSelected: onMonthSelected')); - expect(yearView, contains('weekNumbersInteractive: true')); expect(yearView, contains('onWeekSelected: onWeekSelected')); + expect( + yearView, + contains('miniCalendarMarkerColorsForItems(context, items)'), + ); expect(yearView, isNot(contains('height: 142'))); expect(yearView, contains('SingleChildScrollView(')); expect(yearView, isNot(contains('class _YearMonthGrid'))); diff --git a/test/features/tasks/presentation/desktop_date_time_fields_test.dart b/test/features/tasks/presentation/desktop_date_time_fields_test.dart index 69f5345..e740df1 100644 --- a/test/features/tasks/presentation/desktop_date_time_fields_test.dart +++ b/test/features/tasks/presentation/desktop_date_time_fields_test.dart @@ -652,7 +652,7 @@ void main() { ); await tester.pumpAndSettle(); - expect(find.byType(MiniCalendar), findsOneWidget); + expect(find.byType(MiniCalendarGrid), findsOneWidget); expect(find.text('July'), findsOneWidget); expect(find.text('2026'), findsOneWidget); expect(find.byTooltip('Wednesday, July 22, 2026'), findsOneWidget); @@ -682,12 +682,12 @@ void main() { await tester.tap(find.byIcon(YaruIcons.calendar)); await tester.pumpAndSettle(); - expect(find.byType(MiniCalendar), findsOneWidget); + expect(find.byType(MiniCalendarGrid), findsOneWidget); expect(find.text('July'), findsOneWidget); expect(find.text('2026'), findsOneWidget); expect( find.descendant( - of: find.byType(MiniCalendar), + of: find.byType(MiniCalendarGrid), matching: find.byType(TextButton), ), findsNothing, @@ -714,11 +714,16 @@ void main() { await tester.pumpAndSettle(); expect(find.byType(BusyMaxContentPopoverSurface), findsOneWidget); - await tester.tap(find.byIcon(YaruIcons.pan_start).at(0)); + await tester.tap(find.byIcon(YaruIcons.pan_end).at(0)); await tester.pumpAndSettle(); expect(find.byType(BusyMaxContentPopoverSurface), findsOneWidget); + final pagedGrid = tester.widget( + find.byType(MiniCalendarGrid), + ); + expect(pagedGrid.displayedMonth, DateTime(2026, 8)); + expect(pagedGrid.selectedDate, DateTime(2026, 7, 22)); - await tester.tap(find.byIcon(YaruIcons.pan_end).at(0)); + await tester.tap(find.byIcon(YaruIcons.pan_start).at(0)); await tester.pumpAndSettle(); expect(find.byType(BusyMaxContentPopoverSurface), findsOneWidget); }); From b9e176c28bbb83fc5e10a128b7f79725255a21a1 Mon Sep 17 00:00:00 2001 From: albert Date: Wed, 29 Jul 2026 17:08:11 -0700 Subject: [PATCH 40/73] Update localizations --- lib/l10n/app_it.arb | 389 ++++++ lib/l10n/app_ja.arb | 10 +- lib/l10n/app_ko.arb | 22 +- lib/l10n/generated/app_localizations.dart | 10 + lib/l10n/generated/app_localizations_it.dart | 1308 ++++++++++++++++++ lib/l10n/generated/app_localizations_ja.dart | 10 +- lib/l10n/generated/app_localizations_ko.dart | 22 +- 7 files changed, 1739 insertions(+), 32 deletions(-) create mode 100644 lib/l10n/app_it.arb create mode 100644 lib/l10n/generated/app_localizations_it.dart diff --git a/lib/l10n/app_it.arb b/lib/l10n/app_it.arb new file mode 100644 index 0000000..69b9e49 --- /dev/null +++ b/lib/l10n/app_it.arb @@ -0,0 +1,389 @@ +{ + "@@locale": "it", + "appTitle": "BusyMax", + "connectGoogleAccount": "Collega gli account Google e Microsoft per sincronizzare calendari e attività.", + "googlePermissionsConsentNotice": "Nella schermata delle autorizzazioni di Google, seleziona sia l’autorizzazione per il calendario sia quella per le attività.", + "googlePermissionsRequiredRetry": "Sono necessarie le autorizzazioni per Google Calendar e Google Tasks. Riprova e seleziona entrambe le caselle.", + "finishSetup": "Completa configurazione", + "continueSetup": "Continua", + "onboardingSetupTitle": "Configura BusyMax", + "onboardingAccountsStepTitle": "Collega gli account", + "onboardingAccountsStepDescription": "Aggiungi tutti gli account Google e Microsoft che vuoi utilizzare. BusyMax sincronizza calendari, eventi, elenchi di attività e attività di ogni account.", + "onboardingPreferencesStepTitle": "Scegli le impostazioni di sistema", + "onboardingPreferencesStepDescription": "Prima di aprire l’agenda, configura il comportamento sul desktop, i promemoria, il livello di dettaglio delle notifiche e l’aspetto.", + "signInWithGoogle": "Accedi con Google", + "signInWithMicrosoft": "Accedi con Microsoft", + "googleTasksProvider": "Google Tasks", + "microsoftTodoProvider": "Microsoft To Do", + "providerNotConfigured": "Questo servizio non è configurato.", + "waitingForGoogleSignIn": "In attesa dell’accesso a Google...", + "waitingForMicrosoftSignIn": "In attesa dell’accesso a Microsoft...", + "microsoftSignInNotConfigured": "L’accesso a Microsoft non è configurato. Imposta MICROSOFT_OAUTH_CLIENT_ID.", + "cancel": "Annulla", + "close": "Chiudi", + "exit": "Esci", + "options": "Opzioni", + "hide": "Nascondi", + "show": "Mostra", + "export": "Esporta", + "save": "Salva", + "settings": "Impostazioni", + "all": "Tutto", + "calendarEvents": "Eventi", + "calendarTasks": "Attività", + "calendar": "Calendario", + "calendars": "Calendari", + "newEvent": "Nuovo evento", + "refreshCalendar": "Aggiorna calendario", + "openInProvider": "Apri nel servizio", + "hideFromSchedule": "Nascondi dall’agenda", + "showInSchedule": "Mostra nell’agenda", + "noCalendarsSynced": "Nessun calendario ancora sincronizzato.", + "allDay": "Tutto il giorno", + "moreItems": "+{count} altri", + "noEventsOrTasks": "Nessun evento o attività", + "scheduleLoading": "Caricamento agenda...", + "scheduleUnavailable": "Agenda non disponibile", + "scheduleNoSources": "Nessun calendario o elenco di attività visibile", + "scheduleNoSourcesDescription": "Scegli cosa mostrare nelle Impostazioni, quindi aggiorna l’agenda.", + "scheduleSignInRequired": "Collega un account", + "scheduleSignInDescription": "Accedi per sincronizzare calendari e attività.", + "scheduleNoSearchResults": "Nessun evento o attività corrispondente", + "scheduleNoSearchResultsDescription": "Prova una ricerca diversa o cancella i filtri attuali.", + "trayAgendaLoading": "Caricamento agenda...", + "trayAgendaSignInRequired": "Accedi per mostrare l’agenda.", + "trayAgendaNoSources": "Nessun calendario o elenco di attività visibile.", + "trayAgendaOpenBusyMax": "Apri applicazione", + "trayAgendaRefresh": "Aggiorna", + "trayAgendaError": "Agenda non disponibile", + "compactAgendaTitle": "Agenda", + "compactAgendaSubtitle": "In arrivo", + "compactAgendaOverdue": "Scadute", + "compactAgendaClear": "Nessun impegno per ora", + "compactAgendaOpenBusyMax": "Apri BusyMax", + "compactAgendaHide": "Nascondi", + "compactAgendaNewTask": "Nuova attività", + "compactAgendaRetry": "Riprova", + "compactAgendaRefresh": "Aggiorna", + "compactAgendaAllDay": "Tutto il giorno", + "compactAgendaDueToday": "Scadenza: oggi", + "compactAgendaDueTomorrow": "Scadenza: domani", + "compactAgendaDueOn": "Scadenza: {date}", + "compactAgendaMoreOverdue": "Carica altre attività scadute", + "agendaLoadMoreOverdue": "Carica altre attività scadute", + "agendaLoadMoreNoDate": "Carica altre attività senza data", + "viewDay": "Giorno", + "viewWeek": "Settimana", + "viewMonth": "Mese", + "viewYear": "Anno", + "viewAgenda": "Agenda", + "scheduleSettings": "Agenda", + "scheduleDisplaySettings": "Visualizzazione agenda", + "scheduleDisplayHoursDescription": "Le viste Giorno e Settimana mostrano inizialmente questo intervallo orario. Gli elementi precedenti o successivi lo estendono quando necessario.", + "scheduleDayStartsAt": "Inizio giornata", + "scheduleDayEndsAt": "Fine giornata", + "sourceCalendar": "Calendario", + "sourceTaskList": "Elenco di attività", + "createChoiceTitle": "Crea", + "createEventAtTime": "Evento", + "createTaskAtDate": "Attività", + "editEvent": "Modifica evento", + "eventTitle": "Titolo dell’evento", + "location": "Luogo", + "timeSlot": "Fascia oraria", + "startDateTime": "Data/ora di inizio", + "endDateTime": "Data/ora di fine", + "doesNotRepeat": "Non si ripete", + "defaultReminder": "Promemoria predefinito", + "guests": "Invitati", + "noGuests": "Nessun invitato", + "description": "Descrizione", + "availabilityShowAs": "Disponibilità / Mostra come", + "busy": "Occupato", + "visibility": "Visibilità", + "defaultVisibility": "Visibilità predefinita", + "conference": "Riunione", + "noConference": "Nessuna riunione", + "providerCalendar": "Calendario del servizio", + "formatBoldShortLabel": "G", + "formatBoldTooltip": "Grassetto", + "formatItalicShortLabel": "C", + "formatItalicTooltip": "Corsivo", + "formatUnderlineShortLabel": "S", + "formatUnderlineTooltip": "Sottolineato", + "reminderMinutesBefore": "{minutes, plural, =1{1 minuto prima} other{{minutes} minuti prima}}", + "reminderAtStart": "All’inizio", + "reminderHoursBefore": "{hours, plural, =1{1 ora prima} other{{hours} ore prima}}", + "reminderDaysBefore": "{days, plural, =1{1 giorno prima} other{{days} giorni prima}}", + "availabilityFree": "Libero", + "availabilityTentative": "Provvisorio", + "availabilityOutOfOffice": "Fuori sede", + "availabilityWorkingElsewhere": "Lavora altrove", + "visibilityDefault": "Predefinita", + "visibilityPublic": "Pubblica", + "visibilityPrivate": "Privata", + "visibilityConfidential": "Riservata", + "sensitivityNormal": "Normale", + "sensitivityPersonal": "Personale", + "tasks": "Attività", + "allTasks": "Tutte le attività", + "tasksInList": "Attività in {title}", + "taskLists": "Elenchi di attività", + "navigation": "Navigazione", + "mainMenu": "Menu principale", + "keyboardShortcuts": "Scorciatoie da tastiera", + "shortcutGroupGeneral": "Generali", + "shortcutKeyboardShortcutsDescription": "Mostra questo elenco di scorciatoie", + "shortcutGroupNavigation": "Navigazione", + "shortcutNextPeriod": "Periodo successivo", + "shortcutNextPeriodDescription": "Settimana successiva nella vista settimanale, mese successivo nella vista mensile e così via", + "shortcutPreviousPeriod": "Periodo precedente", + "shortcutPreviousPeriodDescription": "Settimana precedente nella vista settimanale, mese precedente nella vista mensile e così via", + "shortcutJumpToToday": "Vai alla data odierna", + "shortcutGroupView": "Vista", + "shortcutDayView": "Vista giornaliera", + "shortcutWeekView": "Vista settimanale", + "shortcutMonthView": "Vista mensile", + "shortcutYearView": "Vista annuale", + "shortcutAgendaView": "Vista agenda", + "shortcutGroupCreateAndEdit": "Creazione e modifica", + "shortcutSaveItem": "Salva evento o attività", + "shortcutDeleteItem": "Elimina evento o attività", + "shortcutGroupTaskEditing": "Modifica delle attività", + "shortcutCancelEditing": "Annulla modifica", + "shortcutCancelEditingDescription": "Chiudi la modifica o i dettagli dell’attività", + "shortcutGroupCompactAgenda": "Agenda compatta", + "shortcutRefreshCompactAgendaDescription": "Aggiorna la finestra dell’agenda compatta", + "shortcutHideCompactAgendaDescription": "Nascondi la finestra dell’agenda compatta", + "aboutBusyMax": "Informazioni su BusyMax", + "aboutBusyMaxDescription": "Attività e calendario", + "website": "Sito web", + "reportAnIssue": "Segnala un problema", + "sendFeedback": "Invia feedback", + "feedbackSubmit": "Invia", + "feedbackCategory": "Categoria", + "feedbackSelectCategory": "Seleziona una categoria", + "feedbackCategoryProblem": "Problema o errore", + "feedbackCategoryFeature": "Richiesta di funzionalità", + "feedbackCategoryPrivacySecurity": "Problema di privacy o sicurezza", + "feedbackCategoryUsability": "Problema di usabilità", + "feedbackCategoryOther": "Altro", + "feedbackSubject": "Oggetto", + "feedbackDetailedMessage": "Messaggio dettagliato", + "feedbackReplyEmail": "Indirizzo email per la risposta (facoltativo)", + "feedbackIncludeTechnicalDetails": "Includi dettagli tecnici", + "feedbackTechnicalDetailsDisclosure": "Aggiunge soltanto la versione del sistema operativo Linux e le impostazioni locali dell’applicazione. Non vengono inclusi log, dati degli account, nomi di file o altre informazioni diagnostiche.", + "feedbackCategoryRequired": "Seleziona una categoria.", + "feedbackSubjectLengthError": "L’oggetto deve contenere da 3 a 120 caratteri.", + "feedbackMessageLengthError": "Il messaggio deve contenere da 10 a 5.000 caratteri.", + "feedbackInvalidEmail": "Inserisci un indirizzo email valido.", + "feedbackConnectionError": "Impossibile connettersi a BusyStack. Controlla la connessione e riprova.", + "feedbackTimeoutError": "La richiesta è scaduta. Il feedback non è stato cancellato; riprova.", + "feedbackRateLimitedError": "Sono stati inviati troppi feedback da questa rete. Attendi e riprova.", + "feedbackRejectedError": "Il server ha rifiutato l’invio. Controlla i campi e riprova.", + "feedbackServerError": "BusyStack non può accettare il feedback in questo momento. Il feedback non è stato cancellato; riprova.", + "feedbackSuccess": "Feedback inviato. Riferimento: {id}", + "toggleSidebar": "Mostra o nascondi la barra laterale", + "accounts": "Account", + "currentAccount": "Account attuale", + "switchAccount": "Cambia account", + "addGoogleAccount": "Aggiungi account Google", + "addMicrosoftAccount": "Aggiungi account Microsoft", + "googleProvider": "Google", + "microsoftProvider": "Microsoft", + "signedInAccount": "Accesso effettuato", + "removeAccount": "Rimuovi account…", + "removingAccount": "Rimozione account…", + "removeAccountDescription": "Interrompi la sincronizzazione e rimuovi i dati di questo account dal dispositivo.", + "removeAccountTitle": "Rimuovere {account} da BusyMax?", + "removeAccountConfirmation": "Questa azione elimina dal dispositivo attività, calendari, eventi e promemoria memorizzati nella cache, oltre alle modifiche offline in sospeso. Le modifiche non sincronizzate andranno perse. Non verrà eliminato nulla da Google o Microsoft.", + "revokeGoogleAccess": "Revoca anche l’accesso di BusyMax a questo account Google", + "revokeGoogleAccessDescription": "Dovrai concedere nuovamente l’accesso prima di riconnetterti.", + "removeAccountAction": "Rimuovi account", + "removeAccountFailed": "Impossibile completare la rimozione dell’account. Riprova.", + "accountRemovedGoogleRevokeFailed": "L’account è stato rimosso da questo dispositivo, ma BusyMax non ha potuto revocare l’accesso all’account Google. Puoi revocarlo dalle impostazioni dell’account Google.", + "newList": "Nuovo elenco", + "signInToViewTaskLists": "Accedi per visualizzare gli elenchi di attività.", + "noTaskListsSynced": "Nessun elenco di attività ancora sincronizzato.", + "listActions": "Azioni dell’elenco", + "rename": "Rinomina", + "delete": "Elimina", + "renameList": "Rinomina elenco", + "deleteList": "Elimina elenco", + "builtInMicrosoftList": "Integrato", + "builtInMicrosoftListCannotRenameDelete": "Gli elenchi integrati di Microsoft To Do non possono essere rinominati o eliminati.", + "deleteListConfirmation": "Eliminare «{title}» da Google Tasks?", + "deleteEvent": "Elimina evento", + "title": "Titolo", + "create": "Crea", + "newTask": "Nuova attività", + "clearCompleted": "Cancella attività completate", + "refreshList": "Aggiorna elenco", + "refreshAll": "Aggiorna tutto", + "listRefreshed": "Elenco aggiornato.", + "allTasksRefreshed": "Tutti gli account sono stati aggiornati.", + "exportedFile": "Esportato in {path}", + "exportFailed": "Esportazione non riuscita: {error}", + "refreshFailed": "Aggiornamento non riuscito: {error}", + "selectOrCreateTaskList": "Seleziona o crea un elenco di attività per iniziare.", + "signInToViewTasks": "Accedi per visualizzare le attività.", + "noTasks": "Nessuna attività.", + "noTasksYet": "Ancora nessuna attività", + "noTasksYetMessage": "Crea un’attività o aggiorna gli account per iniziare.", + "noTasksInList": "Nessuna attività in questo elenco.", + "overdue": "Scadute", + "today": "Oggi", + "tomorrow": "Domani", + "upcoming": "In arrivo", + "noDate": "Senza data", + "completed": "Completate", + "duePrefix": "Scadenza: {date}", + "dateTimeDisplay": "{date} · {time}", + "taskDetails": "Dettagli attività", + "editTask": "Modifica attività", + "noTaskSelected": "Nessuna attività selezionata.", + "noTaskSelectedHelper": "Seleziona un’attività per visualizzarne e modificarne i dettagli.", + "taskUnavailable": "Attività non disponibile.", + "signInToEditTasks": "Accedi per modificare le attività.", + "refreshTask": "Aggiorna attività", + "primarySection": "Principale", + "statusSection": "Stato", + "openStatus": "Aperta", + "doneStatus": "Completata", + "notes": "Note", + "dueDate": "Data di scadenza", + "clearDueDate": "Cancella data di scadenza", + "dueTime": "Ora di scadenza", + "startDate": "Data di inizio", + "startTime": "Ora di inizio", + "endDate": "Data di fine", + "endTime": "Ora di fine", + "reminderDate": "Data del promemoria", + "reminderTime": "Ora del promemoria", + "reminder": "Promemoria", + "addReminder": "Aggiungi promemoria", + "addGuest": "Aggiungi invitato", + "addGuestEmail": "Aggiungi email dell’invitato", + "removeReminder": "Rimuovi promemoria", + "off": "Disattivato", + "repeat": "Ripeti", + "repeatNone": "Nessuna ripetizione", + "noneValue": "Nessuno", + "repeatDaily": "Ogni giorno", + "repeatWeekly": "Ogni settimana", + "repeatMonthly": "Ogni mese", + "repeatYearly": "Ogni anno", + "importance": "Importanza", + "importanceLow": "Bassa", + "importanceNormal": "Normale", + "importanceHigh": "Alta", + "categories": "Categorie", + "scheduleSection": "Programmazione", + "dueGroup": "Scadenza", + "startGroup": "Inizio", + "reminderGroup": "Promemoria", + "organizationSection": "Organizzazione", + "actionsSection": "Azioni", + "advancedSection": "Avanzate", + "addCategory": "Aggiungi categoria", + "list": "Elenco", + "microsoftMoveUnsupported": "In questa versione non è possibile spostare attività tra elenchi negli account Microsoft To Do.", + "createSubtask": "Crea sottoattività", + "moveToTop": "Sposta in cima", + "deleteTask": "Elimina attività", + "newSubtask": "Nuova sottoattività", + "deleteTaskConfirmation": "Eliminare «{title}» da Google Tasks?", + "metadata": "Metadati", + "id": "ID", + "etag": "ETag", + "updated": "Aggiornato", + "parent": "Attività principale", + "position": "Posizione", + "webLink": "Collegamento web", + "assignment": "Assegnazione", + "localState": "Stato locale", + "pendingSync": "Sincronizzazione in sospeso", + "synced": "Sincronizzato", + "account": "Account", + "sync": "Sincronizzazione", + "manualFullSync": "Sincronizzazione completa manuale", + "runInBackgroundWhenClosed": "Continua l’esecuzione quando la finestra viene chiusa", + "showTrayIcon": "Mostra icona nell’area di notifica", + "startMinimizedToTray": "Avvia ridotto a icona nell’area di notifica", + "requiresTrayIcon": "Richiede l’icona nell’area di notifica.", + "syncComplete": "Sincronizzazione completata.", + "syncFailed": "Sincronizzazione non riuscita: {error}", + "notifySyncFailures": "Notifiche in caso di errore di sincronizzazione", + "notifyConflicts": "Notifiche in caso di conflitto", + "notifyDueToday": "Notifiche per attività in scadenza oggi", + "eventReminders": "Promemoria degli eventi", + "taskReminders": "Promemoria delle attività", + "notificationDetailLevel": "Livello di dettaglio delle notifiche", + "notificationDetailPrivate": "Privato", + "notificationDetailNormal": "Normale", + "quietHours": "Ore di silenzio", + "quietHoursDescription": "Sospendi le notifiche durante questo periodo.", + "quietHoursStart": "Inizio delle ore di silenzio", + "quietHoursEnd": "Fine delle ore di silenzio", + "notifications": "Notifiche", + "appearance": "Aspetto", + "theme": "Tema", + "themeSystem": "Sistema", + "themeLight": "Chiaro", + "themeDark": "Scuro", + "themeFamily": "Famiglia di temi", + "themeFamilyYaru": "Tema nativo di Ubuntu (Yaru)", + "localization": "Lingua e area geografica", + "currentLocale": "Impostazioni locali correnti", + "privacy": "Privacy", + "redactTaskContentInDiagnostics": "Nascondi il contenuto delle attività nelle informazioni diagnostiche", + "developerDiagnostics": "Diagnostica per sviluppatori", + "diagnostics": "Diagnostica", + "apiInspectorDisabled": "Mostra controllo API", + "googleTasksApi": "API Google Tasks", + "discoveryRevision": "Revisione Discovery: {revision}", + "implementedMethods": "Metodi implementati", + "supportsTasksScopes": "Supporta gli ambiti tasks e tasks.readonly", + "requiresTasksScope": "Richiede l’ambito tasks", + "blockedPendingOperations": "Operazioni in sospeso bloccate", + "signInToInspectPendingOperations": "Accedi per esaminare le operazioni in sospeso.", + "noBlockedPendingOperations": "Nessuna operazione in sospeso bloccata.", + "operationActions": "Azioni dell’operazione", + "pendingOpListId": "elenco={id}", + "pendingOpTaskId": "attività={id}", + "pendingOpAttempts": "tentativi={count}", + "retry": "Riprova", + "discard": "Scarta", + "discardChanges": "Scartare le modifiche?", + "discardChangesConfirmation": "Questa azione scarta le modifiche non salvate dell’attività.", + "retryCompleted": "Nuovo tentativo completato.", + "discardPendingOperation": "Scartare l’operazione in sospeso?", + "discardPendingOperationConfirmation": "Questa azione rimuove l’operazione locale bloccata. Alla prossima sincronizzazione, i dati verranno ricaricati da Google Tasks.", + "pendingOperationDiscarded": "Operazione in sospeso scartata.", + "syncFailureNotificationTitle": "Sincronizzazione di BusyMax non riuscita", + "syncFailureNotificationBody": "Sincronizzazione in background non riuscita. {message}", + "conflictNotificationTitle": "Conflitto di sincronizzazione di BusyMax", + "conflictNotificationBody": "Una modifica locale in sospeso è stata bloccata. {summary}", + "dueTodayNotificationTitle": "Attività in scadenza oggi", + "dueTodayNotificationBody": "{count, plural, =1{Un’attività scade oggi.} other{{count} attività scadono oggi.}}", + "eventReminderNotificationTitle": "Promemoria evento", + "taskReminderNotificationTitle": "Promemoria attività", + "eventReminderNotificationBody": "L’evento inizierà a breve.", + "taskReminderNotificationBody": "L’attività scadrà a breve.", + "notificationOpenAction": "Apri", + "notificationDetailsHidden": "I dettagli sono nascosti dalle impostazioni sulla privacy.", + "previousMonth": "Mese precedente", + "nextMonth": "Mese successivo", + "openMonthView": "Apri vista mensile", + "previousYear": "Anno precedente", + "nextYear": "Anno successivo", + "openYearView": "Apri vista annuale", + "weekNumberTooltip": "Settimana {number}", + "resizeAllDayPanel": "Ridimensiona il pannello per l’intera giornata", + "scheduleItemCount": "{count, plural, =1{1 elemento} other{{count} elementi}}", + "readOnlyCalendar": "Questo calendario è di sola lettura.", + "selectTimeZone": "Seleziona fuso orario", + "searchLocations": "Cerca luoghi", + "noLocationsFound": "Nessun luogo trovato", + "deleteCalendarConfirmation": "Eliminare «{title}»?" +} diff --git a/lib/l10n/app_ja.arb b/lib/l10n/app_ja.arb index b460770..2749026 100644 --- a/lib/l10n/app_ja.arb +++ b/lib/l10n/app_ja.arb @@ -35,7 +35,7 @@ "calendars": "カレンダー", "newEvent": "新しい予定", "refreshCalendar": "カレンダーを更新", - "openInProvider": "プロバイダーで開く", + "openInProvider": "サービスで開く", "hideFromSchedule": "スケジュールから非表示", "showInSchedule": "スケジュールに表示", "noCalendarsSynced": "同期済みのカレンダーはまだありません。", @@ -79,7 +79,7 @@ "viewAgenda": "予定一覧", "scheduleSettings": "スケジュール", "scheduleDisplaySettings": "スケジュール表示", - "scheduleDisplayHoursDescription": "日表示と週表示では、最初にこの時間範囲が表示されます。必要に応じて、範囲外の早い項目や遅い項目まで表示範囲が広がります。", + "scheduleDisplayHoursDescription": "日表示と週表示では、最初にこの時間範囲が表示されます。必要に応じて、この範囲より前または後の項目も表示されるように範囲が広がります。", "scheduleDayStartsAt": "一日の開始時刻", "scheduleDayEndsAt": "一日の終了時刻", "sourceCalendar": "カレンダー", @@ -112,7 +112,7 @@ "formatUnderlineShortLabel": "U", "formatUnderlineTooltip": "下線", "reminderMinutesBefore": "{minutes, plural, =1{1分前} other{{minutes}分前}}", - "reminderAtStart": "開始時刻", + "reminderAtStart": "開始時", "reminderHoursBefore": "{hours, plural, =1{1時間前} other{{hours}時間前}}", "reminderDaysBefore": "{days, plural, =1{1日前} other{{days}日前}}", "availabilityFree": "空き時間", @@ -201,7 +201,7 @@ "revokeGoogleAccessDescription": "再接続する前に、もう一度アクセスを許可する必要があります。", "removeAccountAction": "アカウントを削除", "removeAccountFailed": "アカウントの削除を完了できませんでした。もう一度お試しください。", - "accountRemovedGoogleRevokeFailed": "アカウントはこのデバイスから削除されましたが、BusyMax は Google へのアクセス権を取り消せませんでした。Google アカウントから取り消すことができます。", + "accountRemovedGoogleRevokeFailed": "アカウントはこのデバイスから削除されましたが、BusyMax は Google アカウントへのアクセス権を取り消せませんでした。Google アカウントの設定から取り消すことができます。", "newList": "新しいリスト", "signInToViewTaskLists": "タスクリストを表示するにはサインインしてください。", "noTaskListsSynced": "同期済みのタスクリストはまだありません。", @@ -348,7 +348,7 @@ "blockedPendingOperations": "ブロックされた保留中の操作", "signInToInspectPendingOperations": "保留中の操作を確認するにはサインインしてください。", "noBlockedPendingOperations": "ブロックされた保留中の操作はありません。", - "operationActions": "操作のアクション", + "operationActions": "操作への対応", "pendingOpListId": "リスト={id}", "pendingOpTaskId": "タスク={id}", "pendingOpAttempts": "試行回数={count}", diff --git a/lib/l10n/app_ko.arb b/lib/l10n/app_ko.arb index 67f5f99..bf4b502 100644 --- a/lib/l10n/app_ko.arb +++ b/lib/l10n/app_ko.arb @@ -15,7 +15,7 @@ "signInWithMicrosoft": "Microsoft로 로그인", "googleTasksProvider": "Google Tasks", "microsoftTodoProvider": "Microsoft To Do", - "providerNotConfigured": "이 공급자는 구성되지 않았습니다.", + "providerNotConfigured": "이 서비스는 설정되어 있지 않습니다.", "waitingForGoogleSignIn": "Google 로그인을 기다리는 중...", "waitingForMicrosoftSignIn": "Microsoft 로그인을 기다리는 중...", "microsoftSignInNotConfigured": "Microsoft 로그인이 구성되지 않았습니다. MICROSOFT_OAUTH_CLIENT_ID를 설정하세요.", @@ -35,7 +35,7 @@ "calendars": "캘린더", "newEvent": "새 일정", "refreshCalendar": "캘린더 새로 고침", - "openInProvider": "공급자에서 열기", + "openInProvider": "서비스에서 열기", "hideFromSchedule": "일정에서 숨기기", "showInSchedule": "일정에 표시", "noCalendarsSynced": "아직 동기화된 캘린더가 없습니다.", @@ -98,13 +98,13 @@ "guests": "참석자", "noGuests": "참석자 없음", "description": "설명", - "availabilityShowAs": "상태 / 다음으로 표시", + "availabilityShowAs": "일정 상태 / 표시 방식", "busy": "바쁨", "visibility": "공개 범위", "defaultVisibility": "기본 공개 범위", "conference": "회의", "noConference": "회의 없음", - "providerCalendar": "공급자 캘린더", + "providerCalendar": "서비스 캘린더", "formatBoldShortLabel": "B", "formatBoldTooltip": "굵게", "formatItalicShortLabel": "I", @@ -112,7 +112,7 @@ "formatUnderlineShortLabel": "U", "formatUnderlineTooltip": "밑줄", "reminderMinutesBefore": "{minutes, plural, =1{1분 전} other{{minutes}분 전}}", - "reminderAtStart": "시작 시간", + "reminderAtStart": "시작 시", "reminderHoursBefore": "{hours, plural, =1{1시간 전} other{{hours}시간 전}}", "reminderDaysBefore": "{days, plural, =1{1일 전} other{{days}일 전}}", "availabilityFree": "한가함", @@ -166,7 +166,7 @@ "feedbackCategoryProblem": "문제 또는 버그", "feedbackCategoryFeature": "기능 요청", "feedbackCategoryPrivacySecurity": "개인정보 보호 또는 보안 우려", - "feedbackCategoryUsability": "사용성 관련 의견", + "feedbackCategoryUsability": "사용 편의성 문제", "feedbackCategoryOther": "기타", "feedbackSubject": "제목", "feedbackDetailedMessage": "자세한 내용", @@ -201,7 +201,7 @@ "revokeGoogleAccessDescription": "다시 연결하기 전에 액세스 권한을 다시 부여해야 합니다.", "removeAccountAction": "계정 삭제", "removeAccountFailed": "계정 삭제를 완료할 수 없습니다. 다시 시도하세요.", - "accountRemovedGoogleRevokeFailed": "이 기기에서 계정은 삭제되었지만 BusyMax가 Google 액세스 권한을 취소하지 못했습니다. Google 계정에서 직접 취소할 수 있습니다.", + "accountRemovedGoogleRevokeFailed": "계정은 이 기기에서 삭제되었지만 BusyMax가 Google 계정 액세스 권한을 취소하지 못했습니다. Google 계정에서 직접 취소할 수 있습니다.", "newList": "새 목록", "signInToViewTaskLists": "할 일 목록을 보려면 로그인하세요.", "noTaskListsSynced": "아직 동기화된 할 일 목록이 없습니다.", @@ -222,7 +222,7 @@ "refreshAll": "모두 새로 고침", "listRefreshed": "목록을 새로 고쳤습니다.", "allTasksRefreshed": "모든 계정을 새로 고쳤습니다.", - "exportedFile": "{path}(으)로 내보냈습니다", + "exportedFile": "{path}에 내보냈습니다", "exportFailed": "내보내기 실패: {error}", "refreshFailed": "새로 고침 실패: {error}", "selectOrCreateTaskList": "시작하려면 할 일 목록을 선택하거나 만드세요.", @@ -248,7 +248,7 @@ "refreshTask": "할 일 새로 고침", "primarySection": "기본", "statusSection": "상태", - "openStatus": "진행 중", + "openStatus": "미완료", "doneStatus": "완료", "notes": "메모", "dueDate": "마감일", @@ -341,14 +341,14 @@ "diagnostics": "진단", "apiInspectorDisabled": "API 검사기 표시", "googleTasksApi": "Google Tasks API", - "discoveryRevision": "검색 버전: {revision}", + "discoveryRevision": "Discovery 리비전: {revision}", "implementedMethods": "구현된 메서드", "supportsTasksScopes": "tasks 및 tasks.readonly 범위 지원", "requiresTasksScope": "tasks 범위 필요", "blockedPendingOperations": "차단된 보류 작업", "signInToInspectPendingOperations": "보류 작업을 확인하려면 로그인하세요.", "noBlockedPendingOperations": "차단된 보류 작업이 없습니다.", - "operationActions": "작업 동작", + "operationActions": "작업별 조치", "pendingOpListId": "목록={id}", "pendingOpTaskId": "할 일={id}", "pendingOpAttempts": "시도={count}", diff --git a/lib/l10n/generated/app_localizations.dart b/lib/l10n/generated/app_localizations.dart index 71e9643..2dd9007 100644 --- a/lib/l10n/generated/app_localizations.dart +++ b/lib/l10n/generated/app_localizations.dart @@ -13,10 +13,12 @@ import 'app_localizations_fa.dart'; import 'app_localizations_fi.dart'; import 'app_localizations_fr.dart'; import 'app_localizations_hi.dart'; +import 'app_localizations_it.dart'; import 'app_localizations_ja.dart'; import 'app_localizations_ko.dart'; import 'app_localizations_pt.dart'; import 'app_localizations_ru.dart'; +import 'app_localizations_vi.dart'; import 'app_localizations_zh.dart'; // ignore_for_file: type=lint @@ -113,10 +115,12 @@ abstract class AppLocalizations { Locale('fi'), Locale('fr'), Locale('hi'), + Locale('it'), Locale('ja'), Locale('ko'), Locale('pt'), Locale('ru'), + Locale('vi'), Locale('zh'), Locale.fromSubtags(languageCode: 'zh', scriptCode: 'Hans'), Locale.fromSubtags(languageCode: 'zh', scriptCode: 'Hant'), @@ -2458,10 +2462,12 @@ class _AppLocalizationsDelegate 'fi', 'fr', 'hi', + 'it', 'ja', 'ko', 'pt', 'ru', + 'vi', 'zh', ].contains(locale.languageCode); @@ -2502,6 +2508,8 @@ AppLocalizations lookupAppLocalizations(Locale locale) { return AppLocalizationsFr(); case 'hi': return AppLocalizationsHi(); + case 'it': + return AppLocalizationsIt(); case 'ja': return AppLocalizationsJa(); case 'ko': @@ -2510,6 +2518,8 @@ AppLocalizations lookupAppLocalizations(Locale locale) { return AppLocalizationsPt(); case 'ru': return AppLocalizationsRu(); + case 'vi': + return AppLocalizationsVi(); case 'zh': return AppLocalizationsZh(); } diff --git a/lib/l10n/generated/app_localizations_it.dart b/lib/l10n/generated/app_localizations_it.dart new file mode 100644 index 0000000..708cda8 --- /dev/null +++ b/lib/l10n/generated/app_localizations_it.dart @@ -0,0 +1,1308 @@ +// ignore: unused_import +import 'package:intl/intl.dart' as intl; +import 'app_localizations.dart'; + +// ignore_for_file: type=lint + +/// The translations for Italian (`it`). +class AppLocalizationsIt extends AppLocalizations { + AppLocalizationsIt([String locale = 'it']) : super(locale); + + @override + String get appTitle => 'BusyMax'; + + @override + String get connectGoogleAccount => + 'Collega gli account Google e Microsoft per sincronizzare calendari e attività.'; + + @override + String get googlePermissionsConsentNotice => + 'Nella schermata delle autorizzazioni di Google, seleziona sia l’autorizzazione per il calendario sia quella per le attività.'; + + @override + String get googlePermissionsRequiredRetry => + 'Sono necessarie le autorizzazioni per Google Calendar e Google Tasks. Riprova e seleziona entrambe le caselle.'; + + @override + String get finishSetup => 'Completa configurazione'; + + @override + String get continueSetup => 'Continua'; + + @override + String get onboardingSetupTitle => 'Configura BusyMax'; + + @override + String get onboardingAccountsStepTitle => 'Collega gli account'; + + @override + String get onboardingAccountsStepDescription => + 'Aggiungi tutti gli account Google e Microsoft che vuoi utilizzare. BusyMax sincronizza calendari, eventi, elenchi di attività e attività di ogni account.'; + + @override + String get onboardingPreferencesStepTitle => + 'Scegli le impostazioni di sistema'; + + @override + String get onboardingPreferencesStepDescription => + 'Prima di aprire l’agenda, configura il comportamento sul desktop, i promemoria, il livello di dettaglio delle notifiche e l’aspetto.'; + + @override + String get signInWithGoogle => 'Accedi con Google'; + + @override + String get signInWithMicrosoft => 'Accedi con Microsoft'; + + @override + String get googleTasksProvider => 'Google Tasks'; + + @override + String get microsoftTodoProvider => 'Microsoft To Do'; + + @override + String get providerNotConfigured => 'Questo servizio non è configurato.'; + + @override + String get waitingForGoogleSignIn => 'In attesa dell’accesso a Google...'; + + @override + String get waitingForMicrosoftSignIn => + 'In attesa dell’accesso a Microsoft...'; + + @override + String get microsoftSignInNotConfigured => + 'L’accesso a Microsoft non è configurato. Imposta MICROSOFT_OAUTH_CLIENT_ID.'; + + @override + String get cancel => 'Annulla'; + + @override + String get close => 'Chiudi'; + + @override + String get exit => 'Esci'; + + @override + String get options => 'Opzioni'; + + @override + String get hide => 'Nascondi'; + + @override + String get show => 'Mostra'; + + @override + String get export => 'Esporta'; + + @override + String get save => 'Salva'; + + @override + String get settings => 'Impostazioni'; + + @override + String get all => 'Tutto'; + + @override + String get calendarEvents => 'Eventi'; + + @override + String get calendarTasks => 'Attività'; + + @override + String get calendar => 'Calendario'; + + @override + String get calendars => 'Calendari'; + + @override + String get newEvent => 'Nuovo evento'; + + @override + String get refreshCalendar => 'Aggiorna calendario'; + + @override + String get openInProvider => 'Apri nel servizio'; + + @override + String get hideFromSchedule => 'Nascondi dall’agenda'; + + @override + String get showInSchedule => 'Mostra nell’agenda'; + + @override + String get noCalendarsSynced => 'Nessun calendario ancora sincronizzato.'; + + @override + String get allDay => 'Tutto il giorno'; + + @override + String moreItems(int count) { + return '+$count altri'; + } + + @override + String get noEventsOrTasks => 'Nessun evento o attività'; + + @override + String get scheduleLoading => 'Caricamento agenda...'; + + @override + String get scheduleUnavailable => 'Agenda non disponibile'; + + @override + String get scheduleNoSources => + 'Nessun calendario o elenco di attività visibile'; + + @override + String get scheduleNoSourcesDescription => + 'Scegli cosa mostrare nelle Impostazioni, quindi aggiorna l’agenda.'; + + @override + String get scheduleSignInRequired => 'Collega un account'; + + @override + String get scheduleSignInDescription => + 'Accedi per sincronizzare calendari e attività.'; + + @override + String get scheduleNoSearchResults => + 'Nessun evento o attività corrispondente'; + + @override + String get scheduleNoSearchResultsDescription => + 'Prova una ricerca diversa o cancella i filtri attuali.'; + + @override + String get trayAgendaLoading => 'Caricamento agenda...'; + + @override + String get trayAgendaSignInRequired => 'Accedi per mostrare l’agenda.'; + + @override + String get trayAgendaNoSources => + 'Nessun calendario o elenco di attività visibile.'; + + @override + String get trayAgendaOpenBusyMax => 'Apri applicazione'; + + @override + String get trayAgendaRefresh => 'Aggiorna'; + + @override + String get trayAgendaError => 'Agenda non disponibile'; + + @override + String get compactAgendaTitle => 'Agenda'; + + @override + String get compactAgendaSubtitle => 'In arrivo'; + + @override + String get compactAgendaOverdue => 'Scadute'; + + @override + String get compactAgendaClear => 'Nessun impegno per ora'; + + @override + String get compactAgendaOpenBusyMax => 'Apri BusyMax'; + + @override + String get compactAgendaHide => 'Nascondi'; + + @override + String get compactAgendaNewTask => 'Nuova attività'; + + @override + String get compactAgendaRetry => 'Riprova'; + + @override + String get compactAgendaRefresh => 'Aggiorna'; + + @override + String get compactAgendaAllDay => 'Tutto il giorno'; + + @override + String get compactAgendaDueToday => 'Scadenza: oggi'; + + @override + String get compactAgendaDueTomorrow => 'Scadenza: domani'; + + @override + String compactAgendaDueOn(String date) { + return 'Scadenza: $date'; + } + + @override + String get compactAgendaMoreOverdue => 'Carica altre attività scadute'; + + @override + String get agendaLoadMoreOverdue => 'Carica altre attività scadute'; + + @override + String get agendaLoadMoreNoDate => 'Carica altre attività senza data'; + + @override + String get viewDay => 'Giorno'; + + @override + String get viewWeek => 'Settimana'; + + @override + String get viewMonth => 'Mese'; + + @override + String get viewYear => 'Anno'; + + @override + String get viewAgenda => 'Agenda'; + + @override + String get scheduleSettings => 'Agenda'; + + @override + String get scheduleDisplaySettings => 'Visualizzazione agenda'; + + @override + String get scheduleDisplayHoursDescription => + 'Le viste Giorno e Settimana mostrano inizialmente questo intervallo orario. Gli elementi precedenti o successivi lo estendono quando necessario.'; + + @override + String get scheduleDayStartsAt => 'Inizio giornata'; + + @override + String get scheduleDayEndsAt => 'Fine giornata'; + + @override + String get sourceCalendar => 'Calendario'; + + @override + String get sourceTaskList => 'Elenco di attività'; + + @override + String get createChoiceTitle => 'Crea'; + + @override + String get createEventAtTime => 'Evento'; + + @override + String get createTaskAtDate => 'Attività'; + + @override + String get editEvent => 'Modifica evento'; + + @override + String get eventTitle => 'Titolo dell’evento'; + + @override + String get location => 'Luogo'; + + @override + String get timeSlot => 'Fascia oraria'; + + @override + String get startDateTime => 'Data/ora di inizio'; + + @override + String get endDateTime => 'Data/ora di fine'; + + @override + String get doesNotRepeat => 'Non si ripete'; + + @override + String get defaultReminder => 'Promemoria predefinito'; + + @override + String get guests => 'Invitati'; + + @override + String get noGuests => 'Nessun invitato'; + + @override + String get description => 'Descrizione'; + + @override + String get availabilityShowAs => 'Disponibilità / Mostra come'; + + @override + String get busy => 'Occupato'; + + @override + String get visibility => 'Visibilità'; + + @override + String get defaultVisibility => 'Visibilità predefinita'; + + @override + String get conference => 'Riunione'; + + @override + String get noConference => 'Nessuna riunione'; + + @override + String get providerCalendar => 'Calendario del servizio'; + + @override + String get formatBoldShortLabel => 'G'; + + @override + String get formatBoldTooltip => 'Grassetto'; + + @override + String get formatItalicShortLabel => 'C'; + + @override + String get formatItalicTooltip => 'Corsivo'; + + @override + String get formatUnderlineShortLabel => 'S'; + + @override + String get formatUnderlineTooltip => 'Sottolineato'; + + @override + String reminderMinutesBefore(int minutes) { + String _temp0 = intl.Intl.pluralLogic( + minutes, + locale: localeName, + other: '$minutes minuti prima', + one: '1 minuto prima', + ); + return '$_temp0'; + } + + @override + String get reminderAtStart => 'All’inizio'; + + @override + String reminderHoursBefore(int hours) { + String _temp0 = intl.Intl.pluralLogic( + hours, + locale: localeName, + other: '$hours ore prima', + one: '1 ora prima', + ); + return '$_temp0'; + } + + @override + String reminderDaysBefore(int days) { + String _temp0 = intl.Intl.pluralLogic( + days, + locale: localeName, + other: '$days giorni prima', + one: '1 giorno prima', + ); + return '$_temp0'; + } + + @override + String get availabilityFree => 'Libero'; + + @override + String get availabilityTentative => 'Provvisorio'; + + @override + String get availabilityOutOfOffice => 'Fuori sede'; + + @override + String get availabilityWorkingElsewhere => 'Lavora altrove'; + + @override + String get visibilityDefault => 'Predefinita'; + + @override + String get visibilityPublic => 'Pubblica'; + + @override + String get visibilityPrivate => 'Privata'; + + @override + String get visibilityConfidential => 'Riservata'; + + @override + String get sensitivityNormal => 'Normale'; + + @override + String get sensitivityPersonal => 'Personale'; + + @override + String get tasks => 'Attività'; + + @override + String get allTasks => 'Tutte le attività'; + + @override + String tasksInList(String title) { + return 'Attività in $title'; + } + + @override + String get taskLists => 'Elenchi di attività'; + + @override + String get navigation => 'Navigazione'; + + @override + String get mainMenu => 'Menu principale'; + + @override + String get keyboardShortcuts => 'Scorciatoie da tastiera'; + + @override + String get shortcutGroupGeneral => 'Generali'; + + @override + String get shortcutKeyboardShortcutsDescription => + 'Mostra questo elenco di scorciatoie'; + + @override + String get shortcutGroupNavigation => 'Navigazione'; + + @override + String get shortcutNextPeriod => 'Periodo successivo'; + + @override + String get shortcutNextPeriodDescription => + 'Settimana successiva nella vista settimanale, mese successivo nella vista mensile e così via'; + + @override + String get shortcutPreviousPeriod => 'Periodo precedente'; + + @override + String get shortcutPreviousPeriodDescription => + 'Settimana precedente nella vista settimanale, mese precedente nella vista mensile e così via'; + + @override + String get shortcutJumpToToday => 'Vai alla data odierna'; + + @override + String get shortcutGroupView => 'Vista'; + + @override + String get shortcutDayView => 'Vista giornaliera'; + + @override + String get shortcutWeekView => 'Vista settimanale'; + + @override + String get shortcutMonthView => 'Vista mensile'; + + @override + String get shortcutYearView => 'Vista annuale'; + + @override + String get shortcutAgendaView => 'Vista agenda'; + + @override + String get shortcutGroupCreateAndEdit => 'Creazione e modifica'; + + @override + String get shortcutSaveItem => 'Salva evento o attività'; + + @override + String get shortcutDeleteItem => 'Elimina evento o attività'; + + @override + String get shortcutGroupTaskEditing => 'Modifica delle attività'; + + @override + String get shortcutCancelEditing => 'Annulla modifica'; + + @override + String get shortcutCancelEditingDescription => + 'Chiudi la modifica o i dettagli dell’attività'; + + @override + String get shortcutGroupCompactAgenda => 'Agenda compatta'; + + @override + String get shortcutRefreshCompactAgendaDescription => + 'Aggiorna la finestra dell’agenda compatta'; + + @override + String get shortcutHideCompactAgendaDescription => + 'Nascondi la finestra dell’agenda compatta'; + + @override + String get aboutBusyMax => 'Informazioni su BusyMax'; + + @override + String get aboutBusyMaxDescription => 'Attività e calendario'; + + @override + String get website => 'Sito web'; + + @override + String get reportAnIssue => 'Segnala un problema'; + + @override + String get sendFeedback => 'Invia feedback'; + + @override + String get feedbackSubmit => 'Invia'; + + @override + String get feedbackCategory => 'Categoria'; + + @override + String get feedbackSelectCategory => 'Seleziona una categoria'; + + @override + String get feedbackCategoryProblem => 'Problema o errore'; + + @override + String get feedbackCategoryFeature => 'Richiesta di funzionalità'; + + @override + String get feedbackCategoryPrivacySecurity => + 'Problema di privacy o sicurezza'; + + @override + String get feedbackCategoryUsability => 'Problema di usabilità'; + + @override + String get feedbackCategoryOther => 'Altro'; + + @override + String get feedbackSubject => 'Oggetto'; + + @override + String get feedbackDetailedMessage => 'Messaggio dettagliato'; + + @override + String get feedbackReplyEmail => + 'Indirizzo email per la risposta (facoltativo)'; + + @override + String get feedbackIncludeTechnicalDetails => 'Includi dettagli tecnici'; + + @override + String get feedbackTechnicalDetailsDisclosure => + 'Aggiunge soltanto la versione del sistema operativo Linux e le impostazioni locali dell’applicazione. Non vengono inclusi log, dati degli account, nomi di file o altre informazioni diagnostiche.'; + + @override + String get feedbackCategoryRequired => 'Seleziona una categoria.'; + + @override + String get feedbackSubjectLengthError => + 'L’oggetto deve contenere da 3 a 120 caratteri.'; + + @override + String get feedbackMessageLengthError => + 'Il messaggio deve contenere da 10 a 5.000 caratteri.'; + + @override + String get feedbackInvalidEmail => 'Inserisci un indirizzo email valido.'; + + @override + String get feedbackConnectionError => + 'Impossibile connettersi a BusyStack. Controlla la connessione e riprova.'; + + @override + String get feedbackTimeoutError => + 'La richiesta è scaduta. Il feedback non è stato cancellato; riprova.'; + + @override + String get feedbackRateLimitedError => + 'Sono stati inviati troppi feedback da questa rete. Attendi e riprova.'; + + @override + String get feedbackRejectedError => + 'Il server ha rifiutato l’invio. Controlla i campi e riprova.'; + + @override + String get feedbackServerError => + 'BusyStack non può accettare il feedback in questo momento. Il feedback non è stato cancellato; riprova.'; + + @override + String feedbackSuccess(String id) { + return 'Feedback inviato. Riferimento: $id'; + } + + @override + String get toggleSidebar => 'Mostra o nascondi la barra laterale'; + + @override + String get accounts => 'Account'; + + @override + String get currentAccount => 'Account attuale'; + + @override + String get switchAccount => 'Cambia account'; + + @override + String get addGoogleAccount => 'Aggiungi account Google'; + + @override + String get addMicrosoftAccount => 'Aggiungi account Microsoft'; + + @override + String get googleProvider => 'Google'; + + @override + String get microsoftProvider => 'Microsoft'; + + @override + String get signedInAccount => 'Accesso effettuato'; + + @override + String get removeAccount => 'Rimuovi account…'; + + @override + String get removingAccount => 'Rimozione account…'; + + @override + String get removeAccountDescription => + 'Interrompi la sincronizzazione e rimuovi i dati di questo account dal dispositivo.'; + + @override + String removeAccountTitle(String account) { + return 'Rimuovere $account da BusyMax?'; + } + + @override + String get removeAccountConfirmation => + 'Questa azione elimina dal dispositivo attività, calendari, eventi e promemoria memorizzati nella cache, oltre alle modifiche offline in sospeso. Le modifiche non sincronizzate andranno perse. Non verrà eliminato nulla da Google o Microsoft.'; + + @override + String get revokeGoogleAccess => + 'Revoca anche l’accesso di BusyMax a questo account Google'; + + @override + String get revokeGoogleAccessDescription => + 'Dovrai concedere nuovamente l’accesso prima di riconnetterti.'; + + @override + String get removeAccountAction => 'Rimuovi account'; + + @override + String get removeAccountFailed => + 'Impossibile completare la rimozione dell’account. Riprova.'; + + @override + String get accountRemovedGoogleRevokeFailed => + 'L’account è stato rimosso da questo dispositivo, ma BusyMax non ha potuto revocare l’accesso all’account Google. Puoi revocarlo dalle impostazioni dell’account Google.'; + + @override + String get newList => 'Nuovo elenco'; + + @override + String get signInToViewTaskLists => + 'Accedi per visualizzare gli elenchi di attività.'; + + @override + String get noTaskListsSynced => + 'Nessun elenco di attività ancora sincronizzato.'; + + @override + String get listActions => 'Azioni dell’elenco'; + + @override + String get rename => 'Rinomina'; + + @override + String get delete => 'Elimina'; + + @override + String get renameList => 'Rinomina elenco'; + + @override + String get deleteList => 'Elimina elenco'; + + @override + String get builtInMicrosoftList => 'Integrato'; + + @override + String get builtInMicrosoftListCannotRenameDelete => + 'Gli elenchi integrati di Microsoft To Do non possono essere rinominati o eliminati.'; + + @override + String deleteListConfirmation(String title) { + return 'Eliminare «$title» da Google Tasks?'; + } + + @override + String get deleteEvent => 'Elimina evento'; + + @override + String get title => 'Titolo'; + + @override + String get create => 'Crea'; + + @override + String get newTask => 'Nuova attività'; + + @override + String get clearCompleted => 'Cancella attività completate'; + + @override + String get refreshList => 'Aggiorna elenco'; + + @override + String get refreshAll => 'Aggiorna tutto'; + + @override + String get listRefreshed => 'Elenco aggiornato.'; + + @override + String get allTasksRefreshed => 'Tutti gli account sono stati aggiornati.'; + + @override + String exportedFile(String path) { + return 'Esportato in $path'; + } + + @override + String exportFailed(String error) { + return 'Esportazione non riuscita: $error'; + } + + @override + String refreshFailed(String error) { + return 'Aggiornamento non riuscito: $error'; + } + + @override + String get selectOrCreateTaskList => + 'Seleziona o crea un elenco di attività per iniziare.'; + + @override + String get signInToViewTasks => 'Accedi per visualizzare le attività.'; + + @override + String get noTasks => 'Nessuna attività.'; + + @override + String get noTasksYet => 'Ancora nessuna attività'; + + @override + String get noTasksYetMessage => + 'Crea un’attività o aggiorna gli account per iniziare.'; + + @override + String get noTasksInList => 'Nessuna attività in questo elenco.'; + + @override + String get overdue => 'Scadute'; + + @override + String get today => 'Oggi'; + + @override + String get tomorrow => 'Domani'; + + @override + String get upcoming => 'In arrivo'; + + @override + String get noDate => 'Senza data'; + + @override + String get completed => 'Completate'; + + @override + String duePrefix(String date) { + return 'Scadenza: $date'; + } + + @override + String dateTimeDisplay(String date, String time) { + return '$date · $time'; + } + + @override + String get taskDetails => 'Dettagli attività'; + + @override + String get editTask => 'Modifica attività'; + + @override + String get noTaskSelected => 'Nessuna attività selezionata.'; + + @override + String get noTaskSelectedHelper => + 'Seleziona un’attività per visualizzarne e modificarne i dettagli.'; + + @override + String get taskUnavailable => 'Attività non disponibile.'; + + @override + String get signInToEditTasks => 'Accedi per modificare le attività.'; + + @override + String get refreshTask => 'Aggiorna attività'; + + @override + String get primarySection => 'Principale'; + + @override + String get statusSection => 'Stato'; + + @override + String get openStatus => 'Aperta'; + + @override + String get doneStatus => 'Completata'; + + @override + String get notes => 'Note'; + + @override + String get dueDate => 'Data di scadenza'; + + @override + String get clearDueDate => 'Cancella data di scadenza'; + + @override + String get dueTime => 'Ora di scadenza'; + + @override + String get startDate => 'Data di inizio'; + + @override + String get startTime => 'Ora di inizio'; + + @override + String get endDate => 'Data di fine'; + + @override + String get endTime => 'Ora di fine'; + + @override + String get reminderDate => 'Data del promemoria'; + + @override + String get reminderTime => 'Ora del promemoria'; + + @override + String get reminder => 'Promemoria'; + + @override + String get addReminder => 'Aggiungi promemoria'; + + @override + String get addGuest => 'Aggiungi invitato'; + + @override + String get addGuestEmail => 'Aggiungi email dell’invitato'; + + @override + String get removeReminder => 'Rimuovi promemoria'; + + @override + String get off => 'Disattivato'; + + @override + String get repeat => 'Ripeti'; + + @override + String get repeatNone => 'Nessuna ripetizione'; + + @override + String get noneValue => 'Nessuno'; + + @override + String get repeatDaily => 'Ogni giorno'; + + @override + String get repeatWeekly => 'Ogni settimana'; + + @override + String get repeatMonthly => 'Ogni mese'; + + @override + String get repeatYearly => 'Ogni anno'; + + @override + String get importance => 'Importanza'; + + @override + String get importanceLow => 'Bassa'; + + @override + String get importanceNormal => 'Normale'; + + @override + String get importanceHigh => 'Alta'; + + @override + String get categories => 'Categorie'; + + @override + String get scheduleSection => 'Programmazione'; + + @override + String get dueGroup => 'Scadenza'; + + @override + String get startGroup => 'Inizio'; + + @override + String get reminderGroup => 'Promemoria'; + + @override + String get organizationSection => 'Organizzazione'; + + @override + String get actionsSection => 'Azioni'; + + @override + String get advancedSection => 'Avanzate'; + + @override + String get addCategory => 'Aggiungi categoria'; + + @override + String get list => 'Elenco'; + + @override + String get microsoftMoveUnsupported => + 'In questa versione non è possibile spostare attività tra elenchi negli account Microsoft To Do.'; + + @override + String get createSubtask => 'Crea sottoattività'; + + @override + String get moveToTop => 'Sposta in cima'; + + @override + String get deleteTask => 'Elimina attività'; + + @override + String get newSubtask => 'Nuova sottoattività'; + + @override + String deleteTaskConfirmation(String title) { + return 'Eliminare «$title» da Google Tasks?'; + } + + @override + String get metadata => 'Metadati'; + + @override + String get id => 'ID'; + + @override + String get etag => 'ETag'; + + @override + String get updated => 'Aggiornato'; + + @override + String get parent => 'Attività principale'; + + @override + String get position => 'Posizione'; + + @override + String get webLink => 'Collegamento web'; + + @override + String get assignment => 'Assegnazione'; + + @override + String get localState => 'Stato locale'; + + @override + String get pendingSync => 'Sincronizzazione in sospeso'; + + @override + String get synced => 'Sincronizzato'; + + @override + String get account => 'Account'; + + @override + String get sync => 'Sincronizzazione'; + + @override + String get manualFullSync => 'Sincronizzazione completa manuale'; + + @override + String get runInBackgroundWhenClosed => + 'Continua l’esecuzione quando la finestra viene chiusa'; + + @override + String get showTrayIcon => 'Mostra icona nell’area di notifica'; + + @override + String get startMinimizedToTray => + 'Avvia ridotto a icona nell’area di notifica'; + + @override + String get requiresTrayIcon => 'Richiede l’icona nell’area di notifica.'; + + @override + String get syncComplete => 'Sincronizzazione completata.'; + + @override + String syncFailed(String error) { + return 'Sincronizzazione non riuscita: $error'; + } + + @override + String get notifySyncFailures => + 'Notifiche in caso di errore di sincronizzazione'; + + @override + String get notifyConflicts => 'Notifiche in caso di conflitto'; + + @override + String get notifyDueToday => 'Notifiche per attività in scadenza oggi'; + + @override + String get eventReminders => 'Promemoria degli eventi'; + + @override + String get taskReminders => 'Promemoria delle attività'; + + @override + String get notificationDetailLevel => 'Livello di dettaglio delle notifiche'; + + @override + String get notificationDetailPrivate => 'Privato'; + + @override + String get notificationDetailNormal => 'Normale'; + + @override + String get quietHours => 'Ore di silenzio'; + + @override + String get quietHoursDescription => + 'Sospendi le notifiche durante questo periodo.'; + + @override + String get quietHoursStart => 'Inizio delle ore di silenzio'; + + @override + String get quietHoursEnd => 'Fine delle ore di silenzio'; + + @override + String get notifications => 'Notifiche'; + + @override + String get appearance => 'Aspetto'; + + @override + String get theme => 'Tema'; + + @override + String get themeSystem => 'Sistema'; + + @override + String get themeLight => 'Chiaro'; + + @override + String get themeDark => 'Scuro'; + + @override + String get themeFamily => 'Famiglia di temi'; + + @override + String get themeFamilyYaru => 'Tema nativo di Ubuntu (Yaru)'; + + @override + String get localization => 'Lingua e area geografica'; + + @override + String get currentLocale => 'Impostazioni locali correnti'; + + @override + String get privacy => 'Privacy'; + + @override + String get redactTaskContentInDiagnostics => + 'Nascondi il contenuto delle attività nelle informazioni diagnostiche'; + + @override + String get developerDiagnostics => 'Diagnostica per sviluppatori'; + + @override + String get diagnostics => 'Diagnostica'; + + @override + String get apiInspectorDisabled => 'Mostra controllo API'; + + @override + String get googleTasksApi => 'API Google Tasks'; + + @override + String discoveryRevision(String revision) { + return 'Revisione Discovery: $revision'; + } + + @override + String get implementedMethods => 'Metodi implementati'; + + @override + String get supportsTasksScopes => + 'Supporta gli ambiti tasks e tasks.readonly'; + + @override + String get requiresTasksScope => 'Richiede l’ambito tasks'; + + @override + String get blockedPendingOperations => 'Operazioni in sospeso bloccate'; + + @override + String get signInToInspectPendingOperations => + 'Accedi per esaminare le operazioni in sospeso.'; + + @override + String get noBlockedPendingOperations => + 'Nessuna operazione in sospeso bloccata.'; + + @override + String get operationActions => 'Azioni dell’operazione'; + + @override + String pendingOpListId(String id) { + return 'elenco=$id'; + } + + @override + String pendingOpTaskId(String id) { + return 'attività=$id'; + } + + @override + String pendingOpAttempts(int count) { + return 'tentativi=$count'; + } + + @override + String get retry => 'Riprova'; + + @override + String get discard => 'Scarta'; + + @override + String get discardChanges => 'Scartare le modifiche?'; + + @override + String get discardChangesConfirmation => + 'Questa azione scarta le modifiche non salvate dell’attività.'; + + @override + String get retryCompleted => 'Nuovo tentativo completato.'; + + @override + String get discardPendingOperation => 'Scartare l’operazione in sospeso?'; + + @override + String get discardPendingOperationConfirmation => + 'Questa azione rimuove l’operazione locale bloccata. Alla prossima sincronizzazione, i dati verranno ricaricati da Google Tasks.'; + + @override + String get pendingOperationDiscarded => 'Operazione in sospeso scartata.'; + + @override + String get syncFailureNotificationTitle => + 'Sincronizzazione di BusyMax non riuscita'; + + @override + String syncFailureNotificationBody(String message) { + return 'Sincronizzazione in background non riuscita. $message'; + } + + @override + String get conflictNotificationTitle => + 'Conflitto di sincronizzazione di BusyMax'; + + @override + String conflictNotificationBody(String summary) { + return 'Una modifica locale in sospeso è stata bloccata. $summary'; + } + + @override + String get dueTodayNotificationTitle => 'Attività in scadenza oggi'; + + @override + String dueTodayNotificationBody(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count attività scadono oggi.', + one: 'Un’attività scade oggi.', + ); + return '$_temp0'; + } + + @override + String get eventReminderNotificationTitle => 'Promemoria evento'; + + @override + String get taskReminderNotificationTitle => 'Promemoria attività'; + + @override + String get eventReminderNotificationBody => 'L’evento inizierà a breve.'; + + @override + String get taskReminderNotificationBody => 'L’attività scadrà a breve.'; + + @override + String get notificationOpenAction => 'Apri'; + + @override + String get notificationDetailsHidden => + 'I dettagli sono nascosti dalle impostazioni sulla privacy.'; + + @override + String get previousMonth => 'Mese precedente'; + + @override + String get nextMonth => 'Mese successivo'; + + @override + String get openMonthView => 'Apri vista mensile'; + + @override + String get previousYear => 'Anno precedente'; + + @override + String get nextYear => 'Anno successivo'; + + @override + String get openYearView => 'Apri vista annuale'; + + @override + String weekNumberTooltip(int number) { + return 'Settimana $number'; + } + + @override + String get resizeAllDayPanel => + 'Ridimensiona il pannello per l’intera giornata'; + + @override + String scheduleItemCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count elementi', + one: '1 elemento', + ); + return '$_temp0'; + } + + @override + String get readOnlyCalendar => 'Questo calendario è di sola lettura.'; + + @override + String get selectTimeZone => 'Seleziona fuso orario'; + + @override + String get searchLocations => 'Cerca luoghi'; + + @override + String get noLocationsFound => 'Nessun luogo trovato'; + + @override + String deleteCalendarConfirmation(String title) { + return 'Eliminare «$title»?'; + } +} diff --git a/lib/l10n/generated/app_localizations_ja.dart b/lib/l10n/generated/app_localizations_ja.dart index 4fc644a..c787c56 100644 --- a/lib/l10n/generated/app_localizations_ja.dart +++ b/lib/l10n/generated/app_localizations_ja.dart @@ -120,7 +120,7 @@ class AppLocalizationsJa extends AppLocalizations { String get refreshCalendar => 'カレンダーを更新'; @override - String get openInProvider => 'プロバイダーで開く'; + String get openInProvider => 'サービスで開く'; @override String get hideFromSchedule => 'スケジュールから非表示'; @@ -258,7 +258,7 @@ class AppLocalizationsJa extends AppLocalizations { @override String get scheduleDisplayHoursDescription => - '日表示と週表示では、最初にこの時間範囲が表示されます。必要に応じて、範囲外の早い項目や遅い項目まで表示範囲が広がります。'; + '日表示と週表示では、最初にこの時間範囲が表示されます。必要に応じて、この範囲より前または後の項目も表示されるように範囲が広がります。'; @override String get scheduleDayStartsAt => '一日の開始時刻'; @@ -365,7 +365,7 @@ class AppLocalizationsJa extends AppLocalizations { } @override - String get reminderAtStart => '開始時刻'; + String get reminderAtStart => '開始時'; @override String reminderHoursBefore(int hours) { @@ -662,7 +662,7 @@ class AppLocalizationsJa extends AppLocalizations { @override String get accountRemovedGoogleRevokeFailed => - 'アカウントはこのデバイスから削除されましたが、BusyMax は Google へのアクセス権を取り消せませんでした。Google アカウントから取り消すことができます。'; + 'アカウントはこのデバイスから削除されましたが、BusyMax は Google アカウントへのアクセス権を取り消せませんでした。Google アカウントの設定から取り消すことができます。'; @override String get newList => '新しいリスト'; @@ -1123,7 +1123,7 @@ class AppLocalizationsJa extends AppLocalizations { String get noBlockedPendingOperations => 'ブロックされた保留中の操作はありません。'; @override - String get operationActions => '操作のアクション'; + String get operationActions => '操作への対応'; @override String pendingOpListId(String id) { diff --git a/lib/l10n/generated/app_localizations_ko.dart b/lib/l10n/generated/app_localizations_ko.dart index a9443b2..466a41f 100644 --- a/lib/l10n/generated/app_localizations_ko.dart +++ b/lib/l10n/generated/app_localizations_ko.dart @@ -59,7 +59,7 @@ class AppLocalizationsKo extends AppLocalizations { String get microsoftTodoProvider => 'Microsoft To Do'; @override - String get providerNotConfigured => '이 공급자는 구성되지 않았습니다.'; + String get providerNotConfigured => '이 서비스는 설정되어 있지 않습니다.'; @override String get waitingForGoogleSignIn => 'Google 로그인을 기다리는 중...'; @@ -120,7 +120,7 @@ class AppLocalizationsKo extends AppLocalizations { String get refreshCalendar => '캘린더 새로 고침'; @override - String get openInProvider => '공급자에서 열기'; + String get openInProvider => '서비스에서 열기'; @override String get hideFromSchedule => '일정에서 숨기기'; @@ -314,7 +314,7 @@ class AppLocalizationsKo extends AppLocalizations { String get description => '설명'; @override - String get availabilityShowAs => '상태 / 다음으로 표시'; + String get availabilityShowAs => '일정 상태 / 표시 방식'; @override String get busy => '바쁨'; @@ -332,7 +332,7 @@ class AppLocalizationsKo extends AppLocalizations { String get noConference => '회의 없음'; @override - String get providerCalendar => '공급자 캘린더'; + String get providerCalendar => '서비스 캘린더'; @override String get formatBoldShortLabel => 'B'; @@ -364,7 +364,7 @@ class AppLocalizationsKo extends AppLocalizations { } @override - String get reminderAtStart => '시작 시간'; + String get reminderAtStart => '시작 시'; @override String reminderHoursBefore(int hours) { @@ -546,7 +546,7 @@ class AppLocalizationsKo extends AppLocalizations { String get feedbackCategoryPrivacySecurity => '개인정보 보호 또는 보안 우려'; @override - String get feedbackCategoryUsability => '사용성 관련 의견'; + String get feedbackCategoryUsability => '사용 편의성 문제'; @override String get feedbackCategoryOther => '기타'; @@ -662,7 +662,7 @@ class AppLocalizationsKo extends AppLocalizations { @override String get accountRemovedGoogleRevokeFailed => - '이 기기에서 계정은 삭제되었지만 BusyMax가 Google 액세스 권한을 취소하지 못했습니다. Google 계정에서 직접 취소할 수 있습니다.'; + '계정은 이 기기에서 삭제되었지만 BusyMax가 Google 계정 액세스 권한을 취소하지 못했습니다. Google 계정에서 직접 취소할 수 있습니다.'; @override String get newList => '새 목록'; @@ -729,7 +729,7 @@ class AppLocalizationsKo extends AppLocalizations { @override String exportedFile(String path) { - return '$path(으)로 내보냈습니다'; + return '$path에 내보냈습니다'; } @override @@ -816,7 +816,7 @@ class AppLocalizationsKo extends AppLocalizations { String get statusSection => '상태'; @override - String get openStatus => '진행 중'; + String get openStatus => '미완료'; @override String get doneStatus => '완료'; @@ -1101,7 +1101,7 @@ class AppLocalizationsKo extends AppLocalizations { @override String discoveryRevision(String revision) { - return '검색 버전: $revision'; + return 'Discovery 리비전: $revision'; } @override @@ -1123,7 +1123,7 @@ class AppLocalizationsKo extends AppLocalizations { String get noBlockedPendingOperations => '차단된 보류 작업이 없습니다.'; @override - String get operationActions => '작업 동작'; + String get operationActions => '작업별 조치'; @override String pendingOpListId(String id) { From ae57febea225d053175eec7123851c74ccf1667c Mon Sep 17 00:00:00 2001 From: albert Date: Wed, 29 Jul 2026 17:21:26 -0700 Subject: [PATCH 41/73] Update localizations --- lib/l10n/app_ar.arb | 52 +- lib/l10n/app_et.arb | 420 ++++++ lib/l10n/app_fa.arb | 116 +- lib/l10n/app_it.arb | 16 +- lib/l10n/app_ru.arb | 6 +- lib/l10n/app_vi.arb | 389 ++++++ lib/l10n/app_zh.arb | 8 +- lib/l10n/app_zh_Hans.arb | 8 +- lib/l10n/app_zh_Hant.arb | 12 +- lib/l10n/generated/app_localizations.dart | 8 + lib/l10n/generated/app_localizations_ar.dart | 75 +- lib/l10n/generated/app_localizations_de.dart | 3 + lib/l10n/generated/app_localizations_en.dart | 3 + lib/l10n/generated/app_localizations_es.dart | 3 + lib/l10n/generated/app_localizations_et.dart | 1301 ++++++++++++++++++ lib/l10n/generated/app_localizations_fa.dart | 88 +- lib/l10n/generated/app_localizations_fi.dart | 3 + lib/l10n/generated/app_localizations_fr.dart | 3 + lib/l10n/generated/app_localizations_hi.dart | 3 + lib/l10n/generated/app_localizations_it.dart | 19 +- lib/l10n/generated/app_localizations_ja.dart | 3 + lib/l10n/generated/app_localizations_ko.dart | 3 + lib/l10n/generated/app_localizations_pt.dart | 3 + lib/l10n/generated/app_localizations_ru.dart | 9 +- lib/l10n/generated/app_localizations_vi.dart | 1300 +++++++++++++++++ lib/l10n/generated/app_localizations_zh.dart | 31 +- 26 files changed, 3721 insertions(+), 164 deletions(-) create mode 100644 lib/l10n/app_et.arb create mode 100644 lib/l10n/app_vi.arb create mode 100644 lib/l10n/generated/app_localizations_et.dart create mode 100644 lib/l10n/generated/app_localizations_vi.dart diff --git a/lib/l10n/app_ar.arb b/lib/l10n/app_ar.arb index 7c98990..a6f138b 100644 --- a/lib/l10n/app_ar.arb +++ b/lib/l10n/app_ar.arb @@ -40,7 +40,7 @@ "showInSchedule": "إظهار في الجدول", "noCalendarsSynced": "لم تتم مزامنة أي تقويمات بعد.", "allDay": "طوال اليوم", - "moreItems": "+{count} عناصر أخرى", + "moreItems": "+\u2068{count}\u2069 عناصر أخرى", "noEventsOrTasks": "لا توجد أحداث أو مهام", "scheduleLoading": "جارٍ تحميل الجدول...", "scheduleUnavailable": "الجدول غير متاح", @@ -68,7 +68,7 @@ "compactAgendaAllDay": "طوال اليوم", "compactAgendaDueToday": "مستحقة اليوم", "compactAgendaDueTomorrow": "مستحقة غدًا", - "compactAgendaDueOn": "مستحقة في {date}", + "compactAgendaDueOn": "مستحقة في \u2068{date}\u2069", "compactAgendaMoreOverdue": "تحميل المزيد من المهام المتأخرة", "agendaLoadMoreOverdue": "تحميل المزيد من المهام المتأخرة", "agendaLoadMoreNoDate": "تحميل المزيد من المهام بلا تاريخ", @@ -111,10 +111,10 @@ "formatItalicTooltip": "مائل", "formatUnderlineShortLabel": "U", "formatUnderlineTooltip": "تحته خط", - "reminderMinutesBefore": "{minutes, plural, =0{عند البدء} =1{قبل دقيقة واحدة} =2{قبل دقيقتين} few{قبل {minutes} دقائق} many{قبل {minutes} دقيقة} other{قبل {minutes} دقيقة}}", + "reminderMinutesBefore": "{minutes, plural, =0{عند البدء} =1{قبل دقيقة واحدة} =2{قبل دقيقتين} few{قبل \u2068{minutes}\u2069 دقائق} many{قبل \u2068{minutes}\u2069 دقيقة} other{قبل \u2068{minutes}\u2069 دقيقة}}", "reminderAtStart": "عند البدء", - "reminderHoursBefore": "{hours, plural, =0{عند البدء} =1{قبل ساعة واحدة} =2{قبل ساعتين} few{قبل {hours} ساعات} many{قبل {hours} ساعة} other{قبل {hours} ساعة}}", - "reminderDaysBefore": "{days, plural, =0{في اليوم نفسه} =1{قبل يوم واحد} =2{قبل يومين} few{قبل {days} أيام} many{قبل {days} يومًا} other{قبل {days} يوم}}", + "reminderHoursBefore": "{hours, plural, =0{عند البدء} =1{قبل ساعة واحدة} =2{قبل ساعتين} few{قبل \u2068{hours}\u2069 ساعات} many{قبل \u2068{hours}\u2069 ساعة} other{قبل \u2068{hours}\u2069 ساعة}}", + "reminderDaysBefore": "{days, plural, =0{في اليوم نفسه} =1{قبل يوم واحد} =2{قبل يومين} few{قبل \u2068{days}\u2069 أيام} many{قبل \u2068{days}\u2069 يومًا} other{قبل \u2068{days}\u2069 يوم}}", "availabilityFree": "متاح", "availabilityTentative": "مبدئي", "availabilityOutOfOffice": "خارج المكتب", @@ -127,7 +127,7 @@ "sensitivityPersonal": "شخصي", "tasks": "المهام", "allTasks": "كل المهام", - "tasksInList": "المهام في {title}", + "tasksInList": "المهام في \u2068{title}\u2069", "taskLists": "قوائم المهام", "navigation": "التنقل", "mainMenu": "القائمة الرئيسية", @@ -182,7 +182,7 @@ "feedbackRateLimitedError": "أُرسلت ملاحظات كثيرة جدًا من هذه الشبكة. انتظر وحاول مرة أخرى.", "feedbackRejectedError": "رفض الخادم الإرسال. راجع الحقول وحاول مرة أخرى.", "feedbackServerError": "يتعذر على BusyStack قبول ملاحظاتك الآن. لم تُمسح ملاحظاتك؛ حاول مرة أخرى.", - "feedbackSuccess": "تم إرسال الملاحظات. المرجع: {id}", + "feedbackSuccess": "تم إرسال الملاحظات. المرجع: \u2068{id}\u2069", "toggleSidebar": "إظهار الشريط الجانبي أو إخفاؤه", "accounts": "الحسابات", "currentAccount": "الحساب الحالي", @@ -195,7 +195,7 @@ "removeAccount": "إزالة الحساب…", "removingAccount": "جارٍ إزالة الحساب…", "removeAccountDescription": "إيقاف المزامنة وإزالة بيانات هذا الحساب من هذا الجهاز.", - "removeAccountTitle": "إزالة {account} من BusyMax؟", + "removeAccountTitle": "إزالة \u2068{account}\u2069 من BusyMax؟", "removeAccountConfirmation": "سيؤدي ذلك إلى حذف المهام والتقويمات والأحداث والتذكيرات والتغييرات غير المتصلة المعلّقة المخزّنة مؤقتًا من هذا الجهاز. ستُفقد التغييرات غير المتزامنة. لن يُحذف أي شيء من Google أو Microsoft.", "revokeGoogleAccess": "إلغاء وصول BusyMax إلى حساب Google هذا أيضًا", "revokeGoogleAccessDescription": "ستحتاج إلى منح الوصول مرة أخرى قبل إعادة الاتصال.", @@ -212,7 +212,7 @@ "deleteList": "حذف القائمة", "builtInMicrosoftList": "مدمجة", "builtInMicrosoftListCannotRenameDelete": "لا يمكن إعادة تسمية قوائم Microsoft To Do المدمجة أو حذفها.", - "deleteListConfirmation": "حذف «{title}» من Google Tasks؟", + "deleteListConfirmation": "حذف «\u2068{title}\u2069» من Google Tasks؟", "deleteEvent": "حذف الحدث", "title": "العنوان", "create": "إنشاء", @@ -222,9 +222,9 @@ "refreshAll": "تحديث الكل", "listRefreshed": "تم تحديث القائمة.", "allTasksRefreshed": "تم تحديث جميع الحسابات.", - "exportedFile": "تم التصدير إلى {path}", - "exportFailed": "فشل التصدير: {error}", - "refreshFailed": "فشل التحديث: {error}", + "exportedFile": "تم التصدير إلى \u2068{path}\u2069", + "exportFailed": "فشل التصدير: \u2068{error}\u2069", + "refreshFailed": "فشل التحديث: \u2068{error}\u2069", "selectOrCreateTaskList": "اختر قائمة مهام أو أنشئ واحدة للبدء.", "signInToViewTasks": "سجّل الدخول لعرض المهام.", "noTasks": "لا توجد مهام.", @@ -237,8 +237,8 @@ "upcoming": "القادمة", "noDate": "بلا تاريخ", "completed": "مكتملة", - "duePrefix": "مستحقة في {date}", - "dateTimeDisplay": "{date} · {time}", + "duePrefix": "مستحقة في \u2068{date}\u2069", + "dateTimeDisplay": "\u2068{date}\u2069 · \u2068{time}\u2069", "taskDetails": "تفاصيل المهمة", "editTask": "تعديل المهمة", "noTaskSelected": "لم يتم تحديد مهمة.", @@ -292,7 +292,7 @@ "moveToTop": "نقل إلى الأعلى", "deleteTask": "حذف المهمة", "newSubtask": "مهمة فرعية جديدة", - "deleteTaskConfirmation": "حذف «{title}» من Google Tasks؟", + "deleteTaskConfirmation": "حذف «\u2068{title}\u2069» من Google Tasks؟", "metadata": "البيانات الوصفية", "id": "المعرّف", "etag": "ETag", @@ -312,7 +312,7 @@ "startMinimizedToTray": "البدء مصغّرًا في شريط النظام", "requiresTrayIcon": "يتطلب أيقونة شريط النظام.", "syncComplete": "اكتملت المزامنة.", - "syncFailed": "فشلت المزامنة: {error}", + "syncFailed": "فشلت المزامنة: \u2068{error}\u2069", "notifySyncFailures": "إشعارات عند فشل المزامنة", "notifyConflicts": "إشعارات عند حدوث تعارضات", "notifyDueToday": "إشعارات المهام المستحقة اليوم", @@ -341,7 +341,7 @@ "diagnostics": "التشخيصات", "apiInspectorDisabled": "إظهار فاحص API", "googleTasksApi": "واجهة Google Tasks API", - "discoveryRevision": "مراجعة Discovery: ‏{revision}", + "discoveryRevision": "مراجعة Discovery: \u2068{revision}\u2069", "implementedMethods": "الطرق المنفذة", "supportsTasksScopes": "يدعم نطاقَي tasks وtasks.readonly", "requiresTasksScope": "يتطلب نطاق tasks", @@ -349,9 +349,9 @@ "signInToInspectPendingOperations": "سجّل الدخول لفحص العمليات المعلّقة.", "noBlockedPendingOperations": "لا توجد عمليات معلّقة محظورة.", "operationActions": "إجراءات العملية", - "pendingOpListId": "القائمة={id}", - "pendingOpTaskId": "المهمة={id}", - "pendingOpAttempts": "المحاولات={count}", + "pendingOpListId": "القائمة=\u2068{id}\u2069", + "pendingOpTaskId": "المهمة=\u2068{id}\u2069", + "pendingOpAttempts": "المحاولات=\u2068{count}\u2069", "retry": "إعادة المحاولة", "discard": "تجاهل", "discardChanges": "تجاهل التغييرات؟", @@ -361,11 +361,11 @@ "discardPendingOperationConfirmation": "سيؤدي ذلك إلى إزالة العملية المحلية المحظورة. ستُحدّث البيانات من Google Tasks في المزامنة التالية.", "pendingOperationDiscarded": "تم تجاهل العملية المعلّقة.", "syncFailureNotificationTitle": "فشلت مزامنة BusyMax", - "syncFailureNotificationBody": "فشلت المزامنة في الخلفية. {message}", + "syncFailureNotificationBody": "فشلت المزامنة في الخلفية. \u2068{message}\u2069", "conflictNotificationTitle": "تعارض في مزامنة BusyMax", - "conflictNotificationBody": "تم حظر تغيير محلي معلّق. {summary}", + "conflictNotificationBody": "تم حظر تغيير محلي معلّق. \u2068{summary}\u2069", "dueTodayNotificationTitle": "المهام المستحقة اليوم", - "dueTodayNotificationBody": "{count, plural, =0{لا توجد مهام مستحقة اليوم.} =1{هناك مهمة واحدة مستحقة اليوم.} =2{هناك مهمتان مستحقتان اليوم.} few{هناك {count} مهام مستحقة اليوم.} many{هناك {count} مهمة مستحقة اليوم.} other{هناك {count} مهمة مستحقة اليوم.}}", + "dueTodayNotificationBody": "{count, plural, =0{لا توجد مهام مستحقة اليوم.} =1{هناك مهمة واحدة مستحقة اليوم.} =2{هناك مهمتان مستحقتان اليوم.} few{هناك \u2068{count}\u2069 مهام مستحقة اليوم.} many{هناك \u2068{count}\u2069 مهمة مستحقة اليوم.} other{هناك \u2068{count}\u2069 مهمة مستحقة اليوم.}}", "eventReminderNotificationTitle": "تذكير بحدث", "taskReminderNotificationTitle": "تذكير بمهمة", "eventReminderNotificationBody": "سيبدأ الحدث قريبًا.", @@ -378,12 +378,12 @@ "previousYear": "السنة السابقة", "nextYear": "السنة التالية", "openYearView": "فتح عرض السنة", - "weekNumberTooltip": "الأسبوع {number}", + "weekNumberTooltip": "الأسبوع \u2068{number}\u2069", "resizeAllDayPanel": "تغيير حجم لوحة اليوم الكامل", - "scheduleItemCount": "{count, plural, =0{لا عناصر} =1{عنصر واحد} =2{عنصران} few{{count} عناصر} many{{count} عنصرًا} other{{count} عنصر}}", + "scheduleItemCount": "{count, plural, =0{لا عناصر} =1{عنصر واحد} =2{عنصران} few{\u2068{count}\u2069 عناصر} many{\u2068{count}\u2069 عنصرًا} other{\u2068{count}\u2069 عنصر}}", "readOnlyCalendar": "هذا التقويم للقراءة فقط.", "selectTimeZone": "اختيار المنطقة الزمنية", "searchLocations": "البحث عن مواقع", "noLocationsFound": "لم يتم العثور على مواقع", - "deleteCalendarConfirmation": "حذف «{title}»؟" + "deleteCalendarConfirmation": "حذف «\u2068{title}\u2069»؟" } diff --git a/lib/l10n/app_et.arb b/lib/l10n/app_et.arb new file mode 100644 index 0000000..f94de36 --- /dev/null +++ b/lib/l10n/app_et.arb @@ -0,0 +1,420 @@ +{ + "@@locale": "et", + "appTitle": "BusyMax", + "connectGoogleAccount": "Ühendage Google'i ja Microsofti kontod kalendrite ja ülesannete sünkroonimiseks.", + "googlePermissionsConsentNotice": "Valige Google'i õiguste kuval nii kalendri kui ka ülesannete õigused.", + "googlePermissionsRequiredRetry": "Google Calendari ja Google Tasksi õigused on nõutavad. Proovige uuesti ja märkige mõlemad ruudud.", + "finishSetup": "Lõpeta seadistamine", + "continueSetup": "Jätka", + "onboardingSetupTitle": "BusyMaxi seadistamine", + "onboardingAccountsStepTitle": "Kontode ühendamine", + "onboardingAccountsStepDescription": "Lisage kõik Google'i ja Microsofti kontod, mida soovite kasutada. BusyMax sünkroonib iga konto kalendrid, sündmused, ülesandeloendid ja ülesanded.", + "onboardingPreferencesStepTitle": "Süsteemiseadete valimine", + "onboardingPreferencesStepDescription": "Enne ajakava avamist määrake töölauakäitumine, meeldetuletused, teavituste üksikasjalikkus ja välimus.", + "signInWithGoogle": "Logi Google'iga sisse", + "signInWithMicrosoft": "Logi Microsoftiga sisse", + "googleTasksProvider": "Google Tasks", + "microsoftTodoProvider": "Microsoft To Do", + "providerNotConfigured": "See teenusepakkuja pole seadistatud.", + "waitingForGoogleSignIn": "Google'isse sisselogimise ootel...", + "waitingForMicrosoftSignIn": "Microsofti sisselogimise ootel...", + "microsoftSignInNotConfigured": "Microsofti sisselogimine pole seadistatud. Määrake MICROSOFT_OAUTH_CLIENT_ID.", + "cancel": "Tühista", + "close": "Sulge", + "exit": "Välju", + "options": "Valikud", + "hide": "Peida", + "show": "Kuva", + "export": "Ekspordi", + "save": "Salvesta", + "settings": "Seaded", + "all": "Kõik", + "calendarEvents": "Sündmused", + "calendarTasks": "Ülesanded", + "calendar": "Kalender", + "calendars": "Kalendrid", + "newEvent": "Uus sündmus", + "refreshCalendar": "Värskenda kalendrit", + "openInProvider": "Ava teenuses", + "hideFromSchedule": "Peida ajakavast", + "showInSchedule": "Kuva ajakavas", + "noCalendarsSynced": "Ühtegi kalendrit pole veel sünkroonitud.", + "allDay": "Kogu päev", + "moreItems": "+{count} veel", + "@moreItems": {"placeholders": {"count": {"type": "int"}}}, + "noEventsOrTasks": "Sündmusi ega ülesandeid pole", + "scheduleLoading": "Ajakava laadimine...", + "scheduleUnavailable": "Ajakava pole saadaval", + "scheduleNoSources": "Nähtavaid kalendreid ega ülesandeloendeid pole", + "scheduleNoSourcesDescription": "Valige seadetes, mida kuvada, ja seejärel värskendage.", + "scheduleSignInRequired": "Ühendage konto", + "scheduleSignInDescription": "Kalendrite ja ülesannete sünkroonimiseks logige sisse.", + "scheduleNoSearchResults": "Sobivaid sündmusi ega ülesandeid ei leitud", + "scheduleNoSearchResultsDescription": "Proovige teistsugust otsingut või eemaldage praegused filtrid.", + "trayAgendaLoading": "Päevakava laadimine...", + "trayAgendaSignInRequired": "Päevakava kuvamiseks logige sisse.", + "trayAgendaNoSources": "Nähtavaid kalendreid ega ülesandeloendeid pole.", + "trayAgendaOpenBusyMax": "Ava rakendus", + "trayAgendaRefresh": "Värskenda", + "trayAgendaError": "Päevakava pole saadaval", + "compactAgendaTitle": "Päevakava", + "compactAgendaSubtitle": "Tulekul", + "compactAgendaOverdue": "Tähtaja ületanud", + "compactAgendaClear": "Praegu vaba", + "compactAgendaOpenBusyMax": "Ava BusyMax", + "compactAgendaHide": "Peida", + "compactAgendaNewTask": "Uus ülesanne", + "compactAgendaRetry": "Proovi uuesti", + "compactAgendaRefresh": "Värskenda", + "compactAgendaAllDay": "Kogu päev", + "compactAgendaDueToday": "Tähtaeg täna", + "compactAgendaDueTomorrow": "Tähtaeg homme", + "compactAgendaDueOn": "Tähtaeg {date}", + "@compactAgendaDueOn": {"placeholders": {"date": {"type": "String"}}}, + "compactAgendaMoreOverdue": "Laadi veel tähtaja ületanud ülesandeid", + "agendaLoadMoreOverdue": "Laadi veel tähtaja ületanud ülesandeid", + "agendaLoadMoreNoDate": "Laadi veel kuupäevata ülesandeid", + "viewDay": "Päev", + "viewWeek": "Nädal", + "viewMonth": "Kuu", + "viewYear": "Aasta", + "viewAgenda": "Päevakava", + "scheduleSettings": "Ajakava", + "scheduleDisplaySettings": "Ajakava kuvamine", + "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", + "sourceCalendar": "Kalender", + "sourceTaskList": "Ülesandeloend", + "createChoiceTitle": "Loo", + "createEventAtTime": "Sündmus", + "createTaskAtDate": "Ülesanne", + "editEvent": "Muuda sündmust", + "eventTitle": "Sündmuse pealkiri", + "location": "Asukoht", + "timeSlot": "Ajavahemik", + "startDateTime": "Alguskuupäev ja -kellaaeg", + "endDateTime": "Lõppkuupäev ja -kellaaeg", + "doesNotRepeat": "Ei kordu", + "defaultReminder": "Vaikemeeldetuletus", + "guests": "Külalised", + "noGuests": "Külalisi pole", + "description": "Kirjeldus", + "availabilityShowAs": "Hõivatus / Kuva kui", + "busy": "Hõivatud", + "visibility": "Nähtavus", + "defaultVisibility": "Vaikimisi nähtavus", + "conference": "Konverents", + "noConference": "Konverentsi pole", + "providerCalendar": "Teenuse kalender", + "formatBoldShortLabel": "R", + "formatBoldTooltip": "Rasvane", + "formatItalicShortLabel": "K", + "formatItalicTooltip": "Kursiiv", + "formatUnderlineShortLabel": "A", + "formatUnderlineTooltip": "Allajoonitud", + "reminderMinutesBefore": "{minutes, plural, =1{1 minut varem} other{{minutes} minutit varem}}", + "@reminderMinutesBefore": {"placeholders": {"minutes": {"type": "int"}}}, + "reminderAtStart": "Algusajal", + "reminderHoursBefore": "{hours, plural, =1{1 tund varem} other{{hours} tundi varem}}", + "@reminderHoursBefore": {"placeholders": {"hours": {"type": "int"}}}, + "reminderDaysBefore": "{days, plural, =1{1 päev varem} other{{days} päeva varem}}", + "@reminderDaysBefore": {"placeholders": {"days": {"type": "int"}}}, + "availabilityFree": "Vaba", + "availabilityTentative": "Esialgne", + "availabilityOutOfOffice": "Kontorist väljas", + "availabilityWorkingElsewhere": "Töötab mujal", + "visibilityDefault": "Vaikimisi", + "visibilityPublic": "Avalik", + "visibilityPrivate": "Privaatne", + "visibilityConfidential": "Konfidentsiaalne", + "sensitivityNormal": "Tavaline", + "sensitivityPersonal": "Isiklik", + "tasks": "Ülesanded", + "allTasks": "Kõik ülesanded", + "tasksInList": "Loendi „{title}” ülesanded", + "@tasksInList": {"placeholders": {"title": {"type": "String"}}}, + "taskLists": "Ülesandeloendid", + "navigation": "Navigeerimine", + "mainMenu": "Peamenüü", + "keyboardShortcuts": "Klaviatuuri otseteed", + "shortcutGroupGeneral": "Üldine", + "shortcutKeyboardShortcutsDescription": "Kuva klaviatuuri otseteede loend", + "shortcutGroupNavigation": "Navigeerimine", + "shortcutNextPeriod": "Järgmine periood", + "shortcutNextPeriodDescription": "Nädalavaates järgmine nädal, kuuvaates järgmine kuu jne", + "shortcutPreviousPeriod": "Eelmine periood", + "shortcutPreviousPeriodDescription": "Nädalavaates eelmine nädal, kuuvaates eelmine kuu jne", + "shortcutJumpToToday": "Mine tänasele kuupäevale", + "shortcutGroupView": "Vaade", + "shortcutDayView": "Päevavaade", + "shortcutWeekView": "Nädalavaade", + "shortcutMonthView": "Kuuvaade", + "shortcutYearView": "Aastavaade", + "shortcutAgendaView": "Päevakavavaade", + "shortcutGroupCreateAndEdit": "Loomine ja muutmine", + "shortcutSaveItem": "Salvesta sündmus või ülesanne", + "shortcutDeleteItem": "Kustuta sündmus või ülesanne", + "shortcutGroupTaskEditing": "Ülesande muutmine", + "shortcutCancelEditing": "Tühista muutmine", + "shortcutCancelEditingDescription": "Sulge ülesande muutmine või ülesande üksikasjad", + "shortcutGroupCompactAgenda": "Kompaktne päevakava", + "shortcutRefreshCompactAgendaDescription": "Värskenda kompaktse päevakava akent", + "shortcutHideCompactAgendaDescription": "Peida kompaktse päevakava aken", + "aboutBusyMax": "Teave BusyMaxi kohta", + "aboutBusyMaxDescription": "Ülesanded ja kalender", + "website": "Veebisait", + "reportAnIssue": "Teata probleemist", + "sendFeedback": "Saada tagasisidet", + "feedbackSubmit": "Saada", + "feedbackCategory": "Kategooria", + "feedbackSelectCategory": "Valige kategooria", + "feedbackCategoryProblem": "Probleem või viga", + "feedbackCategoryFeature": "Funktsioonisoov", + "feedbackCategoryPrivacySecurity": "Privaatsus- või turbeprobleem", + "feedbackCategoryUsability": "Kasutatavusprobleem", + "feedbackCategoryOther": "Muu", + "feedbackSubject": "Teema", + "feedbackDetailedMessage": "Üksikasjalik sõnum", + "feedbackReplyEmail": "Vastamise e-posti aadress (valikuline)", + "feedbackIncludeTechnicalDetails": "Lisa tehnilised üksikasjad", + "feedbackTechnicalDetailsDisclosure": "Lisatakse ainult teie Linuxi operatsioonisüsteemi versioon ning rakenduse keel ja piirkonnaseaded. Logisid, kontoandmeid, failinimesid ega muid diagnostikaandmeid ei lisata.", + "feedbackCategoryRequired": "Valige kategooria.", + "feedbackSubjectLengthError": "Teema peab olema 3–120 tähemärki pikk.", + "feedbackMessageLengthError": "Sõnum peab olema 10–5000 tähemärki pikk.", + "feedbackInvalidEmail": "Sisestage kehtiv e-posti aadress.", + "feedbackConnectionError": "BusyStackiga ei saanud ühendust luua. Kontrollige ühendust ja proovige uuesti.", + "feedbackTimeoutError": "Päring aegus. Teie tagasisidet ei kustutatud; proovige uuesti.", + "feedbackRateLimitedError": "Sellest võrgust on saadetud liiga palju tagasisidet. Oodake ja proovige uuesti.", + "feedbackRejectedError": "Server lükkas saatmise tagasi. Kontrollige välju ja proovige uuesti.", + "feedbackServerError": "BusyStack ei saa praegu teie tagasisidet vastu võtta. Teie tagasisidet ei kustutatud; proovige uuesti.", + "feedbackSuccess": "Tagasiside saadetud. Viide: {id}", + "@feedbackSuccess": {"placeholders": {"id": {"type": "String"}}}, + "toggleSidebar": "Kuva või peida külgriba", + "accounts": "Kontod", + "currentAccount": "Praegune konto", + "switchAccount": "Vaheta kontot", + "addGoogleAccount": "Lisa Google'i konto", + "addMicrosoftAccount": "Lisa Microsofti konto", + "googleProvider": "Google", + "microsoftProvider": "Microsoft", + "signedInAccount": "Sisse logitud", + "removeAccount": "Eemalda konto…", + "removingAccount": "Konto eemaldamine…", + "removeAccountDescription": "Lõpeta sünkroonimine ja eemalda selle konto andmed seadmest.", + "removeAccountTitle": "Kas eemaldada {account} BusyMaxist?", + "@removeAccountTitle": {"placeholders": {"account": {"type": "String"}}}, + "removeAccountConfirmation": "See kustutab seadmest vahemällu salvestatud ülesanded, kalendrid, sündmused, meeldetuletused ja sünkroonimist ootavad võrguühenduseta muudatused. Sünkroonimata muudatused lähevad kaotsi. Google'ist ega Microsoftist midagi ei kustutata.", + "revokeGoogleAccess": "Tühista ka BusyMaxi juurdepääs sellele Google'i kontole", + "revokeGoogleAccessDescription": "Enne uuesti ühendamist peate juurdepääsu uuesti andma.", + "removeAccountAction": "Eemalda konto", + "removeAccountFailed": "Konto eemaldamist ei saanud lõpetada. Proovige uuesti.", + "accountRemovedGoogleRevokeFailed": "Konto eemaldati sellest seadmest, kuid BusyMaxi juurdepääsu teie Google'i kontole ei saanud tühistada. Saate selle oma Google'i kontol käsitsi tühistada.", + "newList": "Uus loend", + "signInToViewTaskLists": "Ülesandeloendite vaatamiseks logige sisse.", + "noTaskListsSynced": "Ühtegi ülesandeloendit pole veel sünkroonitud.", + "listActions": "Loendi toimingud", + "rename": "Nimeta ümber", + "delete": "Kustuta", + "renameList": "Nimeta loend ümber", + "deleteList": "Kustuta loend", + "builtInMicrosoftList": "Sisseehitatud", + "builtInMicrosoftListCannotRenameDelete": "Microsoft To Do sisseehitatud loendeid ei saa ümber nimetada ega kustutada.", + "deleteListConfirmation": "Kas kustutada „{title}” Google Tasksist?", + "@deleteListConfirmation": {"placeholders": {"title": {"type": "String"}}}, + "deleteEvent": "Kustuta sündmus", + "title": "Pealkiri", + "create": "Loo", + "newTask": "Uus ülesanne", + "clearCompleted": "Eemalda lõpetatud ülesanded", + "refreshList": "Värskenda loendit", + "refreshAll": "Värskenda kõiki", + "listRefreshed": "Loend on värskendatud.", + "allTasksRefreshed": "Kõik kontod on värskendatud.", + "exportedFile": "Eksporditud asukohta {path}", + "@exportedFile": {"placeholders": {"path": {"type": "String"}}}, + "exportFailed": "Eksportimine nurjus: {error}", + "@exportFailed": {"placeholders": {"error": {"type": "String"}}}, + "refreshFailed": "Värskendamine nurjus: {error}", + "@refreshFailed": {"placeholders": {"error": {"type": "String"}}}, + "selectOrCreateTaskList": "Alustamiseks valige või looge ülesandeloend.", + "signInToViewTasks": "Ülesannete vaatamiseks logige sisse.", + "noTasks": "Ülesandeid pole.", + "noTasksYet": "Ülesandeid veel pole", + "noTasksYetMessage": "Alustamiseks looge ülesanne või värskendage kontosid.", + "noTasksInList": "Selles loendis pole ülesandeid.", + "overdue": "Tähtaja ületanud", + "today": "Täna", + "tomorrow": "Homme", + "upcoming": "Tulekul", + "noDate": "Kuupäevata", + "completed": "Lõpetatud", + "duePrefix": "Tähtaeg {date}", + "@duePrefix": {"placeholders": {"date": {"type": "String"}}}, + "dateTimeDisplay": "{date} · {time}", + "@dateTimeDisplay": { + "placeholders": { + "date": {"type": "String"}, + "time": {"type": "String"} + } + }, + "taskDetails": "Ülesande üksikasjad", + "editTask": "Muuda ülesannet", + "noTaskSelected": "Ülesannet pole valitud.", + "noTaskSelectedHelper": "Üksikasjade vaatamiseks ja muutmiseks valige ülesanne.", + "taskUnavailable": "Ülesanne pole saadaval.", + "signInToEditTasks": "Ülesannete muutmiseks logige sisse.", + "refreshTask": "Värskenda ülesannet", + "primarySection": "Põhiteave", + "statusSection": "Olek", + "openStatus": "Pooleli", + "doneStatus": "Valmis", + "notes": "Märkmed", + "dueDate": "Tähtaeg", + "clearDueDate": "Eemalda tähtaeg", + "dueTime": "Tähtaja kellaaeg", + "startDate": "Alguskuupäev", + "startTime": "Alguskellaaeg", + "endDate": "Lõppkuupäev", + "endTime": "Lõppkellaaeg", + "reminderDate": "Meeldetuletuse kuupäev", + "reminderTime": "Meeldetuletuse kellaaeg", + "reminder": "Meeldetuletus", + "addReminder": "Lisa meeldetuletus", + "addGuest": "Lisa külaline", + "addGuestEmail": "Lisa külalise e-posti aadress", + "removeReminder": "Eemalda meeldetuletus", + "off": "Väljas", + "repeat": "Kordus", + "repeatNone": "Ei kordu", + "noneValue": "Puudub", + "repeatDaily": "Iga päev", + "repeatWeekly": "Iga nädal", + "repeatMonthly": "Iga kuu", + "repeatYearly": "Iga aasta", + "importance": "Tähtsus", + "importanceLow": "Madal", + "importanceNormal": "Tavaline", + "importanceHigh": "Kõrge", + "categories": "Kategooriad", + "scheduleSection": "Ajakava", + "dueGroup": "Tähtaeg", + "startGroup": "Algus", + "reminderGroup": "Meeldetuletus", + "organizationSection": "Korraldus", + "actionsSection": "Toimingud", + "advancedSection": "Täpsemad seaded", + "addCategory": "Lisa kategooria", + "list": "Loend", + "microsoftMoveUnsupported": "Selles versioonis ei toetata Microsoft To Do kontodel ülesannete teisaldamist loendite vahel.", + "createSubtask": "Loo alamülesanne", + "moveToTop": "Teisalda kõige üles", + "deleteTask": "Kustuta ülesanne", + "newSubtask": "Uus alamülesanne", + "deleteTaskConfirmation": "Kas kustutada „{title}” Google Tasksist?", + "@deleteTaskConfirmation": {"placeholders": {"title": {"type": "String"}}}, + "metadata": "Metaandmed", + "id": "ID", + "etag": "ETag", + "updated": "Uuendatud", + "parent": "Ülemülesanne", + "position": "Asukoht", + "webLink": "Veebilink", + "assignment": "Määramine", + "localState": "Kohalik olek", + "pendingSync": "Sünkroonimise ootel", + "synced": "Sünkroonitud", + "account": "Konto", + "sync": "Sünkroonimine", + "manualFullSync": "Käsitsi täielik sünkroonimine", + "runInBackgroundWhenClosed": "Jätka töötamist, kui aken on suletud", + "showTrayIcon": "Kuva süsteemisalve ikoon", + "startMinimizedToTray": "Käivita minimeerituna süsteemisalves", + "requiresTrayIcon": "Nõuab süsteemisalve ikooni.", + "syncComplete": "Sünkroonimine on lõpetatud.", + "syncFailed": "Sünkroonimine nurjus: {error}", + "@syncFailed": {"placeholders": {"error": {"type": "String"}}}, + "notifySyncFailures": "Teavitused sünkroonimise nurjumisel", + "notifyConflicts": "Teavitused konfliktide korral", + "notifyDueToday": "Täna tähtuvate ülesannete teavitused", + "eventReminders": "Sündmuste meeldetuletused", + "taskReminders": "Ülesannete meeldetuletused", + "notificationDetailLevel": "Teavituste üksikasjalikkus", + "notificationDetailPrivate": "Privaatne", + "notificationDetailNormal": "Tavaline", + "quietHours": "Vaikne aeg", + "quietHoursDescription": "Peata teavitused selleks ajavahemikuks.", + "quietHoursStart": "Vaikse aja algus", + "quietHoursEnd": "Vaikse aja lõpp", + "notifications": "Teavitused", + "appearance": "Välimus", + "theme": "Kujundus", + "themeSystem": "Süsteem", + "themeLight": "Hele", + "themeDark": "Tume", + "themeFamily": "Kujunduse perekond", + "themeFamilyYaru": "Ubuntu algupärane kujundus (Yaru)", + "localization": "Keel ja piirkond", + "currentLocale": "Praegune lokaat", + "privacy": "Privaatsus", + "redactTaskContentInDiagnostics": "Peida diagnostikas ülesannete sisu", + "developerDiagnostics": "Arendaja diagnostika", + "diagnostics": "Diagnostika", + "apiInspectorDisabled": "Kuva API-inspektor", + "googleTasksApi": "Google Tasks API", + "discoveryRevision": "Discovery versioon: {revision}", + "@discoveryRevision": {"placeholders": {"revision": {"type": "String"}}}, + "implementedMethods": "Rakendatud meetodid", + "supportsTasksScopes": "Toetab õiguse ulatusi tasks ja tasks.readonly", + "requiresTasksScope": "Nõuab õiguse ulatust tasks", + "blockedPendingOperations": "Blokeeritud ootel toimingud", + "signInToInspectPendingOperations": "Ootel toimingute kontrollimiseks logige sisse.", + "noBlockedPendingOperations": "Blokeeritud ootel toiminguid pole.", + "operationActions": "Toimingu tegevused", + "pendingOpListId": "loend={id}", + "@pendingOpListId": {"placeholders": {"id": {"type": "String"}}}, + "pendingOpTaskId": "ülesanne={id}", + "@pendingOpTaskId": {"placeholders": {"id": {"type": "String"}}}, + "pendingOpAttempts": "katseid={count}", + "@pendingOpAttempts": {"placeholders": {"count": {"type": "int"}}}, + "retry": "Proovi uuesti", + "discard": "Hülga", + "discardChanges": "Kas hüljata muudatused?", + "discardChangesConfirmation": "See hülgab ülesande salvestamata muudatused.", + "retryCompleted": "Uuesti proovimine lõpetatud.", + "discardPendingOperation": "Kas hüljata ootel toiming?", + "discardPendingOperationConfirmation": "See eemaldab blokeeritud kohaliku toimingu. Järgmine sünkroonimine laadib andmed Google Tasksist uuesti.", + "pendingOperationDiscarded": "Ootel toiming hüljatud.", + "syncFailureNotificationTitle": "BusyMaxi sünkroonimine nurjus", + "syncFailureNotificationBody": "Taustal sünkroonimine nurjus. {message}", + "@syncFailureNotificationBody": {"placeholders": {"message": {"type": "String"}}}, + "conflictNotificationTitle": "BusyMaxi sünkroonimiskonflikt", + "conflictNotificationBody": "Ootel kohalik muudatus blokeeriti. {summary}", + "@conflictNotificationBody": {"placeholders": {"summary": {"type": "String"}}}, + "dueTodayNotificationTitle": "Täna tähtuvad ülesanded", + "dueTodayNotificationBody": "{count, plural, =1{Üks ülesanne tähtub täna.} other{{count} ülesannet tähtub täna.}}", + "@dueTodayNotificationBody": {"placeholders": {"count": {"type": "int"}}}, + "eventReminderNotificationTitle": "Sündmuse meeldetuletus", + "taskReminderNotificationTitle": "Ülesande meeldetuletus", + "eventReminderNotificationBody": "Sündmus algab varsti.", + "taskReminderNotificationBody": "Ülesande tähtaeg on varsti.", + "notificationOpenAction": "Ava", + "notificationDetailsHidden": "Üksikasjad on privaatsusseadete tõttu peidetud.", + "previousMonth": "Eelmine kuu", + "nextMonth": "Järgmine kuu", + "openMonthView": "Ava kuuvaade", + "previousYear": "Eelmine aasta", + "nextYear": "Järgmine aasta", + "openYearView": "Ava aastavaade", + "weekNumberTooltip": "Nädal {number}", + "@weekNumberTooltip": {"placeholders": {"number": {"type": "int"}}}, + "resizeAllDayPanel": "Muuda kogu päeva paneeli suurust", + "scheduleItemCount": "{count, plural, =1{1 kirje} other{{count} kirjet}}", + "@scheduleItemCount": {"placeholders": {"count": {"type": "int"}}}, + "readOnlyCalendar": "See kalender on kirjutuskaitstud.", + "selectTimeZone": "Valige ajavöönd", + "searchLocations": "Otsi asukohti", + "noLocationsFound": "Asukohti ei leitud", + "deleteCalendarConfirmation": "Kas kustutada „{title}”?", + "@deleteCalendarConfirmation": {"placeholders": {"title": {"type": "String"}}} +} diff --git a/lib/l10n/app_fa.arb b/lib/l10n/app_fa.arb index 89fb630..7756e42 100644 --- a/lib/l10n/app_fa.arb +++ b/lib/l10n/app_fa.arb @@ -40,7 +40,7 @@ "showInSchedule": "نمایش در برنامه", "noCalendarsSynced": "هنوز هیچ تقویمی همگام نشده است.", "allDay": "تمام روز", - "moreItems": "+{count} مورد دیگر", + "moreItems": "+\u2068{count}\u2069 مورد دیگر", "noEventsOrTasks": "هیچ رویداد یا کاری وجود ندارد", "scheduleLoading": "در حال بارگیری برنامه...", "scheduleUnavailable": "برنامه در دسترس نیست", @@ -68,7 +68,7 @@ "compactAgendaAllDay": "تمام روز", "compactAgendaDueToday": "سررسید امروز", "compactAgendaDueTomorrow": "سررسید فردا", - "compactAgendaDueOn": "سررسید: {date}", + "compactAgendaDueOn": "سررسید: \u2068{date}\u2069", "compactAgendaMoreOverdue": "بارگیری کارهای عقب‌افتادهٔ بیشتر", "agendaLoadMoreOverdue": "بارگیری کارهای عقب‌افتادهٔ بیشتر", "agendaLoadMoreNoDate": "بارگیری کارهای بدون تاریخ بیشتر", @@ -111,10 +111,10 @@ "formatItalicTooltip": "مورب", "formatUnderlineShortLabel": "U", "formatUnderlineTooltip": "زیرخط", - "reminderMinutesBefore": "{minutes, plural, =0{هنگام شروع} =1{یک دقیقه قبل} other{{minutes} دقیقه قبل}}", + "reminderMinutesBefore": "{minutes, plural, =0{هنگام شروع} =1{یک دقیقه قبل} other{\u2068{minutes}\u2069 دقیقه قبل}}", "reminderAtStart": "هنگام شروع", - "reminderHoursBefore": "{hours, plural, =0{هنگام شروع} =1{یک ساعت قبل} other{{hours} ساعت قبل}}", - "reminderDaysBefore": "{days, plural, =0{همان روز} =1{یک روز قبل} other{{days} روز قبل}}", + "reminderHoursBefore": "{hours, plural, =0{هنگام شروع} =1{یک ساعت قبل} other{\u2068{hours}\u2069 ساعت قبل}}", + "reminderDaysBefore": "{days, plural, =0{همان روز} =1{یک روز قبل} other{\u2068{days}\u2069 روز قبل}}", "availabilityFree": "آزاد", "availabilityTentative": "احتمالی", "availabilityOutOfOffice": "خارج از دفتر", @@ -127,7 +127,7 @@ "sensitivityPersonal": "شخصی", "tasks": "کارها", "allTasks": "همهٔ کارها", - "tasksInList": "کارهای {title}", + "tasksInList": "کارهای \u2068{title}\u2069", "taskLists": "فهرست‌های کار", "navigation": "پیمایش", "mainMenu": "منوی اصلی", @@ -182,7 +182,7 @@ "feedbackRateLimitedError": "بازخوردهای بیش از حدی از این شبکه ارسال شده است. کمی صبر کنید و دوباره تلاش کنید.", "feedbackRejectedError": "سرور ارسال را رد کرد. فیلدها را بررسی و دوباره تلاش کنید.", "feedbackServerError": "BusyStack اکنون نمی‌تواند بازخورد شما را بپذیرد. بازخورد شما پاک نشده است؛ دوباره تلاش کنید.", - "feedbackSuccess": "بازخورد ارسال شد. شناسهٔ پیگیری: {id}", + "feedbackSuccess": "بازخورد ارسال شد. شناسهٔ پیگیری: \u2068{id}\u2069", "toggleSidebar": "نمایش یا پنهان کردن نوار کناری", "accounts": "حساب‌ها", "currentAccount": "حساب فعلی", @@ -195,7 +195,7 @@ "removeAccount": "حذف حساب…", "removingAccount": "در حال حذف حساب…", "removeAccountDescription": "همگام‌سازی را متوقف و داده‌های این حساب را از این دستگاه حذف کنید.", - "removeAccountTitle": "حذف {account} از BusyMax؟", + "removeAccountTitle": "حذف \u2068{account}\u2069 از BusyMax؟", "removeAccountConfirmation": "با این کار، کارها، تقویم‌ها، رویدادها، یادآورها و تغییرات آفلاین در انتظار از حافظهٔ نهان این دستگاه حذف می‌شوند. تغییرات همگام‌نشده از دست می‌روند. هیچ چیزی از Google یا Microsoft حذف نمی‌شود.", "revokeGoogleAccess": "دسترسی BusyMax به این حساب Google نیز لغو شود", "revokeGoogleAccessDescription": "پیش از اتصال دوباره باید دسترسی را دوباره اعطا کنید.", @@ -212,7 +212,7 @@ "deleteList": "حذف فهرست", "builtInMicrosoftList": "داخلی", "builtInMicrosoftListCannotRenameDelete": "فهرست‌های داخلی Microsoft To Do را نمی‌توان تغییر نام داد یا حذف کرد.", - "deleteListConfirmation": "«{title}» از Google Tasks حذف شود؟", + "deleteListConfirmation": "«\u2068{title}\u2069» از Google Tasks حذف شود؟", "deleteEvent": "حذف رویداد", "title": "عنوان", "create": "ایجاد", @@ -222,9 +222,9 @@ "refreshAll": "تازه‌سازی همه", "listRefreshed": "فهرست تازه‌سازی شد.", "allTasksRefreshed": "همهٔ حساب‌ها تازه‌سازی شدند.", - "exportedFile": "در {path} خروجی گرفته شد", - "exportFailed": "خروجی گرفتن ناموفق بود: {error}", - "refreshFailed": "تازه‌سازی ناموفق بود: {error}", + "exportedFile": "در \u2068{path}\u2069 خروجی گرفته شد", + "exportFailed": "خروجی گرفتن ناموفق بود: \u2068{error}\u2069", + "refreshFailed": "تازه‌سازی ناموفق بود: \u2068{error}\u2069", "selectOrCreateTaskList": "برای شروع، یک فهرست کار انتخاب یا ایجاد کنید.", "signInToViewTasks": "برای دیدن کارها وارد شوید.", "noTasks": "هیچ کاری وجود ندارد.", @@ -237,8 +237,8 @@ "upcoming": "پیش رو", "noDate": "بدون تاریخ", "completed": "انجام‌شده", - "duePrefix": "سررسید: {date}", - "dateTimeDisplay": "{date} · {time}", + "duePrefix": "سررسید: \u2068{date}\u2069", + "dateTimeDisplay": "\u2068{date}\u2069 · \u2068{time}\u2069", "taskDetails": "جزئیات کار", "editTask": "ویرایش کار", "noTaskSelected": "هیچ کاری انتخاب نشده است.", @@ -292,7 +292,7 @@ "moveToTop": "انتقال به بالاترین جایگاه", "deleteTask": "حذف کار", "newSubtask": "زیرکار جدید", - "deleteTaskConfirmation": "«{title}» از Google Tasks حذف شود؟", + "deleteTaskConfirmation": "«\u2068{title}\u2069» از Google Tasks حذف شود؟", "metadata": "فراداده", "id": "شناسه", "etag": "ETag", @@ -312,7 +312,7 @@ "startMinimizedToTray": "شروع به‌صورت کوچک‌شده در سینی سیستم", "requiresTrayIcon": "به نماد سینی سیستم نیاز دارد.", "syncComplete": "همگام‌سازی کامل شد.", - "syncFailed": "همگام‌سازی ناموفق بود: {error}", + "syncFailed": "همگام‌سازی ناموفق بود: \u2068{error}\u2069", "notifySyncFailures": "اعلان هنگام شکست همگام‌سازی", "notifyConflicts": "اعلان هنگام تداخل", "notifyDueToday": "اعلان کارهای دارای سررسید امروز", @@ -341,7 +341,7 @@ "diagnostics": "اطلاعات تشخیصی", "apiInspectorDisabled": "نمایش بازرس API", "googleTasksApi": "رابط Google Tasks API", - "discoveryRevision": "بازبینی Discovery: ‏{revision}", + "discoveryRevision": "بازبینی Discovery: \u2068{revision}\u2069", "implementedMethods": "روش‌های پیاده‌سازی‌شده", "supportsTasksScopes": "از محدوده‌های tasks و tasks.readonly پشتیبانی می‌کند", "requiresTasksScope": "به محدودهٔ tasks نیاز دارد", @@ -349,9 +349,9 @@ "signInToInspectPendingOperations": "برای بررسی عملیات در انتظار وارد شوید.", "noBlockedPendingOperations": "هیچ عملیات در انتظار مسدودشده‌ای وجود ندارد.", "operationActions": "اقدامات عملیات", - "pendingOpListId": "فهرست={id}", - "pendingOpTaskId": "کار={id}", - "pendingOpAttempts": "تلاش‌ها={count}", + "pendingOpListId": "فهرست=\u2068{id}\u2069", + "pendingOpTaskId": "کار=\u2068{id}\u2069", + "pendingOpAttempts": "تلاش‌ها=\u2068{count}\u2069", "retry": "تلاش دوباره", "discard": "کنار گذاشتن", "discardChanges": "تغییرات کنار گذاشته شوند؟", @@ -361,11 +361,11 @@ "discardPendingOperationConfirmation": "با این کار عملیات محلی مسدودشده حذف می‌شود. در همگام‌سازی بعدی، داده‌ها از Google Tasks تازه‌سازی می‌شوند.", "pendingOperationDiscarded": "عملیات در انتظار کنار گذاشته شد.", "syncFailureNotificationTitle": "همگام‌سازی BusyMax ناموفق بود", - "syncFailureNotificationBody": "همگام‌سازی پس‌زمینه ناموفق بود. {message}", + "syncFailureNotificationBody": "همگام‌سازی پس‌زمینه ناموفق بود. \u2068{message}\u2069", "conflictNotificationTitle": "تداخل همگام‌سازی BusyMax", - "conflictNotificationBody": "یک تغییر محلی در انتظار مسدود شد. {summary}", + "conflictNotificationBody": "یک تغییر محلی در انتظار مسدود شد. \u2068{summary}\u2069", "dueTodayNotificationTitle": "کارهای دارای سررسید امروز", - "dueTodayNotificationBody": "{count, plural, =0{امروز هیچ کاری سررسید ندارد.} =1{امروز یک کار سررسید دارد.} other{امروز {count} کار سررسید دارند.}}", + "dueTodayNotificationBody": "{count, plural, =0{امروز هیچ کاری سررسید ندارد.} =1{امروز یک کار سررسید دارد.} other{امروز \u2068{count}\u2069 کار سررسید دارند.}}", "eventReminderNotificationTitle": "یادآور رویداد", "taskReminderNotificationTitle": "یادآور کار", "eventReminderNotificationBody": "رویداد به‌زودی شروع می‌شود.", @@ -378,12 +378,76 @@ "previousYear": "سال قبل", "nextYear": "سال بعد", "openYearView": "باز کردن نمای سال", - "weekNumberTooltip": "هفتهٔ {number}", + "weekNumberTooltip": "هفتهٔ \u2068{number}\u2069", "resizeAllDayPanel": "تغییر اندازهٔ پنل تمام‌روز", - "scheduleItemCount": "{count, plural, =0{هیچ موردی} =1{یک مورد} other{{count} مورد}}", + "scheduleItemCount": "{count, plural, =0{هیچ موردی} =1{یک مورد} other{\u2068{count}\u2069 مورد}}", "readOnlyCalendar": "این تقویم فقط‌خواندنی است.", "selectTimeZone": "انتخاب منطقهٔ زمانی", "searchLocations": "جست‌وجوی مکان‌ها", "noLocationsFound": "مکانی پیدا نشد", - "deleteCalendarConfirmation": "«{title}» حذف شود؟" + "deleteCalendarConfirmation": "«\u2068{title}\u2069» حذف شود؟", + "@moreItems": { + "placeholders": { + "count": { + "type": "int", + "format": "decimalPattern" + } + } + }, + "@reminderMinutesBefore": { + "placeholders": { + "minutes": { + "type": "int", + "format": "decimalPattern" + } + } + }, + "@reminderHoursBefore": { + "placeholders": { + "hours": { + "type": "int", + "format": "decimalPattern" + } + } + }, + "@reminderDaysBefore": { + "placeholders": { + "days": { + "type": "int", + "format": "decimalPattern" + } + } + }, + "@pendingOpAttempts": { + "placeholders": { + "count": { + "type": "int", + "format": "decimalPattern" + } + } + }, + "@dueTodayNotificationBody": { + "placeholders": { + "count": { + "type": "int", + "format": "decimalPattern" + } + } + }, + "@weekNumberTooltip": { + "placeholders": { + "number": { + "type": "int", + "format": "decimalPattern" + } + } + }, + "@scheduleItemCount": { + "placeholders": { + "count": { + "type": "int", + "format": "decimalPattern" + } + } + } } diff --git a/lib/l10n/app_it.arb b/lib/l10n/app_it.arb index 69b9e49..923308f 100644 --- a/lib/l10n/app_it.arb +++ b/lib/l10n/app_it.arb @@ -4,7 +4,7 @@ "connectGoogleAccount": "Collega gli account Google e Microsoft per sincronizzare calendari e attività.", "googlePermissionsConsentNotice": "Nella schermata delle autorizzazioni di Google, seleziona sia l’autorizzazione per il calendario sia quella per le attività.", "googlePermissionsRequiredRetry": "Sono necessarie le autorizzazioni per Google Calendar e Google Tasks. Riprova e seleziona entrambe le caselle.", - "finishSetup": "Completa configurazione", + "finishSetup": "Completa la configurazione", "continueSetup": "Continua", "onboardingSetupTitle": "Configura BusyMax", "onboardingAccountsStepTitle": "Collega gli account", @@ -40,7 +40,7 @@ "showInSchedule": "Mostra nell’agenda", "noCalendarsSynced": "Nessun calendario ancora sincronizzato.", "allDay": "Tutto il giorno", - "moreItems": "+{count} altri", + "moreItems": "+{count} in più", "noEventsOrTasks": "Nessun evento o attività", "scheduleLoading": "Caricamento agenda...", "scheduleUnavailable": "Agenda non disponibile", @@ -102,8 +102,8 @@ "busy": "Occupato", "visibility": "Visibilità", "defaultVisibility": "Visibilità predefinita", - "conference": "Riunione", - "noConference": "Nessuna riunione", + "conference": "Conferenza", + "noConference": "Nessuna conferenza", "providerCalendar": "Calendario del servizio", "formatBoldShortLabel": "G", "formatBoldTooltip": "Grassetto", @@ -178,7 +178,7 @@ "feedbackMessageLengthError": "Il messaggio deve contenere da 10 a 5.000 caratteri.", "feedbackInvalidEmail": "Inserisci un indirizzo email valido.", "feedbackConnectionError": "Impossibile connettersi a BusyStack. Controlla la connessione e riprova.", - "feedbackTimeoutError": "La richiesta è scaduta. Il feedback non è stato cancellato; riprova.", + "feedbackTimeoutError": "La richiesta ha superato il tempo limite. Il feedback non è stato cancellato; riprova.", "feedbackRateLimitedError": "Sono stati inviati troppi feedback da questa rete. Attendi e riprova.", "feedbackRejectedError": "Il server ha rifiutato l’invio. Controlla i campi e riprova.", "feedbackServerError": "BusyStack non può accettare il feedback in questo momento. Il feedback non è stato cancellato; riprova.", @@ -201,7 +201,7 @@ "revokeGoogleAccessDescription": "Dovrai concedere nuovamente l’accesso prima di riconnetterti.", "removeAccountAction": "Rimuovi account", "removeAccountFailed": "Impossibile completare la rimozione dell’account. Riprova.", - "accountRemovedGoogleRevokeFailed": "L’account è stato rimosso da questo dispositivo, ma BusyMax non ha potuto revocare l’accesso all’account Google. Puoi revocarlo dalle impostazioni dell’account Google.", + "accountRemovedGoogleRevokeFailed": "L’account è stato rimosso da questo dispositivo, ma BusyMax non è riuscito a revocare il proprio accesso a Google. Puoi revocarlo dal tuo account Google.", "newList": "Nuovo elenco", "signInToViewTaskLists": "Accedi per visualizzare gli elenchi di attività.", "noTaskListsSynced": "Nessun elenco di attività ancora sincronizzato.", @@ -307,7 +307,7 @@ "account": "Account", "sync": "Sincronizzazione", "manualFullSync": "Sincronizzazione completa manuale", - "runInBackgroundWhenClosed": "Continua l’esecuzione quando la finestra viene chiusa", + "runInBackgroundWhenClosed": "Continua a funzionare quando la finestra è chiusa", "showTrayIcon": "Mostra icona nell’area di notifica", "startMinimizedToTray": "Avvia ridotto a icona nell’area di notifica", "requiresTrayIcon": "Richiede l’icona nell’area di notifica.", @@ -339,7 +339,7 @@ "redactTaskContentInDiagnostics": "Nascondi il contenuto delle attività nelle informazioni diagnostiche", "developerDiagnostics": "Diagnostica per sviluppatori", "diagnostics": "Diagnostica", - "apiInspectorDisabled": "Mostra controllo API", + "apiInspectorDisabled": "Mostra l’ispettore API", "googleTasksApi": "API Google Tasks", "discoveryRevision": "Revisione Discovery: {revision}", "implementedMethods": "Metodi implementati", diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index dcc363a..2909bd7 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -15,7 +15,7 @@ "signInWithMicrosoft": "Войти через Microsoft", "googleTasksProvider": "Google Tasks", "microsoftTodoProvider": "Microsoft To Do", - "providerNotConfigured": "Этот поставщик не настроен.", + "providerNotConfigured": "Этот сервис не настроен.", "waitingForGoogleSignIn": "Ожидание входа через Google...", "waitingForMicrosoftSignIn": "Ожидание входа через Microsoft...", "microsoftSignInNotConfigured": "Вход через Microsoft не настроен. Задайте MICROSOFT_OAUTH_CLIENT_ID.", @@ -104,7 +104,7 @@ "defaultVisibility": "Видимость по умолчанию", "conference": "Конференция", "noConference": "Без конференции", - "providerCalendar": "Календарь поставщика", + "providerCalendar": "Календарь сервиса", "formatBoldShortLabel": "Ж", "formatBoldTooltip": "Полужирный", "formatItalicShortLabel": "К", @@ -263,7 +263,7 @@ "reminder": "Напоминание", "addReminder": "Добавить напоминание", "addGuest": "Добавить гостя", - "addGuestEmail": "Добавить адрес гостя", + "addGuestEmail": "Добавить адрес электронной почты гостя", "removeReminder": "Удалить напоминание", "off": "Выкл.", "repeat": "Повтор", diff --git a/lib/l10n/app_vi.arb b/lib/l10n/app_vi.arb new file mode 100644 index 0000000..68031f3 --- /dev/null +++ b/lib/l10n/app_vi.arb @@ -0,0 +1,389 @@ +{ + "@@locale": "vi", + "appTitle": "BusyMax", + "connectGoogleAccount": "Kết nối tài khoản Google và Microsoft để đồng bộ lịch và công việc.", + "googlePermissionsConsentNotice": "Trên màn hình cấp quyền của Google, hãy chọn cả quyền truy cập Lịch và Công việc.", + "googlePermissionsRequiredRetry": "Cần có quyền truy cập Google Calendar và Google Tasks. Vui lòng thử lại và chọn cả hai hộp kiểm.", + "finishSetup": "Hoàn tất thiết lập", + "continueSetup": "Tiếp tục", + "onboardingSetupTitle": "Thiết lập BusyMax", + "onboardingAccountsStepTitle": "Kết nối tài khoản", + "onboardingAccountsStepDescription": "Thêm tất cả tài khoản Google và Microsoft bạn muốn sử dụng. BusyMax đồng bộ lịch, sự kiện, danh sách công việc và công việc từ mỗi tài khoản.", + "onboardingPreferencesStepTitle": "Chọn cài đặt hệ thống", + "onboardingPreferencesStepDescription": "Thiết lập cách ứng dụng hoạt động trên máy tính, lời nhắc, mức độ chi tiết của thông báo và giao diện trước khi mở lịch biểu.", + "signInWithGoogle": "Đăng nhập bằng Google", + "signInWithMicrosoft": "Đăng nhập bằng Microsoft", + "googleTasksProvider": "Google Tasks", + "microsoftTodoProvider": "Microsoft To Do", + "providerNotConfigured": "Dịch vụ này chưa được cấu hình.", + "waitingForGoogleSignIn": "Đang chờ đăng nhập Google...", + "waitingForMicrosoftSignIn": "Đang chờ đăng nhập Microsoft...", + "microsoftSignInNotConfigured": "Tính năng đăng nhập Microsoft chưa được cấu hình. Hãy đặt MICROSOFT_OAUTH_CLIENT_ID.", + "cancel": "Hủy", + "close": "Đóng", + "exit": "Thoát", + "options": "Tùy chọn", + "hide": "Ẩn", + "show": "Hiện", + "export": "Xuất", + "save": "Lưu", + "settings": "Cài đặt", + "all": "Tất cả", + "calendarEvents": "Sự kiện", + "calendarTasks": "Công việc", + "calendar": "Lịch", + "calendars": "Lịch", + "newEvent": "Sự kiện mới", + "refreshCalendar": "Làm mới lịch", + "openInProvider": "Mở trong dịch vụ", + "hideFromSchedule": "Ẩn khỏi lịch biểu", + "showInSchedule": "Hiện trong lịch biểu", + "noCalendarsSynced": "Chưa có lịch nào được đồng bộ.", + "allDay": "Cả ngày", + "moreItems": "+{count} mục khác", + "noEventsOrTasks": "Không có sự kiện hoặc công việc", + "scheduleLoading": "Đang tải lịch biểu...", + "scheduleUnavailable": "Lịch biểu không khả dụng", + "scheduleNoSources": "Không có lịch hoặc danh sách công việc nào đang hiển thị", + "scheduleNoSourcesDescription": "Chọn nội dung cần hiển thị trong Cài đặt, sau đó làm mới lịch biểu.", + "scheduleSignInRequired": "Kết nối tài khoản", + "scheduleSignInDescription": "Đăng nhập để đồng bộ lịch và công việc.", + "scheduleNoSearchResults": "Không có sự kiện hoặc công việc phù hợp", + "scheduleNoSearchResultsDescription": "Thử tìm kiếm khác hoặc xóa các bộ lọc hiện tại.", + "trayAgendaLoading": "Đang tải lịch biểu...", + "trayAgendaSignInRequired": "Đăng nhập để hiển thị lịch biểu.", + "trayAgendaNoSources": "Không có lịch hoặc danh sách công việc nào đang hiển thị.", + "trayAgendaOpenBusyMax": "Mở ứng dụng", + "trayAgendaRefresh": "Làm mới", + "trayAgendaError": "Lịch biểu không khả dụng", + "compactAgendaTitle": "Lịch biểu", + "compactAgendaSubtitle": "Sắp tới", + "compactAgendaOverdue": "Quá hạn", + "compactAgendaClear": "Hiện chưa có lịch", + "compactAgendaOpenBusyMax": "Mở BusyMax", + "compactAgendaHide": "Ẩn", + "compactAgendaNewTask": "Công việc mới", + "compactAgendaRetry": "Thử lại", + "compactAgendaRefresh": "Làm mới", + "compactAgendaAllDay": "Cả ngày", + "compactAgendaDueToday": "Đến hạn hôm nay", + "compactAgendaDueTomorrow": "Đến hạn ngày mai", + "compactAgendaDueOn": "Đến hạn {date}", + "compactAgendaMoreOverdue": "Tải thêm công việc quá hạn", + "agendaLoadMoreOverdue": "Tải thêm công việc quá hạn", + "agendaLoadMoreNoDate": "Tải thêm công việc không có ngày", + "viewDay": "Ngày", + "viewWeek": "Tuần", + "viewMonth": "Tháng", + "viewYear": "Năm", + "viewAgenda": "Lịch biểu", + "scheduleSettings": "Lịch biểu", + "scheduleDisplaySettings": "Hiển thị lịch biểu", + "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", + "sourceCalendar": "Lịch", + "sourceTaskList": "Danh sách công việc", + "createChoiceTitle": "Tạo", + "createEventAtTime": "Sự kiện", + "createTaskAtDate": "Công việc", + "editEvent": "Chỉnh sửa sự kiện", + "eventTitle": "Tiêu đề sự kiện", + "location": "Địa điểm", + "timeSlot": "Khoảng thời gian", + "startDateTime": "Ngày/giờ bắt đầu", + "endDateTime": "Ngày/giờ kết thúc", + "doesNotRepeat": "Không lặp lại", + "defaultReminder": "Lời nhắc mặc định", + "guests": "Khách mời", + "noGuests": "Không có khách mời", + "description": "Mô tả", + "availabilityShowAs": "Tình trạng rảnh/bận / Hiển thị là", + "busy": "Bận", + "visibility": "Chế độ hiển thị", + "defaultVisibility": "Chế độ hiển thị mặc định", + "conference": "Cuộc họp", + "noConference": "Không có cuộc họp", + "providerCalendar": "Lịch của dịch vụ", + "formatBoldShortLabel": "B", + "formatBoldTooltip": "Đậm", + "formatItalicShortLabel": "I", + "formatItalicTooltip": "Nghiêng", + "formatUnderlineShortLabel": "U", + "formatUnderlineTooltip": "Gạch chân", + "reminderMinutesBefore": "{minutes, plural, =1{Trước 1 phút} other{Trước {minutes} phút}}", + "reminderAtStart": "Khi bắt đầu", + "reminderHoursBefore": "{hours, plural, =1{Trước 1 giờ} other{Trước {hours} giờ}}", + "reminderDaysBefore": "{days, plural, =1{Trước 1 ngày} other{Trước {days} ngày}}", + "availabilityFree": "Rảnh", + "availabilityTentative": "Dự kiến", + "availabilityOutOfOffice": "Vắng mặt", + "availabilityWorkingElsewhere": "Làm việc ở nơi khác", + "visibilityDefault": "Mặc định", + "visibilityPublic": "Công khai", + "visibilityPrivate": "Riêng tư", + "visibilityConfidential": "Bảo mật", + "sensitivityNormal": "Bình thường", + "sensitivityPersonal": "Cá nhân", + "tasks": "Công việc", + "allTasks": "Tất cả công việc", + "tasksInList": "Công việc trong {title}", + "taskLists": "Danh sách công việc", + "navigation": "Điều hướng", + "mainMenu": "Trình đơn chính", + "keyboardShortcuts": "Phím tắt", + "shortcutGroupGeneral": "Chung", + "shortcutKeyboardShortcutsDescription": "Hiển thị bảng tham khảo phím tắt này", + "shortcutGroupNavigation": "Điều hướng", + "shortcutNextPeriod": "Khoảng tiếp theo", + "shortcutNextPeriodDescription": "Tuần tiếp theo trong chế độ xem tuần, tháng tiếp theo trong chế độ xem tháng, v.v.", + "shortcutPreviousPeriod": "Khoảng trước đó", + "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", + "shortcutDayView": "Chế độ xem ngày", + "shortcutWeekView": "Chế độ xem tuần", + "shortcutMonthView": "Chế độ xem tháng", + "shortcutYearView": "Chế độ xem năm", + "shortcutAgendaView": "Chế độ xem lịch biểu", + "shortcutGroupCreateAndEdit": "Tạo và chỉnh sửa", + "shortcutSaveItem": "Lưu sự kiện hoặc công việc", + "shortcutDeleteItem": "Xóa sự kiện hoặc công việc", + "shortcutGroupTaskEditing": "Chỉnh sửa công việc", + "shortcutCancelEditing": "Hủy chỉnh sửa", + "shortcutCancelEditingDescription": "Đóng phần chỉnh sửa hoặc chi tiết công việc", + "shortcutGroupCompactAgenda": "Lịch biểu thu gọn", + "shortcutRefreshCompactAgendaDescription": "Làm mới cửa sổ lịch biểu thu gọn", + "shortcutHideCompactAgendaDescription": "Ẩn cửa sổ lịch biểu thu gọn", + "aboutBusyMax": "Giới thiệu BusyMax", + "aboutBusyMaxDescription": "Công việc và lịch", + "website": "Trang web", + "reportAnIssue": "Báo cáo sự cố", + "sendFeedback": "Gửi phản hồi", + "feedbackSubmit": "Gửi", + "feedbackCategory": "Danh mục", + "feedbackSelectCategory": "Chọn một danh mục", + "feedbackCategoryProblem": "Sự cố hoặc lỗi", + "feedbackCategoryFeature": "Yêu cầu tính năng", + "feedbackCategoryPrivacySecurity": "Vấn đề về quyền riêng tư hoặc bảo mật", + "feedbackCategoryUsability": "Vấn đề về khả năng sử dụng", + "feedbackCategoryOther": "Khác", + "feedbackSubject": "Chủ đề", + "feedbackDetailedMessage": "Nội dung chi tiết", + "feedbackReplyEmail": "Địa chỉ email để nhận phản hồi (không bắt buộc)", + "feedbackIncludeTechnicalDetails": "Bao gồm chi tiết kỹ thuật", + "feedbackTechnicalDetailsDisclosure": "Chỉ thêm phiên bản hệ điều hành Linux và ngôn ngữ, khu vực của ứng dụng. Không bao gồm nhật ký, dữ liệu tài khoản, tên tệp hoặc thông tin chẩn đoán khác.", + "feedbackCategoryRequired": "Hãy chọn một danh mục.", + "feedbackSubjectLengthError": "Chủ đề phải có từ 3 đến 120 ký tự.", + "feedbackMessageLengthError": "Nội dung phải có từ 10 đến 5.000 ký tự.", + "feedbackInvalidEmail": "Nhập địa chỉ email hợp lệ.", + "feedbackConnectionError": "Không thể kết nối với BusyStack. Hãy kiểm tra kết nối và thử lại.", + "feedbackTimeoutError": "Yêu cầu đã hết thời gian chờ. Phản hồi của bạn chưa bị xóa; hãy thử lại.", + "feedbackRateLimitedError": "Đã gửi quá nhiều phản hồi từ mạng này. Vui lòng chờ rồi thử lại.", + "feedbackRejectedError": "Máy chủ đã từ chối nội dung gửi. Hãy kiểm tra các trường và thử lại.", + "feedbackServerError": "BusyStack hiện không thể nhận phản hồi của bạn. Phản hồi chưa bị xóa; hãy thử lại.", + "feedbackSuccess": "Đã gửi phản hồi. Mã tham chiếu: {id}", + "toggleSidebar": "Hiện hoặc ẩn thanh bên", + "accounts": "Tài khoản", + "currentAccount": "Tài khoản hiện tại", + "switchAccount": "Chuyển tài khoản", + "addGoogleAccount": "Thêm tài khoản Google", + "addMicrosoftAccount": "Thêm tài khoản Microsoft", + "googleProvider": "Google", + "microsoftProvider": "Microsoft", + "signedInAccount": "Đã đăng nhập", + "removeAccount": "Xóa tài khoản…", + "removingAccount": "Đang xóa tài khoản…", + "removeAccountDescription": "Dừng đồng bộ và xóa dữ liệu của tài khoản này khỏi thiết bị.", + "removeAccountTitle": "Xóa {account} khỏi BusyMax?", + "removeAccountConfirmation": "Thao tác này sẽ xóa công việc, lịch, sự kiện, lời nhắc đã lưu trong bộ nhớ đệm và các thay đổi ngoại tuyến đang chờ khỏi thiết bị. Các thay đổi chưa đồng bộ sẽ bị mất. Không có dữ liệu nào bị xóa khỏi Google hoặc Microsoft.", + "revokeGoogleAccess": "Đồng thời thu hồi quyền truy cập của BusyMax vào tài khoản Google này", + "revokeGoogleAccessDescription": "Bạn sẽ cần cấp lại quyền truy cập trước khi kết nối lại.", + "removeAccountAction": "Xóa tài khoản", + "removeAccountFailed": "Không thể hoàn tất việc xóa tài khoản. Hãy thử lại.", + "accountRemovedGoogleRevokeFailed": "Tài khoản đã bị xóa khỏi thiết bị này, nhưng BusyMax không thể thu hồi quyền truy cập vào tài khoản Google. Bạn có thể thu hồi quyền trong phần cài đặt Tài khoản Google.", + "newList": "Danh sách mới", + "signInToViewTaskLists": "Đăng nhập để xem danh sách công việc.", + "noTaskListsSynced": "Chưa có danh sách công việc nào được đồng bộ.", + "listActions": "Thao tác với danh sách", + "rename": "Đổi tên", + "delete": "Xóa", + "renameList": "Đổi tên danh sách", + "deleteList": "Xóa danh sách", + "builtInMicrosoftList": "Tích hợp sẵn", + "builtInMicrosoftListCannotRenameDelete": "Không thể đổi tên hoặc xóa danh sách tích hợp sẵn của Microsoft To Do.", + "deleteListConfirmation": "Xóa “{title}” khỏi Google Tasks?", + "deleteEvent": "Xóa sự kiện", + "title": "Tiêu đề", + "create": "Tạo", + "newTask": "Công việc mới", + "clearCompleted": "Xóa các công việc đã hoàn thành", + "refreshList": "Làm mới danh sách", + "refreshAll": "Làm mới tất cả", + "listRefreshed": "Đã làm mới danh sách.", + "allTasksRefreshed": "Đã làm mới tất cả tài khoản.", + "exportedFile": "Đã xuất sang {path}", + "exportFailed": "Xuất không thành công: {error}", + "refreshFailed": "Làm mới không thành công: {error}", + "selectOrCreateTaskList": "Chọn hoặc tạo một danh sách công việc để bắt đầu.", + "signInToViewTasks": "Đăng nhập để xem công việc.", + "noTasks": "Không có công việc.", + "noTasksYet": "Chưa có công việc", + "noTasksYetMessage": "Tạo một công việc hoặc làm mới tài khoản để bắt đầu.", + "noTasksInList": "Không có công việc nào trong danh sách này.", + "overdue": "Quá hạn", + "today": "Hôm nay", + "tomorrow": "Ngày mai", + "upcoming": "Sắp tới", + "noDate": "Không có ngày", + "completed": "Đã hoàn thành", + "duePrefix": "Đến hạn {date}", + "dateTimeDisplay": "{date} · {time}", + "taskDetails": "Chi tiết công việc", + "editTask": "Chỉnh sửa công việc", + "noTaskSelected": "Chưa chọn công việc.", + "noTaskSelectedHelper": "Chọn một công việc để xem và chỉnh sửa chi tiết.", + "taskUnavailable": "Công việc không khả dụng.", + "signInToEditTasks": "Đăng nhập để chỉnh sửa công việc.", + "refreshTask": "Làm mới công việc", + "primarySection": "Chính", + "statusSection": "Trạng thái", + "openStatus": "Chưa hoàn thành", + "doneStatus": "Đã hoàn thành", + "notes": "Ghi chú", + "dueDate": "Ngày đến hạn", + "clearDueDate": "Xóa ngày đến hạn", + "dueTime": "Giờ đến hạn", + "startDate": "Ngày bắt đầu", + "startTime": "Giờ bắt đầu", + "endDate": "Ngày kết thúc", + "endTime": "Giờ kết thúc", + "reminderDate": "Ngày nhắc", + "reminderTime": "Giờ nhắc", + "reminder": "Lời nhắc", + "addReminder": "Thêm lời nhắc", + "addGuest": "Thêm khách mời", + "addGuestEmail": "Thêm email khách mời", + "removeReminder": "Xóa lời nhắc", + "off": "Tắt", + "repeat": "Lặp lại", + "repeatNone": "Không lặp lại", + "noneValue": "Không có", + "repeatDaily": "Hằng ngày", + "repeatWeekly": "Hằng tuần", + "repeatMonthly": "Hằng tháng", + "repeatYearly": "Hằng năm", + "importance": "Mức độ quan trọng", + "importanceLow": "Thấp", + "importanceNormal": "Bình thường", + "importanceHigh": "Cao", + "categories": "Danh mục", + "scheduleSection": "Lịch", + "dueGroup": "Đến hạn", + "startGroup": "Bắt đầu", + "reminderGroup": "Lời nhắc", + "organizationSection": "Sắp xếp", + "actionsSection": "Thao tác", + "advancedSection": "Nâng cao", + "addCategory": "Thêm danh mục", + "list": "Danh sách", + "microsoftMoveUnsupported": "Phiên bản này không hỗ trợ di chuyển công việc giữa các danh sách trong tài khoản Microsoft To Do.", + "createSubtask": "Tạo công việc con", + "moveToTop": "Chuyển lên đầu", + "deleteTask": "Xóa công việc", + "newSubtask": "Công việc con mới", + "deleteTaskConfirmation": "Xóa “{title}” khỏi Google Tasks?", + "metadata": "Siêu dữ liệu", + "id": "ID", + "etag": "ETag", + "updated": "Đã cập nhật", + "parent": "Công việc cha", + "position": "Vị trí", + "webLink": "Liên kết web", + "assignment": "Phân công", + "localState": "Trạng thái cục bộ", + "pendingSync": "Đang chờ đồng bộ", + "synced": "Đã đồng bộ", + "account": "Tài khoản", + "sync": "Đồng bộ", + "manualFullSync": "Đồng bộ toàn bộ thủ công", + "runInBackgroundWhenClosed": "Tiếp tục chạy khi đóng cửa sổ", + "showTrayIcon": "Hiện biểu tượng khay hệ thống", + "startMinimizedToTray": "Khởi động thu nhỏ vào khay hệ thống", + "requiresTrayIcon": "Yêu cầu biểu tượng khay hệ thống.", + "syncComplete": "Đồng bộ hoàn tất.", + "syncFailed": "Đồng bộ không thành công: {error}", + "notifySyncFailures": "Thông báo khi đồng bộ thất bại", + "notifyConflicts": "Thông báo khi có xung đột", + "notifyDueToday": "Thông báo công việc đến hạn hôm nay", + "eventReminders": "Lời nhắc sự kiện", + "taskReminders": "Lời nhắc công việc", + "notificationDetailLevel": "Mức độ chi tiết của thông báo", + "notificationDetailPrivate": "Riêng tư", + "notificationDetailNormal": "Bình thường", + "quietHours": "Giờ yên tĩnh", + "quietHoursDescription": "Tạm dừng thông báo trong khoảng thời gian này.", + "quietHoursStart": "Bắt đầu giờ yên tĩnh", + "quietHoursEnd": "Kết thúc giờ yên tĩnh", + "notifications": "Thông báo", + "appearance": "Giao diện", + "theme": "Chủ đề", + "themeSystem": "Hệ thống", + "themeLight": "Sáng", + "themeDark": "Tối", + "themeFamily": "Họ chủ đề", + "themeFamilyYaru": "Chủ đề Ubuntu nguyên bản (Yaru)", + "localization": "Ngôn ngữ và khu vực", + "currentLocale": "Ngôn ngữ và khu vực hiện tại", + "privacy": "Quyền riêng tư", + "redactTaskContentInDiagnostics": "Ẩn nội dung công việc trong thông tin chẩn đoán", + "developerDiagnostics": "Chẩn đoán dành cho nhà phát triển", + "diagnostics": "Chẩn đoán", + "apiInspectorDisabled": "Hiện trình kiểm tra API", + "googleTasksApi": "API Google Tasks", + "discoveryRevision": "Bản sửa đổi Discovery: {revision}", + "implementedMethods": "Phương thức đã triển khai", + "supportsTasksScopes": "Hỗ trợ phạm vi tasks và tasks.readonly", + "requiresTasksScope": "Yêu cầu phạm vi tasks", + "blockedPendingOperations": "Thao tác đang chờ bị chặn", + "signInToInspectPendingOperations": "Đăng nhập để kiểm tra các thao tác đang chờ.", + "noBlockedPendingOperations": "Không có thao tác đang chờ nào bị chặn.", + "operationActions": "Hành động cho thao tác", + "pendingOpListId": "danh_sách={id}", + "pendingOpTaskId": "công_việc={id}", + "pendingOpAttempts": "số_lần_thử={count}", + "retry": "Thử lại", + "discard": "Hủy bỏ", + "discardChanges": "Hủy bỏ thay đổi?", + "discardChangesConfirmation": "Thao tác này sẽ hủy bỏ các chỉnh sửa chưa lưu đối với công việc.", + "retryCompleted": "Đã thử lại.", + "discardPendingOperation": "Hủy bỏ thao tác đang chờ?", + "discardPendingOperationConfirmation": "Thao tác này sẽ xóa thao tác cục bộ bị chặn. Lần đồng bộ tiếp theo sẽ tải lại dữ liệu từ Google Tasks.", + "pendingOperationDiscarded": "Đã hủy bỏ thao tác đang chờ.", + "syncFailureNotificationTitle": "Đồng bộ BusyMax không thành công", + "syncFailureNotificationBody": "Đồng bộ nền không thành công. {message}", + "conflictNotificationTitle": "Xung đột đồng bộ BusyMax", + "conflictNotificationBody": "Một thay đổi cục bộ đang chờ đã bị chặn. {summary}", + "dueTodayNotificationTitle": "Công việc đến hạn hôm nay", + "dueTodayNotificationBody": "{count, plural, =1{Có một công việc đến hạn hôm nay.} other{Có {count} công việc đến hạn hôm nay.}}", + "eventReminderNotificationTitle": "Lời nhắc sự kiện", + "taskReminderNotificationTitle": "Lời nhắc công việc", + "eventReminderNotificationBody": "Sự kiện sắp bắt đầu.", + "taskReminderNotificationBody": "Công việc sắp đến hạn.", + "notificationOpenAction": "Mở", + "notificationDetailsHidden": "Chi tiết bị ẩn theo cài đặt quyền riêng tư.", + "previousMonth": "Tháng trước", + "nextMonth": "Tháng sau", + "openMonthView": "Mở chế độ xem tháng", + "previousYear": "Năm trước", + "nextYear": "Năm sau", + "openYearView": "Mở chế độ xem năm", + "weekNumberTooltip": "Tuần {number}", + "resizeAllDayPanel": "Đổi kích thước bảng cả ngày", + "scheduleItemCount": "{count, plural, =1{1 mục} other{{count} mục}}", + "readOnlyCalendar": "Lịch này chỉ có thể đọc.", + "selectTimeZone": "Chọn múi giờ", + "searchLocations": "Tìm kiếm địa điểm", + "noLocationsFound": "Không tìm thấy địa điểm", + "deleteCalendarConfirmation": "Xóa “{title}”?" +} diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 549ccac..91b4d8c 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -57,7 +57,7 @@ "trayAgendaRefresh": "刷新", "trayAgendaError": "日程不可用", "compactAgendaTitle": "日程", - "compactAgendaSubtitle": "即将开始", + "compactAgendaSubtitle": "接下来", "compactAgendaOverdue": "已逾期", "compactAgendaClear": "目前空闲", "compactAgendaOpenBusyMax": "打开 BusyMax", @@ -133,7 +133,7 @@ "mainMenu": "主菜单", "keyboardShortcuts": "键盘快捷键", "shortcutGroupGeneral": "常规", - "shortcutKeyboardShortcutsDescription": "显示此快捷键参考", + "shortcutKeyboardShortcutsDescription": "显示快捷键参考表", "shortcutGroupNavigation": "导航", "shortcutNextPeriod": "下一时段", "shortcutNextPeriodDescription": "在周视图中前往下一周,在月视图中前往下个月,依此类推", @@ -201,7 +201,7 @@ "revokeGoogleAccessDescription": "重新连接之前,您需要再次授予访问权限。", "removeAccountAction": "移除帐户", "removeAccountFailed": "无法完成帐户移除。请重试。", - "accountRemovedGoogleRevokeFailed": "已从此设备移除该帐户,但 BusyMax 无法撤销 Google 访问权限。您可以在 Google 帐户中撤销。", + "accountRemovedGoogleRevokeFailed": "该帐户已从此设备移除,但无法撤销 BusyMax 对您的 Google 帐户的访问权限。您可以在 Google 帐户中手动撤销该权限。", "newList": "新建列表", "signInToViewTaskLists": "登录以查看任务列表。", "noTaskListsSynced": "尚未同步任何任务列表。", @@ -234,7 +234,7 @@ "overdue": "已逾期", "today": "今天", "tomorrow": "明天", - "upcoming": "即将开始", + "upcoming": "即将到期", "noDate": "无日期", "completed": "已完成", "duePrefix": "{date} 到期", diff --git a/lib/l10n/app_zh_Hans.arb b/lib/l10n/app_zh_Hans.arb index b738fe5..c8e3c12 100644 --- a/lib/l10n/app_zh_Hans.arb +++ b/lib/l10n/app_zh_Hans.arb @@ -57,7 +57,7 @@ "trayAgendaRefresh": "刷新", "trayAgendaError": "日程不可用", "compactAgendaTitle": "日程", - "compactAgendaSubtitle": "即将开始", + "compactAgendaSubtitle": "接下来", "compactAgendaOverdue": "已逾期", "compactAgendaClear": "目前空闲", "compactAgendaOpenBusyMax": "打开 BusyMax", @@ -133,7 +133,7 @@ "mainMenu": "主菜单", "keyboardShortcuts": "键盘快捷键", "shortcutGroupGeneral": "常规", - "shortcutKeyboardShortcutsDescription": "显示此快捷键参考", + "shortcutKeyboardShortcutsDescription": "显示快捷键参考表", "shortcutGroupNavigation": "导航", "shortcutNextPeriod": "下一时段", "shortcutNextPeriodDescription": "在周视图中前往下一周,在月视图中前往下个月,依此类推", @@ -201,7 +201,7 @@ "revokeGoogleAccessDescription": "重新连接之前,您需要再次授予访问权限。", "removeAccountAction": "移除帐户", "removeAccountFailed": "无法完成帐户移除。请重试。", - "accountRemovedGoogleRevokeFailed": "已从此设备移除该帐户,但 BusyMax 无法撤销 Google 访问权限。您可以在 Google 帐户中撤销。", + "accountRemovedGoogleRevokeFailed": "该帐户已从此设备移除,但无法撤销 BusyMax 对您的 Google 帐户的访问权限。您可以在 Google 帐户中手动撤销该权限。", "newList": "新建列表", "signInToViewTaskLists": "登录以查看任务列表。", "noTaskListsSynced": "尚未同步任何任务列表。", @@ -234,7 +234,7 @@ "overdue": "已逾期", "today": "今天", "tomorrow": "明天", - "upcoming": "即将开始", + "upcoming": "即将到期", "noDate": "无日期", "completed": "已完成", "duePrefix": "{date} 到期", diff --git a/lib/l10n/app_zh_Hant.arb b/lib/l10n/app_zh_Hant.arb index 90b5802..62b0af5 100644 --- a/lib/l10n/app_zh_Hant.arb +++ b/lib/l10n/app_zh_Hant.arb @@ -57,7 +57,7 @@ "trayAgendaRefresh": "重新整理", "trayAgendaError": "無法使用行程", "compactAgendaTitle": "行程", - "compactAgendaSubtitle": "即將開始", + "compactAgendaSubtitle": "接下來", "compactAgendaOverdue": "已逾期", "compactAgendaClear": "目前沒有安排", "compactAgendaOpenBusyMax": "開啟 BusyMax", @@ -133,7 +133,7 @@ "mainMenu": "主選單", "keyboardShortcuts": "鍵盤快速鍵", "shortcutGroupGeneral": "一般", - "shortcutKeyboardShortcutsDescription": "顯示此快速鍵參考", + "shortcutKeyboardShortcutsDescription": "顯示快速鍵參考表", "shortcutGroupNavigation": "導覽", "shortcutNextPeriod": "下一時段", "shortcutNextPeriodDescription": "在週檢視中前往下一週,在月檢視中前往下個月,依此類推", @@ -164,7 +164,7 @@ "feedbackCategory": "類別", "feedbackSelectCategory": "選擇類別", "feedbackCategoryProblem": "問題或錯誤", - "feedbackCategoryFeature": "功能要求", + "feedbackCategoryFeature": "功能請求", "feedbackCategoryPrivacySecurity": "隱私權或安全性疑慮", "feedbackCategoryUsability": "易用性疑慮", "feedbackCategoryOther": "其他", @@ -201,7 +201,7 @@ "revokeGoogleAccessDescription": "重新連結前,您必須再次授予存取權。", "removeAccountAction": "移除帳戶", "removeAccountFailed": "無法完成帳戶移除。請再試一次。", - "accountRemovedGoogleRevokeFailed": "已從此裝置移除該帳戶,但 BusyMax 無法撤銷 Google 存取權。您可以在 Google 帳戶中撤銷。", + "accountRemovedGoogleRevokeFailed": "該帳戶已從此裝置移除,但無法撤銷 BusyMax 對您的 Google 帳戶的存取權。您可以在 Google 帳戶中手動撤銷該權限。", "newList": "新增清單", "signInToViewTaskLists": "登入以查看待辦清單。", "noTaskListsSynced": "尚未同步任何待辦清單。", @@ -234,7 +234,7 @@ "overdue": "已逾期", "today": "今天", "tomorrow": "明天", - "upcoming": "即將開始", + "upcoming": "即將到期", "noDate": "無日期", "completed": "已完成", "duePrefix": "{date} 到期", @@ -348,7 +348,7 @@ "blockedPendingOperations": "遭封鎖的待處理作業", "signInToInspectPendingOperations": "登入以檢查待處理作業。", "noBlockedPendingOperations": "沒有遭封鎖的待處理作業。", - "operationActions": "作業動作", + "operationActions": "操作選項", "pendingOpListId": "清單={id}", "pendingOpTaskId": "待辦事項={id}", "pendingOpAttempts": "嘗試次數={count}", diff --git a/lib/l10n/generated/app_localizations.dart b/lib/l10n/generated/app_localizations.dart index 2dd9007..444309c 100644 --- a/lib/l10n/generated/app_localizations.dart +++ b/lib/l10n/generated/app_localizations.dart @@ -1,3 +1,6 @@ +// coverage:ignore-file +// GENERATED CODE - DO NOT MODIFY BY HAND + import 'dart:async'; import 'package:flutter/foundation.dart'; @@ -9,6 +12,7 @@ import 'app_localizations_ar.dart'; import 'app_localizations_de.dart'; import 'app_localizations_en.dart'; import 'app_localizations_es.dart'; +import 'app_localizations_et.dart'; import 'app_localizations_fa.dart'; import 'app_localizations_fi.dart'; import 'app_localizations_fr.dart'; @@ -111,6 +115,7 @@ abstract class AppLocalizations { Locale('de'), Locale('en'), Locale('es'), + Locale('et'), Locale('fa'), Locale('fi'), Locale('fr'), @@ -2458,6 +2463,7 @@ class _AppLocalizationsDelegate 'de', 'en', 'es', + 'et', 'fa', 'fi', 'fr', @@ -2500,6 +2506,8 @@ AppLocalizations lookupAppLocalizations(Locale locale) { return AppLocalizationsEn(); case 'es': return AppLocalizationsEs(); + case 'et': + return AppLocalizationsEt(); case 'fa': return AppLocalizationsFa(); case 'fi': diff --git a/lib/l10n/generated/app_localizations_ar.dart b/lib/l10n/generated/app_localizations_ar.dart index 04e00a1..49e681a 100644 --- a/lib/l10n/generated/app_localizations_ar.dart +++ b/lib/l10n/generated/app_localizations_ar.dart @@ -1,3 +1,6 @@ +// coverage:ignore-file +// GENERATED CODE - DO NOT MODIFY BY HAND + // ignore: unused_import import 'package:intl/intl.dart' as intl; import 'app_localizations.dart'; @@ -137,7 +140,7 @@ class AppLocalizationsAr extends AppLocalizations { @override String moreItems(int count) { - return '+$count عناصر أخرى'; + return '+⁨$count⁩ عناصر أخرى'; } @override @@ -226,7 +229,7 @@ class AppLocalizationsAr extends AppLocalizations { @override String compactAgendaDueOn(String date) { - return 'مستحقة في $date'; + return 'مستحقة في ⁨$date⁩'; } @override @@ -361,9 +364,9 @@ class AppLocalizationsAr extends AppLocalizations { String _temp0 = intl.Intl.pluralLogic( minutes, locale: localeName, - other: 'قبل $minutes دقيقة', - many: 'قبل $minutes دقيقة', - few: 'قبل $minutes دقائق', + other: 'قبل ⁨$minutes⁩ دقيقة', + many: 'قبل ⁨$minutes⁩ دقيقة', + few: 'قبل ⁨$minutes⁩ دقائق', two: 'قبل دقيقتين', one: 'قبل دقيقة واحدة', zero: 'عند البدء', @@ -379,9 +382,9 @@ class AppLocalizationsAr extends AppLocalizations { String _temp0 = intl.Intl.pluralLogic( hours, locale: localeName, - other: 'قبل $hours ساعة', - many: 'قبل $hours ساعة', - few: 'قبل $hours ساعات', + other: 'قبل ⁨$hours⁩ ساعة', + many: 'قبل ⁨$hours⁩ ساعة', + few: 'قبل ⁨$hours⁩ ساعات', two: 'قبل ساعتين', one: 'قبل ساعة واحدة', zero: 'عند البدء', @@ -394,9 +397,9 @@ class AppLocalizationsAr extends AppLocalizations { String _temp0 = intl.Intl.pluralLogic( days, locale: localeName, - other: 'قبل $days يوم', - many: 'قبل $days يومًا', - few: 'قبل $days أيام', + other: 'قبل ⁨$days⁩ يوم', + many: 'قبل ⁨$days⁩ يومًا', + few: 'قبل ⁨$days⁩ أيام', two: 'قبل يومين', one: 'قبل يوم واحد', zero: 'في اليوم نفسه', @@ -442,7 +445,7 @@ class AppLocalizationsAr extends AppLocalizations { @override String tasksInList(String title) { - return 'المهام في $title'; + return 'المهام في ⁨$title⁩'; } @override @@ -624,7 +627,7 @@ class AppLocalizationsAr extends AppLocalizations { @override String feedbackSuccess(String id) { - return 'تم إرسال الملاحظات. المرجع: $id'; + return 'تم إرسال الملاحظات. المرجع: ⁨$id⁩'; } @override @@ -666,7 +669,7 @@ class AppLocalizationsAr extends AppLocalizations { @override String removeAccountTitle(String account) { - return 'إزالة $account من BusyMax؟'; + return 'إزالة ⁨$account⁩ من BusyMax؟'; } @override @@ -724,7 +727,7 @@ class AppLocalizationsAr extends AppLocalizations { @override String deleteListConfirmation(String title) { - return 'حذف «$title» من Google Tasks؟'; + return 'حذف «⁨$title⁩» من Google Tasks؟'; } @override @@ -756,17 +759,17 @@ class AppLocalizationsAr extends AppLocalizations { @override String exportedFile(String path) { - return 'تم التصدير إلى $path'; + return 'تم التصدير إلى ⁨$path⁩'; } @override String exportFailed(String error) { - return 'فشل التصدير: $error'; + return 'فشل التصدير: ⁨$error⁩'; } @override String refreshFailed(String error) { - return 'فشل التحديث: $error'; + return 'فشل التحديث: ⁨$error⁩'; } @override @@ -807,12 +810,12 @@ class AppLocalizationsAr extends AppLocalizations { @override String duePrefix(String date) { - return 'مستحقة في $date'; + return 'مستحقة في ⁨$date⁩'; } @override String dateTimeDisplay(String date, String time) { - return '$date · $time'; + return '⁨$date⁩ · ⁨$time⁩'; } @override @@ -977,7 +980,7 @@ class AppLocalizationsAr extends AppLocalizations { @override String deleteTaskConfirmation(String title) { - return 'حذف «$title» من Google Tasks؟'; + return 'حذف «⁨$title⁩» من Google Tasks؟'; } @override @@ -1039,7 +1042,7 @@ class AppLocalizationsAr extends AppLocalizations { @override String syncFailed(String error) { - return 'فشلت المزامنة: $error'; + return 'فشلت المزامنة: ⁨$error⁩'; } @override @@ -1129,7 +1132,7 @@ class AppLocalizationsAr extends AppLocalizations { @override String discoveryRevision(String revision) { - return 'مراجعة Discovery: ‏$revision'; + return 'مراجعة Discovery: ⁨$revision⁩'; } @override @@ -1156,17 +1159,17 @@ class AppLocalizationsAr extends AppLocalizations { @override String pendingOpListId(String id) { - return 'القائمة=$id'; + return 'القائمة=⁨$id⁩'; } @override String pendingOpTaskId(String id) { - return 'المهمة=$id'; + return 'المهمة=⁨$id⁩'; } @override String pendingOpAttempts(int count) { - return 'المحاولات=$count'; + return 'المحاولات=⁨$count⁩'; } @override @@ -1200,7 +1203,7 @@ class AppLocalizationsAr extends AppLocalizations { @override String syncFailureNotificationBody(String message) { - return 'فشلت المزامنة في الخلفية. $message'; + return 'فشلت المزامنة في الخلفية. ⁨$message⁩'; } @override @@ -1208,7 +1211,7 @@ class AppLocalizationsAr extends AppLocalizations { @override String conflictNotificationBody(String summary) { - return 'تم حظر تغيير محلي معلّق. $summary'; + return 'تم حظر تغيير محلي معلّق. ⁨$summary⁩'; } @override @@ -1219,9 +1222,9 @@ class AppLocalizationsAr extends AppLocalizations { String _temp0 = intl.Intl.pluralLogic( count, locale: localeName, - other: 'هناك $count مهمة مستحقة اليوم.', - many: 'هناك $count مهمة مستحقة اليوم.', - few: 'هناك $count مهام مستحقة اليوم.', + other: 'هناك ⁨$count⁩ مهمة مستحقة اليوم.', + many: 'هناك ⁨$count⁩ مهمة مستحقة اليوم.', + few: 'هناك ⁨$count⁩ مهام مستحقة اليوم.', two: 'هناك مهمتان مستحقتان اليوم.', one: 'هناك مهمة واحدة مستحقة اليوم.', zero: 'لا توجد مهام مستحقة اليوم.', @@ -1268,7 +1271,7 @@ class AppLocalizationsAr extends AppLocalizations { @override String weekNumberTooltip(int number) { - return 'الأسبوع $number'; + return 'الأسبوع ⁨$number⁩'; } @override @@ -1279,9 +1282,9 @@ class AppLocalizationsAr extends AppLocalizations { String _temp0 = intl.Intl.pluralLogic( count, locale: localeName, - other: '$count عنصر', - many: '$count عنصرًا', - few: '$count عناصر', + other: '⁨$count⁩ عنصر', + many: '⁨$count⁩ عنصرًا', + few: '⁨$count⁩ عناصر', two: 'عنصران', one: 'عنصر واحد', zero: 'لا عناصر', @@ -1303,6 +1306,6 @@ class AppLocalizationsAr extends AppLocalizations { @override String deleteCalendarConfirmation(String title) { - return 'حذف «$title»؟'; + return 'حذف «⁨$title⁩»؟'; } } diff --git a/lib/l10n/generated/app_localizations_de.dart b/lib/l10n/generated/app_localizations_de.dart index adf85b7..1ec219d 100644 --- a/lib/l10n/generated/app_localizations_de.dart +++ b/lib/l10n/generated/app_localizations_de.dart @@ -1,3 +1,6 @@ +// coverage:ignore-file +// GENERATED CODE - DO NOT MODIFY BY HAND + // ignore: unused_import import 'package:intl/intl.dart' as intl; import 'app_localizations.dart'; diff --git a/lib/l10n/generated/app_localizations_en.dart b/lib/l10n/generated/app_localizations_en.dart index 2fd547a..e5317eb 100644 --- a/lib/l10n/generated/app_localizations_en.dart +++ b/lib/l10n/generated/app_localizations_en.dart @@ -1,3 +1,6 @@ +// coverage:ignore-file +// GENERATED CODE - DO NOT MODIFY BY HAND + // ignore: unused_import import 'package:intl/intl.dart' as intl; import 'app_localizations.dart'; diff --git a/lib/l10n/generated/app_localizations_es.dart b/lib/l10n/generated/app_localizations_es.dart index e4f7b5e..f8d6cea 100644 --- a/lib/l10n/generated/app_localizations_es.dart +++ b/lib/l10n/generated/app_localizations_es.dart @@ -1,3 +1,6 @@ +// coverage:ignore-file +// GENERATED CODE - DO NOT MODIFY BY HAND + // ignore: unused_import import 'package:intl/intl.dart' as intl; import 'app_localizations.dart'; diff --git a/lib/l10n/generated/app_localizations_et.dart b/lib/l10n/generated/app_localizations_et.dart new file mode 100644 index 0000000..24ca372 --- /dev/null +++ b/lib/l10n/generated/app_localizations_et.dart @@ -0,0 +1,1301 @@ +// coverage:ignore-file +// GENERATED CODE - DO NOT MODIFY BY HAND + +// ignore: unused_import +import 'package:intl/intl.dart' as intl; +import 'app_localizations.dart'; + +// ignore_for_file: type=lint + +/// The translations for Estonian (`et`). +class AppLocalizationsEt extends AppLocalizations { + AppLocalizationsEt([String locale = 'et']) : super(locale); + + @override + String get appTitle => 'BusyMax'; + + @override + String get connectGoogleAccount => + 'Ühendage Google\'i ja Microsofti kontod kalendrite ja ülesannete sünkroonimiseks.'; + + @override + String get googlePermissionsConsentNotice => + 'Valige Google\'i õiguste kuval nii kalendri kui ka ülesannete õigused.'; + + @override + String get googlePermissionsRequiredRetry => + 'Google Calendari ja Google Tasksi õigused on nõutavad. Proovige uuesti ja märkige mõlemad ruudud.'; + + @override + String get finishSetup => 'Lõpeta seadistamine'; + + @override + String get continueSetup => 'Jätka'; + + @override + String get onboardingSetupTitle => 'BusyMaxi seadistamine'; + + @override + String get onboardingAccountsStepTitle => 'Kontode ühendamine'; + + @override + String get onboardingAccountsStepDescription => + 'Lisage kõik Google\'i ja Microsofti kontod, mida soovite kasutada. BusyMax sünkroonib iga konto kalendrid, sündmused, ülesandeloendid ja ülesanded.'; + + @override + String get onboardingPreferencesStepTitle => 'Süsteemiseadete valimine'; + + @override + String get onboardingPreferencesStepDescription => + 'Enne ajakava avamist määrake töölauakäitumine, meeldetuletused, teavituste üksikasjalikkus ja välimus.'; + + @override + String get signInWithGoogle => 'Logi Google\'iga sisse'; + + @override + String get signInWithMicrosoft => 'Logi Microsoftiga sisse'; + + @override + String get googleTasksProvider => 'Google Tasks'; + + @override + String get microsoftTodoProvider => 'Microsoft To Do'; + + @override + String get providerNotConfigured => 'See teenusepakkuja pole seadistatud.'; + + @override + String get waitingForGoogleSignIn => 'Google\'isse sisselogimise ootel...'; + + @override + String get waitingForMicrosoftSignIn => 'Microsofti sisselogimise ootel...'; + + @override + String get microsoftSignInNotConfigured => + 'Microsofti sisselogimine pole seadistatud. Määrake MICROSOFT_OAUTH_CLIENT_ID.'; + + @override + String get cancel => 'Tühista'; + + @override + String get close => 'Sulge'; + + @override + String get exit => 'Välju'; + + @override + String get options => 'Valikud'; + + @override + String get hide => 'Peida'; + + @override + String get show => 'Kuva'; + + @override + String get export => 'Ekspordi'; + + @override + String get save => 'Salvesta'; + + @override + String get settings => 'Seaded'; + + @override + String get all => 'Kõik'; + + @override + String get calendarEvents => 'Sündmused'; + + @override + String get calendarTasks => 'Ülesanded'; + + @override + String get calendar => 'Kalender'; + + @override + String get calendars => 'Kalendrid'; + + @override + String get newEvent => 'Uus sündmus'; + + @override + String get refreshCalendar => 'Värskenda kalendrit'; + + @override + String get openInProvider => 'Ava teenuses'; + + @override + String get hideFromSchedule => 'Peida ajakavast'; + + @override + String get showInSchedule => 'Kuva ajakavas'; + + @override + String get noCalendarsSynced => 'Ühtegi kalendrit pole veel sünkroonitud.'; + + @override + String get allDay => 'Kogu päev'; + + @override + String moreItems(int count) { + return '+$count veel'; + } + + @override + String get noEventsOrTasks => 'Sündmusi ega ülesandeid pole'; + + @override + String get scheduleLoading => 'Ajakava laadimine...'; + + @override + String get scheduleUnavailable => 'Ajakava pole saadaval'; + + @override + String get scheduleNoSources => + 'Nähtavaid kalendreid ega ülesandeloendeid pole'; + + @override + String get scheduleNoSourcesDescription => + 'Valige seadetes, mida kuvada, ja seejärel värskendage.'; + + @override + String get scheduleSignInRequired => 'Ühendage konto'; + + @override + String get scheduleSignInDescription => + 'Kalendrite ja ülesannete sünkroonimiseks logige sisse.'; + + @override + String get scheduleNoSearchResults => + 'Sobivaid sündmusi ega ülesandeid ei leitud'; + + @override + String get scheduleNoSearchResultsDescription => + 'Proovige teistsugust otsingut või eemaldage praegused filtrid.'; + + @override + String get trayAgendaLoading => 'Päevakava laadimine...'; + + @override + String get trayAgendaSignInRequired => 'Päevakava kuvamiseks logige sisse.'; + + @override + String get trayAgendaNoSources => + 'Nähtavaid kalendreid ega ülesandeloendeid pole.'; + + @override + String get trayAgendaOpenBusyMax => 'Ava rakendus'; + + @override + String get trayAgendaRefresh => 'Värskenda'; + + @override + String get trayAgendaError => 'Päevakava pole saadaval'; + + @override + String get compactAgendaTitle => 'Päevakava'; + + @override + String get compactAgendaSubtitle => 'Tulekul'; + + @override + String get compactAgendaOverdue => 'Tähtaja ületanud'; + + @override + String get compactAgendaClear => 'Praegu vaba'; + + @override + String get compactAgendaOpenBusyMax => 'Ava BusyMax'; + + @override + String get compactAgendaHide => 'Peida'; + + @override + String get compactAgendaNewTask => 'Uus ülesanne'; + + @override + String get compactAgendaRetry => 'Proovi uuesti'; + + @override + String get compactAgendaRefresh => 'Värskenda'; + + @override + String get compactAgendaAllDay => 'Kogu päev'; + + @override + String get compactAgendaDueToday => 'Tähtaeg täna'; + + @override + String get compactAgendaDueTomorrow => 'Tähtaeg homme'; + + @override + String compactAgendaDueOn(String date) { + return 'Tähtaeg $date'; + } + + @override + String get compactAgendaMoreOverdue => + 'Laadi veel tähtaja ületanud ülesandeid'; + + @override + String get agendaLoadMoreOverdue => 'Laadi veel tähtaja ületanud ülesandeid'; + + @override + String get agendaLoadMoreNoDate => 'Laadi veel kuupäevata ülesandeid'; + + @override + String get viewDay => 'Päev'; + + @override + String get viewWeek => 'Nädal'; + + @override + String get viewMonth => 'Kuu'; + + @override + String get viewYear => 'Aasta'; + + @override + String get viewAgenda => 'Päevakava'; + + @override + String get scheduleSettings => 'Ajakava'; + + @override + String get scheduleDisplaySettings => 'Ajakava kuvamine'; + + @override + String get scheduleDisplayHoursDescription => + 'Päeva- ja nädalavaade avanevad nende kellaaegade piires. Vajaduse korral laiendavad varasemad ja hilisemad kirjed vahemikku.'; + + @override + String get scheduleDayStartsAt => 'Päev algab'; + + @override + String get scheduleDayEndsAt => 'Päev lõpeb'; + + @override + String get sourceCalendar => 'Kalender'; + + @override + String get sourceTaskList => 'Ülesandeloend'; + + @override + String get createChoiceTitle => 'Loo'; + + @override + String get createEventAtTime => 'Sündmus'; + + @override + String get createTaskAtDate => 'Ülesanne'; + + @override + String get editEvent => 'Muuda sündmust'; + + @override + String get eventTitle => 'Sündmuse pealkiri'; + + @override + String get location => 'Asukoht'; + + @override + String get timeSlot => 'Ajavahemik'; + + @override + String get startDateTime => 'Alguskuupäev ja -kellaaeg'; + + @override + String get endDateTime => 'Lõppkuupäev ja -kellaaeg'; + + @override + String get doesNotRepeat => 'Ei kordu'; + + @override + String get defaultReminder => 'Vaikemeeldetuletus'; + + @override + String get guests => 'Külalised'; + + @override + String get noGuests => 'Külalisi pole'; + + @override + String get description => 'Kirjeldus'; + + @override + String get availabilityShowAs => 'Hõivatus / Kuva kui'; + + @override + String get busy => 'Hõivatud'; + + @override + String get visibility => 'Nähtavus'; + + @override + String get defaultVisibility => 'Vaikimisi nähtavus'; + + @override + String get conference => 'Konverents'; + + @override + String get noConference => 'Konverentsi pole'; + + @override + String get providerCalendar => 'Teenuse kalender'; + + @override + String get formatBoldShortLabel => 'R'; + + @override + String get formatBoldTooltip => 'Rasvane'; + + @override + String get formatItalicShortLabel => 'K'; + + @override + String get formatItalicTooltip => 'Kursiiv'; + + @override + String get formatUnderlineShortLabel => 'A'; + + @override + String get formatUnderlineTooltip => 'Allajoonitud'; + + @override + String reminderMinutesBefore(int minutes) { + String _temp0 = intl.Intl.pluralLogic( + minutes, + locale: localeName, + other: '$minutes minutit varem', + one: '1 minut varem', + ); + return '$_temp0'; + } + + @override + String get reminderAtStart => 'Algusajal'; + + @override + String reminderHoursBefore(int hours) { + String _temp0 = intl.Intl.pluralLogic( + hours, + locale: localeName, + other: '$hours tundi varem', + one: '1 tund varem', + ); + return '$_temp0'; + } + + @override + String reminderDaysBefore(int days) { + String _temp0 = intl.Intl.pluralLogic( + days, + locale: localeName, + other: '$days päeva varem', + one: '1 päev varem', + ); + return '$_temp0'; + } + + @override + String get availabilityFree => 'Vaba'; + + @override + String get availabilityTentative => 'Esialgne'; + + @override + String get availabilityOutOfOffice => 'Kontorist väljas'; + + @override + String get availabilityWorkingElsewhere => 'Töötab mujal'; + + @override + String get visibilityDefault => 'Vaikimisi'; + + @override + String get visibilityPublic => 'Avalik'; + + @override + String get visibilityPrivate => 'Privaatne'; + + @override + String get visibilityConfidential => 'Konfidentsiaalne'; + + @override + String get sensitivityNormal => 'Tavaline'; + + @override + String get sensitivityPersonal => 'Isiklik'; + + @override + String get tasks => 'Ülesanded'; + + @override + String get allTasks => 'Kõik ülesanded'; + + @override + String tasksInList(String title) { + return 'Loendi „$title” ülesanded'; + } + + @override + String get taskLists => 'Ülesandeloendid'; + + @override + String get navigation => 'Navigeerimine'; + + @override + String get mainMenu => 'Peamenüü'; + + @override + String get keyboardShortcuts => 'Klaviatuuri otseteed'; + + @override + String get shortcutGroupGeneral => 'Üldine'; + + @override + String get shortcutKeyboardShortcutsDescription => + 'Kuva klaviatuuri otseteede loend'; + + @override + String get shortcutGroupNavigation => 'Navigeerimine'; + + @override + String get shortcutNextPeriod => 'Järgmine periood'; + + @override + String get shortcutNextPeriodDescription => + 'Nädalavaates järgmine nädal, kuuvaates järgmine kuu jne'; + + @override + String get shortcutPreviousPeriod => 'Eelmine periood'; + + @override + String get shortcutPreviousPeriodDescription => + 'Nädalavaates eelmine nädal, kuuvaates eelmine kuu jne'; + + @override + String get shortcutJumpToToday => 'Mine tänasele kuupäevale'; + + @override + String get shortcutGroupView => 'Vaade'; + + @override + String get shortcutDayView => 'Päevavaade'; + + @override + String get shortcutWeekView => 'Nädalavaade'; + + @override + String get shortcutMonthView => 'Kuuvaade'; + + @override + String get shortcutYearView => 'Aastavaade'; + + @override + String get shortcutAgendaView => 'Päevakavavaade'; + + @override + String get shortcutGroupCreateAndEdit => 'Loomine ja muutmine'; + + @override + String get shortcutSaveItem => 'Salvesta sündmus või ülesanne'; + + @override + String get shortcutDeleteItem => 'Kustuta sündmus või ülesanne'; + + @override + String get shortcutGroupTaskEditing => 'Ülesande muutmine'; + + @override + String get shortcutCancelEditing => 'Tühista muutmine'; + + @override + String get shortcutCancelEditingDescription => + 'Sulge ülesande muutmine või ülesande üksikasjad'; + + @override + String get shortcutGroupCompactAgenda => 'Kompaktne päevakava'; + + @override + String get shortcutRefreshCompactAgendaDescription => + 'Värskenda kompaktse päevakava akent'; + + @override + String get shortcutHideCompactAgendaDescription => + 'Peida kompaktse päevakava aken'; + + @override + String get aboutBusyMax => 'Teave BusyMaxi kohta'; + + @override + String get aboutBusyMaxDescription => 'Ülesanded ja kalender'; + + @override + String get website => 'Veebisait'; + + @override + String get reportAnIssue => 'Teata probleemist'; + + @override + String get sendFeedback => 'Saada tagasisidet'; + + @override + String get feedbackSubmit => 'Saada'; + + @override + String get feedbackCategory => 'Kategooria'; + + @override + String get feedbackSelectCategory => 'Valige kategooria'; + + @override + String get feedbackCategoryProblem => 'Probleem või viga'; + + @override + String get feedbackCategoryFeature => 'Funktsioonisoov'; + + @override + String get feedbackCategoryPrivacySecurity => 'Privaatsus- või turbeprobleem'; + + @override + String get feedbackCategoryUsability => 'Kasutatavusprobleem'; + + @override + String get feedbackCategoryOther => 'Muu'; + + @override + String get feedbackSubject => 'Teema'; + + @override + String get feedbackDetailedMessage => 'Üksikasjalik sõnum'; + + @override + String get feedbackReplyEmail => 'Vastamise e-posti aadress (valikuline)'; + + @override + String get feedbackIncludeTechnicalDetails => 'Lisa tehnilised üksikasjad'; + + @override + String get feedbackTechnicalDetailsDisclosure => + 'Lisatakse ainult teie Linuxi operatsioonisüsteemi versioon ning rakenduse keel ja piirkonnaseaded. Logisid, kontoandmeid, failinimesid ega muid diagnostikaandmeid ei lisata.'; + + @override + String get feedbackCategoryRequired => 'Valige kategooria.'; + + @override + String get feedbackSubjectLengthError => + 'Teema peab olema 3–120 tähemärki pikk.'; + + @override + String get feedbackMessageLengthError => + 'Sõnum peab olema 10–5000 tähemärki pikk.'; + + @override + String get feedbackInvalidEmail => 'Sisestage kehtiv e-posti aadress.'; + + @override + String get feedbackConnectionError => + 'BusyStackiga ei saanud ühendust luua. Kontrollige ühendust ja proovige uuesti.'; + + @override + String get feedbackTimeoutError => + 'Päring aegus. Teie tagasisidet ei kustutatud; proovige uuesti.'; + + @override + String get feedbackRateLimitedError => + 'Sellest võrgust on saadetud liiga palju tagasisidet. Oodake ja proovige uuesti.'; + + @override + String get feedbackRejectedError => + 'Server lükkas saatmise tagasi. Kontrollige välju ja proovige uuesti.'; + + @override + String get feedbackServerError => + 'BusyStack ei saa praegu teie tagasisidet vastu võtta. Teie tagasisidet ei kustutatud; proovige uuesti.'; + + @override + String feedbackSuccess(String id) { + return 'Tagasiside saadetud. Viide: $id'; + } + + @override + String get toggleSidebar => 'Kuva või peida külgriba'; + + @override + String get accounts => 'Kontod'; + + @override + String get currentAccount => 'Praegune konto'; + + @override + String get switchAccount => 'Vaheta kontot'; + + @override + String get addGoogleAccount => 'Lisa Google\'i konto'; + + @override + String get addMicrosoftAccount => 'Lisa Microsofti konto'; + + @override + String get googleProvider => 'Google'; + + @override + String get microsoftProvider => 'Microsoft'; + + @override + String get signedInAccount => 'Sisse logitud'; + + @override + String get removeAccount => 'Eemalda konto…'; + + @override + String get removingAccount => 'Konto eemaldamine…'; + + @override + String get removeAccountDescription => + 'Lõpeta sünkroonimine ja eemalda selle konto andmed seadmest.'; + + @override + String removeAccountTitle(String account) { + return 'Kas eemaldada $account BusyMaxist?'; + } + + @override + String get removeAccountConfirmation => + 'See kustutab seadmest vahemällu salvestatud ülesanded, kalendrid, sündmused, meeldetuletused ja sünkroonimist ootavad võrguühenduseta muudatused. Sünkroonimata muudatused lähevad kaotsi. Google\'ist ega Microsoftist midagi ei kustutata.'; + + @override + String get revokeGoogleAccess => + 'Tühista ka BusyMaxi juurdepääs sellele Google\'i kontole'; + + @override + String get revokeGoogleAccessDescription => + 'Enne uuesti ühendamist peate juurdepääsu uuesti andma.'; + + @override + String get removeAccountAction => 'Eemalda konto'; + + @override + String get removeAccountFailed => + 'Konto eemaldamist ei saanud lõpetada. Proovige uuesti.'; + + @override + String get accountRemovedGoogleRevokeFailed => + 'Konto eemaldati sellest seadmest, kuid BusyMaxi juurdepääsu teie Google\'i kontole ei saanud tühistada. Saate selle oma Google\'i kontol käsitsi tühistada.'; + + @override + String get newList => 'Uus loend'; + + @override + String get signInToViewTaskLists => + 'Ülesandeloendite vaatamiseks logige sisse.'; + + @override + String get noTaskListsSynced => + 'Ühtegi ülesandeloendit pole veel sünkroonitud.'; + + @override + String get listActions => 'Loendi toimingud'; + + @override + String get rename => 'Nimeta ümber'; + + @override + String get delete => 'Kustuta'; + + @override + String get renameList => 'Nimeta loend ümber'; + + @override + String get deleteList => 'Kustuta loend'; + + @override + String get builtInMicrosoftList => 'Sisseehitatud'; + + @override + String get builtInMicrosoftListCannotRenameDelete => + 'Microsoft To Do sisseehitatud loendeid ei saa ümber nimetada ega kustutada.'; + + @override + String deleteListConfirmation(String title) { + return 'Kas kustutada „$title” Google Tasksist?'; + } + + @override + String get deleteEvent => 'Kustuta sündmus'; + + @override + String get title => 'Pealkiri'; + + @override + String get create => 'Loo'; + + @override + String get newTask => 'Uus ülesanne'; + + @override + String get clearCompleted => 'Eemalda lõpetatud ülesanded'; + + @override + String get refreshList => 'Värskenda loendit'; + + @override + String get refreshAll => 'Värskenda kõiki'; + + @override + String get listRefreshed => 'Loend on värskendatud.'; + + @override + String get allTasksRefreshed => 'Kõik kontod on värskendatud.'; + + @override + String exportedFile(String path) { + return 'Eksporditud asukohta $path'; + } + + @override + String exportFailed(String error) { + return 'Eksportimine nurjus: $error'; + } + + @override + String refreshFailed(String error) { + return 'Värskendamine nurjus: $error'; + } + + @override + String get selectOrCreateTaskList => + 'Alustamiseks valige või looge ülesandeloend.'; + + @override + String get signInToViewTasks => 'Ülesannete vaatamiseks logige sisse.'; + + @override + String get noTasks => 'Ülesandeid pole.'; + + @override + String get noTasksYet => 'Ülesandeid veel pole'; + + @override + String get noTasksYetMessage => + 'Alustamiseks looge ülesanne või värskendage kontosid.'; + + @override + String get noTasksInList => 'Selles loendis pole ülesandeid.'; + + @override + String get overdue => 'Tähtaja ületanud'; + + @override + String get today => 'Täna'; + + @override + String get tomorrow => 'Homme'; + + @override + String get upcoming => 'Tulekul'; + + @override + String get noDate => 'Kuupäevata'; + + @override + String get completed => 'Lõpetatud'; + + @override + String duePrefix(String date) { + return 'Tähtaeg $date'; + } + + @override + String dateTimeDisplay(String date, String time) { + return '$date · $time'; + } + + @override + String get taskDetails => 'Ülesande üksikasjad'; + + @override + String get editTask => 'Muuda ülesannet'; + + @override + String get noTaskSelected => 'Ülesannet pole valitud.'; + + @override + String get noTaskSelectedHelper => + 'Üksikasjade vaatamiseks ja muutmiseks valige ülesanne.'; + + @override + String get taskUnavailable => 'Ülesanne pole saadaval.'; + + @override + String get signInToEditTasks => 'Ülesannete muutmiseks logige sisse.'; + + @override + String get refreshTask => 'Värskenda ülesannet'; + + @override + String get primarySection => 'Põhiteave'; + + @override + String get statusSection => 'Olek'; + + @override + String get openStatus => 'Pooleli'; + + @override + String get doneStatus => 'Valmis'; + + @override + String get notes => 'Märkmed'; + + @override + String get dueDate => 'Tähtaeg'; + + @override + String get clearDueDate => 'Eemalda tähtaeg'; + + @override + String get dueTime => 'Tähtaja kellaaeg'; + + @override + String get startDate => 'Alguskuupäev'; + + @override + String get startTime => 'Alguskellaaeg'; + + @override + String get endDate => 'Lõppkuupäev'; + + @override + String get endTime => 'Lõppkellaaeg'; + + @override + String get reminderDate => 'Meeldetuletuse kuupäev'; + + @override + String get reminderTime => 'Meeldetuletuse kellaaeg'; + + @override + String get reminder => 'Meeldetuletus'; + + @override + String get addReminder => 'Lisa meeldetuletus'; + + @override + String get addGuest => 'Lisa külaline'; + + @override + String get addGuestEmail => 'Lisa külalise e-posti aadress'; + + @override + String get removeReminder => 'Eemalda meeldetuletus'; + + @override + String get off => 'Väljas'; + + @override + String get repeat => 'Kordus'; + + @override + String get repeatNone => 'Ei kordu'; + + @override + String get noneValue => 'Puudub'; + + @override + String get repeatDaily => 'Iga päev'; + + @override + String get repeatWeekly => 'Iga nädal'; + + @override + String get repeatMonthly => 'Iga kuu'; + + @override + String get repeatYearly => 'Iga aasta'; + + @override + String get importance => 'Tähtsus'; + + @override + String get importanceLow => 'Madal'; + + @override + String get importanceNormal => 'Tavaline'; + + @override + String get importanceHigh => 'Kõrge'; + + @override + String get categories => 'Kategooriad'; + + @override + String get scheduleSection => 'Ajakava'; + + @override + String get dueGroup => 'Tähtaeg'; + + @override + String get startGroup => 'Algus'; + + @override + String get reminderGroup => 'Meeldetuletus'; + + @override + String get organizationSection => 'Korraldus'; + + @override + String get actionsSection => 'Toimingud'; + + @override + String get advancedSection => 'Täpsemad seaded'; + + @override + String get addCategory => 'Lisa kategooria'; + + @override + String get list => 'Loend'; + + @override + String get microsoftMoveUnsupported => + 'Selles versioonis ei toetata Microsoft To Do kontodel ülesannete teisaldamist loendite vahel.'; + + @override + String get createSubtask => 'Loo alamülesanne'; + + @override + String get moveToTop => 'Teisalda kõige üles'; + + @override + String get deleteTask => 'Kustuta ülesanne'; + + @override + String get newSubtask => 'Uus alamülesanne'; + + @override + String deleteTaskConfirmation(String title) { + return 'Kas kustutada „$title” Google Tasksist?'; + } + + @override + String get metadata => 'Metaandmed'; + + @override + String get id => 'ID'; + + @override + String get etag => 'ETag'; + + @override + String get updated => 'Uuendatud'; + + @override + String get parent => 'Ülemülesanne'; + + @override + String get position => 'Asukoht'; + + @override + String get webLink => 'Veebilink'; + + @override + String get assignment => 'Määramine'; + + @override + String get localState => 'Kohalik olek'; + + @override + String get pendingSync => 'Sünkroonimise ootel'; + + @override + String get synced => 'Sünkroonitud'; + + @override + String get account => 'Konto'; + + @override + String get sync => 'Sünkroonimine'; + + @override + String get manualFullSync => 'Käsitsi täielik sünkroonimine'; + + @override + String get runInBackgroundWhenClosed => + 'Jätka töötamist, kui aken on suletud'; + + @override + String get showTrayIcon => 'Kuva süsteemisalve ikoon'; + + @override + String get startMinimizedToTray => 'Käivita minimeerituna süsteemisalves'; + + @override + String get requiresTrayIcon => 'Nõuab süsteemisalve ikooni.'; + + @override + String get syncComplete => 'Sünkroonimine on lõpetatud.'; + + @override + String syncFailed(String error) { + return 'Sünkroonimine nurjus: $error'; + } + + @override + String get notifySyncFailures => 'Teavitused sünkroonimise nurjumisel'; + + @override + String get notifyConflicts => 'Teavitused konfliktide korral'; + + @override + String get notifyDueToday => 'Täna tähtuvate ülesannete teavitused'; + + @override + String get eventReminders => 'Sündmuste meeldetuletused'; + + @override + String get taskReminders => 'Ülesannete meeldetuletused'; + + @override + String get notificationDetailLevel => 'Teavituste üksikasjalikkus'; + + @override + String get notificationDetailPrivate => 'Privaatne'; + + @override + String get notificationDetailNormal => 'Tavaline'; + + @override + String get quietHours => 'Vaikne aeg'; + + @override + String get quietHoursDescription => 'Peata teavitused selleks ajavahemikuks.'; + + @override + String get quietHoursStart => 'Vaikse aja algus'; + + @override + String get quietHoursEnd => 'Vaikse aja lõpp'; + + @override + String get notifications => 'Teavitused'; + + @override + String get appearance => 'Välimus'; + + @override + String get theme => 'Kujundus'; + + @override + String get themeSystem => 'Süsteem'; + + @override + String get themeLight => 'Hele'; + + @override + String get themeDark => 'Tume'; + + @override + String get themeFamily => 'Kujunduse perekond'; + + @override + String get themeFamilyYaru => 'Ubuntu algupärane kujundus (Yaru)'; + + @override + String get localization => 'Keel ja piirkond'; + + @override + String get currentLocale => 'Praegune lokaat'; + + @override + String get privacy => 'Privaatsus'; + + @override + String get redactTaskContentInDiagnostics => + 'Peida diagnostikas ülesannete sisu'; + + @override + String get developerDiagnostics => 'Arendaja diagnostika'; + + @override + String get diagnostics => 'Diagnostika'; + + @override + String get apiInspectorDisabled => 'Kuva API-inspektor'; + + @override + String get googleTasksApi => 'Google Tasks API'; + + @override + String discoveryRevision(String revision) { + return 'Discovery versioon: $revision'; + } + + @override + String get implementedMethods => 'Rakendatud meetodid'; + + @override + String get supportsTasksScopes => + 'Toetab õiguse ulatusi tasks ja tasks.readonly'; + + @override + String get requiresTasksScope => 'Nõuab õiguse ulatust tasks'; + + @override + String get blockedPendingOperations => 'Blokeeritud ootel toimingud'; + + @override + String get signInToInspectPendingOperations => + 'Ootel toimingute kontrollimiseks logige sisse.'; + + @override + String get noBlockedPendingOperations => 'Blokeeritud ootel toiminguid pole.'; + + @override + String get operationActions => 'Toimingu tegevused'; + + @override + String pendingOpListId(String id) { + return 'loend=$id'; + } + + @override + String pendingOpTaskId(String id) { + return 'ülesanne=$id'; + } + + @override + String pendingOpAttempts(int count) { + return 'katseid=$count'; + } + + @override + String get retry => 'Proovi uuesti'; + + @override + String get discard => 'Hülga'; + + @override + String get discardChanges => 'Kas hüljata muudatused?'; + + @override + String get discardChangesConfirmation => + 'See hülgab ülesande salvestamata muudatused.'; + + @override + String get retryCompleted => 'Uuesti proovimine lõpetatud.'; + + @override + String get discardPendingOperation => 'Kas hüljata ootel toiming?'; + + @override + String get discardPendingOperationConfirmation => + 'See eemaldab blokeeritud kohaliku toimingu. Järgmine sünkroonimine laadib andmed Google Tasksist uuesti.'; + + @override + String get pendingOperationDiscarded => 'Ootel toiming hüljatud.'; + + @override + String get syncFailureNotificationTitle => 'BusyMaxi sünkroonimine nurjus'; + + @override + String syncFailureNotificationBody(String message) { + return 'Taustal sünkroonimine nurjus. $message'; + } + + @override + String get conflictNotificationTitle => 'BusyMaxi sünkroonimiskonflikt'; + + @override + String conflictNotificationBody(String summary) { + return 'Ootel kohalik muudatus blokeeriti. $summary'; + } + + @override + String get dueTodayNotificationTitle => 'Täna tähtuvad ülesanded'; + + @override + String dueTodayNotificationBody(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count ülesannet tähtub täna.', + one: 'Üks ülesanne tähtub täna.', + ); + return '$_temp0'; + } + + @override + String get eventReminderNotificationTitle => 'Sündmuse meeldetuletus'; + + @override + String get taskReminderNotificationTitle => 'Ülesande meeldetuletus'; + + @override + String get eventReminderNotificationBody => 'Sündmus algab varsti.'; + + @override + String get taskReminderNotificationBody => 'Ülesande tähtaeg on varsti.'; + + @override + String get notificationOpenAction => 'Ava'; + + @override + String get notificationDetailsHidden => + 'Üksikasjad on privaatsusseadete tõttu peidetud.'; + + @override + String get previousMonth => 'Eelmine kuu'; + + @override + String get nextMonth => 'Järgmine kuu'; + + @override + String get openMonthView => 'Ava kuuvaade'; + + @override + String get previousYear => 'Eelmine aasta'; + + @override + String get nextYear => 'Järgmine aasta'; + + @override + String get openYearView => 'Ava aastavaade'; + + @override + String weekNumberTooltip(int number) { + return 'Nädal $number'; + } + + @override + String get resizeAllDayPanel => 'Muuda kogu päeva paneeli suurust'; + + @override + String scheduleItemCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count kirjet', + one: '1 kirje', + ); + return '$_temp0'; + } + + @override + String get readOnlyCalendar => 'See kalender on kirjutuskaitstud.'; + + @override + String get selectTimeZone => 'Valige ajavöönd'; + + @override + String get searchLocations => 'Otsi asukohti'; + + @override + String get noLocationsFound => 'Asukohti ei leitud'; + + @override + String deleteCalendarConfirmation(String title) { + return 'Kas kustutada „$title”?'; + } +} diff --git a/lib/l10n/generated/app_localizations_fa.dart b/lib/l10n/generated/app_localizations_fa.dart index f52e880..8d8ca33 100644 --- a/lib/l10n/generated/app_localizations_fa.dart +++ b/lib/l10n/generated/app_localizations_fa.dart @@ -1,3 +1,6 @@ +// coverage:ignore-file +// GENERATED CODE - DO NOT MODIFY BY HAND + // ignore: unused_import import 'package:intl/intl.dart' as intl; import 'app_localizations.dart'; @@ -136,7 +139,11 @@ class AppLocalizationsFa extends AppLocalizations { @override String moreItems(int count) { - return '+$count مورد دیگر'; + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + return '+⁨$countString⁩ مورد دیگر'; } @override @@ -227,7 +234,7 @@ class AppLocalizationsFa extends AppLocalizations { @override String compactAgendaDueOn(String date) { - return 'سررسید: $date'; + return 'سررسید: ⁨$date⁩'; } @override @@ -359,10 +366,14 @@ class AppLocalizationsFa extends AppLocalizations { @override String reminderMinutesBefore(int minutes) { + final intl.NumberFormat minutesNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String minutesString = minutesNumberFormat.format(minutes); + String _temp0 = intl.Intl.pluralLogic( minutes, locale: localeName, - other: '$minutes دقیقه قبل', + other: '⁨$minutesString⁩ دقیقه قبل', one: 'یک دقیقه قبل', zero: 'هنگام شروع', ); @@ -374,10 +385,14 @@ class AppLocalizationsFa extends AppLocalizations { @override String reminderHoursBefore(int hours) { + final intl.NumberFormat hoursNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String hoursString = hoursNumberFormat.format(hours); + String _temp0 = intl.Intl.pluralLogic( hours, locale: localeName, - other: '$hours ساعت قبل', + other: '⁨$hoursString⁩ ساعت قبل', one: 'یک ساعت قبل', zero: 'هنگام شروع', ); @@ -386,10 +401,15 @@ class AppLocalizationsFa extends AppLocalizations { @override String reminderDaysBefore(int days) { + final intl.NumberFormat daysNumberFormat = intl.NumberFormat.decimalPattern( + localeName, + ); + final String daysString = daysNumberFormat.format(days); + String _temp0 = intl.Intl.pluralLogic( days, locale: localeName, - other: '$days روز قبل', + other: '⁨$daysString⁩ روز قبل', one: 'یک روز قبل', zero: 'همان روز', ); @@ -434,7 +454,7 @@ class AppLocalizationsFa extends AppLocalizations { @override String tasksInList(String title) { - return 'کارهای $title'; + return 'کارهای ⁨$title⁩'; } @override @@ -615,7 +635,7 @@ class AppLocalizationsFa extends AppLocalizations { @override String feedbackSuccess(String id) { - return 'بازخورد ارسال شد. شناسهٔ پیگیری: $id'; + return 'بازخورد ارسال شد. شناسهٔ پیگیری: ⁨$id⁩'; } @override @@ -657,7 +677,7 @@ class AppLocalizationsFa extends AppLocalizations { @override String removeAccountTitle(String account) { - return 'حذف $account از BusyMax؟'; + return 'حذف ⁨$account⁩ از BusyMax؟'; } @override @@ -715,7 +735,7 @@ class AppLocalizationsFa extends AppLocalizations { @override String deleteListConfirmation(String title) { - return '«$title» از Google Tasks حذف شود؟'; + return '«⁨$title⁩» از Google Tasks حذف شود؟'; } @override @@ -747,17 +767,17 @@ class AppLocalizationsFa extends AppLocalizations { @override String exportedFile(String path) { - return 'در $path خروجی گرفته شد'; + return 'در ⁨$path⁩ خروجی گرفته شد'; } @override String exportFailed(String error) { - return 'خروجی گرفتن ناموفق بود: $error'; + return 'خروجی گرفتن ناموفق بود: ⁨$error⁩'; } @override String refreshFailed(String error) { - return 'تازه‌سازی ناموفق بود: $error'; + return 'تازه‌سازی ناموفق بود: ⁨$error⁩'; } @override @@ -800,12 +820,12 @@ class AppLocalizationsFa extends AppLocalizations { @override String duePrefix(String date) { - return 'سررسید: $date'; + return 'سررسید: ⁨$date⁩'; } @override String dateTimeDisplay(String date, String time) { - return '$date · $time'; + return '⁨$date⁩ · ⁨$time⁩'; } @override @@ -971,7 +991,7 @@ class AppLocalizationsFa extends AppLocalizations { @override String deleteTaskConfirmation(String title) { - return '«$title» از Google Tasks حذف شود؟'; + return '«⁨$title⁩» از Google Tasks حذف شود؟'; } @override @@ -1033,7 +1053,7 @@ class AppLocalizationsFa extends AppLocalizations { @override String syncFailed(String error) { - return 'همگام‌سازی ناموفق بود: $error'; + return 'همگام‌سازی ناموفق بود: ⁨$error⁩'; } @override @@ -1124,7 +1144,7 @@ class AppLocalizationsFa extends AppLocalizations { @override String discoveryRevision(String revision) { - return 'بازبینی Discovery: ‏$revision'; + return 'بازبینی Discovery: ⁨$revision⁩'; } @override @@ -1153,17 +1173,21 @@ class AppLocalizationsFa extends AppLocalizations { @override String pendingOpListId(String id) { - return 'فهرست=$id'; + return 'فهرست=⁨$id⁩'; } @override String pendingOpTaskId(String id) { - return 'کار=$id'; + return 'کار=⁨$id⁩'; } @override String pendingOpAttempts(int count) { - return 'تلاش‌ها=$count'; + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + return 'تلاش‌ها=⁨$countString⁩'; } @override @@ -1197,7 +1221,7 @@ class AppLocalizationsFa extends AppLocalizations { @override String syncFailureNotificationBody(String message) { - return 'همگام‌سازی پس‌زمینه ناموفق بود. $message'; + return 'همگام‌سازی پس‌زمینه ناموفق بود. ⁨$message⁩'; } @override @@ -1205,7 +1229,7 @@ class AppLocalizationsFa extends AppLocalizations { @override String conflictNotificationBody(String summary) { - return 'یک تغییر محلی در انتظار مسدود شد. $summary'; + return 'یک تغییر محلی در انتظار مسدود شد. ⁨$summary⁩'; } @override @@ -1213,10 +1237,14 @@ class AppLocalizationsFa extends AppLocalizations { @override String dueTodayNotificationBody(int count) { + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + String _temp0 = intl.Intl.pluralLogic( count, locale: localeName, - other: 'امروز $count کار سررسید دارند.', + other: 'امروز ⁨$countString⁩ کار سررسید دارند.', one: 'امروز یک کار سررسید دارد.', zero: 'امروز هیچ کاری سررسید ندارد.', ); @@ -1262,7 +1290,11 @@ class AppLocalizationsFa extends AppLocalizations { @override String weekNumberTooltip(int number) { - return 'هفتهٔ $number'; + final intl.NumberFormat numberNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String numberString = numberNumberFormat.format(number); + + return 'هفتهٔ ⁨$numberString⁩'; } @override @@ -1270,10 +1302,14 @@ class AppLocalizationsFa extends AppLocalizations { @override String scheduleItemCount(int count) { + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + String _temp0 = intl.Intl.pluralLogic( count, locale: localeName, - other: '$count مورد', + other: '⁨$countString⁩ مورد', one: 'یک مورد', zero: 'هیچ موردی', ); @@ -1294,6 +1330,6 @@ class AppLocalizationsFa extends AppLocalizations { @override String deleteCalendarConfirmation(String title) { - return '«$title» حذف شود؟'; + return '«⁨$title⁩» حذف شود؟'; } } diff --git a/lib/l10n/generated/app_localizations_fi.dart b/lib/l10n/generated/app_localizations_fi.dart index 30f2eea..a261c1a 100644 --- a/lib/l10n/generated/app_localizations_fi.dart +++ b/lib/l10n/generated/app_localizations_fi.dart @@ -1,3 +1,6 @@ +// coverage:ignore-file +// GENERATED CODE - DO NOT MODIFY BY HAND + // ignore: unused_import import 'package:intl/intl.dart' as intl; import 'app_localizations.dart'; diff --git a/lib/l10n/generated/app_localizations_fr.dart b/lib/l10n/generated/app_localizations_fr.dart index 8ce6b34..46dccb6 100644 --- a/lib/l10n/generated/app_localizations_fr.dart +++ b/lib/l10n/generated/app_localizations_fr.dart @@ -1,3 +1,6 @@ +// coverage:ignore-file +// GENERATED CODE - DO NOT MODIFY BY HAND + // ignore: unused_import import 'package:intl/intl.dart' as intl; import 'app_localizations.dart'; diff --git a/lib/l10n/generated/app_localizations_hi.dart b/lib/l10n/generated/app_localizations_hi.dart index 4aa1894..ae5ab4e 100644 --- a/lib/l10n/generated/app_localizations_hi.dart +++ b/lib/l10n/generated/app_localizations_hi.dart @@ -1,3 +1,6 @@ +// coverage:ignore-file +// GENERATED CODE - DO NOT MODIFY BY HAND + // ignore: unused_import import 'package:intl/intl.dart' as intl; import 'app_localizations.dart'; diff --git a/lib/l10n/generated/app_localizations_it.dart b/lib/l10n/generated/app_localizations_it.dart index 708cda8..720c08f 100644 --- a/lib/l10n/generated/app_localizations_it.dart +++ b/lib/l10n/generated/app_localizations_it.dart @@ -1,3 +1,6 @@ +// coverage:ignore-file +// GENERATED CODE - DO NOT MODIFY BY HAND + // ignore: unused_import import 'package:intl/intl.dart' as intl; import 'app_localizations.dart'; @@ -24,7 +27,7 @@ class AppLocalizationsIt extends AppLocalizations { 'Sono necessarie le autorizzazioni per Google Calendar e Google Tasks. Riprova e seleziona entrambe le caselle.'; @override - String get finishSetup => 'Completa configurazione'; + String get finishSetup => 'Completa la configurazione'; @override String get continueSetup => 'Continua'; @@ -138,7 +141,7 @@ class AppLocalizationsIt extends AppLocalizations { @override String moreItems(int count) { - return '+$count altri'; + return '+$count in più'; } @override @@ -334,10 +337,10 @@ class AppLocalizationsIt extends AppLocalizations { String get defaultVisibility => 'Visibilità predefinita'; @override - String get conference => 'Riunione'; + String get conference => 'Conferenza'; @override - String get noConference => 'Nessuna riunione'; + String get noConference => 'Nessuna conferenza'; @override String get providerCalendar => 'Calendario del servizio'; @@ -601,7 +604,7 @@ class AppLocalizationsIt extends AppLocalizations { @override String get feedbackTimeoutError => - 'La richiesta è scaduta. Il feedback non è stato cancellato; riprova.'; + 'La richiesta ha superato il tempo limite. Il feedback non è stato cancellato; riprova.'; @override String get feedbackRateLimitedError => @@ -683,7 +686,7 @@ class AppLocalizationsIt extends AppLocalizations { @override String get accountRemovedGoogleRevokeFailed => - 'L’account è stato rimosso da questo dispositivo, ma BusyMax non ha potuto revocare l’accesso all’account Google. Puoi revocarlo dalle impostazioni dell’account Google.'; + 'L’account è stato rimosso da questo dispositivo, ma BusyMax non è riuscito a revocare il proprio accesso a Google. Puoi revocarlo dal tuo account Google.'; @override String get newList => 'Nuovo elenco'; @@ -1023,7 +1026,7 @@ class AppLocalizationsIt extends AppLocalizations { @override String get runInBackgroundWhenClosed => - 'Continua l’esecuzione quando la finestra viene chiusa'; + 'Continua a funzionare quando la finestra è chiusa'; @override String get showTrayIcon => 'Mostra icona nell’area di notifica'; @@ -1125,7 +1128,7 @@ class AppLocalizationsIt extends AppLocalizations { String get diagnostics => 'Diagnostica'; @override - String get apiInspectorDisabled => 'Mostra controllo API'; + String get apiInspectorDisabled => 'Mostra l’ispettore API'; @override String get googleTasksApi => 'API Google Tasks'; diff --git a/lib/l10n/generated/app_localizations_ja.dart b/lib/l10n/generated/app_localizations_ja.dart index c787c56..53c909c 100644 --- a/lib/l10n/generated/app_localizations_ja.dart +++ b/lib/l10n/generated/app_localizations_ja.dart @@ -1,3 +1,6 @@ +// coverage:ignore-file +// GENERATED CODE - DO NOT MODIFY BY HAND + // ignore: unused_import import 'package:intl/intl.dart' as intl; import 'app_localizations.dart'; diff --git a/lib/l10n/generated/app_localizations_ko.dart b/lib/l10n/generated/app_localizations_ko.dart index 466a41f..444320c 100644 --- a/lib/l10n/generated/app_localizations_ko.dart +++ b/lib/l10n/generated/app_localizations_ko.dart @@ -1,3 +1,6 @@ +// coverage:ignore-file +// GENERATED CODE - DO NOT MODIFY BY HAND + // ignore: unused_import import 'package:intl/intl.dart' as intl; import 'app_localizations.dart'; diff --git a/lib/l10n/generated/app_localizations_pt.dart b/lib/l10n/generated/app_localizations_pt.dart index e868a00..e0e2da7 100644 --- a/lib/l10n/generated/app_localizations_pt.dart +++ b/lib/l10n/generated/app_localizations_pt.dart @@ -1,3 +1,6 @@ +// coverage:ignore-file +// GENERATED CODE - DO NOT MODIFY BY HAND + // ignore: unused_import import 'package:intl/intl.dart' as intl; import 'app_localizations.dart'; diff --git a/lib/l10n/generated/app_localizations_ru.dart b/lib/l10n/generated/app_localizations_ru.dart index f75bb32..18328a1 100644 --- a/lib/l10n/generated/app_localizations_ru.dart +++ b/lib/l10n/generated/app_localizations_ru.dart @@ -1,3 +1,6 @@ +// coverage:ignore-file +// GENERATED CODE - DO NOT MODIFY BY HAND + // ignore: unused_import import 'package:intl/intl.dart' as intl; import 'app_localizations.dart'; @@ -59,7 +62,7 @@ class AppLocalizationsRu extends AppLocalizations { String get microsoftTodoProvider => 'Microsoft To Do'; @override - String get providerNotConfigured => 'Этот поставщик не настроен.'; + String get providerNotConfigured => 'Этот сервис не настроен.'; @override String get waitingForGoogleSignIn => 'Ожидание входа через Google...'; @@ -335,7 +338,7 @@ class AppLocalizationsRu extends AppLocalizations { String get noConference => 'Без конференции'; @override - String get providerCalendar => 'Календарь поставщика'; + String get providerCalendar => 'Календарь сервиса'; @override String get formatBoldShortLabel => 'Ж'; @@ -888,7 +891,7 @@ class AppLocalizationsRu extends AppLocalizations { String get addGuest => 'Добавить гостя'; @override - String get addGuestEmail => 'Добавить адрес гостя'; + String get addGuestEmail => 'Добавить адрес электронной почты гостя'; @override String get removeReminder => 'Удалить напоминание'; diff --git a/lib/l10n/generated/app_localizations_vi.dart b/lib/l10n/generated/app_localizations_vi.dart new file mode 100644 index 0000000..b47d3e4 --- /dev/null +++ b/lib/l10n/generated/app_localizations_vi.dart @@ -0,0 +1,1300 @@ +// coverage:ignore-file +// GENERATED CODE - DO NOT MODIFY BY HAND + +// ignore: unused_import +import 'package:intl/intl.dart' as intl; +import 'app_localizations.dart'; + +// ignore_for_file: type=lint + +/// The translations for Vietnamese (`vi`). +class AppLocalizationsVi extends AppLocalizations { + AppLocalizationsVi([String locale = 'vi']) : super(locale); + + @override + String get appTitle => 'BusyMax'; + + @override + String get connectGoogleAccount => + 'Kết nối tài khoản Google và Microsoft để đồng bộ lịch và công việc.'; + + @override + String get googlePermissionsConsentNotice => + 'Trên màn hình cấp quyền của Google, hãy chọn cả quyền truy cập Lịch và Công việc.'; + + @override + String get googlePermissionsRequiredRetry => + 'Cần có quyền truy cập Google Calendar và Google Tasks. Vui lòng thử lại và chọn cả hai hộp kiểm.'; + + @override + String get finishSetup => 'Hoàn tất thiết lập'; + + @override + String get continueSetup => 'Tiếp tục'; + + @override + String get onboardingSetupTitle => 'Thiết lập BusyMax'; + + @override + String get onboardingAccountsStepTitle => 'Kết nối tài khoản'; + + @override + String get onboardingAccountsStepDescription => + 'Thêm tất cả tài khoản Google và Microsoft bạn muốn sử dụng. BusyMax đồng bộ lịch, sự kiện, danh sách công việc và công việc từ mỗi tài khoản.'; + + @override + String get onboardingPreferencesStepTitle => 'Chọn cài đặt hệ thống'; + + @override + String get onboardingPreferencesStepDescription => + 'Thiết lập cách ứng dụng hoạt động trên máy tính, lời nhắc, mức độ chi tiết của thông báo và giao diện trước khi mở lịch biểu.'; + + @override + String get signInWithGoogle => 'Đăng nhập bằng Google'; + + @override + String get signInWithMicrosoft => 'Đăng nhập bằng Microsoft'; + + @override + String get googleTasksProvider => 'Google Tasks'; + + @override + String get microsoftTodoProvider => 'Microsoft To Do'; + + @override + String get providerNotConfigured => 'Dịch vụ này chưa được cấu hình.'; + + @override + String get waitingForGoogleSignIn => 'Đang chờ đăng nhập Google...'; + + @override + String get waitingForMicrosoftSignIn => 'Đang chờ đăng nhập Microsoft...'; + + @override + String get microsoftSignInNotConfigured => + 'Tính năng đăng nhập Microsoft chưa được cấu hình. Hãy đặt MICROSOFT_OAUTH_CLIENT_ID.'; + + @override + String get cancel => 'Hủy'; + + @override + String get close => 'Đóng'; + + @override + String get exit => 'Thoát'; + + @override + String get options => 'Tùy chọn'; + + @override + String get hide => 'Ẩn'; + + @override + String get show => 'Hiện'; + + @override + String get export => 'Xuất'; + + @override + String get save => 'Lưu'; + + @override + String get settings => 'Cài đặt'; + + @override + String get all => 'Tất cả'; + + @override + String get calendarEvents => 'Sự kiện'; + + @override + String get calendarTasks => 'Công việc'; + + @override + String get calendar => 'Lịch'; + + @override + String get calendars => 'Lịch'; + + @override + String get newEvent => 'Sự kiện mới'; + + @override + String get refreshCalendar => 'Làm mới lịch'; + + @override + String get openInProvider => 'Mở trong dịch vụ'; + + @override + String get hideFromSchedule => 'Ẩn khỏi lịch biểu'; + + @override + String get showInSchedule => 'Hiện trong lịch biểu'; + + @override + String get noCalendarsSynced => 'Chưa có lịch nào được đồng bộ.'; + + @override + String get allDay => 'Cả ngày'; + + @override + String moreItems(int count) { + return '+$count mục khác'; + } + + @override + String get noEventsOrTasks => 'Không có sự kiện hoặc công việc'; + + @override + String get scheduleLoading => 'Đang tải lịch biểu...'; + + @override + String get scheduleUnavailable => 'Lịch biểu không khả dụng'; + + @override + String get scheduleNoSources => + 'Không có lịch hoặc danh sách công việc nào đang hiển thị'; + + @override + String get scheduleNoSourcesDescription => + 'Chọn nội dung cần hiển thị trong Cài đặt, sau đó làm mới lịch biểu.'; + + @override + String get scheduleSignInRequired => 'Kết nối tài khoản'; + + @override + String get scheduleSignInDescription => + 'Đăng nhập để đồng bộ lịch và công việc.'; + + @override + String get scheduleNoSearchResults => + 'Không có sự kiện hoặc công việc phù hợp'; + + @override + String get scheduleNoSearchResultsDescription => + 'Thử tìm kiếm khác hoặc xóa các bộ lọc hiện tại.'; + + @override + String get trayAgendaLoading => 'Đang tải lịch biểu...'; + + @override + String get trayAgendaSignInRequired => 'Đăng nhập để hiển thị lịch biểu.'; + + @override + String get trayAgendaNoSources => + 'Không có lịch hoặc danh sách công việc nào đang hiển thị.'; + + @override + String get trayAgendaOpenBusyMax => 'Mở ứng dụng'; + + @override + String get trayAgendaRefresh => 'Làm mới'; + + @override + String get trayAgendaError => 'Lịch biểu không khả dụng'; + + @override + String get compactAgendaTitle => 'Lịch biểu'; + + @override + String get compactAgendaSubtitle => 'Sắp tới'; + + @override + String get compactAgendaOverdue => 'Quá hạn'; + + @override + String get compactAgendaClear => 'Hiện chưa có lịch'; + + @override + String get compactAgendaOpenBusyMax => 'Mở BusyMax'; + + @override + String get compactAgendaHide => 'Ẩn'; + + @override + String get compactAgendaNewTask => 'Công việc mới'; + + @override + String get compactAgendaRetry => 'Thử lại'; + + @override + String get compactAgendaRefresh => 'Làm mới'; + + @override + String get compactAgendaAllDay => 'Cả ngày'; + + @override + String get compactAgendaDueToday => 'Đến hạn hôm nay'; + + @override + String get compactAgendaDueTomorrow => 'Đến hạn ngày mai'; + + @override + String compactAgendaDueOn(String date) { + return 'Đến hạn $date'; + } + + @override + String get compactAgendaMoreOverdue => 'Tải thêm công việc quá hạn'; + + @override + String get agendaLoadMoreOverdue => 'Tải thêm công việc quá hạn'; + + @override + String get agendaLoadMoreNoDate => 'Tải thêm công việc không có ngày'; + + @override + String get viewDay => 'Ngày'; + + @override + String get viewWeek => 'Tuần'; + + @override + String get viewMonth => 'Tháng'; + + @override + String get viewYear => 'Năm'; + + @override + String get viewAgenda => 'Lịch biểu'; + + @override + String get scheduleSettings => 'Lịch biểu'; + + @override + String get scheduleDisplaySettings => 'Hiển thị lịch biểu'; + + @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.'; + + @override + String get scheduleDayStartsAt => 'Ngày bắt đầu lúc'; + + @override + String get scheduleDayEndsAt => 'Ngày kết thúc lúc'; + + @override + String get sourceCalendar => 'Lịch'; + + @override + String get sourceTaskList => 'Danh sách công việc'; + + @override + String get createChoiceTitle => 'Tạo'; + + @override + String get createEventAtTime => 'Sự kiện'; + + @override + String get createTaskAtDate => 'Công việc'; + + @override + String get editEvent => 'Chỉnh sửa sự kiện'; + + @override + String get eventTitle => 'Tiêu đề sự kiện'; + + @override + String get location => 'Địa điểm'; + + @override + String get timeSlot => 'Khoảng thời gian'; + + @override + String get startDateTime => 'Ngày/giờ bắt đầu'; + + @override + String get endDateTime => 'Ngày/giờ kết thúc'; + + @override + String get doesNotRepeat => 'Không lặp lại'; + + @override + String get defaultReminder => 'Lời nhắc mặc định'; + + @override + String get guests => 'Khách mời'; + + @override + String get noGuests => 'Không có khách mời'; + + @override + String get description => 'Mô tả'; + + @override + String get availabilityShowAs => 'Tình trạng rảnh/bận / Hiển thị là'; + + @override + String get busy => 'Bận'; + + @override + String get visibility => 'Chế độ hiển thị'; + + @override + String get defaultVisibility => 'Chế độ hiển thị mặc định'; + + @override + String get conference => 'Cuộc họp'; + + @override + String get noConference => 'Không có cuộc họp'; + + @override + String get providerCalendar => 'Lịch của dịch vụ'; + + @override + String get formatBoldShortLabel => 'B'; + + @override + String get formatBoldTooltip => 'Đậm'; + + @override + String get formatItalicShortLabel => 'I'; + + @override + String get formatItalicTooltip => 'Nghiêng'; + + @override + String get formatUnderlineShortLabel => 'U'; + + @override + String get formatUnderlineTooltip => 'Gạch chân'; + + @override + String reminderMinutesBefore(int minutes) { + String _temp0 = intl.Intl.pluralLogic( + minutes, + locale: localeName, + other: 'Trước $minutes phút', + one: 'Trước 1 phút', + ); + return '$_temp0'; + } + + @override + String get reminderAtStart => 'Khi bắt đầu'; + + @override + String reminderHoursBefore(int hours) { + String _temp0 = intl.Intl.pluralLogic( + hours, + locale: localeName, + other: 'Trước $hours giờ', + one: 'Trước 1 giờ', + ); + return '$_temp0'; + } + + @override + String reminderDaysBefore(int days) { + String _temp0 = intl.Intl.pluralLogic( + days, + locale: localeName, + other: 'Trước $days ngày', + one: 'Trước 1 ngày', + ); + return '$_temp0'; + } + + @override + String get availabilityFree => 'Rảnh'; + + @override + String get availabilityTentative => 'Dự kiến'; + + @override + String get availabilityOutOfOffice => 'Vắng mặt'; + + @override + String get availabilityWorkingElsewhere => 'Làm việc ở nơi khác'; + + @override + String get visibilityDefault => 'Mặc định'; + + @override + String get visibilityPublic => 'Công khai'; + + @override + String get visibilityPrivate => 'Riêng tư'; + + @override + String get visibilityConfidential => 'Bảo mật'; + + @override + String get sensitivityNormal => 'Bình thường'; + + @override + String get sensitivityPersonal => 'Cá nhân'; + + @override + String get tasks => 'Công việc'; + + @override + String get allTasks => 'Tất cả công việc'; + + @override + String tasksInList(String title) { + return 'Công việc trong $title'; + } + + @override + String get taskLists => 'Danh sách công việc'; + + @override + String get navigation => 'Điều hướng'; + + @override + String get mainMenu => 'Trình đơn chính'; + + @override + String get keyboardShortcuts => 'Phím tắt'; + + @override + String get shortcutGroupGeneral => 'Chung'; + + @override + String get shortcutKeyboardShortcutsDescription => + 'Hiển thị bảng tham khảo phím tắt này'; + + @override + String get shortcutGroupNavigation => 'Điều hướng'; + + @override + String get shortcutNextPeriod => 'Khoảng tiếp theo'; + + @override + String get shortcutNextPeriodDescription => + 'Tuần tiếp theo trong chế độ xem tuần, tháng tiếp theo trong chế độ xem tháng, v.v.'; + + @override + String get shortcutPreviousPeriod => 'Khoảng trước đó'; + + @override + String get shortcutPreviousPeriodDescription => + 'Tuần trước trong chế độ xem tuần, tháng trước trong chế độ xem tháng, v.v.'; + + @override + String get shortcutJumpToToday => 'Chuyển đến hôm nay'; + + @override + String get shortcutGroupView => 'Chế độ xem'; + + @override + String get shortcutDayView => 'Chế độ xem ngày'; + + @override + String get shortcutWeekView => 'Chế độ xem tuần'; + + @override + String get shortcutMonthView => 'Chế độ xem tháng'; + + @override + String get shortcutYearView => 'Chế độ xem năm'; + + @override + String get shortcutAgendaView => 'Chế độ xem lịch biểu'; + + @override + String get shortcutGroupCreateAndEdit => 'Tạo và chỉnh sửa'; + + @override + String get shortcutSaveItem => 'Lưu sự kiện hoặc công việc'; + + @override + String get shortcutDeleteItem => 'Xóa sự kiện hoặc công việc'; + + @override + String get shortcutGroupTaskEditing => 'Chỉnh sửa công việc'; + + @override + String get shortcutCancelEditing => 'Hủy chỉnh sửa'; + + @override + String get shortcutCancelEditingDescription => + 'Đóng phần chỉnh sửa hoặc chi tiết công việc'; + + @override + String get shortcutGroupCompactAgenda => 'Lịch biểu thu gọn'; + + @override + String get shortcutRefreshCompactAgendaDescription => + 'Làm mới cửa sổ lịch biểu thu gọn'; + + @override + String get shortcutHideCompactAgendaDescription => + 'Ẩn cửa sổ lịch biểu thu gọn'; + + @override + String get aboutBusyMax => 'Giới thiệu BusyMax'; + + @override + String get aboutBusyMaxDescription => 'Công việc và lịch'; + + @override + String get website => 'Trang web'; + + @override + String get reportAnIssue => 'Báo cáo sự cố'; + + @override + String get sendFeedback => 'Gửi phản hồi'; + + @override + String get feedbackSubmit => 'Gửi'; + + @override + String get feedbackCategory => 'Danh mục'; + + @override + String get feedbackSelectCategory => 'Chọn một danh mục'; + + @override + String get feedbackCategoryProblem => 'Sự cố hoặc lỗi'; + + @override + String get feedbackCategoryFeature => 'Yêu cầu tính năng'; + + @override + String get feedbackCategoryPrivacySecurity => + 'Vấn đề về quyền riêng tư hoặc bảo mật'; + + @override + String get feedbackCategoryUsability => 'Vấn đề về khả năng sử dụng'; + + @override + String get feedbackCategoryOther => 'Khác'; + + @override + String get feedbackSubject => 'Chủ đề'; + + @override + String get feedbackDetailedMessage => 'Nội dung chi tiết'; + + @override + String get feedbackReplyEmail => + 'Địa chỉ email để nhận phản hồi (không bắt buộc)'; + + @override + String get feedbackIncludeTechnicalDetails => 'Bao gồm chi tiết kỹ thuật'; + + @override + String get feedbackTechnicalDetailsDisclosure => + 'Chỉ thêm phiên bản hệ điều hành Linux và ngôn ngữ, khu vực của ứng dụng. Không bao gồm nhật ký, dữ liệu tài khoản, tên tệp hoặc thông tin chẩn đoán khác.'; + + @override + String get feedbackCategoryRequired => 'Hãy chọn một danh mục.'; + + @override + String get feedbackSubjectLengthError => 'Chủ đề phải có từ 3 đến 120 ký tự.'; + + @override + String get feedbackMessageLengthError => + 'Nội dung phải có từ 10 đến 5.000 ký tự.'; + + @override + String get feedbackInvalidEmail => 'Nhập địa chỉ email hợp lệ.'; + + @override + String get feedbackConnectionError => + 'Không thể kết nối với BusyStack. Hãy kiểm tra kết nối và thử lại.'; + + @override + String get feedbackTimeoutError => + 'Yêu cầu đã hết thời gian chờ. Phản hồi của bạn chưa bị xóa; hãy thử lại.'; + + @override + String get feedbackRateLimitedError => + 'Đã gửi quá nhiều phản hồi từ mạng này. Vui lòng chờ rồi thử lại.'; + + @override + String get feedbackRejectedError => + 'Máy chủ đã từ chối nội dung gửi. Hãy kiểm tra các trường và thử lại.'; + + @override + String get feedbackServerError => + 'BusyStack hiện không thể nhận phản hồi của bạn. Phản hồi chưa bị xóa; hãy thử lại.'; + + @override + String feedbackSuccess(String id) { + return 'Đã gửi phản hồi. Mã tham chiếu: $id'; + } + + @override + String get toggleSidebar => 'Hiện hoặc ẩn thanh bên'; + + @override + String get accounts => 'Tài khoản'; + + @override + String get currentAccount => 'Tài khoản hiện tại'; + + @override + String get switchAccount => 'Chuyển tài khoản'; + + @override + String get addGoogleAccount => 'Thêm tài khoản Google'; + + @override + String get addMicrosoftAccount => 'Thêm tài khoản Microsoft'; + + @override + String get googleProvider => 'Google'; + + @override + String get microsoftProvider => 'Microsoft'; + + @override + String get signedInAccount => 'Đã đăng nhập'; + + @override + String get removeAccount => 'Xóa tài khoản…'; + + @override + String get removingAccount => 'Đang xóa tài khoản…'; + + @override + String get removeAccountDescription => + 'Dừng đồng bộ và xóa dữ liệu của tài khoản này khỏi thiết bị.'; + + @override + String removeAccountTitle(String account) { + return 'Xóa $account khỏi BusyMax?'; + } + + @override + String get removeAccountConfirmation => + 'Thao tác này sẽ xóa công việc, lịch, sự kiện, lời nhắc đã lưu trong bộ nhớ đệm và các thay đổi ngoại tuyến đang chờ khỏi thiết bị. Các thay đổi chưa đồng bộ sẽ bị mất. Không có dữ liệu nào bị xóa khỏi Google hoặc Microsoft.'; + + @override + String get revokeGoogleAccess => + 'Đồng thời thu hồi quyền truy cập của BusyMax vào tài khoản Google này'; + + @override + String get revokeGoogleAccessDescription => + 'Bạn sẽ cần cấp lại quyền truy cập trước khi kết nối lại.'; + + @override + String get removeAccountAction => 'Xóa tài khoản'; + + @override + String get removeAccountFailed => + 'Không thể hoàn tất việc xóa tài khoản. Hãy thử lại.'; + + @override + String get accountRemovedGoogleRevokeFailed => + 'Tài khoản đã bị xóa khỏi thiết bị này, nhưng BusyMax không thể thu hồi quyền truy cập vào tài khoản Google. Bạn có thể thu hồi quyền trong phần cài đặt Tài khoản Google.'; + + @override + String get newList => 'Danh sách mới'; + + @override + String get signInToViewTaskLists => 'Đăng nhập để xem danh sách công việc.'; + + @override + String get noTaskListsSynced => + 'Chưa có danh sách công việc nào được đồng bộ.'; + + @override + String get listActions => 'Thao tác với danh sách'; + + @override + String get rename => 'Đổi tên'; + + @override + String get delete => 'Xóa'; + + @override + String get renameList => 'Đổi tên danh sách'; + + @override + String get deleteList => 'Xóa danh sách'; + + @override + String get builtInMicrosoftList => 'Tích hợp sẵn'; + + @override + String get builtInMicrosoftListCannotRenameDelete => + 'Không thể đổi tên hoặc xóa danh sách tích hợp sẵn của Microsoft To Do.'; + + @override + String deleteListConfirmation(String title) { + return 'Xóa “$title” khỏi Google Tasks?'; + } + + @override + String get deleteEvent => 'Xóa sự kiện'; + + @override + String get title => 'Tiêu đề'; + + @override + String get create => 'Tạo'; + + @override + String get newTask => 'Công việc mới'; + + @override + String get clearCompleted => 'Xóa các công việc đã hoàn thành'; + + @override + String get refreshList => 'Làm mới danh sách'; + + @override + String get refreshAll => 'Làm mới tất cả'; + + @override + String get listRefreshed => 'Đã làm mới danh sách.'; + + @override + String get allTasksRefreshed => 'Đã làm mới tất cả tài khoản.'; + + @override + String exportedFile(String path) { + return 'Đã xuất sang $path'; + } + + @override + String exportFailed(String error) { + return 'Xuất không thành công: $error'; + } + + @override + String refreshFailed(String error) { + return 'Làm mới không thành công: $error'; + } + + @override + String get selectOrCreateTaskList => + 'Chọn hoặc tạo một danh sách công việc để bắt đầu.'; + + @override + String get signInToViewTasks => 'Đăng nhập để xem công việc.'; + + @override + String get noTasks => 'Không có công việc.'; + + @override + String get noTasksYet => 'Chưa có công việc'; + + @override + String get noTasksYetMessage => + 'Tạo một công việc hoặc làm mới tài khoản để bắt đầu.'; + + @override + String get noTasksInList => 'Không có công việc nào trong danh sách này.'; + + @override + String get overdue => 'Quá hạn'; + + @override + String get today => 'Hôm nay'; + + @override + String get tomorrow => 'Ngày mai'; + + @override + String get upcoming => 'Sắp tới'; + + @override + String get noDate => 'Không có ngày'; + + @override + String get completed => 'Đã hoàn thành'; + + @override + String duePrefix(String date) { + return 'Đến hạn $date'; + } + + @override + String dateTimeDisplay(String date, String time) { + return '$date · $time'; + } + + @override + String get taskDetails => 'Chi tiết công việc'; + + @override + String get editTask => 'Chỉnh sửa công việc'; + + @override + String get noTaskSelected => 'Chưa chọn công việc.'; + + @override + String get noTaskSelectedHelper => + 'Chọn một công việc để xem và chỉnh sửa chi tiết.'; + + @override + String get taskUnavailable => 'Công việc không khả dụng.'; + + @override + String get signInToEditTasks => 'Đăng nhập để chỉnh sửa công việc.'; + + @override + String get refreshTask => 'Làm mới công việc'; + + @override + String get primarySection => 'Chính'; + + @override + String get statusSection => 'Trạng thái'; + + @override + String get openStatus => 'Chưa hoàn thành'; + + @override + String get doneStatus => 'Đã hoàn thành'; + + @override + String get notes => 'Ghi chú'; + + @override + String get dueDate => 'Ngày đến hạn'; + + @override + String get clearDueDate => 'Xóa ngày đến hạn'; + + @override + String get dueTime => 'Giờ đến hạn'; + + @override + String get startDate => 'Ngày bắt đầu'; + + @override + String get startTime => 'Giờ bắt đầu'; + + @override + String get endDate => 'Ngày kết thúc'; + + @override + String get endTime => 'Giờ kết thúc'; + + @override + String get reminderDate => 'Ngày nhắc'; + + @override + String get reminderTime => 'Giờ nhắc'; + + @override + String get reminder => 'Lời nhắc'; + + @override + String get addReminder => 'Thêm lời nhắc'; + + @override + String get addGuest => 'Thêm khách mời'; + + @override + String get addGuestEmail => 'Thêm email khách mời'; + + @override + String get removeReminder => 'Xóa lời nhắc'; + + @override + String get off => 'Tắt'; + + @override + String get repeat => 'Lặp lại'; + + @override + String get repeatNone => 'Không lặp lại'; + + @override + String get noneValue => 'Không có'; + + @override + String get repeatDaily => 'Hằng ngày'; + + @override + String get repeatWeekly => 'Hằng tuần'; + + @override + String get repeatMonthly => 'Hằng tháng'; + + @override + String get repeatYearly => 'Hằng năm'; + + @override + String get importance => 'Mức độ quan trọng'; + + @override + String get importanceLow => 'Thấp'; + + @override + String get importanceNormal => 'Bình thường'; + + @override + String get importanceHigh => 'Cao'; + + @override + String get categories => 'Danh mục'; + + @override + String get scheduleSection => 'Lịch'; + + @override + String get dueGroup => 'Đến hạn'; + + @override + String get startGroup => 'Bắt đầu'; + + @override + String get reminderGroup => 'Lời nhắc'; + + @override + String get organizationSection => 'Sắp xếp'; + + @override + String get actionsSection => 'Thao tác'; + + @override + String get advancedSection => 'Nâng cao'; + + @override + String get addCategory => 'Thêm danh mục'; + + @override + String get list => 'Danh sách'; + + @override + String get microsoftMoveUnsupported => + 'Phiên bản này không hỗ trợ di chuyển công việc giữa các danh sách trong tài khoản Microsoft To Do.'; + + @override + String get createSubtask => 'Tạo công việc con'; + + @override + String get moveToTop => 'Chuyển lên đầu'; + + @override + String get deleteTask => 'Xóa công việc'; + + @override + String get newSubtask => 'Công việc con mới'; + + @override + String deleteTaskConfirmation(String title) { + return 'Xóa “$title” khỏi Google Tasks?'; + } + + @override + String get metadata => 'Siêu dữ liệu'; + + @override + String get id => 'ID'; + + @override + String get etag => 'ETag'; + + @override + String get updated => 'Đã cập nhật'; + + @override + String get parent => 'Công việc cha'; + + @override + String get position => 'Vị trí'; + + @override + String get webLink => 'Liên kết web'; + + @override + String get assignment => 'Phân công'; + + @override + String get localState => 'Trạng thái cục bộ'; + + @override + String get pendingSync => 'Đang chờ đồng bộ'; + + @override + String get synced => 'Đã đồng bộ'; + + @override + String get account => 'Tài khoản'; + + @override + String get sync => 'Đồng bộ'; + + @override + String get manualFullSync => 'Đồng bộ toàn bộ thủ công'; + + @override + String get runInBackgroundWhenClosed => 'Tiếp tục chạy khi đóng cửa sổ'; + + @override + String get showTrayIcon => 'Hiện biểu tượng khay hệ thống'; + + @override + String get startMinimizedToTray => 'Khởi động thu nhỏ vào khay hệ thống'; + + @override + String get requiresTrayIcon => 'Yêu cầu biểu tượng khay hệ thống.'; + + @override + String get syncComplete => 'Đồng bộ hoàn tất.'; + + @override + String syncFailed(String error) { + return 'Đồng bộ không thành công: $error'; + } + + @override + String get notifySyncFailures => 'Thông báo khi đồng bộ thất bại'; + + @override + String get notifyConflicts => 'Thông báo khi có xung đột'; + + @override + String get notifyDueToday => 'Thông báo công việc đến hạn hôm nay'; + + @override + String get eventReminders => 'Lời nhắc sự kiện'; + + @override + String get taskReminders => 'Lời nhắc công việc'; + + @override + String get notificationDetailLevel => 'Mức độ chi tiết của thông báo'; + + @override + String get notificationDetailPrivate => 'Riêng tư'; + + @override + String get notificationDetailNormal => 'Bình thường'; + + @override + String get quietHours => 'Giờ yên tĩnh'; + + @override + String get quietHoursDescription => + 'Tạm dừng thông báo trong khoảng thời gian này.'; + + @override + String get quietHoursStart => 'Bắt đầu giờ yên tĩnh'; + + @override + String get quietHoursEnd => 'Kết thúc giờ yên tĩnh'; + + @override + String get notifications => 'Thông báo'; + + @override + String get appearance => 'Giao diện'; + + @override + String get theme => 'Chủ đề'; + + @override + String get themeSystem => 'Hệ thống'; + + @override + String get themeLight => 'Sáng'; + + @override + String get themeDark => 'Tối'; + + @override + String get themeFamily => 'Họ chủ đề'; + + @override + String get themeFamilyYaru => 'Chủ đề Ubuntu nguyên bản (Yaru)'; + + @override + String get localization => 'Ngôn ngữ và khu vực'; + + @override + String get currentLocale => 'Ngôn ngữ và khu vực hiện tại'; + + @override + String get privacy => 'Quyền riêng tư'; + + @override + String get redactTaskContentInDiagnostics => + 'Ẩn nội dung công việc trong thông tin chẩn đoán'; + + @override + String get developerDiagnostics => 'Chẩn đoán dành cho nhà phát triển'; + + @override + String get diagnostics => 'Chẩn đoán'; + + @override + String get apiInspectorDisabled => 'Hiện trình kiểm tra API'; + + @override + String get googleTasksApi => 'API Google Tasks'; + + @override + String discoveryRevision(String revision) { + return 'Bản sửa đổi Discovery: $revision'; + } + + @override + String get implementedMethods => 'Phương thức đã triển khai'; + + @override + String get supportsTasksScopes => 'Hỗ trợ phạm vi tasks và tasks.readonly'; + + @override + String get requiresTasksScope => 'Yêu cầu phạm vi tasks'; + + @override + String get blockedPendingOperations => 'Thao tác đang chờ bị chặn'; + + @override + String get signInToInspectPendingOperations => + 'Đăng nhập để kiểm tra các thao tác đang chờ.'; + + @override + String get noBlockedPendingOperations => + 'Không có thao tác đang chờ nào bị chặn.'; + + @override + String get operationActions => 'Hành động cho thao tác'; + + @override + String pendingOpListId(String id) { + return 'danh_sách=$id'; + } + + @override + String pendingOpTaskId(String id) { + return 'công_việc=$id'; + } + + @override + String pendingOpAttempts(int count) { + return 'số_lần_thử=$count'; + } + + @override + String get retry => 'Thử lại'; + + @override + String get discard => 'Hủy bỏ'; + + @override + String get discardChanges => 'Hủy bỏ thay đổi?'; + + @override + String get discardChangesConfirmation => + 'Thao tác này sẽ hủy bỏ các chỉnh sửa chưa lưu đối với công việc.'; + + @override + String get retryCompleted => 'Đã thử lại.'; + + @override + String get discardPendingOperation => 'Hủy bỏ thao tác đang chờ?'; + + @override + String get discardPendingOperationConfirmation => + 'Thao tác này sẽ xóa thao tác cục bộ bị chặn. Lần đồng bộ tiếp theo sẽ tải lại dữ liệu từ Google Tasks.'; + + @override + String get pendingOperationDiscarded => 'Đã hủy bỏ thao tác đang chờ.'; + + @override + String get syncFailureNotificationTitle => 'Đồng bộ BusyMax không thành công'; + + @override + String syncFailureNotificationBody(String message) { + return 'Đồng bộ nền không thành công. $message'; + } + + @override + String get conflictNotificationTitle => 'Xung đột đồng bộ BusyMax'; + + @override + String conflictNotificationBody(String summary) { + return 'Một thay đổi cục bộ đang chờ đã bị chặn. $summary'; + } + + @override + String get dueTodayNotificationTitle => 'Công việc đến hạn hôm nay'; + + @override + String dueTodayNotificationBody(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'Có $count công việc đến hạn hôm nay.', + one: 'Có một công việc đến hạn hôm nay.', + ); + return '$_temp0'; + } + + @override + String get eventReminderNotificationTitle => 'Lời nhắc sự kiện'; + + @override + String get taskReminderNotificationTitle => 'Lời nhắc công việc'; + + @override + String get eventReminderNotificationBody => 'Sự kiện sắp bắt đầu.'; + + @override + String get taskReminderNotificationBody => 'Công việc sắp đến hạn.'; + + @override + String get notificationOpenAction => 'Mở'; + + @override + String get notificationDetailsHidden => + 'Chi tiết bị ẩn theo cài đặt quyền riêng tư.'; + + @override + String get previousMonth => 'Tháng trước'; + + @override + String get nextMonth => 'Tháng sau'; + + @override + String get openMonthView => 'Mở chế độ xem tháng'; + + @override + String get previousYear => 'Năm trước'; + + @override + String get nextYear => 'Năm sau'; + + @override + String get openYearView => 'Mở chế độ xem năm'; + + @override + String weekNumberTooltip(int number) { + return 'Tuần $number'; + } + + @override + String get resizeAllDayPanel => 'Đổi kích thước bảng cả ngày'; + + @override + String scheduleItemCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count mục', + one: '1 mục', + ); + return '$_temp0'; + } + + @override + String get readOnlyCalendar => 'Lịch này chỉ có thể đọc.'; + + @override + String get selectTimeZone => 'Chọn múi giờ'; + + @override + String get searchLocations => 'Tìm kiếm địa điểm'; + + @override + String get noLocationsFound => 'Không tìm thấy địa điểm'; + + @override + String deleteCalendarConfirmation(String title) { + return 'Xóa “$title”?'; + } +} diff --git a/lib/l10n/generated/app_localizations_zh.dart b/lib/l10n/generated/app_localizations_zh.dart index 48273a2..832f699 100644 --- a/lib/l10n/generated/app_localizations_zh.dart +++ b/lib/l10n/generated/app_localizations_zh.dart @@ -1,3 +1,6 @@ +// coverage:ignore-file +// GENERATED CODE - DO NOT MODIFY BY HAND + // ignore: unused_import import 'package:intl/intl.dart' as intl; import 'app_localizations.dart'; @@ -186,7 +189,7 @@ class AppLocalizationsZh extends AppLocalizations { String get compactAgendaTitle => '日程'; @override - String get compactAgendaSubtitle => '即将开始'; + String get compactAgendaSubtitle => '接下来'; @override String get compactAgendaOverdue => '已逾期'; @@ -443,7 +446,7 @@ class AppLocalizationsZh extends AppLocalizations { String get shortcutGroupGeneral => '常规'; @override - String get shortcutKeyboardShortcutsDescription => '显示此快捷键参考'; + String get shortcutKeyboardShortcutsDescription => '显示快捷键参考表'; @override String get shortcutGroupNavigation => '导航'; @@ -654,7 +657,7 @@ class AppLocalizationsZh extends AppLocalizations { @override String get accountRemovedGoogleRevokeFailed => - '已从此设备移除该帐户,但 BusyMax 无法撤销 Google 访问权限。您可以在 Google 帐户中撤销。'; + '该帐户已从此设备移除,但无法撤销 BusyMax 对您的 Google 帐户的访问权限。您可以在 Google 帐户中手动撤销该权限。'; @override String get newList => '新建列表'; @@ -762,7 +765,7 @@ class AppLocalizationsZh extends AppLocalizations { String get tomorrow => '明天'; @override - String get upcoming => '即将开始'; + String get upcoming => '即将到期'; @override String get noDate => '无日期'; @@ -1441,7 +1444,7 @@ class AppLocalizationsZhHans extends AppLocalizationsZh { String get compactAgendaTitle => '日程'; @override - String get compactAgendaSubtitle => '即将开始'; + String get compactAgendaSubtitle => '接下来'; @override String get compactAgendaOverdue => '已逾期'; @@ -1698,7 +1701,7 @@ class AppLocalizationsZhHans extends AppLocalizationsZh { String get shortcutGroupGeneral => '常规'; @override - String get shortcutKeyboardShortcutsDescription => '显示此快捷键参考'; + String get shortcutKeyboardShortcutsDescription => '显示快捷键参考表'; @override String get shortcutGroupNavigation => '导航'; @@ -1909,7 +1912,7 @@ class AppLocalizationsZhHans extends AppLocalizationsZh { @override String get accountRemovedGoogleRevokeFailed => - '已从此设备移除该帐户,但 BusyMax 无法撤销 Google 访问权限。您可以在 Google 帐户中撤销。'; + '该帐户已从此设备移除,但无法撤销 BusyMax 对您的 Google 帐户的访问权限。您可以在 Google 帐户中手动撤销该权限。'; @override String get newList => '新建列表'; @@ -2017,7 +2020,7 @@ class AppLocalizationsZhHans extends AppLocalizationsZh { String get tomorrow => '明天'; @override - String get upcoming => '即将开始'; + String get upcoming => '即将到期'; @override String get noDate => '无日期'; @@ -2696,7 +2699,7 @@ class AppLocalizationsZhHant extends AppLocalizationsZh { String get compactAgendaTitle => '行程'; @override - String get compactAgendaSubtitle => '即將開始'; + String get compactAgendaSubtitle => '接下來'; @override String get compactAgendaOverdue => '已逾期'; @@ -2953,7 +2956,7 @@ class AppLocalizationsZhHant extends AppLocalizationsZh { String get shortcutGroupGeneral => '一般'; @override - String get shortcutKeyboardShortcutsDescription => '顯示此快速鍵參考'; + String get shortcutKeyboardShortcutsDescription => '顯示快速鍵參考表'; @override String get shortcutGroupNavigation => '導覽'; @@ -3046,7 +3049,7 @@ class AppLocalizationsZhHant extends AppLocalizationsZh { String get feedbackCategoryProblem => '問題或錯誤'; @override - String get feedbackCategoryFeature => '功能要求'; + String get feedbackCategoryFeature => '功能請求'; @override String get feedbackCategoryPrivacySecurity => '隱私權或安全性疑慮'; @@ -3164,7 +3167,7 @@ class AppLocalizationsZhHant extends AppLocalizationsZh { @override String get accountRemovedGoogleRevokeFailed => - '已從此裝置移除該帳戶,但 BusyMax 無法撤銷 Google 存取權。您可以在 Google 帳戶中撤銷。'; + '該帳戶已從此裝置移除,但無法撤銷 BusyMax 對您的 Google 帳戶的存取權。您可以在 Google 帳戶中手動撤銷該權限。'; @override String get newList => '新增清單'; @@ -3272,7 +3275,7 @@ class AppLocalizationsZhHant extends AppLocalizationsZh { String get tomorrow => '明天'; @override - String get upcoming => '即將開始'; + String get upcoming => '即將到期'; @override String get noDate => '無日期'; @@ -3625,7 +3628,7 @@ class AppLocalizationsZhHant extends AppLocalizationsZh { String get noBlockedPendingOperations => '沒有遭封鎖的待處理作業。'; @override - String get operationActions => '作業動作'; + String get operationActions => '操作選項'; @override String pendingOpListId(String id) { From 35a072805b75c83b52ff0b682f437c0e81a48e87 Mon Sep 17 00:00:00 2001 From: albert Date: Wed, 29 Jul 2026 17:41:16 -0700 Subject: [PATCH 42/73] Update localizations --- l10n.yaml | 4 ++++ lib/l10n/app_ru.arb | 10 +++++----- lib/l10n/generated/app_localizations.dart | 1 + lib/l10n/generated/app_localizations_ar.dart | 1 + lib/l10n/generated/app_localizations_de.dart | 1 + lib/l10n/generated/app_localizations_en.dart | 1 + lib/l10n/generated/app_localizations_es.dart | 1 + lib/l10n/generated/app_localizations_et.dart | 1 + lib/l10n/generated/app_localizations_fa.dart | 1 + lib/l10n/generated/app_localizations_fi.dart | 1 + lib/l10n/generated/app_localizations_fr.dart | 1 + lib/l10n/generated/app_localizations_hi.dart | 1 + lib/l10n/generated/app_localizations_it.dart | 1 + lib/l10n/generated/app_localizations_ja.dart | 1 + lib/l10n/generated/app_localizations_ko.dart | 1 + lib/l10n/generated/app_localizations_pt.dart | 1 + lib/l10n/generated/app_localizations_ru.dart | 11 ++++++----- lib/l10n/generated/app_localizations_vi.dart | 1 + lib/l10n/generated/app_localizations_zh.dart | 1 + 19 files changed, 31 insertions(+), 10 deletions(-) diff --git a/l10n.yaml b/l10n.yaml index ee89381..80ed2b7 100644 --- a/l10n.yaml +++ b/l10n.yaml @@ -3,3 +3,7 @@ template-arb-file: app_en.arb output-dir: lib/l10n/generated output-localization-file: app_localizations.dart nullable-getter: false +header: | + // coverage:ignore-file + // GENERATED CODE - DO NOT MODIFY BY HAND + // ignore_for_file: text_direction_code_point_in_literal, text_direction_code_point_in_comment diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index 2909bd7..e2d7987 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -353,13 +353,13 @@ "pendingOpTaskId": "задача={id}", "pendingOpAttempts": "попытки={count}", "retry": "Повторить", - "discard": "Отбросить", - "discardChanges": "Отбросить изменения?", - "discardChangesConfirmation": "Несохранённые изменения этой задачи будут отброшены.", + "discard": "Отменить", + "discardChanges": "Отменить изменения?", + "discardChangesConfirmation": "Несохранённые изменения этой задачи будут отменены.", "retryCompleted": "Повторная попытка завершена.", - "discardPendingOperation": "Отбросить ожидающую операцию?", + "discardPendingOperation": "Отменить ожидающую операцию?", "discardPendingOperationConfirmation": "Заблокированная локальная операция будет удалена. При следующей синхронизации данные будут заново загружены из Google Tasks.", - "pendingOperationDiscarded": "Ожидающая операция отброшена.", + "pendingOperationDiscarded": "Ожидающая операция отменена.", "syncFailureNotificationTitle": "Сбой синхронизации BusyMax", "syncFailureNotificationBody": "Сбой фоновой синхронизации. {message}", "conflictNotificationTitle": "Конфликт синхронизации BusyMax", diff --git a/lib/l10n/generated/app_localizations.dart b/lib/l10n/generated/app_localizations.dart index 444309c..8f1bc6e 100644 --- a/lib/l10n/generated/app_localizations.dart +++ b/lib/l10n/generated/app_localizations.dart @@ -1,5 +1,6 @@ // coverage:ignore-file // GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: text_direction_code_point_in_literal, text_direction_code_point_in_comment import 'dart:async'; diff --git a/lib/l10n/generated/app_localizations_ar.dart b/lib/l10n/generated/app_localizations_ar.dart index 49e681a..52a42e9 100644 --- a/lib/l10n/generated/app_localizations_ar.dart +++ b/lib/l10n/generated/app_localizations_ar.dart @@ -1,5 +1,6 @@ // coverage:ignore-file // GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: text_direction_code_point_in_literal, text_direction_code_point_in_comment // ignore: unused_import import 'package:intl/intl.dart' as intl; diff --git a/lib/l10n/generated/app_localizations_de.dart b/lib/l10n/generated/app_localizations_de.dart index 1ec219d..a564e76 100644 --- a/lib/l10n/generated/app_localizations_de.dart +++ b/lib/l10n/generated/app_localizations_de.dart @@ -1,5 +1,6 @@ // coverage:ignore-file // GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: text_direction_code_point_in_literal, text_direction_code_point_in_comment // ignore: unused_import import 'package:intl/intl.dart' as intl; diff --git a/lib/l10n/generated/app_localizations_en.dart b/lib/l10n/generated/app_localizations_en.dart index e5317eb..a9bb031 100644 --- a/lib/l10n/generated/app_localizations_en.dart +++ b/lib/l10n/generated/app_localizations_en.dart @@ -1,5 +1,6 @@ // coverage:ignore-file // GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: text_direction_code_point_in_literal, text_direction_code_point_in_comment // ignore: unused_import import 'package:intl/intl.dart' as intl; diff --git a/lib/l10n/generated/app_localizations_es.dart b/lib/l10n/generated/app_localizations_es.dart index f8d6cea..cf49b4e 100644 --- a/lib/l10n/generated/app_localizations_es.dart +++ b/lib/l10n/generated/app_localizations_es.dart @@ -1,5 +1,6 @@ // coverage:ignore-file // GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: text_direction_code_point_in_literal, text_direction_code_point_in_comment // ignore: unused_import import 'package:intl/intl.dart' as intl; diff --git a/lib/l10n/generated/app_localizations_et.dart b/lib/l10n/generated/app_localizations_et.dart index 24ca372..3db4067 100644 --- a/lib/l10n/generated/app_localizations_et.dart +++ b/lib/l10n/generated/app_localizations_et.dart @@ -1,5 +1,6 @@ // coverage:ignore-file // GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: text_direction_code_point_in_literal, text_direction_code_point_in_comment // ignore: unused_import import 'package:intl/intl.dart' as intl; diff --git a/lib/l10n/generated/app_localizations_fa.dart b/lib/l10n/generated/app_localizations_fa.dart index 8d8ca33..f97e143 100644 --- a/lib/l10n/generated/app_localizations_fa.dart +++ b/lib/l10n/generated/app_localizations_fa.dart @@ -1,5 +1,6 @@ // coverage:ignore-file // GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: text_direction_code_point_in_literal, text_direction_code_point_in_comment // ignore: unused_import import 'package:intl/intl.dart' as intl; diff --git a/lib/l10n/generated/app_localizations_fi.dart b/lib/l10n/generated/app_localizations_fi.dart index a261c1a..643d315 100644 --- a/lib/l10n/generated/app_localizations_fi.dart +++ b/lib/l10n/generated/app_localizations_fi.dart @@ -1,5 +1,6 @@ // coverage:ignore-file // GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: text_direction_code_point_in_literal, text_direction_code_point_in_comment // ignore: unused_import import 'package:intl/intl.dart' as intl; diff --git a/lib/l10n/generated/app_localizations_fr.dart b/lib/l10n/generated/app_localizations_fr.dart index 46dccb6..e5cb1bc 100644 --- a/lib/l10n/generated/app_localizations_fr.dart +++ b/lib/l10n/generated/app_localizations_fr.dart @@ -1,5 +1,6 @@ // coverage:ignore-file // GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: text_direction_code_point_in_literal, text_direction_code_point_in_comment // ignore: unused_import import 'package:intl/intl.dart' as intl; diff --git a/lib/l10n/generated/app_localizations_hi.dart b/lib/l10n/generated/app_localizations_hi.dart index ae5ab4e..707fed6 100644 --- a/lib/l10n/generated/app_localizations_hi.dart +++ b/lib/l10n/generated/app_localizations_hi.dart @@ -1,5 +1,6 @@ // coverage:ignore-file // GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: text_direction_code_point_in_literal, text_direction_code_point_in_comment // ignore: unused_import import 'package:intl/intl.dart' as intl; diff --git a/lib/l10n/generated/app_localizations_it.dart b/lib/l10n/generated/app_localizations_it.dart index 720c08f..ee97eb6 100644 --- a/lib/l10n/generated/app_localizations_it.dart +++ b/lib/l10n/generated/app_localizations_it.dart @@ -1,5 +1,6 @@ // coverage:ignore-file // GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: text_direction_code_point_in_literal, text_direction_code_point_in_comment // ignore: unused_import import 'package:intl/intl.dart' as intl; diff --git a/lib/l10n/generated/app_localizations_ja.dart b/lib/l10n/generated/app_localizations_ja.dart index 53c909c..dc9ca23 100644 --- a/lib/l10n/generated/app_localizations_ja.dart +++ b/lib/l10n/generated/app_localizations_ja.dart @@ -1,5 +1,6 @@ // coverage:ignore-file // GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: text_direction_code_point_in_literal, text_direction_code_point_in_comment // ignore: unused_import import 'package:intl/intl.dart' as intl; diff --git a/lib/l10n/generated/app_localizations_ko.dart b/lib/l10n/generated/app_localizations_ko.dart index 444320c..772eda1 100644 --- a/lib/l10n/generated/app_localizations_ko.dart +++ b/lib/l10n/generated/app_localizations_ko.dart @@ -1,5 +1,6 @@ // coverage:ignore-file // GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: text_direction_code_point_in_literal, text_direction_code_point_in_comment // ignore: unused_import import 'package:intl/intl.dart' as intl; diff --git a/lib/l10n/generated/app_localizations_pt.dart b/lib/l10n/generated/app_localizations_pt.dart index e0e2da7..acf9841 100644 --- a/lib/l10n/generated/app_localizations_pt.dart +++ b/lib/l10n/generated/app_localizations_pt.dart @@ -1,5 +1,6 @@ // coverage:ignore-file // GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: text_direction_code_point_in_literal, text_direction_code_point_in_comment // ignore: unused_import import 'package:intl/intl.dart' as intl; diff --git a/lib/l10n/generated/app_localizations_ru.dart b/lib/l10n/generated/app_localizations_ru.dart index 18328a1..394e521 100644 --- a/lib/l10n/generated/app_localizations_ru.dart +++ b/lib/l10n/generated/app_localizations_ru.dart @@ -1,5 +1,6 @@ // coverage:ignore-file // GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: text_direction_code_point_in_literal, text_direction_code_point_in_comment // ignore: unused_import import 'package:intl/intl.dart' as intl; @@ -1181,27 +1182,27 @@ class AppLocalizationsRu extends AppLocalizations { String get retry => 'Повторить'; @override - String get discard => 'Отбросить'; + String get discard => 'Отменить'; @override - String get discardChanges => 'Отбросить изменения?'; + String get discardChanges => 'Отменить изменения?'; @override String get discardChangesConfirmation => - 'Несохранённые изменения этой задачи будут отброшены.'; + 'Несохранённые изменения этой задачи будут отменены.'; @override String get retryCompleted => 'Повторная попытка завершена.'; @override - String get discardPendingOperation => 'Отбросить ожидающую операцию?'; + String get discardPendingOperation => 'Отменить ожидающую операцию?'; @override String get discardPendingOperationConfirmation => 'Заблокированная локальная операция будет удалена. При следующей синхронизации данные будут заново загружены из Google Tasks.'; @override - String get pendingOperationDiscarded => 'Ожидающая операция отброшена.'; + String get pendingOperationDiscarded => 'Ожидающая операция отменена.'; @override String get syncFailureNotificationTitle => 'Сбой синхронизации BusyMax'; diff --git a/lib/l10n/generated/app_localizations_vi.dart b/lib/l10n/generated/app_localizations_vi.dart index b47d3e4..489ff5d 100644 --- a/lib/l10n/generated/app_localizations_vi.dart +++ b/lib/l10n/generated/app_localizations_vi.dart @@ -1,5 +1,6 @@ // coverage:ignore-file // GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: text_direction_code_point_in_literal, text_direction_code_point_in_comment // ignore: unused_import import 'package:intl/intl.dart' as intl; diff --git a/lib/l10n/generated/app_localizations_zh.dart b/lib/l10n/generated/app_localizations_zh.dart index 832f699..483657a 100644 --- a/lib/l10n/generated/app_localizations_zh.dart +++ b/lib/l10n/generated/app_localizations_zh.dart @@ -1,5 +1,6 @@ // coverage:ignore-file // GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: text_direction_code_point_in_literal, text_direction_code_point_in_comment // ignore: unused_import import 'package:intl/intl.dart' as intl; From 6c15d790c603aa3f5fdcab1428e1d42d57d924c0 Mon Sep 17 00:00:00 2001 From: albert Date: Wed, 29 Jul 2026 18:35:39 -0700 Subject: [PATCH 43/73] Update localizations --- lib/l10n/app_ar.arb | 2 +- lib/l10n/app_de.arb | 2 +- lib/l10n/app_en.arb | 2 +- lib/l10n/app_es.arb | 2 +- lib/l10n/app_et.arb | 2 +- lib/l10n/app_fa.arb | 2 +- lib/l10n/app_fi.arb | 2 +- lib/l10n/app_fr.arb | 2 +- lib/l10n/app_hi.arb | 2 +- lib/l10n/app_it.arb | 2 +- lib/l10n/app_ja.arb | 2 +- lib/l10n/app_ko.arb | 2 +- lib/l10n/app_pt.arb | 2 +- lib/l10n/app_ru.arb | 88 +++++++++--------- lib/l10n/app_vi.arb | 2 +- lib/l10n/app_zh.arb | 2 +- lib/l10n/app_zh_Hans.arb | 2 +- lib/l10n/app_zh_Hant.arb | 2 +- lib/l10n/generated/app_localizations.dart | 2 +- lib/l10n/generated/app_localizations_ar.dart | 2 +- lib/l10n/generated/app_localizations_de.dart | 2 +- lib/l10n/generated/app_localizations_en.dart | 2 +- lib/l10n/generated/app_localizations_es.dart | 2 +- lib/l10n/generated/app_localizations_et.dart | 2 +- lib/l10n/generated/app_localizations_fa.dart | 2 +- lib/l10n/generated/app_localizations_fi.dart | 2 +- lib/l10n/generated/app_localizations_fr.dart | 2 +- lib/l10n/generated/app_localizations_hi.dart | 2 +- lib/l10n/generated/app_localizations_it.dart | 2 +- lib/l10n/generated/app_localizations_ja.dart | 2 +- lib/l10n/generated/app_localizations_ko.dart | 2 +- lib/l10n/generated/app_localizations_pt.dart | 2 +- lib/l10n/generated/app_localizations_ru.dart | 97 ++++++++++---------- lib/l10n/generated/app_localizations_vi.dart | 2 +- lib/l10n/generated/app_localizations_zh.dart | 6 +- linux/io.busystack.busymax.desktop | 34 +++++++ linux/io.busystack.busymax.metainfo.xml | 51 ++++++++++ 37 files changed, 212 insertions(+), 128 deletions(-) diff --git a/lib/l10n/app_ar.arb b/lib/l10n/app_ar.arb index a6f138b..4b51306 100644 --- a/lib/l10n/app_ar.arb +++ b/lib/l10n/app_ar.arb @@ -156,7 +156,7 @@ "shortcutRefreshCompactAgendaDescription": "تحديث نافذة جدول الأعمال المصغّر", "shortcutHideCompactAgendaDescription": "إخفاء نافذة جدول الأعمال المصغّر", "aboutBusyMax": "حول BusyMax", - "aboutBusyMaxDescription": "المهام والتقويم", + "aboutBusyMaxDescription": "التقويم والمهام", "website": "الموقع الإلكتروني", "reportAnIssue": "الإبلاغ عن مشكلة", "sendFeedback": "إرسال الملاحظات", diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index 02fb01f..3206549 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -159,7 +159,7 @@ "shortcutRefreshCompactAgendaDescription": "Das kompakte Agenda-Fenster aktualisieren", "shortcutHideCompactAgendaDescription": "Das kompakte Agenda-Fenster ausblenden", "aboutBusyMax": "Über BusyMax", - "aboutBusyMaxDescription": "Aufgaben und Kalender", + "aboutBusyMaxDescription": "Kalender und Aufgaben", "website": "Website", "reportAnIssue": "Problem melden", "sendFeedback": "Feedback senden", diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index b0e7a9c..db660c1 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -162,7 +162,7 @@ "shortcutRefreshCompactAgendaDescription": "Refresh the compact agenda window", "shortcutHideCompactAgendaDescription": "Hide the compact agenda window", "aboutBusyMax": "About BusyMax", - "aboutBusyMaxDescription": "ToDo and Calendar", + "aboutBusyMaxDescription": "Calendar and tasks", "website": "Website", "reportAnIssue": "Report an issue", "sendFeedback": "Send feedback", diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index b344a34..79585e8 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -159,7 +159,7 @@ "shortcutRefreshCompactAgendaDescription": "Actualizar la ventana de agenda compacta", "shortcutHideCompactAgendaDescription": "Ocultar la ventana de agenda compacta", "aboutBusyMax": "Acerca de BusyMax", - "aboutBusyMaxDescription": "Tareas y calendario", + "aboutBusyMaxDescription": "Calendario y tareas", "website": "Sitio web", "reportAnIssue": "Informar de un problema", "sendFeedback": "Enviar comentarios", diff --git a/lib/l10n/app_et.arb b/lib/l10n/app_et.arb index f94de36..a338cba 100644 --- a/lib/l10n/app_et.arb +++ b/lib/l10n/app_et.arb @@ -162,7 +162,7 @@ "shortcutRefreshCompactAgendaDescription": "Värskenda kompaktse päevakava akent", "shortcutHideCompactAgendaDescription": "Peida kompaktse päevakava aken", "aboutBusyMax": "Teave BusyMaxi kohta", - "aboutBusyMaxDescription": "Ülesanded ja kalender", + "aboutBusyMaxDescription": "Kalender ja ülesanded", "website": "Veebisait", "reportAnIssue": "Teata probleemist", "sendFeedback": "Saada tagasisidet", diff --git a/lib/l10n/app_fa.arb b/lib/l10n/app_fa.arb index 7756e42..c4678b0 100644 --- a/lib/l10n/app_fa.arb +++ b/lib/l10n/app_fa.arb @@ -156,7 +156,7 @@ "shortcutRefreshCompactAgendaDescription": "تازه‌سازی پنجرهٔ برنامهٔ فشرده", "shortcutHideCompactAgendaDescription": "پنهان کردن پنجرهٔ برنامهٔ فشرده", "aboutBusyMax": "دربارهٔ BusyMax", - "aboutBusyMaxDescription": "کارها و تقویم", + "aboutBusyMaxDescription": "تقویم و کارها", "website": "وب‌سایت", "reportAnIssue": "گزارش مشکل", "sendFeedback": "ارسال بازخورد", diff --git a/lib/l10n/app_fi.arb b/lib/l10n/app_fi.arb index f9ba2ee..080ccc1 100644 --- a/lib/l10n/app_fi.arb +++ b/lib/l10n/app_fi.arb @@ -156,7 +156,7 @@ "shortcutRefreshCompactAgendaDescription": "Päivitä kompaktin agendan ikkuna", "shortcutHideCompactAgendaDescription": "Piilota kompaktin agendan ikkuna", "aboutBusyMax": "Tietoja BusyMaxista", - "aboutBusyMaxDescription": "Tehtävät ja kalenteri", + "aboutBusyMaxDescription": "Kalenteri ja tehtävät", "website": "Verkkosivusto", "reportAnIssue": "Ilmoita ongelmasta", "sendFeedback": "Lähetä palautetta", diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index 6779af4..cb01676 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -159,7 +159,7 @@ "shortcutRefreshCompactAgendaDescription": "Actualiser la fenêtre d'agenda compact", "shortcutHideCompactAgendaDescription": "Masquer la fenêtre d'agenda compact", "aboutBusyMax": "À propos de BusyMax", - "aboutBusyMaxDescription": "Tâches et calendrier", + "aboutBusyMaxDescription": "Calendrier et tâches", "website": "Site web", "reportAnIssue": "Signaler un problème", "sendFeedback": "Envoyer des commentaires", diff --git a/lib/l10n/app_hi.arb b/lib/l10n/app_hi.arb index 9acf645..fd62653 100644 --- a/lib/l10n/app_hi.arb +++ b/lib/l10n/app_hi.arb @@ -156,7 +156,7 @@ "shortcutRefreshCompactAgendaDescription": "संक्षिप्त कार्यसूची विंडो रीफ़्रेश करें", "shortcutHideCompactAgendaDescription": "संक्षिप्त कार्यसूची विंडो छिपाएँ", "aboutBusyMax": "BusyMax के बारे में", - "aboutBusyMaxDescription": "कार्य और कैलेंडर", + "aboutBusyMaxDescription": "कैलेंडर और कार्य", "website": "वेबसाइट", "reportAnIssue": "समस्या की रिपोर्ट करें", "sendFeedback": "प्रतिक्रिया भेजें", diff --git a/lib/l10n/app_it.arb b/lib/l10n/app_it.arb index 923308f..983e58c 100644 --- a/lib/l10n/app_it.arb +++ b/lib/l10n/app_it.arb @@ -156,7 +156,7 @@ "shortcutRefreshCompactAgendaDescription": "Aggiorna la finestra dell’agenda compatta", "shortcutHideCompactAgendaDescription": "Nascondi la finestra dell’agenda compatta", "aboutBusyMax": "Informazioni su BusyMax", - "aboutBusyMaxDescription": "Attività e calendario", + "aboutBusyMaxDescription": "Calendario e attività", "website": "Sito web", "reportAnIssue": "Segnala un problema", "sendFeedback": "Invia feedback", diff --git a/lib/l10n/app_ja.arb b/lib/l10n/app_ja.arb index 2749026..2510636 100644 --- a/lib/l10n/app_ja.arb +++ b/lib/l10n/app_ja.arb @@ -156,7 +156,7 @@ "shortcutRefreshCompactAgendaDescription": "コンパクト予定一覧ウィンドウを更新", "shortcutHideCompactAgendaDescription": "コンパクト予定一覧ウィンドウを非表示", "aboutBusyMax": "BusyMax について", - "aboutBusyMaxDescription": "タスクとカレンダー", + "aboutBusyMaxDescription": "カレンダーとタスク", "website": "ウェブサイト", "reportAnIssue": "問題を報告", "sendFeedback": "フィードバックを送信", diff --git a/lib/l10n/app_ko.arb b/lib/l10n/app_ko.arb index bf4b502..383758e 100644 --- a/lib/l10n/app_ko.arb +++ b/lib/l10n/app_ko.arb @@ -156,7 +156,7 @@ "shortcutRefreshCompactAgendaDescription": "간단 일정 목록 창 새로 고침", "shortcutHideCompactAgendaDescription": "간단 일정 목록 창 숨기기", "aboutBusyMax": "BusyMax 정보", - "aboutBusyMaxDescription": "할 일 및 캘린더", + "aboutBusyMaxDescription": "캘린더와 할 일", "website": "웹사이트", "reportAnIssue": "문제 신고", "sendFeedback": "의견 보내기", diff --git a/lib/l10n/app_pt.arb b/lib/l10n/app_pt.arb index 65e30c3..072b0b5 100644 --- a/lib/l10n/app_pt.arb +++ b/lib/l10n/app_pt.arb @@ -156,7 +156,7 @@ "shortcutRefreshCompactAgendaDescription": "Atualizar a janela da agenda compacta", "shortcutHideCompactAgendaDescription": "Ocultar a janela da agenda compacta", "aboutBusyMax": "Acerca do BusyMax", - "aboutBusyMaxDescription": "Tarefas e calendário", + "aboutBusyMaxDescription": "Calendário e tarefas", "website": "Site", "reportAnIssue": "Comunicar um problema", "sendFeedback": "Enviar comentários", diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index e2d7987..f00a12a 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -2,8 +2,8 @@ "@@locale": "ru", "appTitle": "BusyMax", "connectGoogleAccount": "Подключите аккаунты Google и Microsoft, чтобы синхронизировать календари и задачи.", - "googlePermissionsConsentNotice": "На экране разрешений Google выберите разрешения и для Календаря, и для Задач.", - "googlePermissionsRequiredRetry": "Необходимы разрешения для Google Календаря и Google Tasks. Повторите попытку и установите оба флажка.", + "googlePermissionsConsentNotice": "На экране запроса доступа Google установите флажки «Google Календарь» и «Google Задачи».", + "googlePermissionsRequiredRetry": "BusyMax требуется доступ к Google Календарю и Google Задачам. Повторите попытку и установите оба флажка.", "finishSetup": "Завершить настройку", "continueSetup": "Продолжить", "onboardingSetupTitle": "Настройка BusyMax", @@ -50,25 +50,25 @@ "scheduleSignInDescription": "Войдите, чтобы синхронизировать календари и задачи.", "scheduleNoSearchResults": "Подходящих событий или задач нет", "scheduleNoSearchResultsDescription": "Попробуйте изменить запрос или сбросить текущие фильтры.", - "trayAgendaLoading": "Загрузка повестки...", - "trayAgendaSignInRequired": "Войдите, чтобы просмотреть повестку.", + "trayAgendaLoading": "Загрузка расписания...", + "trayAgendaSignInRequired": "Войдите, чтобы просмотреть расписание.", "trayAgendaNoSources": "Нет видимых календарей или списков задач.", "trayAgendaOpenBusyMax": "Открыть приложение", "trayAgendaRefresh": "Обновить", - "trayAgendaError": "Повестка недоступна", - "compactAgendaTitle": "Повестка", + "trayAgendaError": "Расписание недоступно", + "compactAgendaTitle": "Расписание", "compactAgendaSubtitle": "Предстоящие", "compactAgendaOverdue": "Просроченные", - "compactAgendaClear": "На ближайшее время всё свободно", + "compactAgendaClear": "На ближайшее время ничего нет", "compactAgendaOpenBusyMax": "Открыть BusyMax", "compactAgendaHide": "Скрыть", "compactAgendaNewTask": "Новая задача", "compactAgendaRetry": "Повторить", "compactAgendaRefresh": "Обновить", "compactAgendaAllDay": "Весь день", - "compactAgendaDueToday": "Срок сегодня", - "compactAgendaDueTomorrow": "Срок завтра", - "compactAgendaDueOn": "Срок: {date}", + "compactAgendaDueToday": "Срок — сегодня", + "compactAgendaDueTomorrow": "Срок — завтра", + "compactAgendaDueOn": "Срок — {date}", "compactAgendaMoreOverdue": "Загрузить ещё просроченные задачи", "agendaLoadMoreOverdue": "Загрузить ещё просроченные задачи", "agendaLoadMoreNoDate": "Загрузить ещё задачи без даты", @@ -76,7 +76,7 @@ "viewWeek": "Неделя", "viewMonth": "Месяц", "viewYear": "Год", - "viewAgenda": "Повестка", + "viewAgenda": "Расписание", "scheduleSettings": "Расписание", "scheduleDisplaySettings": "Отображение расписания", "scheduleDisplayHoursDescription": "В представлениях дня и недели изначально отображается этот период. Более ранние или поздние записи при необходимости расширяют его.", @@ -98,7 +98,7 @@ "guests": "Гости", "noGuests": "Нет гостей", "description": "Описание", - "availabilityShowAs": "Доступность / Показывать как", + "availabilityShowAs": "Показывать как", "busy": "Занят", "visibility": "Видимость", "defaultVisibility": "Видимость по умолчанию", @@ -117,8 +117,8 @@ "reminderDaysBefore": "{days, plural, one{За {days} день} few{За {days} дня} many{За {days} дней} other{За {days} дня}}", "availabilityFree": "Свободен", "availabilityTentative": "Под вопросом", - "availabilityOutOfOffice": "Не на работе", - "availabilityWorkingElsewhere": "Работает в другом месте", + "availabilityOutOfOffice": "Нет на рабочем месте", + "availabilityWorkingElsewhere": "Работа в другом месте", "visibilityDefault": "По умолчанию", "visibilityPublic": "Общедоступное", "visibilityPrivate": "Личное", @@ -139,24 +139,24 @@ "shortcutNextPeriodDescription": "Следующая неделя в представлении недели, следующий месяц в представлении месяца и так далее", "shortcutPreviousPeriod": "Предыдущий период", "shortcutPreviousPeriodDescription": "Предыдущая неделя в представлении недели, предыдущий месяц в представлении месяца и так далее", - "shortcutJumpToToday": "Перейти к сегодняшней дате", + "shortcutJumpToToday": "Перейти к сегодняшнему дню", "shortcutGroupView": "Представление", "shortcutDayView": "Представление дня", "shortcutWeekView": "Представление недели", "shortcutMonthView": "Представление месяца", "shortcutYearView": "Представление года", - "shortcutAgendaView": "Представление повестки", + "shortcutAgendaView": "Расписание", "shortcutGroupCreateAndEdit": "Создание и редактирование", "shortcutSaveItem": "Сохранить событие или задачу", "shortcutDeleteItem": "Удалить событие или задачу", "shortcutGroupTaskEditing": "Редактирование задач", "shortcutCancelEditing": "Отменить редактирование", "shortcutCancelEditingDescription": "Выйти из режима редактирования задачи или закрыть сведения о ней", - "shortcutGroupCompactAgenda": "Компактная повестка", - "shortcutRefreshCompactAgendaDescription": "Обновить окно компактной повестки", - "shortcutHideCompactAgendaDescription": "Скрыть окно компактной повестки", + "shortcutGroupCompactAgenda": "Компактное расписание", + "shortcutRefreshCompactAgendaDescription": "Обновить окно компактного расписания", + "shortcutHideCompactAgendaDescription": "Скрыть окно компактного расписания", "aboutBusyMax": "О приложении BusyMax", - "aboutBusyMaxDescription": "Задачи и календарь", + "aboutBusyMaxDescription": "Календарь и задачи", "website": "Веб-сайт", "reportAnIssue": "Сообщить о проблеме", "sendFeedback": "Отправить отзыв", @@ -172,16 +172,16 @@ "feedbackDetailedMessage": "Подробное сообщение", "feedbackReplyEmail": "Адрес электронной почты для ответа (необязательно)", "feedbackIncludeTechnicalDetails": "Включить технические сведения", - "feedbackTechnicalDetailsDisclosure": "Будут добавлены только версия операционной системы Linux и локаль приложения. Журналы, данные аккаунтов, имена файлов и другие диагностические сведения не включаются.", + "feedbackTechnicalDetailsDisclosure": "Будут добавлены только версия Linux и выбранный язык приложения. Журналы, данные аккаунтов, имена файлов и другие диагностические сведения не добавляются.", "feedbackCategoryRequired": "Выберите категорию.", "feedbackSubjectLengthError": "Тема должна содержать от 3 до 120 символов.", "feedbackMessageLengthError": "Сообщение должно содержать от 10 до 5 000 символов.", "feedbackInvalidEmail": "Введите действительный адрес электронной почты.", "feedbackConnectionError": "Не удалось подключиться к BusyStack. Проверьте подключение и повторите попытку.", - "feedbackTimeoutError": "Время ожидания запроса истекло. Ваш отзыв не был удалён. Повторите попытку.", + "feedbackTimeoutError": "Время ожидания запроса истекло. Текст отзыва сохранён. Повторите попытку.", "feedbackRateLimitedError": "Из этой сети было отправлено слишком много отзывов. Подождите и повторите попытку.", "feedbackRejectedError": "Сервер отклонил отправку. Проверьте поля и повторите попытку.", - "feedbackServerError": "BusyStack сейчас не может принять ваш отзыв. Ваш отзыв не был удалён. Повторите попытку.", + "feedbackServerError": "BusyStack сейчас не может принять ваш отзыв. Текст отзыва сохранён. Повторите попытку.", "feedbackSuccess": "Отзыв отправлен. Номер: {id}", "toggleSidebar": "Показать или скрыть боковую панель", "accounts": "Аккаунты", @@ -217,7 +217,7 @@ "title": "Название", "create": "Создать", "newTask": "Новая задача", - "clearCompleted": "Удалить завершённые", + "clearCompleted": "Удалить выполненные", "refreshList": "Обновить список", "refreshAll": "Обновить всё", "listRefreshed": "Список обновлён.", @@ -236,7 +236,7 @@ "tomorrow": "Завтра", "upcoming": "Предстоящие", "noDate": "Без даты", - "completed": "Завершённые", + "completed": "Выполненные", "duePrefix": "Срок: {date}", "dateTimeDisplay": "{date}, {time}", "taskDetails": "Сведения о задаче", @@ -318,13 +318,13 @@ "notifyDueToday": "Уведомлять о задачах на сегодня", "eventReminders": "Напоминания о событиях", "taskReminders": "Напоминания о задачах", - "notificationDetailLevel": "Уровень детализации уведомлений", - "notificationDetailPrivate": "Конфиденциальный", - "notificationDetailNormal": "Обычный", - "quietHours": "Период тишины", - "quietHoursDescription": "Приостановить уведомления на этот период.", - "quietHoursStart": "Начало периода тишины", - "quietHoursEnd": "Конец периода тишины", + "notificationDetailLevel": "Содержимое уведомлений", + "notificationDetailPrivate": "Скрывать подробности", + "notificationDetailNormal": "Показывать подробности", + "quietHours": "Период без уведомлений", + "quietHoursDescription": "Не показывать уведомления в это время.", + "quietHoursStart": "Начало периода", + "quietHoursEnd": "Конец периода", "notifications": "Уведомления", "appearance": "Внешний вид", "theme": "Тема", @@ -332,9 +332,9 @@ "themeLight": "Светлая", "themeDark": "Тёмная", "themeFamily": "Семейство тем", - "themeFamilyYaru": "Нативная тема Ubuntu (Yaru)", - "localization": "Локализация", - "currentLocale": "Текущая локаль", + "themeFamilyYaru": "Стандартная тема Ubuntu (Yaru)", + "localization": "Язык", + "currentLocale": "Язык приложения", "privacy": "Конфиденциальность", "redactTaskContentInDiagnostics": "Скрывать содержимое задач в диагностике", "developerDiagnostics": "Диагностика для разработчиков", @@ -345,9 +345,9 @@ "implementedMethods": "Реализованные методы", "supportsTasksScopes": "Поддерживает области разрешений tasks и tasks.readonly", "requiresTasksScope": "Требуется область разрешений tasks", - "blockedPendingOperations": "Заблокированные ожидающие операции", + "blockedPendingOperations": "Заблокированные операции", "signInToInspectPendingOperations": "Войдите, чтобы просмотреть ожидающие операции.", - "noBlockedPendingOperations": "Заблокированных ожидающих операций нет.", + "noBlockedPendingOperations": "Заблокированных операций нет.", "operationActions": "Действия с операцией", "pendingOpListId": "список={id}", "pendingOpTaskId": "задача={id}", @@ -357,13 +357,13 @@ "discardChanges": "Отменить изменения?", "discardChangesConfirmation": "Несохранённые изменения этой задачи будут отменены.", "retryCompleted": "Повторная попытка завершена.", - "discardPendingOperation": "Отменить ожидающую операцию?", + "discardPendingOperation": "Удалить заблокированную операцию?", "discardPendingOperationConfirmation": "Заблокированная локальная операция будет удалена. При следующей синхронизации данные будут заново загружены из Google Tasks.", - "pendingOperationDiscarded": "Ожидающая операция отменена.", + "pendingOperationDiscarded": "Заблокированная операция удалена.", "syncFailureNotificationTitle": "Сбой синхронизации BusyMax", "syncFailureNotificationBody": "Сбой фоновой синхронизации. {message}", "conflictNotificationTitle": "Конфликт синхронизации BusyMax", - "conflictNotificationBody": "Ожидающее локальное изменение было заблокировано. {summary}", + "conflictNotificationBody": "Локальное изменение не удалось синхронизировать. {summary}", "dueTodayNotificationTitle": "Задачи на сегодня", "dueTodayNotificationBody": "{count, plural, one{Сегодня нужно выполнить {count} задачу.} few{Сегодня нужно выполнить {count} задачи.} many{Сегодня нужно выполнить {count} задач.} other{Сегодня нужно выполнить {count} задачи.}}", "eventReminderNotificationTitle": "Напоминание о событии", @@ -379,11 +379,11 @@ "nextYear": "Следующий год", "openYearView": "Открыть представление года", "weekNumberTooltip": "Неделя {number}", - "resizeAllDayPanel": "Изменить размер панели событий на весь день", - "scheduleItemCount": "{count, plural, one{{count} элемент} few{{count} элемента} many{{count} элементов} other{{count} элемента}}", + "resizeAllDayPanel": "Изменить размер панели «Весь день»", + "scheduleItemCount": "{count, plural, one{{count} запись} few{{count} записи} many{{count} записей} other{{count} записи}}", "readOnlyCalendar": "Этот календарь доступен только для чтения.", "selectTimeZone": "Выберите часовой пояс", - "searchLocations": "Поиск мест", - "noLocationsFound": "Места не найдены", + "searchLocations": "Поиск города", + "noLocationsFound": "Ничего не найдено", "deleteCalendarConfirmation": "Удалить «{title}»?" } diff --git a/lib/l10n/app_vi.arb b/lib/l10n/app_vi.arb index 68031f3..24663ad 100644 --- a/lib/l10n/app_vi.arb +++ b/lib/l10n/app_vi.arb @@ -156,7 +156,7 @@ "shortcutRefreshCompactAgendaDescription": "Làm mới cửa sổ lịch biểu thu gọn", "shortcutHideCompactAgendaDescription": "Ẩn cửa sổ lịch biểu thu gọn", "aboutBusyMax": "Giới thiệu BusyMax", - "aboutBusyMaxDescription": "Công việc và lịch", + "aboutBusyMaxDescription": "Lịch và công việc", "website": "Trang web", "reportAnIssue": "Báo cáo sự cố", "sendFeedback": "Gửi phản hồi", diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 91b4d8c..30c1e7a 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -156,7 +156,7 @@ "shortcutRefreshCompactAgendaDescription": "刷新紧凑日程窗口", "shortcutHideCompactAgendaDescription": "隐藏紧凑日程窗口", "aboutBusyMax": "关于 BusyMax", - "aboutBusyMaxDescription": "任务和日历", + "aboutBusyMaxDescription": "日历和任务", "website": "网站", "reportAnIssue": "报告问题", "sendFeedback": "发送反馈", diff --git a/lib/l10n/app_zh_Hans.arb b/lib/l10n/app_zh_Hans.arb index c8e3c12..5e08baf 100644 --- a/lib/l10n/app_zh_Hans.arb +++ b/lib/l10n/app_zh_Hans.arb @@ -156,7 +156,7 @@ "shortcutRefreshCompactAgendaDescription": "刷新紧凑日程窗口", "shortcutHideCompactAgendaDescription": "隐藏紧凑日程窗口", "aboutBusyMax": "关于 BusyMax", - "aboutBusyMaxDescription": "任务和日历", + "aboutBusyMaxDescription": "日历和任务", "website": "网站", "reportAnIssue": "报告问题", "sendFeedback": "发送反馈", diff --git a/lib/l10n/app_zh_Hant.arb b/lib/l10n/app_zh_Hant.arb index 62b0af5..f6e510b 100644 --- a/lib/l10n/app_zh_Hant.arb +++ b/lib/l10n/app_zh_Hant.arb @@ -156,7 +156,7 @@ "shortcutRefreshCompactAgendaDescription": "重新整理精簡行程視窗", "shortcutHideCompactAgendaDescription": "隱藏精簡行程視窗", "aboutBusyMax": "關於 BusyMax", - "aboutBusyMaxDescription": "待辦事項和行事曆", + "aboutBusyMaxDescription": "行事曆與待辦事項", "website": "網站", "reportAnIssue": "回報問題", "sendFeedback": "傳送意見", diff --git a/lib/l10n/generated/app_localizations.dart b/lib/l10n/generated/app_localizations.dart index 8f1bc6e..6e1c958 100644 --- a/lib/l10n/generated/app_localizations.dart +++ b/lib/l10n/generated/app_localizations.dart @@ -1071,7 +1071,7 @@ abstract class AppLocalizations { /// No description provided for @aboutBusyMaxDescription. /// /// In en, this message translates to: - /// **'ToDo and Calendar'** + /// **'Calendar and tasks'** String get aboutBusyMaxDescription; /// No description provided for @website. diff --git a/lib/l10n/generated/app_localizations_ar.dart b/lib/l10n/generated/app_localizations_ar.dart index 52a42e9..ca80b86 100644 --- a/lib/l10n/generated/app_localizations_ar.dart +++ b/lib/l10n/generated/app_localizations_ar.dart @@ -540,7 +540,7 @@ class AppLocalizationsAr extends AppLocalizations { String get aboutBusyMax => 'حول BusyMax'; @override - String get aboutBusyMaxDescription => 'المهام والتقويم'; + String get aboutBusyMaxDescription => 'التقويم والمهام'; @override String get website => 'الموقع الإلكتروني'; diff --git a/lib/l10n/generated/app_localizations_de.dart b/lib/l10n/generated/app_localizations_de.dart index a564e76..5cc9ba9 100644 --- a/lib/l10n/generated/app_localizations_de.dart +++ b/lib/l10n/generated/app_localizations_de.dart @@ -530,7 +530,7 @@ class AppLocalizationsDe extends AppLocalizations { String get aboutBusyMax => 'Über BusyMax'; @override - String get aboutBusyMaxDescription => 'Aufgaben und Kalender'; + String get aboutBusyMaxDescription => 'Kalender und Aufgaben'; @override String get website => 'Website'; diff --git a/lib/l10n/generated/app_localizations_en.dart b/lib/l10n/generated/app_localizations_en.dart index a9bb031..c9186f5 100644 --- a/lib/l10n/generated/app_localizations_en.dart +++ b/lib/l10n/generated/app_localizations_en.dart @@ -527,7 +527,7 @@ class AppLocalizationsEn extends AppLocalizations { String get aboutBusyMax => 'About BusyMax'; @override - String get aboutBusyMaxDescription => 'ToDo and Calendar'; + String get aboutBusyMaxDescription => 'Calendar and tasks'; @override String get website => 'Website'; diff --git a/lib/l10n/generated/app_localizations_es.dart b/lib/l10n/generated/app_localizations_es.dart index cf49b4e..21c2071 100644 --- a/lib/l10n/generated/app_localizations_es.dart +++ b/lib/l10n/generated/app_localizations_es.dart @@ -532,7 +532,7 @@ class AppLocalizationsEs extends AppLocalizations { String get aboutBusyMax => 'Acerca de BusyMax'; @override - String get aboutBusyMaxDescription => 'Tareas y calendario'; + String get aboutBusyMaxDescription => 'Calendario y tareas'; @override String get website => 'Sitio web'; diff --git a/lib/l10n/generated/app_localizations_et.dart b/lib/l10n/generated/app_localizations_et.dart index 3db4067..ef51e7b 100644 --- a/lib/l10n/generated/app_localizations_et.dart +++ b/lib/l10n/generated/app_localizations_et.dart @@ -531,7 +531,7 @@ class AppLocalizationsEt extends AppLocalizations { String get aboutBusyMax => 'Teave BusyMaxi kohta'; @override - String get aboutBusyMaxDescription => 'Ülesanded ja kalender'; + String get aboutBusyMaxDescription => 'Kalender ja ülesanded'; @override String get website => 'Veebisait'; diff --git a/lib/l10n/generated/app_localizations_fa.dart b/lib/l10n/generated/app_localizations_fa.dart index f97e143..46256b4 100644 --- a/lib/l10n/generated/app_localizations_fa.dart +++ b/lib/l10n/generated/app_localizations_fa.dart @@ -548,7 +548,7 @@ class AppLocalizationsFa extends AppLocalizations { String get aboutBusyMax => 'دربارهٔ BusyMax'; @override - String get aboutBusyMaxDescription => 'کارها و تقویم'; + String get aboutBusyMaxDescription => 'تقویم و کارها'; @override String get website => 'وب‌سایت'; diff --git a/lib/l10n/generated/app_localizations_fi.dart b/lib/l10n/generated/app_localizations_fi.dart index 643d315..a9fc4c2 100644 --- a/lib/l10n/generated/app_localizations_fi.dart +++ b/lib/l10n/generated/app_localizations_fi.dart @@ -531,7 +531,7 @@ class AppLocalizationsFi extends AppLocalizations { String get aboutBusyMax => 'Tietoja BusyMaxista'; @override - String get aboutBusyMaxDescription => 'Tehtävät ja kalenteri'; + String get aboutBusyMaxDescription => 'Kalenteri ja tehtävät'; @override String get website => 'Verkkosivusto'; diff --git a/lib/l10n/generated/app_localizations_fr.dart b/lib/l10n/generated/app_localizations_fr.dart index e5cb1bc..3f77451 100644 --- a/lib/l10n/generated/app_localizations_fr.dart +++ b/lib/l10n/generated/app_localizations_fr.dart @@ -531,7 +531,7 @@ class AppLocalizationsFr extends AppLocalizations { String get aboutBusyMax => 'À propos de BusyMax'; @override - String get aboutBusyMaxDescription => 'Tâches et calendrier'; + String get aboutBusyMaxDescription => 'Calendrier et tâches'; @override String get website => 'Site web'; diff --git a/lib/l10n/generated/app_localizations_hi.dart b/lib/l10n/generated/app_localizations_hi.dart index 707fed6..2679a8a 100644 --- a/lib/l10n/generated/app_localizations_hi.dart +++ b/lib/l10n/generated/app_localizations_hi.dart @@ -533,7 +533,7 @@ class AppLocalizationsHi extends AppLocalizations { String get aboutBusyMax => 'BusyMax के बारे में'; @override - String get aboutBusyMaxDescription => 'कार्य और कैलेंडर'; + String get aboutBusyMaxDescription => 'कैलेंडर और कार्य'; @override String get website => 'वेबसाइट'; diff --git a/lib/l10n/generated/app_localizations_it.dart b/lib/l10n/generated/app_localizations_it.dart index ee97eb6..24e8d30 100644 --- a/lib/l10n/generated/app_localizations_it.dart +++ b/lib/l10n/generated/app_localizations_it.dart @@ -532,7 +532,7 @@ class AppLocalizationsIt extends AppLocalizations { String get aboutBusyMax => 'Informazioni su BusyMax'; @override - String get aboutBusyMaxDescription => 'Attività e calendario'; + String get aboutBusyMaxDescription => 'Calendario e attività'; @override String get website => 'Sito web'; diff --git a/lib/l10n/generated/app_localizations_ja.dart b/lib/l10n/generated/app_localizations_ja.dart index dc9ca23..2d4e1d1 100644 --- a/lib/l10n/generated/app_localizations_ja.dart +++ b/lib/l10n/generated/app_localizations_ja.dart @@ -520,7 +520,7 @@ class AppLocalizationsJa extends AppLocalizations { String get aboutBusyMax => 'BusyMax について'; @override - String get aboutBusyMaxDescription => 'タスクとカレンダー'; + String get aboutBusyMaxDescription => 'カレンダーとタスク'; @override String get website => 'ウェブサイト'; diff --git a/lib/l10n/generated/app_localizations_ko.dart b/lib/l10n/generated/app_localizations_ko.dart index 772eda1..9f8c656 100644 --- a/lib/l10n/generated/app_localizations_ko.dart +++ b/lib/l10n/generated/app_localizations_ko.dart @@ -520,7 +520,7 @@ class AppLocalizationsKo extends AppLocalizations { String get aboutBusyMax => 'BusyMax 정보'; @override - String get aboutBusyMaxDescription => '할 일 및 캘린더'; + String get aboutBusyMaxDescription => '캘린더와 할 일'; @override String get website => '웹사이트'; diff --git a/lib/l10n/generated/app_localizations_pt.dart b/lib/l10n/generated/app_localizations_pt.dart index acf9841..39705fc 100644 --- a/lib/l10n/generated/app_localizations_pt.dart +++ b/lib/l10n/generated/app_localizations_pt.dart @@ -532,7 +532,7 @@ class AppLocalizationsPt extends AppLocalizations { String get aboutBusyMax => 'Acerca do BusyMax'; @override - String get aboutBusyMaxDescription => 'Tarefas e calendário'; + String get aboutBusyMaxDescription => 'Calendário e tarefas'; @override String get website => 'Site'; diff --git a/lib/l10n/generated/app_localizations_ru.dart b/lib/l10n/generated/app_localizations_ru.dart index 394e521..3e946de 100644 --- a/lib/l10n/generated/app_localizations_ru.dart +++ b/lib/l10n/generated/app_localizations_ru.dart @@ -21,11 +21,11 @@ class AppLocalizationsRu extends AppLocalizations { @override String get googlePermissionsConsentNotice => - 'На экране разрешений Google выберите разрешения и для Календаря, и для Задач.'; + 'На экране запроса доступа Google установите флажки «Google Календарь» и «Google Задачи».'; @override String get googlePermissionsRequiredRetry => - 'Необходимы разрешения для Google Календаря и Google Tasks. Повторите попытку и установите оба флажка.'; + 'BusyMax требуется доступ к Google Календарю и Google Задачам. Повторите попытку и установите оба флажка.'; @override String get finishSetup => 'Завершить настройку'; @@ -174,10 +174,11 @@ class AppLocalizationsRu extends AppLocalizations { 'Попробуйте изменить запрос или сбросить текущие фильтры.'; @override - String get trayAgendaLoading => 'Загрузка повестки...'; + String get trayAgendaLoading => 'Загрузка расписания...'; @override - String get trayAgendaSignInRequired => 'Войдите, чтобы просмотреть повестку.'; + String get trayAgendaSignInRequired => + 'Войдите, чтобы просмотреть расписание.'; @override String get trayAgendaNoSources => 'Нет видимых календарей или списков задач.'; @@ -189,10 +190,10 @@ class AppLocalizationsRu extends AppLocalizations { String get trayAgendaRefresh => 'Обновить'; @override - String get trayAgendaError => 'Повестка недоступна'; + String get trayAgendaError => 'Расписание недоступно'; @override - String get compactAgendaTitle => 'Повестка'; + String get compactAgendaTitle => 'Расписание'; @override String get compactAgendaSubtitle => 'Предстоящие'; @@ -201,7 +202,7 @@ class AppLocalizationsRu extends AppLocalizations { String get compactAgendaOverdue => 'Просроченные'; @override - String get compactAgendaClear => 'На ближайшее время всё свободно'; + String get compactAgendaClear => 'На ближайшее время ничего нет'; @override String get compactAgendaOpenBusyMax => 'Открыть BusyMax'; @@ -222,14 +223,14 @@ class AppLocalizationsRu extends AppLocalizations { String get compactAgendaAllDay => 'Весь день'; @override - String get compactAgendaDueToday => 'Срок сегодня'; + String get compactAgendaDueToday => 'Срок — сегодня'; @override - String get compactAgendaDueTomorrow => 'Срок завтра'; + String get compactAgendaDueTomorrow => 'Срок — завтра'; @override String compactAgendaDueOn(String date) { - return 'Срок: $date'; + return 'Срок — $date'; } @override @@ -254,7 +255,7 @@ class AppLocalizationsRu extends AppLocalizations { String get viewYear => 'Год'; @override - String get viewAgenda => 'Повестка'; + String get viewAgenda => 'Расписание'; @override String get scheduleSettings => 'Расписание'; @@ -321,7 +322,7 @@ class AppLocalizationsRu extends AppLocalizations { String get description => 'Описание'; @override - String get availabilityShowAs => 'Доступность / Показывать как'; + String get availabilityShowAs => 'Показывать как'; @override String get busy => 'Занят'; @@ -408,10 +409,10 @@ class AppLocalizationsRu extends AppLocalizations { String get availabilityTentative => 'Под вопросом'; @override - String get availabilityOutOfOffice => 'Не на работе'; + String get availabilityOutOfOffice => 'Нет на рабочем месте'; @override - String get availabilityWorkingElsewhere => 'Работает в другом месте'; + String get availabilityWorkingElsewhere => 'Работа в другом месте'; @override String get visibilityDefault => 'По умолчанию'; @@ -479,7 +480,7 @@ class AppLocalizationsRu extends AppLocalizations { 'Предыдущая неделя в представлении недели, предыдущий месяц в представлении месяца и так далее'; @override - String get shortcutJumpToToday => 'Перейти к сегодняшней дате'; + String get shortcutJumpToToday => 'Перейти к сегодняшнему дню'; @override String get shortcutGroupView => 'Представление'; @@ -497,7 +498,7 @@ class AppLocalizationsRu extends AppLocalizations { String get shortcutYearView => 'Представление года'; @override - String get shortcutAgendaView => 'Представление повестки'; + String get shortcutAgendaView => 'Расписание'; @override String get shortcutGroupCreateAndEdit => 'Создание и редактирование'; @@ -519,21 +520,21 @@ class AppLocalizationsRu extends AppLocalizations { 'Выйти из режима редактирования задачи или закрыть сведения о ней'; @override - String get shortcutGroupCompactAgenda => 'Компактная повестка'; + String get shortcutGroupCompactAgenda => 'Компактное расписание'; @override String get shortcutRefreshCompactAgendaDescription => - 'Обновить окно компактной повестки'; + 'Обновить окно компактного расписания'; @override String get shortcutHideCompactAgendaDescription => - 'Скрыть окно компактной повестки'; + 'Скрыть окно компактного расписания'; @override String get aboutBusyMax => 'О приложении BusyMax'; @override - String get aboutBusyMaxDescription => 'Задачи и календарь'; + String get aboutBusyMaxDescription => 'Календарь и задачи'; @override String get website => 'Веб-сайт'; @@ -584,7 +585,7 @@ class AppLocalizationsRu extends AppLocalizations { @override String get feedbackTechnicalDetailsDisclosure => - 'Будут добавлены только версия операционной системы Linux и локаль приложения. Журналы, данные аккаунтов, имена файлов и другие диагностические сведения не включаются.'; + 'Будут добавлены только версия Linux и выбранный язык приложения. Журналы, данные аккаунтов, имена файлов и другие диагностические сведения не добавляются.'; @override String get feedbackCategoryRequired => 'Выберите категорию.'; @@ -607,7 +608,7 @@ class AppLocalizationsRu extends AppLocalizations { @override String get feedbackTimeoutError => - 'Время ожидания запроса истекло. Ваш отзыв не был удалён. Повторите попытку.'; + 'Время ожидания запроса истекло. Текст отзыва сохранён. Повторите попытку.'; @override String get feedbackRateLimitedError => @@ -619,7 +620,7 @@ class AppLocalizationsRu extends AppLocalizations { @override String get feedbackServerError => - 'BusyStack сейчас не может принять ваш отзыв. Ваш отзыв не был удалён. Повторите попытку.'; + 'BusyStack сейчас не может принять ваш отзыв. Текст отзыва сохранён. Повторите попытку.'; @override String feedbackSuccess(String id) { @@ -741,7 +742,7 @@ class AppLocalizationsRu extends AppLocalizations { String get newTask => 'Новая задача'; @override - String get clearCompleted => 'Удалить завершённые'; + String get clearCompleted => 'Удалить выполненные'; @override String get refreshList => 'Обновить список'; @@ -806,7 +807,7 @@ class AppLocalizationsRu extends AppLocalizations { String get noDate => 'Без даты'; @override - String get completed => 'Завершённые'; + String get completed => 'Выполненные'; @override String duePrefix(String date) { @@ -1064,26 +1065,25 @@ class AppLocalizationsRu extends AppLocalizations { String get taskReminders => 'Напоминания о задачах'; @override - String get notificationDetailLevel => 'Уровень детализации уведомлений'; + String get notificationDetailLevel => 'Содержимое уведомлений'; @override - String get notificationDetailPrivate => 'Конфиденциальный'; + String get notificationDetailPrivate => 'Скрывать подробности'; @override - String get notificationDetailNormal => 'Обычный'; + String get notificationDetailNormal => 'Показывать подробности'; @override - String get quietHours => 'Период тишины'; + String get quietHours => 'Период без уведомлений'; @override - String get quietHoursDescription => - 'Приостановить уведомления на этот период.'; + String get quietHoursDescription => 'Не показывать уведомления в это время.'; @override - String get quietHoursStart => 'Начало периода тишины'; + String get quietHoursStart => 'Начало периода'; @override - String get quietHoursEnd => 'Конец периода тишины'; + String get quietHoursEnd => 'Конец периода'; @override String get notifications => 'Уведомления'; @@ -1107,13 +1107,13 @@ class AppLocalizationsRu extends AppLocalizations { String get themeFamily => 'Семейство тем'; @override - String get themeFamilyYaru => 'Нативная тема Ubuntu (Yaru)'; + String get themeFamilyYaru => 'Стандартная тема Ubuntu (Yaru)'; @override - String get localization => 'Локализация'; + String get localization => 'Язык'; @override - String get currentLocale => 'Текущая локаль'; + String get currentLocale => 'Язык приложения'; @override String get privacy => 'Конфиденциальность'; @@ -1150,15 +1150,14 @@ class AppLocalizationsRu extends AppLocalizations { String get requiresTasksScope => 'Требуется область разрешений tasks'; @override - String get blockedPendingOperations => 'Заблокированные ожидающие операции'; + String get blockedPendingOperations => 'Заблокированные операции'; @override String get signInToInspectPendingOperations => 'Войдите, чтобы просмотреть ожидающие операции.'; @override - String get noBlockedPendingOperations => - 'Заблокированных ожидающих операций нет.'; + String get noBlockedPendingOperations => 'Заблокированных операций нет.'; @override String get operationActions => 'Действия с операцией'; @@ -1195,14 +1194,14 @@ class AppLocalizationsRu extends AppLocalizations { String get retryCompleted => 'Повторная попытка завершена.'; @override - String get discardPendingOperation => 'Отменить ожидающую операцию?'; + String get discardPendingOperation => 'Удалить заблокированную операцию?'; @override String get discardPendingOperationConfirmation => 'Заблокированная локальная операция будет удалена. При следующей синхронизации данные будут заново загружены из Google Tasks.'; @override - String get pendingOperationDiscarded => 'Ожидающая операция отменена.'; + String get pendingOperationDiscarded => 'Заблокированная операция удалена.'; @override String get syncFailureNotificationTitle => 'Сбой синхронизации BusyMax'; @@ -1217,7 +1216,7 @@ class AppLocalizationsRu extends AppLocalizations { @override String conflictNotificationBody(String summary) { - return 'Ожидающее локальное изменение было заблокировано. $summary'; + return 'Локальное изменение не удалось синхронизировать. $summary'; } @override @@ -1280,17 +1279,17 @@ class AppLocalizationsRu extends AppLocalizations { } @override - String get resizeAllDayPanel => 'Изменить размер панели событий на весь день'; + String get resizeAllDayPanel => 'Изменить размер панели «Весь день»'; @override String scheduleItemCount(int count) { String _temp0 = intl.Intl.pluralLogic( count, locale: localeName, - other: '$count элемента', - many: '$count элементов', - few: '$count элемента', - one: '$count элемент', + other: '$count записи', + many: '$count записей', + few: '$count записи', + one: '$count запись', ); return '$_temp0'; } @@ -1302,10 +1301,10 @@ class AppLocalizationsRu extends AppLocalizations { String get selectTimeZone => 'Выберите часовой пояс'; @override - String get searchLocations => 'Поиск мест'; + String get searchLocations => 'Поиск города'; @override - String get noLocationsFound => 'Места не найдены'; + String get noLocationsFound => 'Ничего не найдено'; @override String deleteCalendarConfirmation(String title) { diff --git a/lib/l10n/generated/app_localizations_vi.dart b/lib/l10n/generated/app_localizations_vi.dart index 489ff5d..bfc26eb 100644 --- a/lib/l10n/generated/app_localizations_vi.dart +++ b/lib/l10n/generated/app_localizations_vi.dart @@ -530,7 +530,7 @@ class AppLocalizationsVi extends AppLocalizations { String get aboutBusyMax => 'Giới thiệu BusyMax'; @override - String get aboutBusyMaxDescription => 'Công việc và lịch'; + String get aboutBusyMaxDescription => 'Lịch và công việc'; @override String get website => 'Trang web'; diff --git a/lib/l10n/generated/app_localizations_zh.dart b/lib/l10n/generated/app_localizations_zh.dart index 483657a..5eff2f8 100644 --- a/lib/l10n/generated/app_localizations_zh.dart +++ b/lib/l10n/generated/app_localizations_zh.dart @@ -516,7 +516,7 @@ class AppLocalizationsZh extends AppLocalizations { String get aboutBusyMax => '关于 BusyMax'; @override - String get aboutBusyMaxDescription => '任务和日历'; + String get aboutBusyMaxDescription => '日历和任务'; @override String get website => '网站'; @@ -1771,7 +1771,7 @@ class AppLocalizationsZhHans extends AppLocalizationsZh { String get aboutBusyMax => '关于 BusyMax'; @override - String get aboutBusyMaxDescription => '任务和日历'; + String get aboutBusyMaxDescription => '日历和任务'; @override String get website => '网站'; @@ -3026,7 +3026,7 @@ class AppLocalizationsZhHant extends AppLocalizationsZh { String get aboutBusyMax => '關於 BusyMax'; @override - String get aboutBusyMaxDescription => '待辦事項和行事曆'; + String get aboutBusyMaxDescription => '行事曆與待辦事項'; @override String get website => '網站'; diff --git a/linux/io.busystack.busymax.desktop b/linux/io.busystack.busymax.desktop index 01f89b8..7499fc6 100644 --- a/linux/io.busystack.busymax.desktop +++ b/linux/io.busystack.busymax.desktop @@ -2,6 +2,40 @@ Type=Application Name=BusyMax Comment=Calendar and task manager +Name[ar]=BusyMax +Comment[ar]=مدير التقويم والمهام +Name[de]=BusyMax +Comment[de]=Kalender- und Aufgabenverwaltung +Name[es]=BusyMax +Comment[es]=Gestor de calendarios y tareas +Name[et]=BusyMax +Comment[et]=Kalendri- ja ülesannete haldur +Name[fa]=BusyMax +Comment[fa]=مدیر تقویم و کارها +Name[fi]=BusyMax +Comment[fi]=Kalenteri- ja tehtäväsovellus +Name[fr]=BusyMax +Comment[fr]=Gestionnaire de calendriers et de tâches +Name[hi]=BusyMax +Comment[hi]=कैलेंडर और कार्य प्रबंधक +Name[it]=BusyMax +Comment[it]=Gestore di calendari e attività +Name[ja]=BusyMax +Comment[ja]=カレンダー・タスク管理アプリ +Name[ko]=BusyMax +Comment[ko]=캘린더와 할 일 관리 +Name[pt]=BusyMax +Comment[pt]=Gestor de calendário e tarefas +Name[ru]=BusyMax +Comment[ru]=Календарь и планировщик задач +Name[vi]=BusyMax +Comment[vi]=Trình quản lý lịch và công việc +Name[zh]=BusyMax +Comment[zh]=日历与任务管理工具 +Name[zh_Hans]=BusyMax +Comment[zh_Hans]=日历与任务管理工具 +Name[zh_Hant]=BusyMax +Comment[zh_Hant]=行事曆與待辦事項管理工具 Exec=busymax Icon=io.busystack.busymax Terminal=false diff --git a/linux/io.busystack.busymax.metainfo.xml b/linux/io.busystack.busymax.metainfo.xml index 43dfa21..1ff2a3c 100644 --- a/linux/io.busystack.busymax.metainfo.xml +++ b/linux/io.busystack.busymax.metainfo.xml @@ -4,9 +4,60 @@ CC0-1.0 Apache-2.0 BusyMax + BusyMax + BusyMax + BusyMax + BusyMax + BusyMax + BusyMax + BusyMax + BusyMax + BusyMax + BusyMax + BusyMax + BusyMax + BusyMax + BusyMax + BusyMax + BusyMax + BusyMax Calendar and task manager + مدير التقويم والمهام + Kalender- und Aufgabenverwaltung + Gestor de calendarios y tareas + Kalendri- ja ülesannete haldur + مدیر تقویم و کارها + Kalenteri- ja tehtäväsovellus + Gestionnaire de calendriers et de tâches + कैलेंडर और कार्य प्रबंधक + Gestore di calendari e attività + カレンダー・タスク管理アプリ + 캘린더와 할 일 관리 + Gestor de calendário e tarefas + Календарь и планировщик задач + Trình quản lý lịch và công việc + 日历与任务管理工具 + 日历与任务管理工具 + 行事曆與待辦事項管理工具

BusyMax is a Linux desktop calendar and task manager for planning events, tasks, reminders, and daily schedules in one native-feeling workspace.

+

BusyMax هو مدير تقويم ومهام لسطح مكتب Linux يتيح تخطيط الأحداث والمهام والتذكيرات والجداول اليومية في مساحة عمل واحدة.

+

BusyMax ist eine Kalender- und Aufgabenverwaltung für den Linux-Desktop, mit der sich Termine, Aufgaben, Erinnerungen und Tagespläne in einem Arbeitsbereich planen lassen.

+

BusyMax es un gestor de calendarios y tareas para el escritorio Linux que permite planificar eventos, tareas, recordatorios y agendas diarias en un único espacio de trabajo.

+

BusyMax on Linuxi töölaua kalendri- ja ülesannete haldur sündmuste, ülesannete, meeldetuletuste ja päevakavade planeerimiseks ühes tööruumis.

+

BusyMax مدیر تقویم و کارها برای میزکار لینوکس است که رویدادها، کارها، یادآورها و برنامه‌های روزانه را در یک فضای کاری گرد هم می‌آورد.

+

BusyMax on Linux-työpöydän kalenteri- ja tehtäväsovellus, jolla voi suunnitella tapahtumia, tehtäviä, muistutuksia ja päiväohjelmia yhdessä työtilassa.

+

BusyMax est un gestionnaire de calendriers et de tâches pour le bureau Linux qui réunit événements, tâches, rappels et emplois du temps quotidiens dans un même espace de travail.

+

BusyMax Linux डेस्कटॉप के लिए एक कैलेंडर और कार्य प्रबंधक है, जिसमें घटनाओं, कार्यों, रिमाइंडर और दैनिक कार्यक्रमों की योजना एक ही कार्यक्षेत्र में बनाई जा सकती है।

+

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

+

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

+

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

+

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

+

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

+

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

+

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

+

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

+

BusyMax 是一款適用於 Linux 桌面的行事曆與待辦事項管理工具,可在同一個工作區中規劃活動、待辦事項、提醒與每日行程。

It supports Google Calendar, Google Tasks, Microsoft Calendar, and Microsoft To Do.

This beta release is intended for early Ubuntu App Center and Snap Store testing.

From d929f90036043268a3a03553b706db6695519daf Mon Sep 17 00:00:00 2001 From: albert Date: Wed, 29 Jul 2026 18:35:54 -0700 Subject: [PATCH 44/73] Enhance header bar functionality with focus state management and modal barrier depth --- linux/runner/my_application.cc | 325 ++++++++++++++++++++++++++------- 1 file changed, 261 insertions(+), 64 deletions(-) diff --git a/linux/runner/my_application.cc b/linux/runner/my_application.cc index d9f969e..6fbd783 100644 --- a/linux/runner/my_application.cc +++ b/linux/runner/my_application.cc @@ -75,6 +75,10 @@ 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; @@ -124,7 +128,9 @@ struct _MyApplication { gboolean header_bar_can_show_sidebar; gboolean header_bar_sidebar_visible; gboolean header_bar_modal_barrier_visible; + gint header_bar_modal_barrier_depth; GtkWindow* main_window; + GtkWindow* header_focus_transient_window; GtkWidget* flutter_view; GtkWidget* titlebar_handle; GtkWidget* titlebar_overlay; @@ -180,10 +186,13 @@ struct _MyApplication { gboolean header_navigation_visible; gboolean header_back_visible; gboolean header_onboarding_controls_visible; + gint header_onboarding_content_width; }; G_DEFINE_TYPE(MyApplication, my_application, GTK_TYPE_APPLICATION) +static void schedule_header_bar_focus_state_refresh(MyApplication* self); + static void style_native_popover(GtkWidget* popover) { if (popover == nullptr || !GTK_IS_POPOVER(popover)) { return; @@ -520,52 +529,6 @@ static void respond_bool(FlMethodCall* method_call, gboolean value) { fl_method_call_respond_success(method_call, result, nullptr); } -static void handle_native_confirmation(FlMethodCall* method_call, - FlValue* args, - GtkWindow* parent) { - const gchar* title = fl_lookup_string_arg(args, "title"); - const gchar* message = fl_lookup_string_arg(args, "message"); - const gchar* cancel_label = fl_lookup_string_arg(args, "cancelLabel"); - const gchar* confirm_label = fl_lookup_string_arg(args, "confirmLabel"); - const gboolean destructive = - fl_lookup_bool_arg(args, "destructive", FALSE); - - GtkWidget* dialog = gtk_message_dialog_new( - parent, - static_cast(GTK_DIALOG_MODAL | - GTK_DIALOG_DESTROY_WITH_PARENT), - destructive ? GTK_MESSAGE_WARNING : GTK_MESSAGE_QUESTION, - GTK_BUTTONS_NONE, "%s", title != nullptr ? title : ""); - if (message != nullptr && message[0] != '\0') { - gtk_message_dialog_format_secondary_text(GTK_MESSAGE_DIALOG(dialog), "%s", - message); - } - gtk_window_set_resizable(GTK_WINDOW(dialog), FALSE); - - GtkWidget* cancel_button = gtk_dialog_add_button( - GTK_DIALOG(dialog), cancel_label != nullptr ? cancel_label : "_Cancel", - GTK_RESPONSE_CANCEL); - GtkWidget* confirm_button = gtk_dialog_add_button( - GTK_DIALOG(dialog), confirm_label != nullptr ? confirm_label : "_OK", - GTK_RESPONSE_ACCEPT); - GtkStyleContext* confirm_context = - gtk_widget_get_style_context(confirm_button); - gtk_style_context_add_class( - confirm_context, destructive ? GTK_STYLE_CLASS_DESTRUCTIVE_ACTION - : GTK_STYLE_CLASS_SUGGESTED_ACTION); - - // Confirmation dialogs default to the safe action. GTK still owns focus - // rendering, keyboard behavior, button order, typography, and accent use. - gtk_widget_set_can_default(cancel_button, TRUE); - gtk_dialog_set_default_response(GTK_DIALOG(dialog), GTK_RESPONSE_CANCEL); - gtk_widget_grab_focus(cancel_button); - - gtk_widget_show_all(dialog); - const gint response = gtk_dialog_run(GTK_DIALOG(dialog)); - respond_bool(method_call, response == GTK_RESPONSE_ACCEPT); - gtk_widget_destroy(dialog); -} - struct NativeTimeZoneOption { gchar* id; gchar* region; @@ -595,6 +558,7 @@ struct NativeGroupedListStyle { }; struct NativeTimeZoneDialogState { + MyApplication* application; GtkWidget* window; GtkWidget* results; GPtrArray* options; @@ -716,11 +680,35 @@ 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); + GtkWindow* parent = gtk_window_get_transient_for(window); + if (parent == nullptr || !gtk_window_is_active(parent) || + !gtk_widget_get_visible(GTK_WIDGET(window)) || + gtk_window_is_active(window)) { + return G_SOURCE_REMOVE; + } + + gtk_window_present_with_time(window, GDK_CURRENT_TIME); + return G_SOURCE_REMOVE; +} + static void native_time_zone_parent_is_active_notify_cb( GtkWindow* parent, GParamSpec*, @@ -731,10 +719,14 @@ static void native_time_zone_parent_is_active_notify_cb( return; } - // Some compositors reactivate the transient parent when switching back to - // the application. Keep the modal as the sole focus owner so both - // toplevels enter and leave GTK's :backdrop state consistently. - gtk_window_present_with_time(window, GDK_CURRENT_TIME); + // GTK can briefly activate the transient parent while focus is leaving the + // application. Wait until that transition settles before deciding whether + // the modal needs focus again, otherwise the dialog steals focus on + // alternating activation cycles. + g_idle_add_full( + G_PRIORITY_DEFAULT_IDLE, + native_time_zone_present_after_parent_activation_cb, + g_object_ref(window), g_object_unref); } static void rebuild_native_time_zone_results( @@ -1048,7 +1040,8 @@ static GtkCssProvider* create_native_grouped_list_provider( static void handle_native_time_zone_selection(FlMethodCall* method_call, FlValue* args, - GtkWindow* parent) { + GtkWindow* parent, + MyApplication* application) { const gchar* title = fl_lookup_string_arg(args, "title"); const gchar* search_placeholder = fl_lookup_string_arg(args, "searchPlaceholder"); @@ -1158,6 +1151,7 @@ static void handle_native_time_zone_selection(FlMethodCall* method_call, GMainLoop* loop = g_main_loop_new(nullptr, FALSE); NativeTimeZoneDialogState state = { + application, window, results, options, @@ -1175,6 +1169,16 @@ 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, @@ -1198,6 +1202,7 @@ 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) { @@ -1207,6 +1212,11 @@ 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); } @@ -1220,27 +1230,32 @@ static void native_dialog_method_call_cb(FlMethodChannel* channel, return; } const gchar* method = fl_method_call_get_name(method_call); - if (strcmp(method, "confirm") == 0) { - handle_native_confirmation(method_call, fl_method_call_get_args(method_call), - parent); - } else if (strcmp(method, "selectTimeZone") == 0) { + if (strcmp(method, "selectTimeZone") == 0) { handle_native_time_zone_selection( - method_call, fl_method_call_get_args(method_call), parent); + method_call, fl_method_call_get_args(method_call), parent, + data->application); } else { fl_method_call_respond_not_implemented(method_call, nullptr); } } static FlMethodChannel* create_native_dialog_channel(FlView* view, - GtkWindow* window) { + GtkWindow* window, + MyApplication* application) { 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); @@ -1250,12 +1265,14 @@ static FlMethodChannel* create_native_dialog_channel(FlView* view, static void register_native_dialogs(MyApplication* self, FlView* view, GtkWindow* window) { - self->native_dialog_channel = create_native_dialog_channel(view, window); + self->native_dialog_channel = + create_native_dialog_channel(view, window, self); } static void register_native_dialogs_for_subwindow(FlView* view, GtkWindow* window) { - FlMethodChannel* channel = create_native_dialog_channel(view, window); + FlMethodChannel* channel = + create_native_dialog_channel(view, window, nullptr); g_object_set_data_full(G_OBJECT(window), "busymax-native-dialogs", channel, g_object_unref); } @@ -1750,6 +1767,12 @@ 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; @@ -1824,6 +1847,17 @@ 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) || @@ -1975,8 +2009,10 @@ static void refresh_header_bar_css(MyApplication* self) { kNativeTimeZoneDialogStyleClass, kNativeDialogStyleClass, kNativeTimeZoneDialogStyleClass, dialog_background_color, kNativeDialogCornerRadius, kNativeDialogCornerRadius); - const gchar* modal_barrier_color = css_color_or( - self->header_bar_modal_barrier_color, kDefaultModalBarrierColor); + 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_depth); const gboolean use_legacy_yaru_compatibility = !self->header_bar_high_contrast && current_gtk_theme_uses_legacy_yaru_shadow(); @@ -2019,6 +2055,55 @@ static void refresh_header_bar_css(MyApplication* self) { css_color_or(self->header_bar_popover_shadow_color, kDefaultHeaderMenuShadowColor)) : g_strdup(""); + 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( @@ -2102,6 +2187,7 @@ static void refresh_header_bar_css(MyApplication* self) { "}" ".busymax-titlebar .busymax-header-brand label {" "color: %s;" + "font-weight: 800;" "}" ".busymax-titlebar .busymax-header-brand label:backdrop {" "color: alpha(%s, %.2f);" @@ -2145,6 +2231,7 @@ static void refresh_header_bar_css(MyApplication* self) { "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 @@ -2223,6 +2310,7 @@ static void refresh_header_bar_css(MyApplication* self) { foreground_color, foreground_color, kHeaderBackdropForegroundOpacity, foreground_color, kHeaderDisabledForegroundOpacity, foreground_color, kHeaderDisabledBackdropForegroundOpacity, + header_focus_css, kHeaderModalOpenStyleClass, kHeaderModalOpenStyleClass, kHeaderModalOpenStyleClass, kHeaderModalOpenStyleClass, kHeaderModalOpenStyleClass, kHeaderModalOpenStyleClass, @@ -2298,9 +2386,50 @@ static void set_header_bar_theme(MyApplication* self, FlValue* args) { static void close_header_menu_button(GtkWidget* menu_button); static void focus_flutter_view(MyApplication* self); -static void set_header_bar_modal_barrier_visible(MyApplication* self, - gboolean visible) { +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; + } + + 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_depth(MyApplication* self, + gint depth) { + const gint effective_depth = std::max(0, depth); + const gboolean visible = effective_depth > 0; + self->header_bar_modal_barrier_depth = effective_depth; self->header_bar_modal_barrier_visible = visible; + refresh_header_bar_css(self); if (self->titlebar_handle != nullptr && GTK_IS_WIDGET(self->titlebar_handle)) { GtkStyleContext* context = @@ -2323,6 +2452,11 @@ static void set_header_bar_modal_barrier_visible(MyApplication* self, } } +static void set_header_bar_modal_barrier_visible(MyApplication* self, + gboolean visible) { + set_header_bar_modal_barrier_depth(self, visible ? 1 : 0); +} + static void clear_header_bar_pointer(MyApplication* self) { if (self->header_bar != nullptr) { g_object_remove_weak_pointer( @@ -2993,7 +3127,8 @@ static void update_header_title_box_geometry(MyApplication* self) { 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 ? kHeaderOnboardingContentWidth : -1; + const gint width = + onboarding ? self->header_onboarding_content_width : -1; gtk_widget_set_size_request(self->header_title_box, width, -1); } @@ -3051,6 +3186,12 @@ static void set_header_onboarding_controls(MyApplication* self, FlValue* args) { 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); @@ -3090,6 +3231,42 @@ static void set_header_sidebar_width(MyApplication* self, gdouble width) { 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; @@ -3546,6 +3723,9 @@ static void header_bar_method_call_cb(FlMethodChannel* channel, } 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); @@ -3579,6 +3759,9 @@ static void header_bar_method_call_cb(FlMethodChannel* channel, } 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, "setTheme") == 0) { set_header_bar_theme(self, args); respond_success(method_call); @@ -4613,6 +4796,9 @@ static void my_application_activate(GApplication* application) { kMainWindowDefaultHeight); 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( @@ -4650,6 +4836,7 @@ static void my_application_activate(GApplication* application) { register_gtk_settings_channel(self, view); gtk_widget_grab_focus(GTK_WIDGET(view)); + schedule_header_bar_focus_state_refresh(self); } // Implements GApplication::local_command_line. @@ -4715,6 +4902,13 @@ static void my_application_dispose(GObject* object) { 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_object_remove_weak_pointer( + G_OBJECT(self->header_focus_transient_window), + reinterpret_cast( + &self->header_focus_transient_window)); + self->header_focus_transient_window = nullptr; + } self->main_window = nullptr; clear_widget_pointer(&self->flutter_view); clear_widget_pointer(&self->titlebar_handle); @@ -4803,6 +4997,7 @@ static void my_application_init(MyApplication* self) { 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 = g_strdup(kDefaultWindowBackgroundColor); @@ -4824,7 +5019,9 @@ static void my_application_init(MyApplication* self) { 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_depth = 0; self->main_window = nullptr; + self->header_focus_transient_window = nullptr; self->flutter_view = nullptr; self->titlebar_handle = nullptr; self->titlebar_overlay = nullptr; From 9ac4a4e06a62d92e15580d5626fb9feea7b53766 Mon Sep 17 00:00:00 2001 From: albert Date: Wed, 29 Jul 2026 18:36:24 -0700 Subject: [PATCH 45/73] Add locale management and formatting utilities for BusyMax --- lib/src/l10n/app_locale.dart | 178 +++++++++++++++++++++++++ lib/src/l10n/locale_resolution.dart | 79 +---------- lib/src/l10n/localized_formatters.dart | 37 +++++ 3 files changed, 216 insertions(+), 78 deletions(-) create mode 100644 lib/src/l10n/app_locale.dart create mode 100644 lib/src/l10n/localized_formatters.dart diff --git a/lib/src/l10n/app_locale.dart b/lib/src/l10n/app_locale.dart new file mode 100644 index 0000000..7574781 --- /dev/null +++ b/lib/src/l10n/app_locale.dart @@ -0,0 +1,178 @@ +import 'package:flutter/widgets.dart'; + +/// A locale that can be selected explicitly in BusyMax. +/// +/// Language names are endonyms on purpose: users should be able to find their +/// language even when the rest of the application is currently unreadable. +class BusyMaxLocaleOption { + const BusyMaxLocaleOption({required this.locale, required this.endonym}); + + final Locale locale; + final String endonym; + + String get tag => locale.toLanguageTag(); +} + +const busyMaxLocaleOptions = [ + BusyMaxLocaleOption(locale: Locale('ar'), endonym: 'العربية'), + BusyMaxLocaleOption(locale: Locale('de'), endonym: 'Deutsch'), + BusyMaxLocaleOption(locale: Locale('en'), endonym: 'English'), + BusyMaxLocaleOption(locale: Locale('es'), endonym: 'Español'), + BusyMaxLocaleOption(locale: Locale('et'), endonym: 'Eesti'), + BusyMaxLocaleOption(locale: Locale('fa'), endonym: 'فارسی'), + BusyMaxLocaleOption(locale: Locale('fi'), endonym: 'Suomi'), + BusyMaxLocaleOption(locale: Locale('fr'), endonym: 'Français'), + BusyMaxLocaleOption(locale: Locale('hi'), endonym: 'हिन्दी'), + BusyMaxLocaleOption(locale: Locale('it'), endonym: 'Italiano'), + BusyMaxLocaleOption(locale: Locale('ja'), endonym: '日本語'), + BusyMaxLocaleOption(locale: Locale('ko'), endonym: '한국어'), + BusyMaxLocaleOption(locale: Locale('pt'), endonym: 'Português'), + BusyMaxLocaleOption(locale: Locale('ru'), endonym: 'Русский'), + BusyMaxLocaleOption(locale: Locale('vi'), endonym: 'Tiếng Việt'), + BusyMaxLocaleOption( + locale: Locale.fromSubtags(languageCode: 'zh', scriptCode: 'Hans'), + endonym: '简体中文', + ), + BusyMaxLocaleOption( + locale: Locale.fromSubtags(languageCode: 'zh', scriptCode: 'Hant'), + endonym: '繁體中文', + ), +]; + +const _traditionalChineseRegions = {'HK', 'MO', 'TW'}; +const _simplifiedChineseRegions = {'CN', 'MY', 'SG'}; + +Locale? busyMaxLocaleFromTag(String? tag) { + final normalized = normalizeBusyMaxLocaleTag(tag); + if (normalized == null) { + return null; + } + for (final option in busyMaxLocaleOptions) { + if (option.tag == normalized) { + return option.locale; + } + } + return null; +} + +String? normalizeBusyMaxLocaleTag(String? tag) { + final trimmed = tag?.trim(); + if (trimmed == null || trimmed.isEmpty) { + return null; + } + + final parsed = _parseLocaleTag(trimmed); + if (parsed == null) { + return null; + } + final normalized = _normalizeChineseLocale(parsed); + for (final option in busyMaxLocaleOptions) { + if (option.locale == normalized) { + return option.tag; + } + } + + // Stored regional variants such as en-CA use the app's base translation. + for (final option in busyMaxLocaleOptions) { + if (option.locale.languageCode == normalized.languageCode && + option.locale.scriptCode == null && + option.locale.countryCode == null) { + return option.tag; + } + } + return null; +} + +String busyMaxLocaleEndonym(String tag) { + final normalized = normalizeBusyMaxLocaleTag(tag); + for (final option in busyMaxLocaleOptions) { + if (option.tag == normalized) { + return option.endonym; + } + } + return tag; +} + +/// Resolves the complete platform language preference list. +/// +/// Flutter's generated locale list is alphabetical, which would otherwise +/// make Arabic the fallback for an unsupported system language. Reordering the +/// supported list keeps English as the deliberate fallback while retaining +/// Flutter's exact/script/region/language preference algorithm. +Locale resolveBusyMaxLocales( + List? requestedLocales, + Iterable supportedLocales, +) { + final supported = supportedLocales.toList(growable: false); + if (supported.isEmpty) { + return const Locale('en'); + } + + final english = supported.cast().firstWhere( + (locale) => locale?.languageCode == 'en', + orElse: () => null, + ); + final orderedSupported = [ + if (english != null) english, + for (final locale in supported) + if (locale != english) locale, + ]; + final normalizedRequested = requestedLocales + ?.map(_normalizeChineseLocale) + .toList(growable: false); + return basicLocaleListResolution(normalizedRequested, orderedSupported); +} + +Locale resolveBusyMaxLocale( + Locale? requestedLocale, + Iterable supportedLocales, +) { + return resolveBusyMaxLocales( + requestedLocale == null ? null : [requestedLocale], + supportedLocales, + ); +} + +Locale _normalizeChineseLocale(Locale locale) { + if (locale.languageCode.toLowerCase() != 'zh' || locale.scriptCode != null) { + return locale; + } + final region = locale.countryCode?.toUpperCase(); + if (_traditionalChineseRegions.contains(region)) { + return Locale.fromSubtags(languageCode: 'zh', scriptCode: 'Hant'); + } + if (_simplifiedChineseRegions.contains(region)) { + return Locale.fromSubtags(languageCode: 'zh', scriptCode: 'Hans'); + } + return locale; +} + +Locale? _parseLocaleTag(String tag) { + final parts = tag.replaceAll('_', '-').split('-'); + if (parts.isEmpty || !RegExp(r'^[A-Za-z]{2,3}$').hasMatch(parts.first)) { + return null; + } + + final languageCode = parts.first.toLowerCase(); + String? scriptCode; + String? countryCode; + for (final part in parts.skip(1)) { + if (scriptCode == null && RegExp(r'^[A-Za-z]{4}$').hasMatch(part)) { + scriptCode = + '${part.substring(0, 1).toUpperCase()}' + '${part.substring(1).toLowerCase()}'; + continue; + } + if (countryCode == null && + RegExp(r'^(?:[A-Za-z]{2}|[0-9]{3})$').hasMatch(part)) { + countryCode = part.toUpperCase(); + continue; + } + return null; + } + return Locale.fromSubtags( + languageCode: languageCode, + scriptCode: scriptCode, + countryCode: countryCode, + ); +} diff --git a/lib/src/l10n/locale_resolution.dart b/lib/src/l10n/locale_resolution.dart index 3b7e4a8..8daf0c7 100644 --- a/lib/src/l10n/locale_resolution.dart +++ b/lib/src/l10n/locale_resolution.dart @@ -1,78 +1 @@ -import 'package:flutter/widgets.dart'; - -const _traditionalChineseRegions = {'HK', 'MO', 'TW'}; -const _simplifiedChineseRegions = {'CN', 'MY', 'SG'}; - -Locale resolveBusyMaxLocale( - Locale? requestedLocale, - Iterable supportedLocales, -) { - final supported = supportedLocales.toList(growable: false); - final english = - _firstMatching(supported, (locale) => locale.languageCode == 'en') ?? - (supported.isNotEmpty ? supported.first : const Locale('en')); - - if (requestedLocale == null) { - return english; - } - - final exact = _firstMatching( - supported, - (locale) => locale == requestedLocale, - ); - if (exact != null) { - return exact; - } - - if (requestedLocale.languageCode == 'zh') { - final scriptCode = - requestedLocale.scriptCode ?? - _chineseScriptForRegion(requestedLocale.countryCode); - if (scriptCode != null) { - final scriptMatch = _firstMatching( - supported, - (locale) => - locale.languageCode == 'zh' && locale.scriptCode == scriptCode, - ); - if (scriptMatch != null) { - return scriptMatch; - } - } - } - - return _firstMatching( - supported, - (locale) => - locale.languageCode == requestedLocale.languageCode && - locale.scriptCode == null && - locale.countryCode == null, - ) ?? - _firstMatching( - supported, - (locale) => locale.languageCode == requestedLocale.languageCode, - ) ?? - english; -} - -String? _chineseScriptForRegion(String? countryCode) { - final normalized = countryCode?.toUpperCase(); - if (_traditionalChineseRegions.contains(normalized)) { - return 'Hant'; - } - if (_simplifiedChineseRegions.contains(normalized)) { - return 'Hans'; - } - return null; -} - -Locale? _firstMatching( - Iterable locales, - bool Function(Locale locale) predicate, -) { - for (final locale in locales) { - if (predicate(locale)) { - return locale; - } - } - return null; -} +export 'app_locale.dart' show resolveBusyMaxLocale, resolveBusyMaxLocales; diff --git a/lib/src/l10n/localized_formatters.dart b/lib/src/l10n/localized_formatters.dart new file mode 100644 index 0000000..cc40a36 --- /dev/null +++ b/lib/src/l10n/localized_formatters.dart @@ -0,0 +1,37 @@ +import 'package:intl/intl.dart'; + +import '../schedule/schedule_range.dart'; + +// `intl` exposes CLDR's middle-of-sentence month names, but does not apply +// CLDR contextTransforms. These supported locales request titlecase-firstword +// for wide standalone month names used as UI headings. +const _titleCasedStandaloneMonthLanguages = {'es', 'fr', 'it', 'ru'}; + +/// Formats a month used by itself as a calendar heading. +/// +/// `LLLL` selects the standalone grammatical form. The casing transform is +/// separate because standalone grammar and standalone UI capitalization are +/// distinct concepts in CLDR. +String localizedMonthHeading(String locale, DateTime month) { + final label = DateFormat.LLLL(locale).format(month); + final language = Intl.canonicalizedLocale(locale).split('_').first; + if (label.isEmpty || + !_titleCasedStandaloneMonthLanguages.contains(language)) { + return label; + } + return '${label[0].toUpperCase()}${label.substring(1)}'; +} + +/// Formats a closed-open schedule range without assuming a month/day/year +/// ordering. Each endpoint is formatted as a complete localized date so the +/// result remains unambiguous in every supported locale. +String localizedScheduleRangeLabel(String locale, ScheduleRange range) { + final end = range.end.subtract(const Duration(days: 1)); + final dateFormat = DateFormat.yMMMd(locale); + return localizedRangeLabel( + dateFormat.format(range.start), + dateFormat.format(end), + ); +} + +String localizedRangeLabel(String start, String end) => '$start – $end'; From 0ba83d8755d5f9d1febc8aa5715077f360643ea5 Mon Sep 17 00:00:00 2001 From: albert Date: Wed, 29 Jul 2026 18:36:43 -0700 Subject: [PATCH 46/73] Add locale support and enhance header bar functionality --- lib/src/app/app_bootstrap.dart | 4 +- lib/src/app/app_settings.dart | 17 ++ lib/src/app/busymax_about_dialog.dart | 5 +- lib/src/app/busymax_app.dart | 6 +- lib/src/app/busymax_design.dart | 15 +- lib/src/app/busymax_dialogs.dart | 62 ++-- lib/src/app/busymax_glyphs.dart | 61 ++++ .../auth/presentation/sign_in_screen.dart | 160 +++++----- .../desktop_notification_service.dart | 14 +- .../presentation/compact_agenda_app.dart | 8 +- .../compact_agenda_formatting.dart | 10 +- .../schedule/presentation/mini_calendar.dart | 13 +- .../presentation/schedule_sidebar.dart | 6 +- .../presentation/schedule_toolbar.dart | 21 +- .../presentation/schedule_workspace.dart | 14 +- .../presentation/settings_screen.dart | 19 +- .../desktop_date_time_fields.dart | 5 +- .../presentation/task_details_editor.dart | 7 +- ...header_bar_configuration_synchronizer.dart | 8 +- .../platform/linux_header_bar_service.dart | 50 +++- lib/src/platform/native_dialog_service.dart | 50 +--- test/app/about_dialog_test.dart | 8 +- test/app/app_settings_test.dart | 42 +++ test/app/busymax_dialogs_test.dart | 142 +++------ test/app/localization_audit_test.dart | 280 +++++++++++++++--- test/app/localized_formatters_test.dart | 53 ++++ test/app/modal_barrier_test.dart | 9 +- test/app/native_ui_audit_test.dart | 130 +++++--- test/app/rtl_glyphs_test.dart | 47 +++ .../auth/presentation/auth_routing_test.dart | 36 +++ .../desktop_notification_service_test.dart | 51 +++- .../presentation/schedule_views_test.dart | 30 +- ...r_bar_configuration_synchronizer_test.dart | 1 + .../linux_header_bar_service_test.dart | 20 +- test/platform/native_dialog_service_test.dart | 66 ----- 35 files changed, 951 insertions(+), 519 deletions(-) create mode 100644 lib/src/app/busymax_glyphs.dart create mode 100644 test/app/localized_formatters_test.dart create mode 100644 test/app/rtl_glyphs_test.dart diff --git a/lib/src/app/app_bootstrap.dart b/lib/src/app/app_bootstrap.dart index 1241259..d61d536 100644 --- a/lib/src/app/app_bootstrap.dart +++ b/lib/src/app/app_bootstrap.dart @@ -138,9 +138,11 @@ final desktopNotificationBackendProvider = Provider( final desktopNotificationServiceProvider = Provider( (ref) { + final settings = ref.watch(appSettingsControllerProvider); return DesktopNotificationService( backend: ref.watch(desktopNotificationBackendProvider), - settings: ref.watch(appSettingsControllerProvider), + settings: settings, + locale: settings.locale, ); }, ); diff --git a/lib/src/app/app_settings.dart b/lib/src/app/app_settings.dart index 186cbd1..78629c9 100644 --- a/lib/src/app/app_settings.dart +++ b/lib/src/app/app_settings.dart @@ -8,6 +8,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:path/path.dart' as p; import 'package:path_provider/path_provider.dart'; +import '../l10n/app_locale.dart'; import '../schedule/schedule_view_mode.dart'; enum BusyMaxThemeFamily { yaru } @@ -18,6 +19,7 @@ enum NotificationDetailLevel { private, normal } const defaultScheduleDayStartMinute = 7 * 60; const defaultScheduleDayEndMinute = 22 * 60; +const Object _unset = Object(); extension BusyMaxThemeModePreferenceX on BusyMaxThemeModePreference { ThemeMode get themeMode { @@ -33,6 +35,7 @@ class AppSettings { const AppSettings({ required this.themeFamily, required this.themeModePreference, + required this.localeTag, required this.notifySyncFailures, required this.notifyConflicts, required this.notifyDueToday, @@ -58,6 +61,7 @@ class AppSettings { return const AppSettings( themeFamily: BusyMaxThemeFamily.yaru, themeModePreference: BusyMaxThemeModePreference.system, + localeTag: null, notifySyncFailures: true, notifyConflicts: true, notifyDueToday: false, @@ -142,6 +146,7 @@ class AppSettings { json['themeModePreference'], defaults.themeModePreference, ), + localeTag: normalizeBusyMaxLocaleTag(json['localeTag']?.toString()), notifySyncFailures: json['notifySyncFailures'] as bool? ?? defaults.notifySyncFailures, notifyConflicts: @@ -181,6 +186,7 @@ class AppSettings { final BusyMaxThemeFamily themeFamily; final BusyMaxThemeModePreference themeModePreference; + final String? localeTag; final bool notifySyncFailures; final bool notifyConflicts; final bool notifyDueToday; @@ -203,10 +209,13 @@ class AppSettings { ThemeMode get themeMode => themeModePreference.themeMode; + Locale? get locale => busyMaxLocaleFromTag(localeTag); + Map toJson() { return { 'themeFamily': themeFamily.name, 'themeModePreference': themeModePreference.name, + 'localeTag': localeTag, 'notifySyncFailures': notifySyncFailures, 'notifyConflicts': notifyConflicts, 'notifyDueToday': notifyDueToday, @@ -232,6 +241,7 @@ class AppSettings { AppSettings copyWith({ BusyMaxThemeFamily? themeFamily, BusyMaxThemeModePreference? themeModePreference, + Object? localeTag = _unset, bool? notifySyncFailures, bool? notifyConflicts, bool? notifyDueToday, @@ -265,6 +275,9 @@ class AppSettings { return AppSettings( themeFamily: themeFamily ?? this.themeFamily, themeModePreference: themeModePreference ?? this.themeModePreference, + localeTag: identical(localeTag, _unset) + ? this.localeTag + : normalizeBusyMaxLocaleTag(localeTag as String?), notifySyncFailures: notifySyncFailures ?? this.notifySyncFailures, notifyConflicts: notifyConflicts ?? this.notifyConflicts, notifyDueToday: notifyDueToday ?? this.notifyDueToday, @@ -368,6 +381,10 @@ class AppSettingsController extends StateNotifier { ); } + Future setLocaleTag(String? localeTag) { + return _mutate((current) => current.copyWith(localeTag: localeTag)); + } + Future setScheduleViewMode(ScheduleViewMode mode) { return _mutate((current) => current.copyWith(scheduleViewMode: mode)); } diff --git a/lib/src/app/busymax_about_dialog.dart b/lib/src/app/busymax_about_dialog.dart index ae149bb..11e8f82 100644 --- a/lib/src/app/busymax_about_dialog.dart +++ b/lib/src/app/busymax_about_dialog.dart @@ -12,6 +12,7 @@ import '../platform/linux_header_bar_service.dart'; import 'busymax_design.dart'; import 'busymax_dialog_identity.dart'; import 'busymax_dialogs.dart'; +import 'busymax_glyphs.dart'; const _busyMaxWebsiteUri = 'https://github.com/busystack/busymax'; const _busyMaxIssuesUri = 'https://github.com/busystack/busymax/issues'; @@ -95,8 +96,8 @@ class BusyMaxAboutDialog extends StatelessWidget { BusyMaxActionRow( title: l10n.sendFeedback, leading: const Icon(Icons.feedback_outlined), - trailing: const Icon( - Icons.chevron_right, + trailing: Icon( + BusyMaxGlyphs.chevronForwardFor(Directionality.of(context)), size: BusyMaxSizes.iconSm, ), onTap: onSendFeedback, diff --git a/lib/src/app/busymax_app.dart b/lib/src/app/busymax_app.dart index ed5b4f9..ff420ca 100644 --- a/lib/src/app/busymax_app.dart +++ b/lib/src/app/busymax_app.dart @@ -95,7 +95,7 @@ class _BusyMaxAppState extends ConsumerState { final accentColor = gtkThemeColors?.accent ?? ubuntuAccentColor ?? systemColor.accent; return MaterialApp.router( - title: 'BusyMax', + onGenerateTitle: (context) => AppLocalizations.of(context).appTitle, debugShowCheckedModeBanner: false, theme: buildBusyMaxTheme( brightness: Brightness.light, @@ -132,11 +132,12 @@ class _BusyMaxAppState extends ConsumerState { highContrast: true, ), themeMode: settings.themeMode, + locale: settings.locale, localizationsDelegates: const [ ...AppLocalizations.localizationsDelegates, ...GlobalUbuntuLocalizations.delegates, ], - localeResolutionCallback: resolveBusyMaxLocale, + localeListResolutionCallback: resolveBusyMaxLocales, supportedLocales: AppLocalizations.supportedLocales, builder: (context, child) { final l10n = AppLocalizations.of(context); @@ -232,6 +233,7 @@ class _BusyMaxAppState extends ConsumerState { BusyMaxHeaderBarConfiguration( labels: labels, sidebarWidth: BusyMaxSizes.sidebarWidth, + textDirection: Directionality.of(context), theme: BusyMaxHeaderBarTheme( preferDark: preferDark, highContrast: MediaQuery.highContrastOf(context), diff --git a/lib/src/app/busymax_design.dart b/lib/src/app/busymax_design.dart index 948338d..a9ce18b 100644 --- a/lib/src/app/busymax_design.dart +++ b/lib/src/app/busymax_design.dart @@ -3753,18 +3753,8 @@ class BusyMaxConfirmDialog extends StatelessWidget { @override Widget build(BuildContext context) { - final dialogSurface = busyMaxDialogSurfaceColor(context); - return AlertDialog( - backgroundColor: dialogSurface, - surfaceTintColor: dialogSurface, - clipBehavior: Clip.antiAlias, - scrollable: true, - titlePadding: EdgeInsets.zero, - title: BusyMaxDialogTitleBar( - title: Text(title), - showDividerInHighContrast: false, - ), - content: Text(message), + return BusyMaxDialogShell( + title: title, actions: [ BusyMaxPushButton.standard( onPressed: () => Navigator.of(context).pop(false), @@ -3782,6 +3772,7 @@ class BusyMaxConfirmDialog extends StatelessWidget { child: Text(confirmLabel), ), ], + children: [Text(message)], ); } } diff --git a/lib/src/app/busymax_dialogs.dart b/lib/src/app/busymax_dialogs.dart index 134257a..9e41f66 100644 --- a/lib/src/app/busymax_dialogs.dart +++ b/lib/src/app/busymax_dialogs.dart @@ -1,10 +1,8 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import '../l10n/l10n.dart'; import '../platform/linux_header_bar_service.dart'; import '../platform/linux_header_bar_provider.dart'; -import '../platform/native_dialog_service.dart'; import 'busymax_design.dart'; import 'busymax_shortcuts.dart'; @@ -148,25 +146,7 @@ Future showBusyMaxConfirm( bool destructive = false, Color? barrierColor, LinuxHeaderBarService? headerBarService, - NativeDialogService nativeDialogService = const NativeDialogService(), }) async { - final nativeResult = await nativeDialogService.confirm( - title: title, - message: message, - cancelLabel: context.l10n.cancel, - confirmLabel: confirmLabel, - destructive: destructive, - ); - if (nativeResult.available) { - // GTK owns parent modality for its transient dialog. Adding BusyMax's - // Flutter/header-bar barrier here would dim only part of the native window - // and duplicate the platform's input blocking. - return nativeResult.confirmed; - } - if (!context.mounted) { - return false; - } - final confirmed = await showBusyMaxModalDialog( context, headerBarService: headerBarService, @@ -190,19 +170,15 @@ Future acquireBusyMaxModalBarrier(LinuxHeaderBarService? service) async { return; } final depth = _modalDepths[service] ?? 0; - _modalDepths[service] = depth + 1; - final visibilityUpdate = depth == 0 - ? _enqueueBusyMaxModalBarrierUpdate(service, visible: true) - : _modalBarrierUpdateTails[service]; - if (visibilityUpdate == null) { - return; - } + final nextDepth = depth + 1; + _modalDepths[service] = nextDepth; + final depthUpdate = _enqueueBusyMaxModalBarrierUpdate( + service, + depth: nextDepth, + ); try { - // Nested callers that arrive while the first native show is pending must - // share its outcome. A dialog must not proceed under a header bar whose - // modal shield failed to open. - await visibilityUpdate; + await depthUpdate; } on Object catch (error, stackTrace) { final remainingDepth = (_modalDepths[service] ?? 0) - 1; if (remainingDepth > 0) { @@ -213,7 +189,7 @@ Future acquireBusyMaxModalBarrier(LinuxHeaderBarService? service) async { // The platform may have applied the visibility change before its // response failed. Restore the safe non-modal state, while preserving // the original acquisition failure for the caller. - await _enqueueBusyMaxModalBarrierUpdate(service, visible: false); + await _enqueueBusyMaxModalBarrierUpdate(service, depth: 0); } on Object { // Best-effort rollback cannot replace the causative exception. } @@ -230,15 +206,17 @@ Future releaseBusyMaxModalBarrier(LinuxHeaderBarService? service) async { final depth = _modalDepths[service] ?? 0; if (depth <= 1) { _modalDepths.remove(service); - await _enqueueBusyMaxModalBarrierUpdate(service, visible: false); + await _enqueueBusyMaxModalBarrierUpdate(service, depth: 0); return; } - _modalDepths[service] = depth - 1; + final nextDepth = depth - 1; + _modalDepths[service] = nextDepth; + await _enqueueBusyMaxModalBarrierUpdate(service, depth: nextDepth); } Future _enqueueBusyMaxModalBarrierUpdate( LinuxHeaderBarService service, { - required bool visible, + required int depth, }) { final previous = _modalBarrierUpdateTails[service] ?? Future.value(); final ready = previous.then( @@ -248,13 +226,13 @@ Future _enqueueBusyMaxModalBarrierUpdate( onError: (Object _, StackTrace _) {}, ); late final Future update; - update = ready - .then((_) => service.setModalBarrierVisible(visible)) - .whenComplete(() { - if (identical(_modalBarrierUpdateTails[service], update)) { - _modalBarrierUpdateTails.remove(service); - } - }); + update = ready.then((_) => service.setModalBarrierDepth(depth)).whenComplete( + () { + if (identical(_modalBarrierUpdateTails[service], update)) { + _modalBarrierUpdateTails.remove(service); + } + }, + ); _modalBarrierUpdateTails[service] = update; return update; } diff --git a/lib/src/app/busymax_glyphs.dart b/lib/src/app/busymax_glyphs.dart new file mode 100644 index 0000000..1e22ae2 --- /dev/null +++ b/lib/src/app/busymax_glyphs.dart @@ -0,0 +1,61 @@ +import 'package:flutter/material.dart' show Icons; +import 'package:flutter/widgets.dart'; +import 'package:yaru/yaru.dart'; + +/// Direction-aware glyphs used by BusyMax navigation and hierarchy controls. +/// +/// Yaru's directional glyphs do not opt in to Flutter's automatic mirroring, +/// so callers select the matching physical glyph explicitly. +abstract final class BusyMaxGlyphs { + const BusyMaxGlyphs._(); + + static IconData backFor(TextDirection direction) { + return direction == TextDirection.rtl + ? YaruIcons.arrow_right + : YaruIcons.arrow_left; + } + + static IconData previousFor(TextDirection direction) => backFor(direction); + + static IconData nextFor(TextDirection direction) { + return direction == TextDirection.rtl + ? YaruIcons.arrow_left + : YaruIcons.arrow_right; + } + + static IconData forwardFor(TextDirection direction) { + return direction == TextDirection.rtl + ? YaruIcons.go_previous + : YaruIcons.go_next; + } + + static IconData collapsedFor(TextDirection direction) { + return direction == TextDirection.rtl + ? YaruIcons.pan_start + : YaruIcons.pan_end; + } + + static IconData startFor(TextDirection direction) { + return direction == TextDirection.rtl + ? YaruIcons.pan_end + : YaruIcons.pan_start; + } + + static IconData endFor(TextDirection direction) { + return direction == TextDirection.rtl + ? YaruIcons.pan_start + : YaruIcons.pan_end; + } + + static IconData chevronForwardFor(TextDirection direction) { + return direction == TextDirection.rtl + ? Icons.chevron_left + : Icons.chevron_right; + } + + static IconData subdirectoryFor(TextDirection direction) { + return direction == TextDirection.rtl + ? Icons.subdirectory_arrow_left + : Icons.subdirectory_arrow_right; + } +} diff --git a/lib/src/features/auth/presentation/sign_in_screen.dart b/lib/src/features/auth/presentation/sign_in_screen.dart index 1f1af4b..1076d47 100644 --- a/lib/src/features/auth/presentation/sign_in_screen.dart +++ b/lib/src/features/auth/presentation/sign_in_screen.dart @@ -10,6 +10,7 @@ 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_yaru_theme.dart'; import '../../accounts/data/accounts_repository.dart'; @@ -82,13 +83,6 @@ class _SignInScreenState extends ConsumerState { ), _OnboardingStep.preferences => true, }; - _updateHeaderBar( - canGoBack: canGoBack, - canContinue: canContinue, - backLabel: backLabel, - continueLabel: continueLabel, - ); - return Scaffold( body: ColoredBox( color: BusyMaxSurfaceColors.of(context).window, @@ -97,79 +91,83 @@ class _SignInScreenState extends ConsumerState { 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 contentWidth = constraints.constrainWidth( + busyMaxOnboardingContentMaxWidth + horizontalPadding * 2, + ); + final contentRailWidth = (contentWidth - horizontalPadding * 2) + .clamp(0.0, busyMaxOnboardingContentMaxWidth) + .toDouble(); + _updateHeaderBar( + canGoBack: canGoBack, + canContinue: canContinue, + backLabel: backLabel, + continueLabel: continueLabel, + contentWidth: contentRailWidth.round(), + ); + return Padding( padding: EdgeInsets.symmetric( - horizontal: compact ? BusyMaxSpacing.md : BusyMaxSpacing.xl, - vertical: compact ? BusyMaxSpacing.md : BusyMaxSpacing.xl, + horizontal: horizontalPadding, + vertical: verticalPadding, ), child: Center( - child: ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 900), - child: BusyMaxSurface( - filled: false, - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Flexible( - child: SingleChildScrollView( - padding: const EdgeInsets.fromLTRB( - BusyMaxSpacing.xl, - BusyMaxSpacing.xxl, - BusyMaxSpacing.xl, - BusyMaxSpacing.xxl, - ), - child: Center( - child: ConstrainedBox( - constraints: const BoxConstraints( - maxWidth: 480, - ), - child: switch (_step) { - _OnboardingStep.accounts => - _AccountsOnboardingStep( - accounts: accounts, - googleConfigured: - config.hasGoogleOAuthClientId, - microsoftConfigured: - config.hasMicrosoftOAuthClientId, - isGoogleSigningIn: - _signingInProvider == - _OnboardingProvider.google, - isMicrosoftSigningIn: - _signingInProvider == - _OnboardingProvider.microsoft, - errorMessage: _errorMessage, - missingConfigMessage: kReleaseMode - ? l10n.providerNotConfigured - : config.missingClientIdMessage, - onAddGoogle: () => - _signIn(_OnboardingProvider.google), - onAddMicrosoft: () => _signIn( - _OnboardingProvider.microsoft, - ), - onCancelSignIn: _cancelSignIn, - ), - _OnboardingStep.preferences => - _PreferencesOnboardingStep( - settings: settings, - settingsController: settingsController, - ), - }, + child: SizedBox( + key: const ValueKey('onboarding-content-rail'), + width: contentRailWidth, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Flexible( + child: SingleChildScrollView( + child: switch (_step) { + _OnboardingStep.accounts => + _AccountsOnboardingStep( + accounts: accounts, + googleConfigured: + config.hasGoogleOAuthClientId, + microsoftConfigured: + config.hasMicrosoftOAuthClientId, + isGoogleSigningIn: + _signingInProvider == + _OnboardingProvider.google, + isMicrosoftSigningIn: + _signingInProvider == + _OnboardingProvider.microsoft, + errorMessage: _errorMessage, + missingConfigMessage: kReleaseMode + ? l10n.providerNotConfigured + : config.missingClientIdMessage, + onAddGoogle: () => + _signIn(_OnboardingProvider.google), + onAddMicrosoft: () => + _signIn(_OnboardingProvider.microsoft), + onCancelSignIn: _cancelSignIn, ), - ), - ), + _OnboardingStep.preferences => + _PreferencesOnboardingStep( + settings: settings, + settingsController: settingsController, + ), + }, + ), + ), + if (_showFlutterFooterFallback) + _OnboardingFooter( + canGoBack: canGoBack, + canContinue: canContinue, + backLabel: backLabel, + continueLabel: continueLabel, + onBack: _previousStep, + onContinue: _nextStep, ), - if (_showFlutterFooterFallback) - _OnboardingFooter( - canGoBack: canGoBack, - canContinue: canContinue, - backLabel: backLabel, - continueLabel: continueLabel, - onBack: _previousStep, - onContinue: _nextStep, - ), - ], - ), + ], ), ), ), @@ -204,6 +202,7 @@ class _SignInScreenState extends ConsumerState { required bool canContinue, required String backLabel, required String continueLabel, + required int contentWidth, }) { if (_finishingSetup) { return; @@ -247,6 +246,7 @@ class _SignInScreenState extends ConsumerState { canContinue: canContinue, backLabel: backLabel, continueLabel: continueLabel, + contentWidth: contentWidth, ); }()); }); @@ -714,7 +714,10 @@ class _ProviderSignInButton extends StatelessWidget { dimension: 18, child: CircularProgressIndicator(strokeWidth: 2), ) - : const Icon(YaruIcons.pan_end, size: BusyMaxSizes.iconSm), + : Icon( + BusyMaxGlyphs.collapsedFor(Directionality.of(context)), + size: BusyMaxSizes.iconSm, + ), enabled: enabled, tooltip: effectiveTooltip, onTap: onPressed, @@ -744,18 +747,17 @@ class _OnboardingFooter extends StatelessWidget { @override Widget build(BuildContext context) { return Padding( - padding: const EdgeInsets.symmetric( - horizontal: BusyMaxSpacing.lg, - vertical: BusyMaxSpacing.md, - ), + padding: const EdgeInsets.only(top: BusyMaxSpacing.xl), child: Row( children: [ BusyMaxPushButton.standard( + key: const ValueKey('onboarding-back-button'), onPressed: canGoBack ? onBack : null, child: Text(backLabel), ), const Spacer(), BusyMaxPushButton.suggested( + key: const ValueKey('onboarding-continue-button'), onPressed: canContinue ? onContinue : null, child: Text(continueLabel), ), diff --git a/lib/src/features/notifications/desktop_notification_service.dart b/lib/src/features/notifications/desktop_notification_service.dart index a4d4eb4..f43deb5 100644 --- a/lib/src/features/notifications/desktop_notification_service.dart +++ b/lib/src/features/notifications/desktop_notification_service.dart @@ -66,9 +66,9 @@ class DesktopNotificationService { DateTime Function()? now, }) : _backend = backend, _settings = settings, - _strings = NotificationStrings.forLocale( - locale ?? PlatformDispatcher.instance.locale, - ), + _strings = locale == null + ? NotificationStrings.forLocales(PlatformDispatcher.instance.locales) + : NotificationStrings.forLocale(locale), _syncFailureDebounce = syncFailureDebounce, _now = now ?? DateTime.now; @@ -263,8 +263,12 @@ class NotificationStrings { }); factory NotificationStrings.forLocale(Locale locale) { - final supportedLocale = resolveBusyMaxLocale( - locale, + return NotificationStrings.forLocales([locale]); + } + + factory NotificationStrings.forLocales(List? locales) { + final supportedLocale = resolveBusyMaxLocales( + locales, AppLocalizations.supportedLocales, ); return NotificationStrings.fromLocalizations( diff --git a/lib/src/features/schedule/presentation/compact_agenda_app.dart b/lib/src/features/schedule/presentation/compact_agenda_app.dart index 8908db2..f2dc6eb 100644 --- a/lib/src/features/schedule/presentation/compact_agenda_app.dart +++ b/lib/src/features/schedule/presentation/compact_agenda_app.dart @@ -287,7 +287,10 @@ class _BusyMaxCompactAgendaAppState final accentColor = gtkThemeColors?.accent ?? ubuntuAccentColor ?? systemColor.accent; return MaterialApp( - title: 'BusyMax Agenda', + onGenerateTitle: (context) { + final l10n = AppLocalizations.of(context); + return '${l10n.appTitle} — ${l10n.compactAgendaTitle}'; + }, debugShowCheckedModeBanner: false, theme: buildBusyMaxTheme( brightness: Brightness.light, @@ -324,11 +327,12 @@ class _BusyMaxCompactAgendaAppState highContrast: true, ), themeMode: settings.themeMode, + locale: settings.locale, localizationsDelegates: const [ ...AppLocalizations.localizationsDelegates, ...GlobalUbuntuLocalizations.delegates, ], - localeResolutionCallback: resolveBusyMaxLocale, + localeListResolutionCallback: resolveBusyMaxLocales, supportedLocales: AppLocalizations.supportedLocales, home: const Scaffold( backgroundColor: Colors.transparent, diff --git a/lib/src/features/schedule/presentation/compact_agenda_formatting.dart b/lib/src/features/schedule/presentation/compact_agenda_formatting.dart index 6e7adb8..82a3db5 100644 --- a/lib/src/features/schedule/presentation/compact_agenda_formatting.dart +++ b/lib/src/features/schedule/presentation/compact_agenda_formatting.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:intl/intl.dart'; import '../../../l10n/l10n.dart'; +import '../../../l10n/localized_formatters.dart'; import '../../../schedule/schedule_item.dart'; import '../../../schedule/schedule_projection.dart'; @@ -19,12 +20,15 @@ String compactAgendaDayLabel( return context.l10n.tomorrow; } final locale = Localizations.localeOf(context).toString(); - return DateFormat('EEE, MMM d', locale).format(normalizedDay); + return DateFormat.MMMEd(locale).format(normalizedDay); } String compactAgendaTodaySubtitle(BuildContext context, DateTime today) { final locale = Localizations.localeOf(context).toString(); - return '${context.l10n.today} - ${DateFormat.MMMd(locale).format(today)}'; + return localizedRangeLabel( + context.l10n.today, + DateFormat.MMMd(locale).format(today), + ); } String compactAgendaItemMeta( @@ -53,7 +57,7 @@ String _eventTimeLabel(BuildContext context, ScheduleItem item) { ScheduleProjection.day(end) != ScheduleProjection.day(start)) { return startText; } - return '$startText-${_formatTime(context, end)}'; + return localizedRangeLabel(startText, _formatTime(context, end)); } String _taskDueLabel( diff --git a/lib/src/features/schedule/presentation/mini_calendar.dart b/lib/src/features/schedule/presentation/mini_calendar.dart index 9f7fedc..085658e 100644 --- a/lib/src/features/schedule/presentation/mini_calendar.dart +++ b/lib/src/features/schedule/presentation/mini_calendar.dart @@ -2,11 +2,12 @@ import 'dart:math' as math; import 'package:flutter/material.dart'; import 'package:intl/intl.dart'; -import 'package:yaru/yaru.dart'; import '../../../app/busymax_design.dart'; +import '../../../app/busymax_glyphs.dart'; import '../../../app/busymax_surface_colors.dart'; import '../../../l10n/l10n.dart'; +import '../../../l10n/localized_formatters.dart'; import '../../../schedule/schedule_item.dart'; import '../../../schedule/schedule_projection.dart'; import 'calendar_day_semantics.dart'; @@ -82,7 +83,7 @@ class _MiniCalendarState extends State { Expanded( flex: _miniCalendarMonthControlFlex, child: _MiniCalendarStepper( - label: DateFormat.MMMM(locale).format(visibleMonth), + label: localizedMonthHeading(locale, visibleMonth), previousTooltip: l10n.previousMonth, nextTooltip: l10n.nextMonth, onPrevious: () => _showMonth( @@ -624,7 +625,7 @@ class _MiniCalendarStepper extends StatelessWidget { context, colorScheme: colorScheme, tooltip: previousTooltip, - icon: YaruIcons.pan_start, + icon: BusyMaxGlyphs.startFor(Directionality.of(context)), onPressed: onPrevious, ), ), @@ -636,7 +637,7 @@ class _MiniCalendarStepper extends StatelessWidget { context, colorScheme: colorScheme, tooltip: nextTooltip, - icon: YaruIcons.pan_end, + icon: BusyMaxGlyphs.endFor(Directionality.of(context)), onPressed: onNext, ), ), @@ -651,7 +652,7 @@ class _MiniCalendarStepper extends StatelessWidget { context, colorScheme: colorScheme, tooltip: previousTooltip, - icon: YaruIcons.pan_start, + icon: BusyMaxGlyphs.startFor(Directionality.of(context)), onPressed: onPrevious, ), const SizedBox(width: BusyMaxSpacing.xs), @@ -661,7 +662,7 @@ class _MiniCalendarStepper extends StatelessWidget { context, colorScheme: colorScheme, tooltip: nextTooltip, - icon: YaruIcons.pan_end, + icon: BusyMaxGlyphs.endFor(Directionality.of(context)), onPressed: onNext, ), ], diff --git a/lib/src/features/schedule/presentation/schedule_sidebar.dart b/lib/src/features/schedule/presentation/schedule_sidebar.dart index e1219ca..5027f93 100644 --- a/lib/src/features/schedule/presentation/schedule_sidebar.dart +++ b/lib/src/features/schedule/presentation/schedule_sidebar.dart @@ -9,6 +9,7 @@ import '../../../app/app_bootstrap.dart'; import '../../../app/busymax_yaru_theme.dart'; import '../../../app/busymax_dialogs.dart'; import '../../../app/busymax_design.dart'; +import '../../../app/busymax_glyphs.dart'; import '../../../calendar_providers/calendar_colors.dart'; import '../../../l10n/l10n.dart'; import '../../../schedule/schedule_item.dart'; @@ -377,7 +378,10 @@ class _AccountHeaderRow extends StatelessWidget { icon: AnimatedRotation( turns: expanded ? 0.25 : 0, duration: const Duration(milliseconds: 160), - child: const Icon(YaruIcons.pan_end, size: 16), + child: Icon( + BusyMaxGlyphs.collapsedFor(Directionality.of(context)), + size: 16, + ), ), onPressed: onToggleExpanded, foregroundColor: colorScheme.onSurfaceVariant, diff --git a/lib/src/features/schedule/presentation/schedule_toolbar.dart b/lib/src/features/schedule/presentation/schedule_toolbar.dart index 63a24d3..af75c1d 100644 --- a/lib/src/features/schedule/presentation/schedule_toolbar.dart +++ b/lib/src/features/schedule/presentation/schedule_toolbar.dart @@ -3,7 +3,9 @@ import 'package:intl/intl.dart'; import 'package:yaru/yaru.dart'; import '../../../app/busymax_design.dart'; +import '../../../app/busymax_glyphs.dart'; import '../../../l10n/l10n.dart'; +import '../../../l10n/localized_formatters.dart'; import '../../../schedule/schedule_range.dart'; import '../../../schedule/schedule_view_mode.dart'; @@ -86,12 +88,14 @@ class ScheduleToolbar extends StatelessWidget { tooltip: MaterialLocalizations.of( context, ).previousPageTooltip, - icon: const Icon(YaruIcons.arrow_left), + icon: Icon( + BusyMaxGlyphs.previousFor(Directionality.of(context)), + ), onPressed: onPrevious, ), YaruIconButton( tooltip: MaterialLocalizations.of(context).nextPageTooltip, - icon: const Icon(YaruIcons.arrow_right), + icon: Icon(BusyMaxGlyphs.nextFor(Directionality.of(context))), onPressed: onNext, ), const SizedBox(width: BusyMaxSpacing.sm), @@ -216,21 +220,10 @@ String _rangeLabel( ScheduleViewMode.month => DateFormat.yMMMM(locale).format(selectedDate), ScheduleViewMode.year => DateFormat.y(locale).format(selectedDate), ScheduleViewMode.agenda => context.l10n.viewAgenda, - ScheduleViewMode.week => _weekRange(locale, range), + ScheduleViewMode.week => localizedScheduleRangeLabel(locale, range), }; } -String _weekRange(String locale, ScheduleRange range) { - final end = range.end.subtract(const Duration(days: 1)); - if (range.start.year == end.year && range.start.month == end.month) { - return '${DateFormat.MMMd(locale).format(range.start)}-${DateFormat.d(locale).format(end)}, ${DateFormat.y(locale).format(end)}'; - } - if (range.start.year == end.year) { - return '${DateFormat.MMMd(locale).format(range.start)} - ${DateFormat.MMMd(locale).format(end)}, ${DateFormat.y(locale).format(end)}'; - } - return '${DateFormat.yMMMd(locale).format(range.start)} - ${DateFormat.yMMMd(locale).format(end)}'; -} - String _modeLabel(BuildContext context, ScheduleViewMode mode) { return switch (mode) { ScheduleViewMode.day => context.l10n.viewDay, diff --git a/lib/src/features/schedule/presentation/schedule_workspace.dart b/lib/src/features/schedule/presentation/schedule_workspace.dart index d19b15a..d6cf013 100644 --- a/lib/src/features/schedule/presentation/schedule_workspace.dart +++ b/lib/src/features/schedule/presentation/schedule_workspace.dart @@ -20,6 +20,7 @@ import '../../../features/accounts/data/accounts_repository.dart'; import '../../../features/calendar/data/calendar_repository.dart'; import '../../../features/sync/sync_auth_error.dart'; import '../../../l10n/l10n.dart'; +import '../../../l10n/localized_formatters.dart'; import '../../../platform/linux_header_bar_service.dart'; import '../../../schedule/schedule_commands.dart'; import '../../../schedule/schedule_filters.dart'; @@ -2657,17 +2658,6 @@ String _scheduleRangeLabel( ScheduleViewMode.month => DateFormat.yMMMM(locale).format(selectedDate), ScheduleViewMode.year => DateFormat.y(locale).format(selectedDate), ScheduleViewMode.agenda => context.l10n.viewAgenda, - ScheduleViewMode.week => _weekRangeLabel(locale, range), + ScheduleViewMode.week => localizedScheduleRangeLabel(locale, range), }; } - -String _weekRangeLabel(String locale, ScheduleRange range) { - final end = range.end.subtract(const Duration(days: 1)); - if (range.start.year == end.year && range.start.month == end.month) { - return '${DateFormat.MMMd(locale).format(range.start)}-${DateFormat.d(locale).format(end)}, ${DateFormat.y(locale).format(end)}'; - } - if (range.start.year == end.year) { - return '${DateFormat.MMMd(locale).format(range.start)} - ${DateFormat.MMMd(locale).format(end)}, ${DateFormat.y(locale).format(end)}'; - } - return '${DateFormat.yMMMd(locale).format(range.start)} - ${DateFormat.yMMMd(locale).format(end)}'; -} diff --git a/lib/src/features/settings/presentation/settings_screen.dart b/lib/src/features/settings/presentation/settings_screen.dart index 8fb9e9f..b8be22b 100644 --- a/lib/src/features/settings/presentation/settings_screen.dart +++ b/lib/src/features/settings/presentation/settings_screen.dart @@ -12,10 +12,12 @@ import '../../../app/busymax_yaru_theme.dart'; import '../../../app/app_bootstrap.dart'; import '../../../app/busymax_design.dart'; import '../../../app/busymax_dialogs.dart'; +import '../../../app/busymax_glyphs.dart'; import '../../../app/busymax_keyboard_shortcuts_dialog.dart'; import '../../../app/busymax_layout.dart'; import '../../../core/logging/redacting_logger.dart'; import '../../../google_tasks/oauth/oauth_models.dart'; +import '../../../l10n/app_locale.dart'; import '../../../l10n/l10n.dart'; import '../../../platform/linux_header_bar_service.dart'; import '../../../task_providers/task_provider.dart'; @@ -27,6 +29,7 @@ import '../../tasks/presentation/desktop_date_time_fields.dart'; import 'account_removal_dialog.dart'; final _settingsLogger = RedactingLogger(Logger('SettingsScreen')); +const _systemLocaleTag = 'system'; class SettingsScreen extends ConsumerStatefulWidget { const SettingsScreen({super.key, this.initialPage = SettingsPage.accounts}); @@ -164,10 +167,20 @@ class _SettingsScreenState extends ConsumerState { labelFor: (value) => _themeModeLabel(context, value), onSelected: themeController.setThemeMode, ), - BusyMaxActionRow( + BusyMaxComboRow( title: l10n.currentLocale, leading: const Icon(Icons.language), - subtitle: Localizations.localeOf(context).toLanguageTag(), + values: [ + _systemLocaleTag, + for (final option in busyMaxLocaleOptions) option.tag, + ], + selected: settings.localeTag ?? _systemLocaleTag, + labelFor: (tag) => tag == _systemLocaleTag + ? l10n.themeSystem + : busyMaxLocaleEndonym(tag), + onSelected: (tag) => settingsController.setLocaleTag( + tag == _systemLocaleTag ? null : tag, + ), ), ], ), @@ -693,7 +706,7 @@ class _SettingsFallbackHeader extends StatelessWidget { const SizedBox(width: BusyMaxSpacing.sm), YaruIconButton( tooltip: MaterialLocalizations.of(context).backButtonTooltip, - icon: const Icon(YaruIcons.go_previous), + icon: Icon(BusyMaxGlyphs.backFor(Directionality.of(context))), onPressed: onBack, ), const SizedBox(width: BusyMaxSpacing.sm), diff --git a/lib/src/features/tasks/presentation/desktop_date_time_fields.dart b/lib/src/features/tasks/presentation/desktop_date_time_fields.dart index 9125323..4809dde 100644 --- a/lib/src/features/tasks/presentation/desktop_date_time_fields.dart +++ b/lib/src/features/tasks/presentation/desktop_date_time_fields.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:intl/intl.dart'; import 'package:busymax/src/app/busymax_design.dart'; +import 'package:busymax/src/app/busymax_glyphs.dart'; import 'package:busymax/src/app/busymax_surface_colors.dart'; import 'package:busymax/src/core/time/local_time_zone.dart'; import 'package:busymax/src/core/time/time_zone_catalog.dart'; @@ -501,7 +502,7 @@ class _DesktopDateValueDialogState extends State<_DesktopDateValueDialog> { context, colorScheme: colorScheme, tooltip: previousTooltip, - icon: YaruIcons.pan_start, + icon: BusyMaxGlyphs.startFor(Directionality.of(context)), onPressed: onPrevious, ), const SizedBox(width: BusyMaxSpacing.xs), @@ -516,7 +517,7 @@ class _DesktopDateValueDialogState extends State<_DesktopDateValueDialog> { context, colorScheme: colorScheme, tooltip: nextTooltip, - icon: YaruIcons.pan_end, + icon: BusyMaxGlyphs.endFor(Directionality.of(context)), onPressed: onNext, ), ], diff --git a/lib/src/features/tasks/presentation/task_details_editor.dart b/lib/src/features/tasks/presentation/task_details_editor.dart index 485bb31..597997a 100644 --- a/lib/src/features/tasks/presentation/task_details_editor.dart +++ b/lib/src/features/tasks/presentation/task_details_editor.dart @@ -7,6 +7,7 @@ import 'package:yaru/yaru.dart'; import '../../../app/busymax_design.dart'; import '../../../app/busymax_dialogs.dart'; +import '../../../app/busymax_glyphs.dart'; import '../../../google_tasks/api/google_tasks_json.dart'; import '../../../l10n/l10n.dart'; import '../../../platform/linux_header_bar_service.dart'; @@ -320,7 +321,11 @@ class _TaskDetailsEditorState extends State { children: [ BusyMaxActionRow( title: l10n.createSubtask, - leading: const Icon(Icons.subdirectory_arrow_right), + leading: Icon( + BusyMaxGlyphs.subdirectoryFor( + Directionality.of(context), + ), + ), onTap: _createSubtask, ), BusyMaxActionRow( diff --git a/lib/src/platform/linux_header_bar_configuration_synchronizer.dart b/lib/src/platform/linux_header_bar_configuration_synchronizer.dart index 05accfd..04e11d0 100644 --- a/lib/src/platform/linux_header_bar_configuration_synchronizer.dart +++ b/lib/src/platform/linux_header_bar_configuration_synchronizer.dart @@ -1,7 +1,7 @@ import 'dart:async'; -import 'package:flutter/foundation.dart'; import 'package:flutter/scheduler.dart'; +import 'package:flutter/widgets.dart'; import 'linux_header_bar_service.dart'; @@ -10,11 +10,13 @@ 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 @@ -23,11 +25,12 @@ final class BusyMaxHeaderBarConfiguration { other is BusyMaxHeaderBarConfiguration && labels == other.labels && sidebarWidth == other.sidebarWidth && + textDirection == other.textDirection && theme == other.theme; } @override - int get hashCode => Object.hash(labels, sidebarWidth, theme); + int get hashCode => Object.hash(labels, sidebarWidth, textDirection, theme); } typedef BusyMaxHeaderBarConfigurationApplier = @@ -48,6 +51,7 @@ final class BusyMaxHeaderBarConfigurationSynchronizer { 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, diff --git a/lib/src/platform/linux_header_bar_service.dart b/lib/src/platform/linux_header_bar_service.dart index 57ffde9..e6bf50d 100644 --- a/lib/src/platform/linux_header_bar_service.dart +++ b/lib/src/platform/linux_header_bar_service.dart @@ -1,11 +1,13 @@ import 'dart:async'; import 'dart:io'; -import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; import '../schedule/schedule_view_mode.dart'; +const int busyMaxOnboardingContentMaxWidth = 480; + enum BusyMaxHeaderBarAction { back, continueSetup, @@ -406,8 +408,9 @@ class LinuxHeaderBarService { bool _available = false; bool _disposed = false; _BusyMaxOnboardingControlsState? _onboardingControls; - bool? _modalBarrierVisible; + int? _modalBarrierDepth; double? _sidebarWidth; + TextDirection? _textDirection; BusyMaxHeaderBarLabels? _labels; BusyMaxHeaderBarTheme? _theme; BusyMaxHeaderBarState? _state; @@ -499,12 +502,21 @@ class LinuxHeaderBarService { 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) { @@ -516,6 +528,7 @@ class LinuxHeaderBarService { canContinue: canContinue, backLabel: backLabel, continueLabel: continueLabel, + contentWidth: contentWidth, ); if (!force && _onboardingControls == state) { return; @@ -543,15 +556,20 @@ class LinuxHeaderBarService { } } - Future setModalBarrierVisible(bool value) async { + Future setModalBarrierDepth(int value) async { if (!_available) { return; } - if (_modalBarrierVisible == value) { + final depth = value < 0 ? 0 : value; + if (_modalBarrierDepth == depth) { return; } - _modalBarrierVisible = value; - await _invokeIfAvailable('setModalBarrierVisible', value); + _modalBarrierDepth = depth; + await _invokeIfAvailable('setModalBarrierDepth', depth); + } + + Future setModalBarrierVisible(bool value) { + return setModalBarrierDepth(value ? 1 : 0); } Future setTheme(BusyMaxHeaderBarTheme theme) async { @@ -747,6 +765,7 @@ class LinuxHeaderBarSession { required bool canContinue, required String backLabel, required String continueLabel, + int contentWidth = busyMaxOnboardingContentMaxWidth, bool force = false, }) async { if (_disposed) { @@ -758,6 +777,7 @@ class LinuxHeaderBarSession { canContinue: canContinue, backLabel: backLabel, continueLabel: continueLabel, + contentWidth: contentWidth, ); _onboardingControls = state; final revision = ++_onboardingRevision; @@ -771,6 +791,7 @@ class LinuxHeaderBarSession { canContinue: state.canContinue, backLabel: state.backLabel, continueLabel: state.continueLabel, + contentWidth: state.contentWidth, force: force, ); } @@ -795,6 +816,7 @@ class LinuxHeaderBarSession { canContinue: onboardingControls.canContinue, backLabel: onboardingControls.backLabel, continueLabel: onboardingControls.continueLabel, + contentWidth: onboardingControls.contentWidth, force: true, ); } @@ -840,6 +862,7 @@ class _BusyMaxOnboardingControlsState { required this.canContinue, required this.backLabel, required this.continueLabel, + required this.contentWidth, }); final bool visible; @@ -847,6 +870,7 @@ class _BusyMaxOnboardingControlsState { final bool canContinue; final String backLabel; final String continueLabel; + final int contentWidth; Map toJson() { return { @@ -855,6 +879,7 @@ class _BusyMaxOnboardingControlsState { 'canContinue': canContinue, 'backLabel': backLabel, 'continueLabel': continueLabel, + 'contentWidth': contentWidth, }; } @@ -866,12 +891,19 @@ class _BusyMaxOnboardingControlsState { canGoBack == other.canGoBack && canContinue == other.canContinue && backLabel == other.backLabel && - continueLabel == other.continueLabel; + continueLabel == other.continueLabel && + contentWidth == other.contentWidth; } @override - int get hashCode => - Object.hash(visible, canGoBack, canContinue, backLabel, continueLabel); + int get hashCode => Object.hash( + visible, + canGoBack, + canContinue, + backLabel, + continueLabel, + contentWidth, + ); } String busyMaxCssColor(Color color) { diff --git a/lib/src/platform/native_dialog_service.dart b/lib/src/platform/native_dialog_service.dart index 3ee39f2..f4c34f9 100644 --- a/lib/src/platform/native_dialog_service.dart +++ b/lib/src/platform/native_dialog_service.dart @@ -6,25 +6,6 @@ import 'linux_header_bar_service.dart'; @visibleForTesting const nativeDialogChannelName = 'busymax/native_dialogs'; -/// Result of asking the platform to present a native confirmation dialog. -/// -/// [available] distinguishes a user cancellation from a platform that does -/// not implement the native dialog bridge. -@immutable -class NativeConfirmationResult { - const NativeConfirmationResult({ - required this.available, - this.confirmed = false, - }); - - const NativeConfirmationResult.unavailable() - : available = false, - confirmed = false; - - final bool available; - final bool confirmed; -} - @immutable class NativeTimeZoneOption { const NativeTimeZoneOption({ @@ -129,10 +110,7 @@ class NativeGroupedListStyle { } } -/// Presents confirmation UI owned by the host desktop toolkit. -/// -/// Linux implements this with a transient `GtkMessageDialog`. Other hosts can -/// omit the channel; callers then use their themed Flutter fallback. +/// Presents desktop dialogs owned by the host toolkit. class NativeDialogService { const NativeDialogService({ MethodChannel channel = const MethodChannel(nativeDialogChannelName), @@ -140,32 +118,6 @@ class NativeDialogService { final MethodChannel _channel; - Future confirm({ - required String title, - required String message, - required String cancelLabel, - required String confirmLabel, - required bool destructive, - }) async { - try { - final confirmed = await _channel.invokeMethod('confirm', { - 'title': title, - 'message': message, - 'cancelLabel': cancelLabel, - 'confirmLabel': confirmLabel, - 'destructive': destructive, - }); - if (confirmed == null) { - return const NativeConfirmationResult.unavailable(); - } - return NativeConfirmationResult(available: true, confirmed: confirmed); - } on MissingPluginException { - return const NativeConfirmationResult.unavailable(); - } on PlatformException { - return const NativeConfirmationResult.unavailable(); - } - } - Future selectTimeZone({ required String title, required String searchPlaceholder, diff --git a/test/app/about_dialog_test.dart b/test/app/about_dialog_test.dart index 17d1675..370d93d 100644 --- a/test/app/about_dialog_test.dart +++ b/test/app/about_dialog_test.dart @@ -189,10 +189,10 @@ void main() { ); expect( calls - .where((call) => call.method == 'setModalBarrierVisible') + .where((call) => call.method == 'setModalBarrierDepth') .single .arguments, - isTrue, + 1, ); await tester.tap(find.byType(YaruWindowControl)); @@ -201,10 +201,10 @@ void main() { await result; expect(find.byType(BusyMaxAboutDialog), findsNothing); final barrierCalls = calls - .where((call) => call.method == 'setModalBarrierVisible') + .where((call) => call.method == 'setModalBarrierDepth') .toList(); expect(barrierCalls, hasLength(2)); - expect(barrierCalls.last.arguments, isFalse); + expect(barrierCalls.last.arguments, 0); expect(tester.takeException(), isNull); }, ); diff --git a/test/app/app_settings_test.dart b/test/app/app_settings_test.dart index 28827b0..164afe6 100644 --- a/test/app/app_settings_test.dart +++ b/test/app/app_settings_test.dart @@ -1,7 +1,49 @@ import 'package:busymax/src/app/app_settings.dart'; +import 'package:flutter/widgets.dart'; import 'package:flutter_test/flutter_test.dart'; void main() { + test('language defaults to the complete system locale preference list', () { + final settings = AppSettings.defaults(); + + expect(settings.localeTag, isNull); + expect(settings.locale, isNull); + expect(settings.toJson()['localeTag'], isNull); + }); + + test('language override persists as a canonical supported locale tag', () { + final regionalEnglish = AppSettings.fromJson(const {'localeTag': 'en_CA'}); + final traditionalChinese = AppSettings.fromJson(const { + 'localeTag': 'zh-TW', + }); + final unsupported = AppSettings.fromJson(const {'localeTag': 'xx-ZZ'}); + + expect(regionalEnglish.localeTag, 'en'); + expect(regionalEnglish.locale, const Locale('en')); + expect(traditionalChinese.localeTag, 'zh-Hant'); + expect( + traditionalChinese.locale, + const Locale.fromSubtags(languageCode: 'zh', scriptCode: 'Hant'), + ); + expect(unsupported.localeTag, isNull); + expect(unsupported.locale, isNull); + }); + + test('language preference can be persisted and reset to system', () async { + final store = _MemorySettingsStore(); + final controller = AppSettingsController(store); + addTearDown(controller.dispose); + await controller.ready; + + await controller.setLocaleTag('it'); + expect(controller.state.localeTag, 'it'); + expect(store.value['localeTag'], 'it'); + + await controller.setLocaleTag(null); + expect(controller.state.localeTag, isNull); + expect(store.value['localeTag'], isNull); + }); + test( 'notification detail level is the only persisted runtime setting', () async { diff --git a/test/app/busymax_dialogs_test.dart b/test/app/busymax_dialogs_test.dart index a03dbf4..f561014 100644 --- a/test/app/busymax_dialogs_test.dart +++ b/test/app/busymax_dialogs_test.dart @@ -6,7 +6,6 @@ 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:busymax/src/platform/native_dialog_service.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter/services.dart'; @@ -17,17 +16,6 @@ import '../test_localized_app.dart'; void main() { TestWidgetsFlutterBinding.ensureInitialized(); - const nativeDialogChannel = MethodChannel(nativeDialogChannelName); - - setUp(() { - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(nativeDialogChannel, (_) async => null); - }); - - tearDown(() { - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(nativeDialogChannel, null); - }); testWidgets( 'prompt action header and confirmation title bar use the dialog surface', @@ -71,7 +59,7 @@ void main() { final titleBar = tester.widget( find.byType(YaruDialogTitleBar), ); - final confirmation = tester.widget(find.byType(AlertDialog)); + final confirmation = tester.widget(find.byType(Dialog)); final titleBarTheme = Theme.of( tester.element(find.byType(YaruDialogTitleBar)), ).appBarTheme; @@ -95,6 +83,7 @@ void main() { expect(confirmation.backgroundColor, colors.dialog); expect(confirmation.surfaceTintColor, colors.dialog); expect(confirmation.clipBehavior, Clip.antiAlias); + expect(find.byType(BusyMaxDialogShell), findsOneWidget); expect(cancelButton.style, isNull); expect(discardButton.style?.shape?.resolve({}), isNull); expect( @@ -116,70 +105,6 @@ void main() { }, ); - testWidgets('confirmation uses the native host when available', ( - tester, - ) async { - const channel = MethodChannel('busymax_test/native_confirmation'); - const headerChannel = MethodChannel( - 'busymax_test/native_confirmation_header', - ); - final calls = []; - final headerCalls = []; - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(channel, (call) async { - calls.add(call); - return true; - }); - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(headerChannel, (call) async { - headerCalls.add(call); - return call.method == 'initialize' ? true : null; - }); - addTearDown(() { - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(channel, null); - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(headerChannel, null); - }); - final headerBarService = LinuxHeaderBarService( - channel: headerChannel, - isLinux: true, - ); - addTearDown(headerBarService.dispose); - await headerBarService.initialize(); - - late BuildContext hostContext; - await tester.pumpWidget( - localizedTestApp( - child: Builder( - builder: (context) { - hostContext = context; - return const SizedBox(); - }, - ), - ), - ); - - final result = showBusyMaxConfirm( - hostContext, - title: 'Discard changes?', - message: 'Unsaved changes will be lost.', - confirmLabel: 'Discard', - destructive: true, - headerBarService: headerBarService, - nativeDialogService: const NativeDialogService(channel: channel), - ); - await tester.pump(); - - expect(await result, isTrue); - expect(find.byType(BusyMaxConfirmDialog), findsNothing); - expect(calls.single.method, 'confirm'); - expect( - headerCalls.where((call) => call.method == 'setModalBarrierVisible'), - isEmpty, - ); - }); - testWidgets('modal coordinator synchronizes the native barrier', ( tester, ) async { @@ -223,24 +148,24 @@ void main() { expect(find.byType(BusyMaxConfirmDialog), findsOneWidget); expect( - calls.where((call) => call.method == 'setModalBarrierVisible'), + calls.where((call) => call.method == 'setModalBarrierDepth'), hasLength(1), ); - expect(calls.last.arguments, isTrue); + expect(calls.last.arguments, 1); await tester.tap(find.text('Remove')); await tester.pumpAndSettle(); expect(await result, isTrue); final barrierCalls = calls - .where((call) => call.method == 'setModalBarrierVisible') + .where((call) => call.method == 'setModalBarrierDepth') .toList(); expect(barrierCalls, hasLength(2)); - expect(barrierCalls.first.arguments, isTrue); - expect(barrierCalls.last.arguments, isFalse); + expect(barrierCalls.first.arguments, 1); + expect(barrierCalls.last.arguments, 0); }); - testWidgets('confirmation fallback scrolls in a short window at 2x text', ( + testWidgets('confirmation scrolls in a short window at 2x text', ( tester, ) async { tester.view @@ -273,12 +198,12 @@ void main() { expect(tester.takeException(), isNull); final scrollView = find.descendant( - of: find.byType(AlertDialog), + of: find.byType(BusyMaxDialogShell), matching: find.byType(SingleChildScrollView), ); expect(scrollView, findsOneWidget); final scrollable = find.descendant( - of: find.byType(AlertDialog), + of: find.byType(BusyMaxDialogShell), matching: find.byType(Scrollable), ); final position = tester.state(scrollable).position; @@ -336,16 +261,20 @@ void main() { await tester.pumpAndSettle(); expect( - calls.where((call) => call.method == 'setModalBarrierVisible'), - hasLength(1), + calls + .where((call) => call.method == 'setModalBarrierDepth') + .map((call) => call.arguments), + [1, 2], ); Navigator.of(hostContext, rootNavigator: true).pop(); await tester.pumpAndSettle(); await second; expect( - calls.where((call) => call.method == 'setModalBarrierVisible'), - hasLength(1), + calls + .where((call) => call.method == 'setModalBarrierDepth') + .map((call) => call.arguments), + [1, 2, 1], ); Navigator.of(hostContext, rootNavigator: true).pop(); @@ -353,10 +282,9 @@ void main() { await first; final barrierCalls = calls - .where((call) => call.method == 'setModalBarrierVisible') + .where((call) => call.method == 'setModalBarrierDepth') .toList(); - expect(barrierCalls, hasLength(2)); - expect(barrierCalls.last.arguments, isFalse); + expect(barrierCalls.map((call) => call.arguments), [1, 2, 1, 0]); }); testWidgets('serializes rapid manual native barrier transitions', ( @@ -364,14 +292,14 @@ void main() { ) async { const channel = MethodChannel('busymax_test/serialized_modal_barrier'); final firstUpdate = Completer(); - final transitions = []; + final transitions = []; TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger .setMockMethodCallHandler(channel, (call) async { if (call.method == 'initialize') { return true; } - if (call.method == 'setModalBarrierVisible') { - transitions.add(call.arguments! as bool); + if (call.method == 'setModalBarrierDepth') { + transitions.add(call.arguments! as int); if (transitions.length == 1) { await firstUpdate.future; } @@ -392,20 +320,20 @@ void main() { final acquire = acquireBusyMaxModalBarrier(service); await tester.pump(); - expect(transitions, [true]); + expect(transitions, [1]); final release = releaseBusyMaxModalBarrier(service); await tester.pump(); expect( transitions, - [true], + [1], reason: 'the native hide must wait for the in-flight native show', ); firstUpdate.complete(); await Future.wait([acquire, release]); - expect(transitions, [true, false]); + expect(transitions, [1, 0]); }); testWidgets('failed native barrier acquisition rolls back and can retry', ( @@ -420,14 +348,14 @@ void main() { ); expect( service.transitions, - [true, false], + [1, 0], reason: 'a failed native show requires a best-effort native rollback', ); await acquireBusyMaxModalBarrier(service); await releaseBusyMaxModalBarrier(service); - expect(service.transitions, [true, false, true, false]); + expect(service.transitions, [1, 0, 1, 0]); }); testWidgets('modal coordinator resolves the service from ProviderScope', ( @@ -474,10 +402,10 @@ void main() { expect(calls.first.method, 'initialize'); expect( calls - .where((call) => call.method == 'setModalBarrierVisible') + .where((call) => call.method == 'setModalBarrierDepth') .single .arguments, - isTrue, + 1, ); await tester.tap(find.text('Cancel')); @@ -485,9 +413,9 @@ void main() { expect(await result, isFalse); final barrierCalls = calls - .where((call) => call.method == 'setModalBarrierVisible') + .where((call) => call.method == 'setModalBarrierDepth') .toList(); - expect(barrierCalls.last.arguments, isFalse); + expect(barrierCalls.last.arguments, 0); }); testWidgets('editor dialog requires an explicit cancel action', ( @@ -688,13 +616,13 @@ class _ApplicationNavigationIntent extends Intent { class _FailingModalBarrierService extends LinuxHeaderBarService { _FailingModalBarrierService() : super(isLinux: false); - final transitions = []; + final transitions = []; var _failNextShow = true; @override - Future setModalBarrierVisible(bool value) async { + Future setModalBarrierDepth(int value) async { transitions.add(value); - if (value && _failNextShow) { + if (value > 0 && _failNextShow) { _failNextShow = false; throw StateError('simulated native response failure'); } diff --git a/test/app/localization_audit_test.dart b/test/app/localization_audit_test.dart index 7d96502..65de817 100644 --- a/test/app/localization_audit_test.dart +++ b/test/app/localization_audit_test.dart @@ -2,7 +2,9 @@ import 'dart:convert'; import 'dart:io'; import 'package:busymax/l10n/generated/app_localizations.dart'; -import 'package:busymax/src/l10n/locale_resolution.dart'; +import 'package:busymax/l10n/generated/app_localizations_ar.dart'; +import 'package:busymax/l10n/generated/app_localizations_fa.dart'; +import 'package:busymax/src/l10n/app_locale.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -10,14 +12,18 @@ void main() { test('localized UI surfaces do not hardcode user-facing text', () { final failures = []; - for (final path in _auditedUiPaths) { - final source = File(path).readAsStringSync(); - for (final match in _userFacingLiteralPattern.allMatches(source)) { - final literal = match.group(1)!; - if (_isTechnicalLiteral(literal)) { - continue; + for (final file in _productionUiDartFiles()) { + final source = file.readAsStringSync(); + for (final pattern in _userFacingLiteralPatterns) { + for (final match in pattern.allMatches(source)) { + final literal = match.group(1)!; + if (_isTechnicalLiteral(literal)) { + continue; + } + failures.add( + '${file.path}:${_lineForOffset(source, match.start)}: $literal', + ); } - failures.add('$path:${_lineForOffset(source, match.start)}: $literal'); } } @@ -30,8 +36,8 @@ void main() { final templateMessages = _messages(templateArb); final failures = []; - for (final path in _translatedArbPaths) { - final file = File(path); + for (final file in _translatedArbFiles()) { + final path = file.path; final messages = _messages(_decodeArb(file)); final templateKeys = templateMessages.keys.toSet(); final translatedKeys = messages.keys.toSet(); @@ -60,6 +66,12 @@ void main() { expect(failures, isEmpty, reason: failures.join('\n')); }); + test('Linux package metadata matches every translated catalog', () { + final failures = _metadataTranslationFailures().toList(); + + expect(failures, isEmpty, reason: failures.join('\n')); + }); + test('Finnish is generated and exposed as a supported locale', () { const locale = Locale('fi'); final localizations = lookupAppLocalizations(locale); @@ -69,6 +81,15 @@ void main() { expect(localizations.today, 'Tänään'); }); + test('Estonian is generated and exposed as a supported locale', () { + const locale = Locale('et'); + final localizations = lookupAppLocalizations(locale); + + expect(AppLocalizations.supportedLocales, contains(locale)); + expect(localizations.settings, 'Seaded'); + expect(localizations.today, 'Täna'); + }); + test('Russian is generated and exposed as a supported locale', () { const locale = Locale('ru'); final localizations = lookupAppLocalizations(locale); @@ -76,6 +97,57 @@ void main() { expect(AppLocalizations.supportedLocales, contains(locale)); expect(localizations.settings, 'Настройки'); expect(localizations.today, 'Сегодня'); + expect(localizations.viewAgenda, 'Расписание'); + expect(localizations.currentLocale, 'Язык приложения'); + }); + + test('package metadata uses reviewed product wording in every locale', () { + final desktop = File( + 'linux/io.busystack.busymax.desktop', + ).readAsStringSync(); + final metainfo = File( + 'linux/io.busystack.busymax.metainfo.xml', + ).readAsStringSync(); + + const summaries = { + 'ar': 'مدير التقويم والمهام', + 'de': 'Kalender- und Aufgabenverwaltung', + 'es': 'Gestor de calendarios y tareas', + 'et': 'Kalendri- ja ülesannete haldur', + 'fa': 'مدیر تقویم و کارها', + 'fi': 'Kalenteri- ja tehtäväsovellus', + 'fr': 'Gestionnaire de calendriers et de tâches', + 'hi': 'कैलेंडर और कार्य प्रबंधक', + 'it': 'Gestore di calendari e attività', + 'ja': 'カレンダー・タスク管理アプリ', + 'ko': '캘린더와 할 일 관리', + 'pt': 'Gestor de calendário e tarefas', + 'ru': 'Календарь и планировщик задач', + 'vi': 'Trình quản lý lịch và công việc', + 'zh': '日历与任务管理工具', + 'zh_Hans': '日历与任务管理工具', + 'zh_Hant': '行事曆與待辦事項管理工具', + }; + + for (final entry in summaries.entries) { + expect( + desktop, + contains('Comment[${entry.key}]=${entry.value}'), + reason: 'desktop ${entry.key}', + ); + final appStreamLocale = entry.key.replaceAll('_', '-'); + expect( + metainfo, + contains( + '${entry.value}', + ), + reason: 'AppStream ${entry.key}', + ); + } + expect( + '$desktop\n$metainfo', + isNot(contains('Менеджер календаря и задач')), + ); }); test('Portuguese is generated and exposed as a supported locale', () { @@ -96,6 +168,15 @@ void main() { expect(localizations.today, 'आज'); }); + test('Italian is generated and exposed as a supported locale', () { + const locale = Locale('it'); + final localizations = lookupAppLocalizations(locale); + + expect(AppLocalizations.supportedLocales, contains(locale)); + expect(localizations.settings, 'Impostazioni'); + expect(localizations.today, 'Oggi'); + }); + test('Japanese is generated and exposed as a supported locale', () { const locale = Locale('ja'); final localizations = lookupAppLocalizations(locale); @@ -114,6 +195,15 @@ void main() { expect(localizations.today, '오늘'); }); + test('Vietnamese is generated and exposed as a supported locale', () { + const locale = Locale('vi'); + final localizations = lookupAppLocalizations(locale); + + expect(AppLocalizations.supportedLocales, contains(locale)); + expect(localizations.settings, 'Cài đặt'); + expect(localizations.today, 'Hôm nay'); + }); + test('Arabic is generated and exposed as a supported locale', () { const locale = Locale('ar'); final localizations = lookupAppLocalizations(locale); @@ -176,6 +266,40 @@ void main() { expect(direction, TextDirection.rtl); }); + test('RTL translations isolate dynamic content', () { + const fsi = '\u2068'; + const pdi = '\u2069'; + final localizations = [ + AppLocalizationsAr(), + AppLocalizationsFa(), + ]; + + for (final l10n in localizations) { + expect( + l10n.exportedFile('exports/schedule-v2.ics'), + contains('${fsi}exports/schedule-v2.ics$pdi'), + ); + expect(l10n.feedbackSuccess('BM-12345'), contains('${fsi}BM-12345$pdi')); + expect( + l10n.dateTimeDisplay('2026-07-29', '14:30'), + allOf(contains('${fsi}2026-07-29$pdi'), contains('${fsi}14:30$pdi')), + ); + } + }); + + test('Persian dynamic numbers use Persian digits', () { + const fsi = '\u2068'; + const pdi = '\u2069'; + final fa = AppLocalizationsFa(); + + expect(fa.moreItems(12), contains('$fsi۱۲$pdi')); + expect(fa.pendingOpAttempts(42), contains('$fsi۴۲$pdi')); + expect(fa.weekNumberTooltip(27), contains('$fsi۲۷$pdi')); + + // package:intl intentionally uses Latin digits for the generic ar locale. + expect(AppLocalizationsAr().moreItems(12), contains('${fsi}12$pdi')); + }); + test('both Chinese scripts are generated and supported', () { const simplified = Locale.fromSubtags( languageCode: 'zh', @@ -217,41 +341,119 @@ void main() { traditional, ); }); + + test('locale resolution considers every system preference', () { + expect( + resolveBusyMaxLocales(const [ + Locale('eo'), + Locale('de', 'DE'), + ], AppLocalizations.supportedLocales), + const Locale('de'), + ); + }); + + test('unsupported locale lists deliberately fall back to English', () { + expect( + resolveBusyMaxLocales(const [ + Locale('eo'), + Locale('kl'), + ], AppLocalizations.supportedLocales), + const Locale('en'), + ); + }); + + test('every selectable locale has a generated catalog', () { + final generated = AppLocalizations.supportedLocales.toSet() + ..remove(const Locale('zh')); + final selectable = busyMaxLocaleOptions + .map((option) => option.locale) + .toSet(); + + expect(selectable, generated); + }); } -const _auditedUiPaths = [ - 'lib/src/features/settings/presentation/settings_screen.dart', - 'lib/src/features/auth/presentation/sign_in_screen.dart', - 'lib/src/features/calendar/presentation/event_description_editor.dart', - 'lib/src/features/calendar/presentation/event_editor.dart', - 'lib/src/features/schedule/presentation/mini_calendar.dart', - 'lib/src/features/schedule/presentation/schedule_day_week_view.dart', - 'lib/src/features/schedule/presentation/schedule_sidebar.dart', -]; +Iterable _productionUiDartFiles() sync* { + for (final entity in Directory('lib').listSync(recursive: true)) { + if (entity is! File || !entity.path.endsWith('.dart')) { + continue; + } + if (entity.path.contains('/l10n/generated/')) { + continue; + } + if (!entity.path.contains('/presentation/') && + !entity.path.contains('/src/app/')) { + continue; + } + yield entity; + } +} -const _translatedArbPaths = [ - 'lib/l10n/app_ar.arb', - 'lib/l10n/app_de.arb', - 'lib/l10n/app_es.arb', - 'lib/l10n/app_fa.arb', - 'lib/l10n/app_fi.arb', - 'lib/l10n/app_fr.arb', - 'lib/l10n/app_hi.arb', - 'lib/l10n/app_ja.arb', - 'lib/l10n/app_ko.arb', - 'lib/l10n/app_pt.arb', - 'lib/l10n/app_ru.arb', - 'lib/l10n/app_zh.arb', - 'lib/l10n/app_zh_Hans.arb', - 'lib/l10n/app_zh_Hant.arb', -]; +Iterable _translatedArbFiles() sync* { + for (final entity in Directory('lib/l10n').listSync()) { + if (entity is File && + entity.path.endsWith('.arb') && + !entity.path.endsWith('app_en.arb')) { + yield entity; + } + } +} + +Iterable _metadataTranslationFailures() sync* { + final targetLocales = [ + for (final file in _translatedArbFiles()) + RegExp(r'app_([A-Za-z_]+)\.arb$').firstMatch(file.path)!.group(1)!, + ]..sort(); + final desktop = File('linux/io.busystack.busymax.desktop').readAsStringSync(); + final metainfo = File( + 'linux/io.busystack.busymax.metainfo.xml', + ).readAsStringSync(); + final snap = File('snap/snapcraft.yaml').readAsStringSync(); + + if (!snap.contains('Snap Store listing translations are managed outside')) { + yield 'snap/snapcraft.yaml: missing external translation note'; + } + for (final locale in targetLocales) { + final xmlLocale = locale.replaceAll('_', '-'); + if (!desktop.contains('Name[$locale]=')) { + yield 'linux/io.busystack.busymax.desktop: missing Name[$locale]'; + } + if (!desktop.contains('Comment[$locale]=')) { + yield 'linux/io.busystack.busymax.desktop: missing Comment[$locale]'; + } + if (!metainfo.contains('')) { + yield 'linux/io.busystack.busymax.metainfo.xml: missing name for ' + '$xmlLocale'; + } + if (!metainfo.contains('')) { + yield 'linux/io.busystack.busymax.metainfo.xml: missing summary for ' + '$xmlLocale'; + } + if (!metainfo.contains('

')) { + yield 'linux/io.busystack.busymax.metainfo.xml: missing description for ' + '$xmlLocale'; + } + } +} -final _userFacingLiteralPattern = RegExp( - r"(?:\bText\(\s*|\b(?:title|subtitle|tooltip|label|message|description|semanticLabel|labelText|hintText|helperText):\s*)'([^']*[A-Za-z][^']*)'", -); +final _userFacingLiteralPatterns = [ + RegExp(r"\bText\(\s*'([^']*[A-Za-z][^']*)'"), + RegExp(r"\bSelectableText\(\s*'([^']*[A-Za-z][^']*)'"), + RegExp( + r"\b(?:title|subtitle|tooltip|label|message|description|semanticLabel|labelText|hintText|helperText):\s*'([^']*[A-Za-z][^']*)'", + ), +]; bool _isTechnicalLiteral(String literal) { - return RegExp(r'^\$\{?[A-Za-z_]\w*(?:\.[A-Za-z_]\w*)*\}?$').hasMatch(literal); + final interpolationStripped = literal.replaceAll( + RegExp(r'\$\{[^}]*\}|\$[A-Za-z_][A-Za-z0-9_]*'), + '', + ); + return literal == 'BusyMax' || + literal == 'iCalendar' || + literal == 'Ubuntu' || + literal.startsWith(r'$') || + !RegExp(r'[A-Za-z]{3,}').hasMatch(interpolationStripped); } Map _decodeArb(File file) { diff --git a/test/app/localized_formatters_test.dart b/test/app/localized_formatters_test.dart new file mode 100644 index 0000000..5d161f5 --- /dev/null +++ b/test/app/localized_formatters_test.dart @@ -0,0 +1,53 @@ +import 'package:busymax/src/l10n/localized_formatters.dart'; +import 'package:busymax/src/schedule/schedule_range.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:intl/date_symbol_data_local.dart'; + +void main() { + test('month headings follow standalone CLDR casing by locale', () async { + const expectedJuly = { + 'ar': 'يوليو', + 'de': 'Juli', + 'en': 'July', + 'es': 'Julio', + 'et': 'juuli', + 'fa': 'ژوئیه', + 'fi': 'heinäkuu', + 'fr': 'Juillet', + 'hi': 'जुलाई', + 'it': 'Luglio', + 'ja': '7月', + 'ko': '7월', + 'pt': 'julho', + 'ru': 'Июль', + 'vi': 'Tháng 7', + 'zh': '七月', + }; + + for (final entry in expectedJuly.entries) { + await initializeDateFormatting(entry.key); + expect( + localizedMonthHeading(entry.key, DateTime(2026, 7)), + entry.value, + reason: entry.key, + ); + } + }); + + test('schedule ranges localize each complete endpoint', () async { + await initializeDateFormatting('de'); + final range = ScheduleRange( + start: DateTime(2026, 7, 27), + end: DateTime(2026, 8, 3), + ); + + expect( + localizedScheduleRangeLabel('en', range), + 'Jul 27, 2026 – Aug 2, 2026', + ); + expect( + localizedScheduleRangeLabel('de', range), + '27. Juli 2026 – 2. Aug. 2026', + ); + }); +} diff --git a/test/app/modal_barrier_test.dart b/test/app/modal_barrier_test.dart index 7d0872d..f3b42f5 100644 --- a/test/app/modal_barrier_test.dart +++ b/test/app/modal_barrier_test.dart @@ -18,12 +18,9 @@ void main() { final nativeAlpha = double.parse(match!.group(1)!); final dartAlpha = busyMaxFallbackSurfaceColors(Brightness.dark).shade.a; expect(nativeAlpha, closeTo(dartAlpha, 0.0001)); - expect( - source, - contains( - 'self->header_bar_modal_barrier_color, kDefaultModalBarrierColor', - ), - ); + expect(source, contains('modal_barrier_color_for_depth(')); + expect(source, contains('self->header_bar_modal_barrier_color')); + expect(source, contains('kDefaultModalBarrierColor')); }); for (final (brightness, expectedAlpha) in [ diff --git a/test/app/native_ui_audit_test.dart b/test/app/native_ui_audit_test.dart index 1d75018..b2b86c1 100644 --- a/test/app/native_ui_audit_test.dart +++ b/test/app/native_ui_audit_test.dart @@ -535,6 +535,8 @@ void main() { 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('onboarding_back_slot')); expect(source, contains('onboarding_back_button')); expect(source, contains('onboarding_continue_slot')); @@ -873,6 +875,7 @@ void main() { 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"')); @@ -916,6 +919,12 @@ void main() { 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('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, @@ -1275,7 +1284,7 @@ void main() { expect(service, contains('on PlatformException')); }); - test('Linux confirmations are native GTK dialogs with a Yaru fallback', () { + test('confirmations use the shared app dialog surface', () { final runner = File('linux/runner/my_application.cc').readAsStringSync(); final dialogs = File( 'lib/src/app/busymax_dialogs.dart', @@ -1285,31 +1294,9 @@ void main() { final confirmBody = design.substring(confirmStart); expect(runner, contains('"busymax/native_dialogs"')); - expect(runner, contains('gtk_message_dialog_new(')); - expect(runner, contains('GTK_DIALOG_DESTROY_WITH_PARENT')); - expect(runner, contains('GTK_STYLE_CLASS_DESTRUCTIVE_ACTION')); - expect(runner, contains('GTK_STYLE_CLASS_SUGGESTED_ACTION')); - final nativeConfirmStart = runner.indexOf( - 'static void handle_native_confirmation', - ); - final nativeConfirmEnd = runner.indexOf( - 'struct NativeTimeZoneOption', - nativeConfirmStart, - ); - final nativeConfirm = runner.substring( - nativeConfirmStart, - nativeConfirmEnd, - ); - expect(nativeConfirm, isNot(contains('style_native_dialog(dialog)'))); - expect(nativeConfirm, isNot(contains('busymax-native-dialog'))); - expect(nativeConfirm, isNot(contains('background_color'))); - expect( - runner, - contains( - 'gtk_dialog_set_default_response(GTK_DIALOG(dialog), ' - 'GTK_RESPONSE_CANCEL)', - ), - ); + expect(runner, isNot(contains('gtk_message_dialog_new('))); + expect(runner, isNot(contains('handle_native_confirmation'))); + expect(runner, isNot(contains('strcmp(method, "confirm")'))); expect(runner, contains('register_native_dialogs(self, view, window)')); expect( runner, @@ -1321,10 +1308,9 @@ void main() { runner, isNot(contains('native_dialog_method_call_cb, g_object_ref(window)')), ); - expect(dialogs, contains('NativeDialogService nativeDialogService')); - expect(confirmBody, contains('return AlertDialog(')); - expect(confirmBody, contains('clipBehavior: Clip.antiAlias')); - expect(confirmBody, isNot(contains('return BusyMaxDialogShell('))); + expect(dialogs, isNot(contains('NativeDialogService'))); + expect(confirmBody, contains('return BusyMaxDialogShell(')); + expect(confirmBody, isNot(contains('return AlertDialog('))); }); test('timezone selection uses native GTK and Handy controls on Linux', () { @@ -1386,11 +1372,78 @@ void main() { 'G_CALLBACK(native_time_zone_parent_is_active_notify_cb), 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('), ); - expect(runner, contains('if (!gtk_window_is_active(parent) ||')); + final headerFocusStart = runner.indexOf( + 'static gboolean refresh_header_bar_focus_state_cb(', + ); + final headerFocusEnd = runner.indexOf( + 'static void set_header_bar_modal_barrier_depth(', + headerFocusStart, + ); + expect(headerFocusStart, isNonNegative); + expect(headerFocusEnd, greaterThan(headerFocusStart)); + final headerFocus = runner.substring(headerFocusStart, headerFocusEnd); + expect(headerFocus, contains('gtk_window_is_active(self->main_window)')); + expect( + headerFocus, + contains('gtk_window_is_active(self->header_focus_transient_window)'), + ); + 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(', + ); + final activationCallbackEnd = runner.indexOf( + 'static void rebuild_native_time_zone_results(', + activationCallbackStart, + ); + final activationCallback = runner.substring( + activationCallbackStart, + activationCallbackEnd, + ); + expect(activationCallback, contains('g_idle_add_full(')); + expect( + activationCallback, + contains('native_time_zone_present_after_parent_activation_cb'), + ); + expect( + activationCallback, + isNot(contains('gtk_window_present_with_time(')), + ); + final deferredActivationStart = runner.indexOf( + 'static gboolean ' + 'native_time_zone_present_after_parent_activation_cb(', + ); + final deferredActivationEnd = activationCallbackStart; + final deferredActivation = runner.substring( + deferredActivationStart, + deferredActivationEnd, + ); + expect(deferredActivation, contains('!gtk_window_is_active(parent)')); + expect(deferredActivation, contains('gtk_window_is_active(window)')); + expect( + deferredActivation, + contains('gtk_window_present_with_time(window, GDK_CURRENT_TIME)'), + ); expect(runner, contains('kNativeTimeZoneDialogContentHeight')); expect(runner, contains('kNativeTimeZoneDialogStyleClass')); expect(runner, contains('kNativeTimeZoneGroupStyleClass')); @@ -1511,8 +1564,8 @@ void main() { expect(nativeDialogs, isNot(contains('respond_native_prompt'))); expect(nativeDialogs, isNot(contains('gtk_entry_new()'))); expect(nativeDialogs, isNot(contains('gtk_dialog_new_with_buttons('))); - expect(nativeDialogs, contains('gtk_message_dialog_new(')); - expect(runner, contains('strcmp(method, "confirm") == 0')); + expect(nativeDialogs, isNot(contains('gtk_message_dialog_new('))); + expect(runner, isNot(contains('strcmp(method, "confirm")'))); expect(runner, isNot(contains('strcmp(method, "prompt")'))); expect(service, isNot(contains('NativeTextPromptResult'))); @@ -1614,7 +1667,7 @@ void main() { ); final nativeTimeZoneDialogCssStart = nativeDialogCssEnd; final nativeTimeZoneDialogCssEnd = source.indexOf( - 'const gchar* modal_barrier_color', + 'g_autofree gchar* modal_barrier_color', nativeTimeZoneDialogCssStart, ); expect(nativePopoverCssStart, isNonNegative); @@ -1744,12 +1797,9 @@ void main() { headerCss, contains('"headerbar button.titlebutton:disabled:backdrop {"'), ); - expect( - source, - contains( - 'self->header_bar_modal_barrier_color, kDefaultModalBarrierColor', - ), - ); + expect(source, contains('modal_barrier_color_for_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"')); diff --git a/test/app/rtl_glyphs_test.dart b/test/app/rtl_glyphs_test.dart new file mode 100644 index 0000000..229c505 --- /dev/null +++ b/test/app/rtl_glyphs_test.dart @@ -0,0 +1,47 @@ +import 'dart:io'; + +import 'package:busymax/src/app/busymax_glyphs.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:yaru/yaru.dart'; + +void main() { + test('navigation glyphs resolve for both reading directions', () { + expect(BusyMaxGlyphs.backFor(TextDirection.ltr), YaruIcons.arrow_left); + expect(BusyMaxGlyphs.backFor(TextDirection.rtl), YaruIcons.arrow_right); + expect(BusyMaxGlyphs.previousFor(TextDirection.ltr), YaruIcons.arrow_left); + expect(BusyMaxGlyphs.previousFor(TextDirection.rtl), YaruIcons.arrow_right); + expect(BusyMaxGlyphs.nextFor(TextDirection.ltr), YaruIcons.arrow_right); + expect(BusyMaxGlyphs.nextFor(TextDirection.rtl), YaruIcons.arrow_left); + expect(BusyMaxGlyphs.forwardFor(TextDirection.ltr), YaruIcons.go_next); + expect(BusyMaxGlyphs.forwardFor(TextDirection.rtl), YaruIcons.go_previous); + expect(BusyMaxGlyphs.collapsedFor(TextDirection.ltr), YaruIcons.pan_end); + expect(BusyMaxGlyphs.collapsedFor(TextDirection.rtl), YaruIcons.pan_start); + }); + + test('hierarchy glyphs resolve for both reading directions', () { + expect( + BusyMaxGlyphs.chevronForwardFor(TextDirection.ltr), + Icons.chevron_right, + ); + expect( + BusyMaxGlyphs.chevronForwardFor(TextDirection.rtl), + Icons.chevron_left, + ); + expect( + BusyMaxGlyphs.subdirectoryFor(TextDirection.ltr), + Icons.subdirectory_arrow_right, + ); + expect( + BusyMaxGlyphs.subdirectoryFor(TextDirection.rtl), + Icons.subdirectory_arrow_left, + ); + }); + + test('Arabic and Persian glyph coverage is packaged in the snap', () { + expect( + File('snap/snapcraft.yaml').readAsStringSync(), + contains('fonts-noto-core'), + ); + }); +} diff --git a/test/features/auth/presentation/auth_routing_test.dart b/test/features/auth/presentation/auth_routing_test.dart index 3dc7ace..af53dae 100644 --- a/test/features/auth/presentation/auth_routing_test.dart +++ b/test/features/auth/presentation/auth_routing_test.dart @@ -81,6 +81,42 @@ void main() { await _disposeApp(tester); }); + testWidgets('onboarding content and actions share one responsive rail', ( + tester, + ) async { + tester.view.devicePixelRatio = 1; + tester.view.physicalSize = const Size(1000, 720); + addTearDown(tester.view.reset); + + await _pumpApp(tester, database: database, oAuth: oAuth); + await tester.pumpAndSettle(); + + void expectAlignedActions({required double expectedRailWidth}) { + final rail = tester.getRect( + find.byKey(const ValueKey('onboarding-content-rail')), + ); + final back = tester.getRect( + find.byKey(const ValueKey('onboarding-back-button')), + ); + final continueButton = tester.getRect( + find.byKey(const ValueKey('onboarding-continue-button')), + ); + + expect(rail.width, closeTo(expectedRailWidth, 0.01)); + expect(back.left, closeTo(rail.left, 0.01)); + expect(continueButton.right, closeTo(rail.right, 0.01)); + } + + expectAlignedActions(expectedRailWidth: 480); + + tester.view.physicalSize = const Size(420, 720); + await tester.pumpAndSettle(); + + expectAlignedActions(expectedRailWidth: 396); + expect(tester.takeException(), null); + await _disposeApp(tester); + }); + test('setup provider actions use BusyMax row patterns', () { final source = File( 'lib/src/features/auth/presentation/sign_in_screen.dart', diff --git a/test/features/notifications/desktop_notification_service_test.dart b/test/features/notifications/desktop_notification_service_test.dart index 69b38a1..903311e 100644 --- a/test/features/notifications/desktop_notification_service_test.dart +++ b/test/features/notifications/desktop_notification_service_test.dart @@ -63,6 +63,20 @@ void main() { expect(backend.notifications.single.body, '2 tehtävää erääntyy tänään.'); }); + test('notification strings use the Estonian ARB catalog', () async { + final backend = _FakeNotificationBackend(); + final service = DesktopNotificationService( + backend: backend, + settings: AppSettings.defaults().copyWith(notifyDueToday: true), + locale: const Locale('et'), + ); + + await service.notifyDueToday(2); + + expect(backend.notifications.single.summary, 'Täna tähtuvad ülesanded'); + expect(backend.notifications.single.body, '2 ülesannet tähtub täna.'); + }); + test('notification strings use Russian plural rules', () async { final backend = _FakeNotificationBackend(); final service = DesktopNotificationService( @@ -97,6 +111,37 @@ void main() { ); }); + test('notification strings use the Italian ARB catalog', () async { + final backend = _FakeNotificationBackend(); + final service = DesktopNotificationService( + backend: backend, + settings: AppSettings.defaults().copyWith(notifyDueToday: true), + locale: const Locale('it'), + ); + + await service.notifyDueToday(2); + + expect(backend.notifications.single.summary, 'Attività in scadenza oggi'); + expect(backend.notifications.single.body, '2 attività scadono oggi.'); + }); + + test('notification strings use the Vietnamese ARB catalog', () async { + final backend = _FakeNotificationBackend(); + final service = DesktopNotificationService( + backend: backend, + settings: AppSettings.defaults().copyWith(notifyDueToday: true), + locale: const Locale('vi'), + ); + + await service.notifyDueToday(2); + + expect(backend.notifications.single.summary, 'Công việc đến hạn hôm nay'); + expect( + backend.notifications.single.body, + 'Có 2 công việc đến hạn hôm nay.', + ); + }); + test('notification strings use the new Asian ARB catalogs', () async { final cases = <({Locale locale, String summary, String body})>[ ( @@ -159,8 +204,8 @@ void main() { expect(backend.notifications.map((notification) => notification.body), [ 'هناك مهمة واحدة مستحقة اليوم.', 'هناك مهمتان مستحقتان اليوم.', - 'هناك 3 مهام مستحقة اليوم.', - 'هناك 11 مهمة مستحقة اليوم.', + 'هناك \u20683\u2069 مهام مستحقة اليوم.', + 'هناك \u206811\u2069 مهمة مستحقة اليوم.', ]); }); @@ -182,7 +227,7 @@ void main() { ); expect(backend.notifications.map((notification) => notification.body), [ 'امروز یک کار سررسید دارد.', - 'امروز 2 کار سررسید دارند.', + 'امروز \u2068۲\u2069 کار سررسید دارند.', ]); }); diff --git a/test/features/schedule/presentation/schedule_views_test.dart b/test/features/schedule/presentation/schedule_views_test.dart index 0f298e7..b3f03e8 100644 --- a/test/features/schedule/presentation/schedule_views_test.dart +++ b/test/features/schedule/presentation/schedule_views_test.dart @@ -2473,7 +2473,7 @@ void main() { expect(sidebar, contains("'schedule-calendar'")); expect(sidebar, contains("'schedule-task-list'")); expect(sidebar, contains('AnimatedRotation')); - expect(sidebar, contains('YaruIcons.pan_end')); + expect(sidebar, contains('BusyMaxGlyphs.collapsedFor')); expect(sidebar, contains('if (_expanded)')); expect(sidebar, contains('MiniCalendar(')); expect(sidebar, isNot(contains('BusyMaxGroupedList('))); @@ -2656,6 +2656,34 @@ void main() { ); }); + testWidgets('mini calendar uses a capitalized standalone Russian month', ( + tester, + ) async { + await tester.pumpWidget( + localizedTestApp( + locale: const Locale('ru'), + child: Scaffold( + body: SizedBox( + width: 300, + child: MiniCalendar( + selectedDate: DateTime(2026, 7, 15), + firstWeekday: DateTime.monday, + onSelected: (_) {}, + onMonthSelected: (_) {}, + onYearSelected: (_) {}, + onWeekSelected: (_) {}, + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('Июль'), findsOneWidget); + expect(find.text('июль'), findsNothing); + expect(find.text('июля'), findsNothing); + }); + testWidgets('mini calendar header arrows page without selecting a date', ( tester, ) async { diff --git a/test/platform/linux_header_bar_configuration_synchronizer_test.dart b/test/platform/linux_header_bar_configuration_synchronizer_test.dart index 22114e0..3f5a420 100644 --- a/test/platform/linux_header_bar_configuration_synchronizer_test.dart +++ b/test/platform/linux_header_bar_configuration_synchronizer_test.dart @@ -122,6 +122,7 @@ BusyMaxHeaderBarConfiguration _configuration({required bool dark}) { aboutBusyMax: 'About BusyMax', ), sidebarWidth: 300, + textDirection: TextDirection.ltr, theme: BusyMaxHeaderBarTheme( preferDark: dark, highContrast: false, diff --git a/test/platform/linux_header_bar_service_test.dart b/test/platform/linux_header_bar_service_test.dart index 2117604..951d00a 100644 --- a/test/platform/linux_header_bar_service_test.dart +++ b/test/platform/linux_header_bar_service_test.dart @@ -85,6 +85,7 @@ void main() { ), ); await service.setSidebarWidth(300); + await service.setTextDirection(TextDirection.rtl); await session.setOnboardingControls( visible: true, canGoBack: false, @@ -92,7 +93,7 @@ void main() { backLabel: 'Back', continueLabel: 'Continue', ); - await service.setModalBarrierVisible(true); + await service.setModalBarrierDepth(2); await service.setTheme( const BusyMaxHeaderBarTheme( preferDark: true, @@ -118,8 +119,9 @@ void main() { 'initialize', 'setLocalizedLabels', 'setSidebarWidth', + 'setTextDirection', 'setOnboardingControls', - 'setModalBarrierVisible', + 'setModalBarrierDepth', 'setTheme', ]), ); @@ -138,9 +140,15 @@ void main() { ); expect(calls[1].arguments, containsPair('aboutBusyMax', 'About BusyMax')); expect(calls[2].arguments, 300); - expect(calls[3].arguments, containsPair('visible', true)); - expect(calls[3].arguments, containsPair('canContinue', true)); - expect(calls[3].arguments, containsPair('continueLabel', 'Continue')); + 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, 2); expect( calls.last.arguments, equals({ @@ -192,7 +200,7 @@ void main() { await service.initialize(); expect(service.isAvailable, isTrue); - await service.setModalBarrierVisible(true); + await service.setModalBarrierDepth(1); expect(service.isAvailable, isFalse); }); diff --git a/test/platform/native_dialog_service_test.dart b/test/platform/native_dialog_service_test.dart index 2a378d9..2fc2002 100644 --- a/test/platform/native_dialog_service_test.dart +++ b/test/platform/native_dialog_service_test.dart @@ -27,72 +27,6 @@ void main() { .setMockMethodCallHandler(channel, null); }); - test('passes semantic confirmation data to the native host', () async { - MethodCall? receivedCall; - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(channel, (call) async { - receivedCall = call; - return true; - }); - const service = NativeDialogService(channel: channel); - - final result = await service.confirm( - title: 'Discard changes?', - message: 'Unsaved changes will be lost.', - cancelLabel: 'Cancel', - confirmLabel: 'Discard', - destructive: true, - ); - - expect(result.available, isTrue); - expect(result.confirmed, isTrue); - expect(receivedCall?.method, 'confirm'); - expect(receivedCall?.arguments, { - 'title': 'Discard changes?', - 'message': 'Unsaved changes will be lost.', - 'cancelLabel': 'Cancel', - 'confirmLabel': 'Discard', - 'destructive': true, - }); - }); - - test('distinguishes native cancellation from an unavailable host', () async { - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(channel, (_) async => false); - const service = NativeDialogService(channel: channel); - - final result = await service.confirm( - title: 'Continue?', - message: 'Confirm this action.', - cancelLabel: 'Cancel', - confirmLabel: 'Continue', - destructive: false, - ); - - expect(result.available, isTrue); - expect(result.confirmed, isFalse); - }); - - test('reports unavailable when the native channel is missing', () async { - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler( - channel, - (_) async => throw MissingPluginException(), - ); - const service = NativeDialogService(channel: channel); - - final result = await service.confirm( - title: 'Continue?', - message: 'Confirm this action.', - cancelLabel: 'Cancel', - confirmLabel: 'Continue', - destructive: false, - ); - - expect(result.available, isFalse); - expect(result.confirmed, isFalse); - }); - test('passes timezone content to the native chooser', () async { MethodCall? receivedCall; TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger From 8ad246829516fa2f01b13f0c0bb9a0237c400c07 Mon Sep 17 00:00:00 2001 From: albert Date: Wed, 29 Jul 2026 19:08:24 -0700 Subject: [PATCH 47/73] Add theme handling for header bar and improve control alignment. Refactor header bar theme management and enhance initial theme application --- lib/main.dart | 41 +++++++++ lib/src/app/busymax_app.dart | 52 +++++++----- lib/src/app/busymax_design.dart | 2 +- linux/runner/my_application.cc | 98 ++++++++++++++++++++++ test/app/busymax_grouped_surface_test.dart | 5 ++ test/app/native_ui_audit_test.dart | 47 +++++++++-- test/app/theme_localization_test.dart | 36 ++++---- 7 files changed, 237 insertions(+), 44 deletions(-) diff --git a/lib/main.dart b/lib/main.dart index 0175546..ce42ec9 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -5,6 +5,7 @@ import 'package:system_theme/system_theme.dart'; import 'package:window_manager/window_manager.dart'; import 'src/app/app_bootstrap.dart'; +import 'src/app/app_theme.dart'; import 'src/app/busymax_app.dart'; import 'src/config/build_config.dart'; import 'src/core/logging/redacting_logger.dart'; @@ -13,6 +14,7 @@ import 'src/features/schedule/application/compact_agenda_data.dart'; import 'src/features/schedule/presentation/compact_agenda_app.dart'; import 'src/platform/busymax_window_args.dart'; import 'src/platform/gtk_font_service.dart'; +import 'src/platform/linux_header_bar_service.dart'; import 'src/platform/main_window_command_client.dart'; Future main(List args) async { @@ -49,6 +51,13 @@ Future main(List args) async { final initialGtkFont = desktopSettings[1] as GtkFontSettings?; final initialGtkThemeColors = desktopSettings[2] as GtkThemeColors?; + if (windowArgs.kind == BusyMaxWindowKind.main) { + await _applyInitialNativeHeaderBarTheme( + settings: initialAppSettings, + gtkFont: initialGtkFont, + gtkThemeColors: initialGtkThemeColors, + ); + } final overrides = [ buildConfigProvider.overrideWithValue(buildConfig), @@ -93,6 +102,38 @@ Future main(List args) async { } } +Future _applyInitialNativeHeaderBarTheme({ + required AppSettings settings, + required GtkFontSettings? gtkFont, + required GtkThemeColors? gtkThemeColors, +}) async { + final platformDispatcher = WidgetsBinding.instance.platformDispatcher; + final brightness = switch (settings.themeModePreference) { + BusyMaxThemeModePreference.system => platformDispatcher.platformBrightness, + BusyMaxThemeModePreference.light => Brightness.light, + BusyMaxThemeModePreference.dark => Brightness.dark, + }; + final highContrast = platformDispatcher.accessibilityFeatures.highContrast; + final theme = buildBusyMaxTheme( + brightness: brightness, + accentColor: gtkThemeColors?.accent ?? SystemTheme.accentColor.accent, + family: settings.themeFamily, + gtkFontFamily: gtkFont?.family, + gtkFontSize: gtkFont?.size, + gtkThemeColors: gtkThemeColors, + highContrast: highContrast, + ); + final headerBarService = LinuxHeaderBarService(); + try { + await headerBarService.initialize(); + await headerBarService.setTheme( + busyMaxHeaderBarThemeFor(theme, highContrast: highContrast), + ); + } finally { + headerBarService.dispose(); + } +} + Future configureCompactAgendaNativeWindow() async { await windowManager.ensureInitialized(); } diff --git a/lib/src/app/busymax_app.dart b/lib/src/app/busymax_app.dart index ff420ca..6396dd8 100644 --- a/lib/src/app/busymax_app.dart +++ b/lib/src/app/busymax_app.dart @@ -30,6 +30,34 @@ typedef BusyMaxTrayServiceFactory = Future Function()? onBeforeQuit, }); +BusyMaxHeaderBarTheme busyMaxHeaderBarThemeFor( + ThemeData theme, { + required bool highContrast, +}) { + final colors = theme.extension()!; + return BusyMaxHeaderBarTheme( + preferDark: theme.brightness == Brightness.dark, + 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, + popoverBackgroundColor: colors.popover, + menuHoverColor: colors.controlHover, + popoverShadowColor: theme.colorScheme.shadow.withValues( + alpha: + theme.colorScheme.shadow.a * + BusyMaxAlpha.nativeHeaderMenuShadowOpacity, + ), + dialogBackgroundColor: colors.dialog, + dialogOutlineColor: colors.dialogOutline, + modalBarrierColor: colors.shade, + ); +} + class BusyMaxApp extends ConsumerStatefulWidget { const BusyMaxApp({super.key, this.trayServiceFactory}); @@ -202,12 +230,9 @@ class _BusyMaxAppState extends ConsumerState { } void _configureNativeHeaderBarTheme(BuildContext context) { - final colors = BusyMaxSurfaceColors.of(context); final l10n = AppLocalizations.of(context); final materialL10n = MaterialLocalizations.of(context); - final modalBarrierColor = busyMaxModalBarrierColor(context); final theme = Theme.of(context); - final preferDark = theme.brightness == Brightness.dark; final labels = BusyMaxHeaderBarLabels( today: l10n.today, day: l10n.viewDay, @@ -234,26 +259,9 @@ class _BusyMaxAppState extends ConsumerState { labels: labels, sidebarWidth: BusyMaxSizes.sidebarWidth, textDirection: Directionality.of(context), - theme: BusyMaxHeaderBarTheme( - preferDark: preferDark, + theme: busyMaxHeaderBarThemeFor( + theme, highContrast: MediaQuery.highContrastOf(context), - 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, - popoverBackgroundColor: colors.popover, - menuHoverColor: colors.controlHover, - popoverShadowColor: theme.colorScheme.shadow.withValues( - alpha: - theme.colorScheme.shadow.a * - BusyMaxAlpha.nativeHeaderMenuShadowOpacity, - ), - dialogBackgroundColor: colors.dialog, - dialogOutlineColor: colors.dialogOutline, - modalBarrierColor: modalBarrierColor, ), ), ); diff --git a/lib/src/app/busymax_design.dart b/lib/src/app/busymax_design.dart index a9ce18b..7ece53e 100644 --- a/lib/src/app/busymax_design.dart +++ b/lib/src/app/busymax_design.dart @@ -3169,7 +3169,7 @@ class BusyMaxEditorHeader extends StatelessWidget { BusyMaxSpacing.headerInset, BusyMaxSpacing.headerInset, BusyMaxSpacing.headerInset, - 0, + BusyMaxSpacing.headerInset, ), child: Row( crossAxisAlignment: CrossAxisAlignment.center, diff --git a/linux/runner/my_application.cc b/linux/runner/my_application.cc index 6fbd783..d93b277 100644 --- a/linux/runner/my_application.cc +++ b/linux/runner/my_application.cc @@ -129,6 +129,7 @@ struct _MyApplication { gboolean header_bar_sidebar_visible; gboolean header_bar_modal_barrier_visible; gint header_bar_modal_barrier_depth; + gboolean header_bar_theme_received; GtkWindow* main_window; GtkWindow* header_focus_transient_window; GtkWidget* flutter_view; @@ -2351,6 +2352,7 @@ 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); @@ -3130,6 +3132,68 @@ static void update_header_title_box_geometry(MyApplication* self) { 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); } static void update_header_control_visibility(MyApplication* self) { @@ -3385,6 +3449,8 @@ static GtkWidget* create_busymax_titlebar(MyApplication* self) { 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, @@ -4069,6 +4135,32 @@ static FlValue* get_gtk_theme_colors() { return result; } +static void apply_gtk_theme_to_bootstrap_chrome(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_popover_background_color, + fl_lookup_string_arg(colors, "popover")); + set_css_color_field(&self->header_bar_menu_hover_color, + fl_lookup_string_arg(colors, "controlHover")); + set_css_color_field(&self->header_bar_dialog_background_color, + fl_lookup_string_arg(colors, "dialog")); +} + static void gtk_settings_method_call_cb(FlMethodChannel* channel, FlMethodCall* method_call, gpointer user_data) { @@ -4178,6 +4270,10 @@ 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); + set_main_flutter_view_background(self); + } refresh_header_bar_css(self); send_gtk_theme_colors_event(self); } @@ -4773,6 +4869,7 @@ static void my_application_activate(GApplication* application) { return; } + apply_gtk_theme_to_bootstrap_chrome(self); GtkWindow* window = GTK_WINDOW(hdy_application_window_new()); gtk_application_add_window(GTK_APPLICATION(application), window); self->main_window = window; @@ -5019,6 +5116,7 @@ static void my_application_init(MyApplication* self) { self->header_bar_can_show_sidebar = TRUE; self->header_bar_sidebar_visible = TRUE; self->header_bar_modal_barrier_visible = FALSE; + self->header_bar_theme_received = FALSE; self->header_bar_modal_barrier_depth = 0; self->main_window = nullptr; self->header_focus_transient_window = nullptr; diff --git a/test/app/busymax_grouped_surface_test.dart b/test/app/busymax_grouped_surface_test.dart index 8342dc9..f62e85b 100644 --- a/test/app/busymax_grouped_surface_test.dart +++ b/test/app/busymax_grouped_surface_test.dart @@ -2001,6 +2001,11 @@ void main() { expect(tester.getSize(save).width, lessThan(slotWidth)); expect(tester.getSize(cancel).height, kYaruButtonHeight); expect(tester.getSize(save).height, kYaruButtonHeight); + final headerBottom = tester + .getBottomRight(find.byType(BusyMaxEditorHeader)) + .dy; + final actionBottom = tester.getBottomRight(save).dy; + expect(headerBottom - actionBottom, BusyMaxSpacing.headerInset); final cancelButton = tester.widget(cancel); final saveButton = tester.widget(save); final actionTextStyle = Theme.of(tester.element(save)).textTheme.titleSmall; diff --git a/test/app/native_ui_audit_test.dart b/test/app/native_ui_audit_test.dart index b2b86c1..cb0fbf0 100644 --- a/test/app/native_ui_audit_test.dart +++ b/test/app/native_ui_audit_test.dart @@ -537,6 +537,8 @@ void main() { 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')); @@ -1062,6 +1064,7 @@ void main() { 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( @@ -1101,6 +1104,35 @@ void main() { 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()')), @@ -2173,6 +2205,7 @@ void main() { 'lib/src/platform/gtk_font_service.dart', ).readAsStringSync(); final app = File('lib/src/app/busymax_app.dart').readAsStringSync(); + final main = File('lib/main.dart').readAsStringSync(); final compactApp = File( 'lib/src/features/schedule/presentation/compact_agenda_app.dart', ).readAsStringSync(); @@ -2227,13 +2260,9 @@ void main() { ); expect(headerBarService, contains('required this.preferDark')); expect(headerBarService, contains("'preferDark': preferDark")); - expect( - app, - contains('final preferDark = theme.brightness == Brightness.dark'), - ); + expect(app, contains('preferDark: theme.brightness == Brightness.dark')); expect(app, contains('popoverShadowColor: theme.colorScheme.shadow')); expect(app, contains('BusyMaxAlpha.nativeHeaderMenuShadowOpacity')); - expect(app, contains('preferDark: preferDark')); expect(source, contains('static void set_gtk_theme_preference')); expect( source, @@ -2299,6 +2328,14 @@ void main() { 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(')); }); test('app code does not bypass centralized typography', () { diff --git a/test/app/theme_localization_test.dart b/test/app/theme_localization_test.dart index 7bb0f08..0c14057 100644 --- a/test/app/theme_localization_test.dart +++ b/test/app/theme_localization_test.dart @@ -27,6 +27,21 @@ import 'package:busymax/src/schedule/schedule_view_mode.dart'; import '../test_localized_app.dart'; void main() { + test('native header theme uses the exact 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); + }); + test('builds with system accent and tokenized control surfaces', () { final light = _buildBusyMaxTheme(brightness: Brightness.light); final dark = _buildBusyMaxTheme(brightness: Brightness.dark); @@ -1600,7 +1615,7 @@ void main() { expect( source, - contains('final colors = BusyMaxSurfaceColors.of(context);'), + contains('final colors = theme.extension()!;'), ); expect(source, contains('_headerBarConfigurationSynchronizer.schedule(')); expect(synchronizer, contains('await service.setTheme(')); @@ -1615,7 +1630,7 @@ void main() { expect(source, contains('menuHoverColor: colors.controlHover')); expect(source, contains('dialogBackgroundColor: colors.dialog')); expect(source, isNot(contains('floatingBorderColor:'))); - expect(source, contains('modalBarrierColor: modalBarrierColor')); + 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')); @@ -1639,20 +1654,12 @@ void main() { final source = File( 'lib/src/features/auth/presentation/sign_in_screen.dart', ).readAsStringSync(); - final shellStart = source.indexOf( - 'constraints: const BoxConstraints(maxWidth: 900)', - ); - final shellEnd = source.indexOf('child: Column(', shellStart); - final shellSource = source.substring(shellStart, shellEnd); expect(source, contains('color: BusyMaxSurfaceColors.of(context).window')); expect( source, isNot(contains('color: BusyMaxSurfaceColors.of(context).view')), ); - expect(shellSource, contains('BusyMaxSurface(')); - expect(shellSource, contains('filled: false')); - expect(shellSource, isNot(contains('filled: true'))); expect(source, contains('final title = context.l10n.onboardingSetupTitle')); expect(source, contains('.claimSession()')); expect(source, contains('_headerBarSession.updateState(')); @@ -1661,12 +1668,9 @@ void main() { expect(source, isNot(contains('class _OnboardingHeader'))); expect(source, isNot(contains('class _OnboardingProgressDots'))); expect(source, isNot(contains('Border(top: BorderSide'))); - expect( - source, - contains('constraints: const BoxConstraints(maxWidth: 900)'), - ); - expect(source, contains('constraints: const BoxConstraints(')); - expect(source, contains('maxWidth: 480')); + expect(source, contains("key: const ValueKey('onboarding-content-rail')")); + expect(source, contains('busyMaxOnboardingContentMaxWidth')); + expect(source, contains('width: contentRailWidth')); }); testWidgets('BusyMaxApp wires localization delegates and system theme', ( From 490b325eae2cdc0561fb76f861734987f307f161 Mon Sep 17 00:00:00 2001 From: albert Date: Wed, 29 Jul 2026 19:35:31 -0700 Subject: [PATCH 48/73] Replace splash screen with BusyMaxStartupView and enhance onboarding layout. Update button styles and improve responsiveness for onboarding steps. --- lib/main.dart | 4 +- lib/src/app/app_router.dart | 40 +++++- .../auth/presentation/sign_in_screen.dart | 135 ++++++++++++------ linux/runner/my_application.cc | 22 ++- test/app/busymax_grouped_surface_test.dart | 37 +++++ test/app/native_ui_audit_test.dart | 17 ++- .../auth/presentation/auth_routing_test.dart | 59 +++++++- .../linux_header_bar_service_test.dart | 3 +- 8 files changed, 262 insertions(+), 55 deletions(-) diff --git a/lib/main.dart b/lib/main.dart index ce42ec9..34704bd 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -18,7 +18,8 @@ import 'src/platform/linux_header_bar_service.dart'; import 'src/platform/main_window_command_client.dart'; Future main(List args) async { - WidgetsFlutterBinding.ensureInitialized(); + final binding = WidgetsFlutterBinding.ensureInitialized(); + binding.deferFirstFrame(); final windowController = await WindowController.fromCurrentEngine(); final windowArgs = BusyMaxWindowArgs.parse(windowController.arguments); final buildConfig = BuildConfig.fromEnvironment(); @@ -100,6 +101,7 @@ Future main(List args) async { ), ); } + binding.allowFirstFrame(); } Future _applyInitialNativeHeaderBarTheme({ diff --git a/lib/src/app/app_router.dart b/lib/src/app/app_router.dart index 5a91211..c0c848a 100644 --- a/lib/src/app/app_router.dart +++ b/lib/src/app/app_router.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; +import 'package:yaru/yaru.dart'; import '../features/auth/data/auth_repository.dart'; import '../features/auth/presentation/sign_in_screen.dart'; @@ -8,6 +9,9 @@ import '../features/settings/presentation/settings_screen.dart'; import '../features/schedule/presentation/schedule_workspace.dart'; import '../schedule/schedule_scope.dart'; import 'app_bootstrap.dart'; +import 'busymax_design.dart'; +import 'busymax_layout.dart'; +import 'busymax_surface_colors.dart'; final rootNavigatorKey = GlobalKey(); @@ -42,7 +46,10 @@ final appRouterProvider = Provider((ref) { return null; }, routes: [ - GoRoute(path: '/', builder: (context, state) => const _SplashScreen()), + GoRoute( + path: '/', + builder: (context, state) => const BusyMaxStartupView(), + ), GoRoute( path: '/sign-in', builder: (context, state) => const SignInScreen(), @@ -100,11 +107,36 @@ Page _tasksWorkspacePage({ ); } -class _SplashScreen extends StatelessWidget { - const _SplashScreen(); +class BusyMaxStartupView extends StatelessWidget { + const BusyMaxStartupView({super.key}); @override Widget build(BuildContext context) { - return const Scaffold(body: Center(child: CircularProgressIndicator())); + final colors = BusyMaxSurfaceColors.of(context); + return Scaffold( + backgroundColor: colors.window, + body: LayoutBuilder( + builder: (context, constraints) { + final content = ColoredBox( + key: const ValueKey('startup-content'), + 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), + ], + ); + }, + ), + ); } } diff --git a/lib/src/features/auth/presentation/sign_in_screen.dart b/lib/src/features/auth/presentation/sign_in_screen.dart index 1076d47..ec113c5 100644 --- a/lib/src/features/auth/presentation/sign_in_screen.dart +++ b/lib/src/features/auth/presentation/sign_in_screen.dart @@ -1,5 +1,6 @@ import 'dart:async'; import 'dart:io'; +import 'dart:math' as math; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; @@ -97,12 +98,19 @@ class _SignInScreenState extends ConsumerState { final verticalPadding = compact ? BusyMaxSpacing.md : BusyMaxSpacing.xxl; - final contentWidth = constraints.constrainWidth( - busyMaxOnboardingContentMaxWidth + horizontalPadding * 2, + final availableWidth = math.max( + 0.0, + constraints.maxWidth - horizontalPadding * 2, ); - final contentRailWidth = (contentWidth - horizontalPadding * 2) - .clamp(0.0, busyMaxOnboardingContentMaxWidth) - .toDouble(); + 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, @@ -118,54 +126,66 @@ class _SignInScreenState extends ConsumerState { ), child: Center( child: SizedBox( - key: const ValueKey('onboarding-content-rail'), - width: contentRailWidth, + width: scrollViewportWidth, child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Flexible( child: SingleChildScrollView( - child: switch (_step) { - _OnboardingStep.accounts => - _AccountsOnboardingStep( - accounts: accounts, - googleConfigured: - config.hasGoogleOAuthClientId, - microsoftConfigured: - config.hasMicrosoftOAuthClientId, - isGoogleSigningIn: - _signingInProvider == - _OnboardingProvider.google, - isMicrosoftSigningIn: - _signingInProvider == - _OnboardingProvider.microsoft, - errorMessage: _errorMessage, - missingConfigMessage: kReleaseMode - ? l10n.providerNotConfigured - : config.missingClientIdMessage, - onAddGoogle: () => - _signIn(_OnboardingProvider.google), - onAddMicrosoft: () => - _signIn(_OnboardingProvider.microsoft), - onCancelSignIn: _cancelSignIn, - ), - _OnboardingStep.preferences => - _PreferencesOnboardingStep( - settings: settings, - settingsController: settingsController, - ), - }, + 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, + errorMessage: _errorMessage, + missingConfigMessage: kReleaseMode + ? l10n.providerNotConfigured + : config.missingClientIdMessage, + onAddGoogle: () => + _signIn(_OnboardingProvider.google), + onAddMicrosoft: () => + _signIn(_OnboardingProvider.microsoft), + onCancelSignIn: _cancelSignIn, + ), + _OnboardingStep.preferences => + _PreferencesOnboardingStep( + settings: settings, + settingsController: settingsController, + ), + }, + ), ), ), if (_showFlutterFooterFallback) - _OnboardingFooter( - canGoBack: canGoBack, - canContinue: canContinue, - backLabel: backLabel, - continueLabel: continueLabel, - onBack: _previousStep, - onContinue: _nextStep, + Padding( + padding: EdgeInsets.symmetric( + horizontal: shadowGutter, + ), + child: _OnboardingFooter( + canGoBack: canGoBack, + canContinue: canContinue, + backLabel: backLabel, + continueLabel: continueLabel, + onBack: _previousStep, + onContinue: _nextStep, + ), ), ], ), @@ -750,15 +770,17 @@ class _OnboardingFooter extends StatelessWidget { padding: const EdgeInsets.only(top: BusyMaxSpacing.xl), child: Row( children: [ - BusyMaxPushButton.standard( + TextButton( key: const ValueKey('onboarding-back-button'), onPressed: canGoBack ? onBack : null, + style: _onboardingTextButtonStyle(context), child: Text(backLabel), ), const Spacer(), - BusyMaxPushButton.suggested( + TextButton( key: const ValueKey('onboarding-continue-button'), onPressed: canContinue ? onContinue : null, + style: _onboardingTextButtonStyle(context), child: Text(continueLabel), ), ], @@ -767,6 +789,27 @@ class _OnboardingFooter extends StatelessWidget { } } +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 OAuthException) { if (error.code == 'OAuthMissingRequiredScope') { diff --git a/linux/runner/my_application.cc b/linux/runner/my_application.cc index d93b277..ab96d1c 100644 --- a/linux/runner/my_application.cc +++ b/linux/runner/my_application.cc @@ -71,6 +71,8 @@ constexpr char kDefaultHeaderMenuShadowColor[] = "rgba(0,0,0,0.3)"; constexpr char kDefaultDialogOutlineColor[] = "rgba(255,255,255,0.07)"; constexpr char kDefaultModalBarrierColor[] = "rgba(0,0,0,0.25)"; constexpr char kHeaderControlStyleClass[] = "busymax-header-control"; +constexpr char kHeaderOnboardingTextButtonStyleClass[] = + "busymax-onboarding-text-button"; constexpr char kHeaderSearchEntryStyleClass[] = "busymax-header-search-entry"; constexpr char kHeaderModalOpenStyleClass[] = "busymax-modal-open"; @@ -2272,6 +2274,21 @@ static void refresh_header_bar_css(MyApplication* self) { "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; @@ -3551,6 +3568,9 @@ static GtkWidget* create_busymax_titlebar(MyApplication* self) { 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), @@ -3609,7 +3629,7 @@ static GtkWidget* create_busymax_titlebar(MyApplication* self) { create_header_text_button("Continue", "Continue")); gtk_style_context_add_class( gtk_widget_get_style_context(self->onboarding_continue_button), - GTK_STYLE_CLASS_SUGGESTED_ACTION); + kHeaderOnboardingTextButtonStyleClass); connect_header_bar_action(self, self->onboarding_continue_button, "continueSetup"); gtk_widget_set_visible(self->onboarding_continue_button, FALSE); diff --git a/test/app/busymax_grouped_surface_test.dart b/test/app/busymax_grouped_surface_test.dart index f62e85b..327bb7f 100644 --- a/test/app/busymax_grouped_surface_test.dart +++ b/test/app/busymax_grouped_surface_test.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'dart:io'; import 'dart:ui' as ui; +import 'package:busymax/src/app/app_router.dart'; import 'package:busymax/src/app/app_theme.dart'; import 'package:busymax/src/app/busymax_design.dart'; import 'package:busymax/src/app/busymax_yaru_theme.dart'; @@ -866,6 +867,42 @@ void main() { expect(border.end.width, BusyMaxStroke.outline); }); + testWidgets('startup view preserves the responsive workspace split', ( + tester, + ) async { + tester.view.devicePixelRatio = 1; + tester.view.physicalSize = const Size(1280, 720); + addTearDown(tester.view.reset); + + await tester.pumpWidget( + MaterialApp( + theme: BusyMaxYaruTheme.build( + brightness: Brightness.light, + accentColor: const Color(0xFF3584E4), + ), + home: const BusyMaxStartupView(), + ), + ); + + final sidebar = find.byKey(const ValueKey('startup-sidebar')); + final content = find.byKey(const ValueKey('startup-content')); + 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(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(tester.takeException(), isNull); + }); + testWidgets( 'sidebar navigation delegates native geometry and states to Yaru', (tester) async { diff --git a/test/app/native_ui_audit_test.dart b/test/app/native_ui_audit_test.dart index cb0fbf0..271d79a 100644 --- a/test/app/native_ui_audit_test.dart +++ b/test/app/native_ui_audit_test.dart @@ -335,6 +335,18 @@ void main() { expect(source, contains('gtk_window_set_default_size')); }); + test('startup defers native presentation until the app frame is ready', () { + final source = File('lib/main.dart').readAsStringSync(); + final deferFrame = source.indexOf('binding.deferFirstFrame();'); + final firstRunApp = source.indexOf('runApp('); + final firstAllowFrame = source.indexOf('binding.allowFirstFrame();'); + + expect(deferFrame, isNonNegative); + expect(firstRunApp, greaterThan(deferFrame)); + expect(firstAllowFrame, greaterThan(firstRunApp)); + expect('binding.allowFirstFrame();'.allMatches(source), hasLength(1)); + }); + test('snap uses portal-backed secret storage without keyring plug', () { final snapcraft = File('snap/snapcraft.yaml').readAsStringSync(); final bootstrap = File( @@ -1010,7 +1022,10 @@ void main() { 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, contains('GTK_STYLE_CLASS_SUGGESTED_ACTION')); + 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, diff --git a/test/features/auth/presentation/auth_routing_test.dart b/test/features/auth/presentation/auth_routing_test.dart index af53dae..b8845d2 100644 --- a/test/features/auth/presentation/auth_routing_test.dart +++ b/test/features/auth/presentation/auth_routing_test.dart @@ -10,6 +10,7 @@ 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/busymax_app.dart'; +import 'package:busymax/src/app/busymax_design.dart'; import 'package:busymax/src/config/build_config.dart'; import 'package:busymax/src/db/app_database.dart'; import 'package:busymax/src/features/accounts/data/accounts_repository.dart'; @@ -108,11 +109,67 @@ void main() { } 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); + } tester.view.physicalSize = const Size(420, 720); await tester.pumpAndSettle(); - expectAlignedActions(expectedRailWidth: 396); + expectAlignedActions(expectedRailWidth: 380); + expect(tester.takeException(), null); + await _disposeApp(tester); + }); + + testWidgets('system settings cards retain their complete shadow gutter', ( + tester, + ) async { + tester.view.devicePixelRatio = 1; + tester.view.physicalSize = const Size(1000, 720); + addTearDown(tester.view.reset); + + await _pumpApp(tester, database: database, oAuth: oAuth); + await tester.pumpAndSettle(); + await tester.tap(find.text('Add Google account')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Continue')); + await tester.pumpAndSettle(); + + final viewport = tester.getRect( + find.byKey(const ValueKey('onboarding-scroll-viewport')), + ); + final rail = tester.getRect( + find.byKey(const ValueKey('onboarding-content-rail')), + ); + expect(viewport.width, rail.width + BusyMaxSpacing.sm * 2); + expect(rail.left - viewport.left, BusyMaxSpacing.sm); + expect(viewport.right - rail.right, BusyMaxSpacing.sm); + + final cards = find.byType(BusyMaxGroupedSurface); + expect(cards, findsNWidgets(3)); + for (final card in cards.evaluate()) { + final rect = tester.getRect(find.byWidget(card.widget)); + expect(rect.left, rail.left); + expect(rect.right, rail.right); + expect(rect.left, greaterThan(viewport.left)); + expect(rect.right, lessThan(viewport.right)); + } expect(tester.takeException(), null); await _disposeApp(tester); }); diff --git a/test/platform/linux_header_bar_service_test.dart b/test/platform/linux_header_bar_service_test.dart index 951d00a..4e1ed3a 100644 --- a/test/platform/linux_header_bar_service_test.dart +++ b/test/platform/linux_header_bar_service_test.dart @@ -679,7 +679,8 @@ void main() { 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, contains('GTK_STYLE_CLASS_SUGGESTED_ACTION')); + expect(source, isNot(contains('GTK_STYLE_CLASS_SUGGESTED_ACTION'))); + expect(source, contains('kHeaderOnboardingTextButtonStyleClass')); expect( source, isNot(contains('button.busymax-header-view-mode-button:focus {"')), From 08aa98392c3cb97179d3573890019728a0f6a2dd Mon Sep 17 00:00:00 2001 From: albert Date: Wed, 29 Jul 2026 19:44:58 -0700 Subject: [PATCH 49/73] Add "Report an issue" label to header menu and implement associated action. Enhance About dialog and feedback functionality. Update website and repository links, add report issue option to header bar, and improve localization for feedback dialogs. Add localization support for license and source code in multiple languages --- lib/l10n/app_ar.arb | 3 + lib/l10n/app_de.arb | 3 + lib/l10n/app_en.arb | 3 + lib/l10n/app_es.arb | 3 + lib/l10n/app_et.arb | 3 + lib/l10n/app_fa.arb | 3 + lib/l10n/app_fi.arb | 3 + lib/l10n/app_fr.arb | 3 + lib/l10n/app_hi.arb | 3 + lib/l10n/app_it.arb | 3 + lib/l10n/app_ja.arb | 3 + lib/l10n/app_ko.arb | 3 + lib/l10n/app_pt.arb | 3 + lib/l10n/app_ru.arb | 3 + lib/l10n/app_vi.arb | 3 + lib/l10n/app_zh.arb | 3 + lib/l10n/app_zh_Hans.arb | 3 + lib/l10n/app_zh_Hant.arb | 3 + lib/l10n/generated/app_localizations.dart | 18 +++++ lib/l10n/generated/app_localizations_ar.dart | 9 +++ lib/l10n/generated/app_localizations_de.dart | 9 +++ lib/l10n/generated/app_localizations_en.dart | 9 +++ lib/l10n/generated/app_localizations_es.dart | 9 +++ lib/l10n/generated/app_localizations_et.dart | 9 +++ lib/l10n/generated/app_localizations_fa.dart | 9 +++ lib/l10n/generated/app_localizations_fi.dart | 9 +++ lib/l10n/generated/app_localizations_fr.dart | 9 +++ lib/l10n/generated/app_localizations_hi.dart | 9 +++ lib/l10n/generated/app_localizations_it.dart | 9 +++ lib/l10n/generated/app_localizations_ja.dart | 9 +++ lib/l10n/generated/app_localizations_ko.dart | 9 +++ lib/l10n/generated/app_localizations_pt.dart | 9 +++ lib/l10n/generated/app_localizations_ru.dart | 9 +++ lib/l10n/generated/app_localizations_vi.dart | 9 +++ lib/l10n/generated/app_localizations_zh.dart | 27 +++++++ lib/src/app/busymax_about_dialog.dart | 71 +++++++------------ lib/src/app/busymax_app.dart | 1 + .../presentation/feedback_dialog.dart | 2 +- .../presentation/schedule_toolbar.dart | 13 +++- .../presentation/schedule_workspace.dart | 14 +++- .../presentation/settings_screen.dart | 14 +++- .../platform/linux_header_bar_service.dart | 7 ++ linux/runner/my_application.cc | 9 +++ test/app/about_dialog_test.dart | 19 +++-- test/app/native_ui_audit_test.dart | 17 +++++ .../presentation/feedback_dialog_test.dart | 1 + .../presentation/schedule_toolbar_test.dart | 11 ++- ...r_bar_configuration_synchronizer_test.dart | 1 + .../linux_header_bar_service_test.dart | 6 +- 49 files changed, 357 insertions(+), 63 deletions(-) diff --git a/lib/l10n/app_ar.arb b/lib/l10n/app_ar.arb index 4b51306..1e2ed00 100644 --- a/lib/l10n/app_ar.arb +++ b/lib/l10n/app_ar.arb @@ -157,7 +157,10 @@ "shortcutHideCompactAgendaDescription": "إخفاء نافذة جدول الأعمال المصغّر", "aboutBusyMax": "حول BusyMax", "aboutBusyMaxDescription": "التقويم والمهام", + "license": "الترخيص", + "apacheLicenseName": "Apache License 2.0", "website": "الموقع الإلكتروني", + "sourceCode": "الشيفرة المصدرية", "reportAnIssue": "الإبلاغ عن مشكلة", "sendFeedback": "إرسال الملاحظات", "feedbackSubmit": "إرسال", diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index 3206549..451331b 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -160,7 +160,10 @@ "shortcutHideCompactAgendaDescription": "Das kompakte Agenda-Fenster ausblenden", "aboutBusyMax": "Über BusyMax", "aboutBusyMaxDescription": "Kalender und Aufgaben", + "license": "Lizenz", + "apacheLicenseName": "Apache License 2.0", "website": "Website", + "sourceCode": "Quellcode", "reportAnIssue": "Problem melden", "sendFeedback": "Feedback senden", "feedbackSubmit": "Senden", diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index db660c1..dd39455 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -163,7 +163,10 @@ "shortcutHideCompactAgendaDescription": "Hide the compact agenda window", "aboutBusyMax": "About BusyMax", "aboutBusyMaxDescription": "Calendar and tasks", + "license": "License", + "apacheLicenseName": "Apache License 2.0", "website": "Website", + "sourceCode": "Source code", "reportAnIssue": "Report an issue", "sendFeedback": "Send feedback", "feedbackSubmit": "Submit", diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index 79585e8..4012293 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -160,7 +160,10 @@ "shortcutHideCompactAgendaDescription": "Ocultar la ventana de agenda compacta", "aboutBusyMax": "Acerca de BusyMax", "aboutBusyMaxDescription": "Calendario y tareas", + "license": "Licencia", + "apacheLicenseName": "Apache License 2.0", "website": "Sitio web", + "sourceCode": "Código fuente", "reportAnIssue": "Informar de un problema", "sendFeedback": "Enviar comentarios", "feedbackSubmit": "Enviar", diff --git a/lib/l10n/app_et.arb b/lib/l10n/app_et.arb index a338cba..a558b14 100644 --- a/lib/l10n/app_et.arb +++ b/lib/l10n/app_et.arb @@ -163,7 +163,10 @@ "shortcutHideCompactAgendaDescription": "Peida kompaktse päevakava aken", "aboutBusyMax": "Teave BusyMaxi kohta", "aboutBusyMaxDescription": "Kalender ja ülesanded", + "license": "Litsents", + "apacheLicenseName": "Apache License 2.0", "website": "Veebisait", + "sourceCode": "Lähtekood", "reportAnIssue": "Teata probleemist", "sendFeedback": "Saada tagasisidet", "feedbackSubmit": "Saada", diff --git a/lib/l10n/app_fa.arb b/lib/l10n/app_fa.arb index c4678b0..72f6900 100644 --- a/lib/l10n/app_fa.arb +++ b/lib/l10n/app_fa.arb @@ -157,7 +157,10 @@ "shortcutHideCompactAgendaDescription": "پنهان کردن پنجرهٔ برنامهٔ فشرده", "aboutBusyMax": "دربارهٔ BusyMax", "aboutBusyMaxDescription": "تقویم و کارها", + "license": "مجوز", + "apacheLicenseName": "Apache License 2.0", "website": "وب‌سایت", + "sourceCode": "کد منبع", "reportAnIssue": "گزارش مشکل", "sendFeedback": "ارسال بازخورد", "feedbackSubmit": "ارسال", diff --git a/lib/l10n/app_fi.arb b/lib/l10n/app_fi.arb index 080ccc1..c7224dd 100644 --- a/lib/l10n/app_fi.arb +++ b/lib/l10n/app_fi.arb @@ -157,7 +157,10 @@ "shortcutHideCompactAgendaDescription": "Piilota kompaktin agendan ikkuna", "aboutBusyMax": "Tietoja BusyMaxista", "aboutBusyMaxDescription": "Kalenteri ja tehtävät", + "license": "Lisenssi", + "apacheLicenseName": "Apache License 2.0", "website": "Verkkosivusto", + "sourceCode": "Lähdekoodi", "reportAnIssue": "Ilmoita ongelmasta", "sendFeedback": "Lähetä palautetta", "feedbackSubmit": "Lähetä", diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index cb01676..8c57074 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -160,7 +160,10 @@ "shortcutHideCompactAgendaDescription": "Masquer la fenêtre d'agenda compact", "aboutBusyMax": "À propos de BusyMax", "aboutBusyMaxDescription": "Calendrier et tâches", + "license": "Licence", + "apacheLicenseName": "Apache License 2.0", "website": "Site web", + "sourceCode": "Code source", "reportAnIssue": "Signaler un problème", "sendFeedback": "Envoyer des commentaires", "feedbackSubmit": "Envoyer", diff --git a/lib/l10n/app_hi.arb b/lib/l10n/app_hi.arb index fd62653..cf3ee9d 100644 --- a/lib/l10n/app_hi.arb +++ b/lib/l10n/app_hi.arb @@ -157,7 +157,10 @@ "shortcutHideCompactAgendaDescription": "संक्षिप्त कार्यसूची विंडो छिपाएँ", "aboutBusyMax": "BusyMax के बारे में", "aboutBusyMaxDescription": "कैलेंडर और कार्य", + "license": "लाइसेंस", + "apacheLicenseName": "Apache License 2.0", "website": "वेबसाइट", + "sourceCode": "स्रोत कोड", "reportAnIssue": "समस्या की रिपोर्ट करें", "sendFeedback": "प्रतिक्रिया भेजें", "feedbackSubmit": "सबमिट करें", diff --git a/lib/l10n/app_it.arb b/lib/l10n/app_it.arb index 983e58c..ebaad93 100644 --- a/lib/l10n/app_it.arb +++ b/lib/l10n/app_it.arb @@ -157,7 +157,10 @@ "shortcutHideCompactAgendaDescription": "Nascondi la finestra dell’agenda compatta", "aboutBusyMax": "Informazioni su BusyMax", "aboutBusyMaxDescription": "Calendario e attività", + "license": "Licenza", + "apacheLicenseName": "Apache License 2.0", "website": "Sito web", + "sourceCode": "Codice sorgente", "reportAnIssue": "Segnala un problema", "sendFeedback": "Invia feedback", "feedbackSubmit": "Invia", diff --git a/lib/l10n/app_ja.arb b/lib/l10n/app_ja.arb index 2510636..500ca8a 100644 --- a/lib/l10n/app_ja.arb +++ b/lib/l10n/app_ja.arb @@ -157,7 +157,10 @@ "shortcutHideCompactAgendaDescription": "コンパクト予定一覧ウィンドウを非表示", "aboutBusyMax": "BusyMax について", "aboutBusyMaxDescription": "カレンダーとタスク", + "license": "ライセンス", + "apacheLicenseName": "Apache License 2.0", "website": "ウェブサイト", + "sourceCode": "ソースコード", "reportAnIssue": "問題を報告", "sendFeedback": "フィードバックを送信", "feedbackSubmit": "送信", diff --git a/lib/l10n/app_ko.arb b/lib/l10n/app_ko.arb index 383758e..690cf26 100644 --- a/lib/l10n/app_ko.arb +++ b/lib/l10n/app_ko.arb @@ -157,7 +157,10 @@ "shortcutHideCompactAgendaDescription": "간단 일정 목록 창 숨기기", "aboutBusyMax": "BusyMax 정보", "aboutBusyMaxDescription": "캘린더와 할 일", + "license": "라이선스", + "apacheLicenseName": "Apache License 2.0", "website": "웹사이트", + "sourceCode": "소스 코드", "reportAnIssue": "문제 신고", "sendFeedback": "의견 보내기", "feedbackSubmit": "제출", diff --git a/lib/l10n/app_pt.arb b/lib/l10n/app_pt.arb index 072b0b5..a461982 100644 --- a/lib/l10n/app_pt.arb +++ b/lib/l10n/app_pt.arb @@ -157,7 +157,10 @@ "shortcutHideCompactAgendaDescription": "Ocultar a janela da agenda compacta", "aboutBusyMax": "Acerca do BusyMax", "aboutBusyMaxDescription": "Calendário e tarefas", + "license": "Licença", + "apacheLicenseName": "Apache License 2.0", "website": "Site", + "sourceCode": "Código-fonte", "reportAnIssue": "Comunicar um problema", "sendFeedback": "Enviar comentários", "feedbackSubmit": "Enviar", diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index f00a12a..4194c03 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -157,7 +157,10 @@ "shortcutHideCompactAgendaDescription": "Скрыть окно компактного расписания", "aboutBusyMax": "О приложении BusyMax", "aboutBusyMaxDescription": "Календарь и задачи", + "license": "Лицензия", + "apacheLicenseName": "Apache License 2.0", "website": "Веб-сайт", + "sourceCode": "Исходный код", "reportAnIssue": "Сообщить о проблеме", "sendFeedback": "Отправить отзыв", "feedbackSubmit": "Отправить", diff --git a/lib/l10n/app_vi.arb b/lib/l10n/app_vi.arb index 24663ad..81b726a 100644 --- a/lib/l10n/app_vi.arb +++ b/lib/l10n/app_vi.arb @@ -157,7 +157,10 @@ "shortcutHideCompactAgendaDescription": "Ẩn cửa sổ lịch biểu thu gọn", "aboutBusyMax": "Giới thiệu BusyMax", "aboutBusyMaxDescription": "Lịch và công việc", + "license": "Giấy phép", + "apacheLicenseName": "Apache License 2.0", "website": "Trang web", + "sourceCode": "Mã nguồn", "reportAnIssue": "Báo cáo sự cố", "sendFeedback": "Gửi phản hồi", "feedbackSubmit": "Gửi", diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 30c1e7a..62bb7a7 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -157,7 +157,10 @@ "shortcutHideCompactAgendaDescription": "隐藏紧凑日程窗口", "aboutBusyMax": "关于 BusyMax", "aboutBusyMaxDescription": "日历和任务", + "license": "许可证", + "apacheLicenseName": "Apache License 2.0", "website": "网站", + "sourceCode": "源代码", "reportAnIssue": "报告问题", "sendFeedback": "发送反馈", "feedbackSubmit": "提交", diff --git a/lib/l10n/app_zh_Hans.arb b/lib/l10n/app_zh_Hans.arb index 5e08baf..d192a3d 100644 --- a/lib/l10n/app_zh_Hans.arb +++ b/lib/l10n/app_zh_Hans.arb @@ -157,7 +157,10 @@ "shortcutHideCompactAgendaDescription": "隐藏紧凑日程窗口", "aboutBusyMax": "关于 BusyMax", "aboutBusyMaxDescription": "日历和任务", + "license": "许可证", + "apacheLicenseName": "Apache License 2.0", "website": "网站", + "sourceCode": "源代码", "reportAnIssue": "报告问题", "sendFeedback": "发送反馈", "feedbackSubmit": "提交", diff --git a/lib/l10n/app_zh_Hant.arb b/lib/l10n/app_zh_Hant.arb index f6e510b..6f23f83 100644 --- a/lib/l10n/app_zh_Hant.arb +++ b/lib/l10n/app_zh_Hant.arb @@ -157,7 +157,10 @@ "shortcutHideCompactAgendaDescription": "隱藏精簡行程視窗", "aboutBusyMax": "關於 BusyMax", "aboutBusyMaxDescription": "行事曆與待辦事項", + "license": "授權", + "apacheLicenseName": "Apache License 2.0", "website": "網站", + "sourceCode": "原始碼", "reportAnIssue": "回報問題", "sendFeedback": "傳送意見", "feedbackSubmit": "提交", diff --git a/lib/l10n/generated/app_localizations.dart b/lib/l10n/generated/app_localizations.dart index 6e1c958..0713355 100644 --- a/lib/l10n/generated/app_localizations.dart +++ b/lib/l10n/generated/app_localizations.dart @@ -1074,12 +1074,30 @@ abstract class AppLocalizations { /// **'Calendar and tasks'** String get aboutBusyMaxDescription; + /// No description provided for @license. + /// + /// In en, this message translates to: + /// **'License'** + String get license; + + /// No description provided for @apacheLicenseName. + /// + /// In en, this message translates to: + /// **'Apache License 2.0'** + String get apacheLicenseName; + /// No description provided for @website. /// /// In en, this message translates to: /// **'Website'** String get website; + /// No description provided for @sourceCode. + /// + /// In en, this message translates to: + /// **'Source code'** + String get sourceCode; + /// No description provided for @reportAnIssue. /// /// 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 ca80b86..272f27a 100644 --- a/lib/l10n/generated/app_localizations_ar.dart +++ b/lib/l10n/generated/app_localizations_ar.dart @@ -542,9 +542,18 @@ class AppLocalizationsAr extends AppLocalizations { @override String get aboutBusyMaxDescription => 'التقويم والمهام'; + @override + String get license => 'الترخيص'; + + @override + String get apacheLicenseName => 'Apache License 2.0'; + @override String get website => 'الموقع الإلكتروني'; + @override + String get sourceCode => 'الشيفرة المصدرية'; + @override String get reportAnIssue => 'الإبلاغ عن مشكلة'; diff --git a/lib/l10n/generated/app_localizations_de.dart b/lib/l10n/generated/app_localizations_de.dart index 5cc9ba9..fba9ac8 100644 --- a/lib/l10n/generated/app_localizations_de.dart +++ b/lib/l10n/generated/app_localizations_de.dart @@ -532,9 +532,18 @@ class AppLocalizationsDe extends AppLocalizations { @override String get aboutBusyMaxDescription => 'Kalender und Aufgaben'; + @override + String get license => 'Lizenz'; + + @override + String get apacheLicenseName => 'Apache License 2.0'; + @override String get website => 'Website'; + @override + String get sourceCode => 'Quellcode'; + @override String get reportAnIssue => 'Problem melden'; diff --git a/lib/l10n/generated/app_localizations_en.dart b/lib/l10n/generated/app_localizations_en.dart index c9186f5..5dee20b 100644 --- a/lib/l10n/generated/app_localizations_en.dart +++ b/lib/l10n/generated/app_localizations_en.dart @@ -529,9 +529,18 @@ class AppLocalizationsEn extends AppLocalizations { @override String get aboutBusyMaxDescription => 'Calendar and tasks'; + @override + String get license => 'License'; + + @override + String get apacheLicenseName => 'Apache License 2.0'; + @override String get website => 'Website'; + @override + String get sourceCode => 'Source code'; + @override String get reportAnIssue => 'Report an issue'; diff --git a/lib/l10n/generated/app_localizations_es.dart b/lib/l10n/generated/app_localizations_es.dart index 21c2071..f879b63 100644 --- a/lib/l10n/generated/app_localizations_es.dart +++ b/lib/l10n/generated/app_localizations_es.dart @@ -534,9 +534,18 @@ class AppLocalizationsEs extends AppLocalizations { @override String get aboutBusyMaxDescription => 'Calendario y tareas'; + @override + String get license => 'Licencia'; + + @override + String get apacheLicenseName => 'Apache License 2.0'; + @override String get website => 'Sitio web'; + @override + String get sourceCode => 'Código fuente'; + @override String get reportAnIssue => 'Informar de un problema'; diff --git a/lib/l10n/generated/app_localizations_et.dart b/lib/l10n/generated/app_localizations_et.dart index ef51e7b..1b37858 100644 --- a/lib/l10n/generated/app_localizations_et.dart +++ b/lib/l10n/generated/app_localizations_et.dart @@ -533,9 +533,18 @@ class AppLocalizationsEt extends AppLocalizations { @override String get aboutBusyMaxDescription => 'Kalender ja ülesanded'; + @override + String get license => 'Litsents'; + + @override + String get apacheLicenseName => 'Apache License 2.0'; + @override String get website => 'Veebisait'; + @override + String get sourceCode => 'Lähtekood'; + @override String get reportAnIssue => 'Teata probleemist'; diff --git a/lib/l10n/generated/app_localizations_fa.dart b/lib/l10n/generated/app_localizations_fa.dart index 46256b4..1965df0 100644 --- a/lib/l10n/generated/app_localizations_fa.dart +++ b/lib/l10n/generated/app_localizations_fa.dart @@ -550,9 +550,18 @@ class AppLocalizationsFa extends AppLocalizations { @override String get aboutBusyMaxDescription => 'تقویم و کارها'; + @override + String get license => 'مجوز'; + + @override + String get apacheLicenseName => 'Apache License 2.0'; + @override String get website => 'وب‌سایت'; + @override + String get sourceCode => 'کد منبع'; + @override String get reportAnIssue => 'گزارش مشکل'; diff --git a/lib/l10n/generated/app_localizations_fi.dart b/lib/l10n/generated/app_localizations_fi.dart index a9fc4c2..de1abec 100644 --- a/lib/l10n/generated/app_localizations_fi.dart +++ b/lib/l10n/generated/app_localizations_fi.dart @@ -533,9 +533,18 @@ class AppLocalizationsFi extends AppLocalizations { @override String get aboutBusyMaxDescription => 'Kalenteri ja tehtävät'; + @override + String get license => 'Lisenssi'; + + @override + String get apacheLicenseName => 'Apache License 2.0'; + @override String get website => 'Verkkosivusto'; + @override + String get sourceCode => 'Lähdekoodi'; + @override String get reportAnIssue => 'Ilmoita ongelmasta'; diff --git a/lib/l10n/generated/app_localizations_fr.dart b/lib/l10n/generated/app_localizations_fr.dart index 3f77451..825191c 100644 --- a/lib/l10n/generated/app_localizations_fr.dart +++ b/lib/l10n/generated/app_localizations_fr.dart @@ -533,9 +533,18 @@ class AppLocalizationsFr extends AppLocalizations { @override String get aboutBusyMaxDescription => 'Calendrier et tâches'; + @override + String get license => 'Licence'; + + @override + String get apacheLicenseName => 'Apache License 2.0'; + @override String get website => 'Site web'; + @override + String get sourceCode => 'Code source'; + @override String get reportAnIssue => 'Signaler un problème'; diff --git a/lib/l10n/generated/app_localizations_hi.dart b/lib/l10n/generated/app_localizations_hi.dart index 2679a8a..6ffd740 100644 --- a/lib/l10n/generated/app_localizations_hi.dart +++ b/lib/l10n/generated/app_localizations_hi.dart @@ -535,9 +535,18 @@ class AppLocalizationsHi extends AppLocalizations { @override String get aboutBusyMaxDescription => 'कैलेंडर और कार्य'; + @override + String get license => 'लाइसेंस'; + + @override + String get apacheLicenseName => 'Apache License 2.0'; + @override String get website => 'वेबसाइट'; + @override + String get sourceCode => 'स्रोत कोड'; + @override String get reportAnIssue => 'समस्या की रिपोर्ट करें'; diff --git a/lib/l10n/generated/app_localizations_it.dart b/lib/l10n/generated/app_localizations_it.dart index 24e8d30..63870c9 100644 --- a/lib/l10n/generated/app_localizations_it.dart +++ b/lib/l10n/generated/app_localizations_it.dart @@ -534,9 +534,18 @@ class AppLocalizationsIt extends AppLocalizations { @override String get aboutBusyMaxDescription => 'Calendario e attività'; + @override + String get license => 'Licenza'; + + @override + String get apacheLicenseName => 'Apache License 2.0'; + @override String get website => 'Sito web'; + @override + String get sourceCode => 'Codice sorgente'; + @override String get reportAnIssue => 'Segnala un problema'; diff --git a/lib/l10n/generated/app_localizations_ja.dart b/lib/l10n/generated/app_localizations_ja.dart index 2d4e1d1..ea134cb 100644 --- a/lib/l10n/generated/app_localizations_ja.dart +++ b/lib/l10n/generated/app_localizations_ja.dart @@ -522,9 +522,18 @@ class AppLocalizationsJa extends AppLocalizations { @override String get aboutBusyMaxDescription => 'カレンダーとタスク'; + @override + String get license => 'ライセンス'; + + @override + String get apacheLicenseName => 'Apache License 2.0'; + @override String get website => 'ウェブサイト'; + @override + String get sourceCode => 'ソースコード'; + @override String get reportAnIssue => '問題を報告'; diff --git a/lib/l10n/generated/app_localizations_ko.dart b/lib/l10n/generated/app_localizations_ko.dart index 9f8c656..d429527 100644 --- a/lib/l10n/generated/app_localizations_ko.dart +++ b/lib/l10n/generated/app_localizations_ko.dart @@ -522,9 +522,18 @@ class AppLocalizationsKo extends AppLocalizations { @override String get aboutBusyMaxDescription => '캘린더와 할 일'; + @override + String get license => '라이선스'; + + @override + String get apacheLicenseName => 'Apache License 2.0'; + @override String get website => '웹사이트'; + @override + String get sourceCode => '소스 코드'; + @override String get reportAnIssue => '문제 신고'; diff --git a/lib/l10n/generated/app_localizations_pt.dart b/lib/l10n/generated/app_localizations_pt.dart index 39705fc..e993d65 100644 --- a/lib/l10n/generated/app_localizations_pt.dart +++ b/lib/l10n/generated/app_localizations_pt.dart @@ -534,9 +534,18 @@ class AppLocalizationsPt extends AppLocalizations { @override String get aboutBusyMaxDescription => 'Calendário e tarefas'; + @override + String get license => 'Licença'; + + @override + String get apacheLicenseName => 'Apache License 2.0'; + @override String get website => 'Site'; + @override + String get sourceCode => 'Código-fonte'; + @override String get reportAnIssue => 'Comunicar um problema'; diff --git a/lib/l10n/generated/app_localizations_ru.dart b/lib/l10n/generated/app_localizations_ru.dart index 3e946de..6895e23 100644 --- a/lib/l10n/generated/app_localizations_ru.dart +++ b/lib/l10n/generated/app_localizations_ru.dart @@ -536,9 +536,18 @@ class AppLocalizationsRu extends AppLocalizations { @override String get aboutBusyMaxDescription => 'Календарь и задачи'; + @override + String get license => 'Лицензия'; + + @override + String get apacheLicenseName => 'Apache License 2.0'; + @override String get website => 'Веб-сайт'; + @override + String get sourceCode => 'Исходный код'; + @override String get reportAnIssue => 'Сообщить о проблеме'; diff --git a/lib/l10n/generated/app_localizations_vi.dart b/lib/l10n/generated/app_localizations_vi.dart index bfc26eb..4d89f91 100644 --- a/lib/l10n/generated/app_localizations_vi.dart +++ b/lib/l10n/generated/app_localizations_vi.dart @@ -532,9 +532,18 @@ class AppLocalizationsVi extends AppLocalizations { @override String get aboutBusyMaxDescription => 'Lịch và công việc'; + @override + String get license => 'Giấy phép'; + + @override + String get apacheLicenseName => 'Apache License 2.0'; + @override String get website => 'Trang web'; + @override + String get sourceCode => 'Mã nguồn'; + @override String get reportAnIssue => 'Báo cáo sự cố'; diff --git a/lib/l10n/generated/app_localizations_zh.dart b/lib/l10n/generated/app_localizations_zh.dart index 5eff2f8..dfb1449 100644 --- a/lib/l10n/generated/app_localizations_zh.dart +++ b/lib/l10n/generated/app_localizations_zh.dart @@ -518,9 +518,18 @@ class AppLocalizationsZh extends AppLocalizations { @override String get aboutBusyMaxDescription => '日历和任务'; + @override + String get license => '许可证'; + + @override + String get apacheLicenseName => 'Apache License 2.0'; + @override String get website => '网站'; + @override + String get sourceCode => '源代码'; + @override String get reportAnIssue => '报告问题'; @@ -1773,9 +1782,18 @@ class AppLocalizationsZhHans extends AppLocalizationsZh { @override String get aboutBusyMaxDescription => '日历和任务'; + @override + String get license => '许可证'; + + @override + String get apacheLicenseName => 'Apache License 2.0'; + @override String get website => '网站'; + @override + String get sourceCode => '源代码'; + @override String get reportAnIssue => '报告问题'; @@ -3028,9 +3046,18 @@ class AppLocalizationsZhHant extends AppLocalizationsZh { @override String get aboutBusyMaxDescription => '行事曆與待辦事項'; + @override + String get license => '授權'; + + @override + String get apacheLicenseName => 'Apache License 2.0'; + @override String get website => '網站'; + @override + String get sourceCode => '原始碼'; + @override String get reportAnIssue => '回報問題'; diff --git a/lib/src/app/busymax_about_dialog.dart b/lib/src/app/busymax_about_dialog.dart index 11e8f82..0d5304f 100644 --- a/lib/src/app/busymax_about_dialog.dart +++ b/lib/src/app/busymax_about_dialog.dart @@ -5,44 +5,29 @@ import 'package:package_info_plus/package_info_plus.dart'; import 'package:url_launcher/url_launcher.dart'; import 'package:yaru/yaru.dart'; -import '../features/feedback/data/feedback_api_client.dart'; -import '../features/feedback/presentation/feedback_dialog.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_glyphs.dart'; -const _busyMaxWebsiteUri = 'https://github.com/busystack/busymax'; -const _busyMaxIssuesUri = 'https://github.com/busystack/busymax/issues'; +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, { - required FeedbackSubmissionService feedbackSubmissionService, LinuxHeaderBarService? headerBarService, -}) async { - final action = await showBusyMaxModalDialog<_BusyMaxAboutAction>( +}) { + return showBusyMaxModalDialog( context, headerBarService: headerBarService, - builder: (dialogContext) => BusyMaxAboutDialog( - onSendFeedback: () => - Navigator.of(dialogContext).pop(_BusyMaxAboutAction.sendFeedback), - ), + builder: (dialogContext) => const BusyMaxAboutDialog(), ); - if (action == _BusyMaxAboutAction.sendFeedback && context.mounted) { - await showBusyMaxFeedbackDialog( - context, - submissionService: feedbackSubmissionService, - headerBarService: headerBarService, - ); - } } class BusyMaxAboutDialog extends StatelessWidget { - const BusyMaxAboutDialog({super.key, this.onSendFeedback}); - - final VoidCallback? onSendFeedback; + const BusyMaxAboutDialog({super.key}); @override Widget build(BuildContext context) { @@ -79,38 +64,34 @@ class BusyMaxAboutDialog extends StatelessWidget { }, ), ), - const SizedBox(height: BusyMaxSpacing.lg), + const SizedBox(height: BusyMaxSpacing.md), BusyMaxGroupedList( filled: true, children: [ BusyMaxActionRow( - title: l10n.website, - leading: const Icon(Icons.language), - trailing: const Icon( - Icons.open_in_new, - size: BusyMaxSizes.iconSm, - ), + title: l10n.license, + subtitle: l10n.apacheLicenseName, + leading: const Icon(YaruIcons.information), + trailing: const Icon(YaruIcons.external_link), onTap: () => - unawaited(_openExternalUri(Uri.parse(_busyMaxWebsiteUri))), + unawaited(_openExternalUri(Uri.parse(_apacheLicenseUrl))), ), BusyMaxActionRow( - title: l10n.sendFeedback, - leading: const Icon(Icons.feedback_outlined), - trailing: Icon( - BusyMaxGlyphs.chevronForwardFor(Directionality.of(context)), - size: BusyMaxSizes.iconSm, - ), - onTap: onSendFeedback, + title: l10n.website, + subtitle: _busyMaxWebsiteUrl, + leading: const Icon(YaruIcons.home), + trailing: const Icon(YaruIcons.external_link), + onTap: () => + unawaited(_openExternalUri(Uri.parse(_busyMaxWebsiteUrl))), ), BusyMaxActionRow( - title: l10n.reportAnIssue, - leading: const Icon(YaruIcons.warning), - trailing: const Icon( - Icons.open_in_new, - size: BusyMaxSizes.iconSm, + title: l10n.sourceCode, + subtitle: _busyMaxRepositoryUrl, + leading: const Icon(YaruIcons.code), + trailing: const Icon(YaruIcons.external_link), + onTap: () => unawaited( + _openExternalUri(Uri.parse(_busyMaxRepositoryUrl)), ), - onTap: () => - unawaited(_openExternalUri(Uri.parse(_busyMaxIssuesUri))), ), ], ), @@ -120,8 +101,6 @@ class BusyMaxAboutDialog extends StatelessWidget { } } -enum _BusyMaxAboutAction { sendFeedback } - class _BusyMaxLogo extends StatelessWidget { const _BusyMaxLogo(); diff --git a/lib/src/app/busymax_app.dart b/lib/src/app/busymax_app.dart index 6396dd8..11a442c 100644 --- a/lib/src/app/busymax_app.dart +++ b/lib/src/app/busymax_app.dart @@ -252,6 +252,7 @@ class _BusyMaxAppState extends ConsumerState { back: materialL10n.backButtonTooltip, settings: l10n.settings, keyboardShortcuts: l10n.keyboardShortcuts, + reportIssue: l10n.reportAnIssue, aboutBusyMax: l10n.aboutBusyMax, ); _headerBarConfigurationSynchronizer.schedule( diff --git a/lib/src/features/feedback/presentation/feedback_dialog.dart b/lib/src/features/feedback/presentation/feedback_dialog.dart index 7a44c1e..8e1759f 100644 --- a/lib/src/features/feedback/presentation/feedback_dialog.dart +++ b/lib/src/features/feedback/presentation/feedback_dialog.dart @@ -120,7 +120,7 @@ class _BusyMaxFeedbackDialogState extends State { child: Focus( autofocus: true, child: BusyMaxModalEditorScaffold( - title: l10n.sendFeedback, + title: l10n.reportAnIssue, cancelLabel: l10n.cancel, saveLabel: l10n.feedbackSubmit, onCancel: () => unawaited(_cancel()), diff --git a/lib/src/features/schedule/presentation/schedule_toolbar.dart b/lib/src/features/schedule/presentation/schedule_toolbar.dart index af75c1d..ae1b1d3 100644 --- a/lib/src/features/schedule/presentation/schedule_toolbar.dart +++ b/lib/src/features/schedule/presentation/schedule_toolbar.dart @@ -9,7 +9,13 @@ import '../../../l10n/localized_formatters.dart'; import '../../../schedule/schedule_range.dart'; import '../../../schedule/schedule_view_mode.dart'; -enum ScheduleToolbarMenuAction { refresh, settings, keyboardShortcuts, about } +enum ScheduleToolbarMenuAction { + refresh, + settings, + keyboardShortcuts, + reportIssue, + about, +} enum _ScheduleCreateAction { event, task } @@ -181,6 +187,11 @@ class ScheduleToolbar extends StatelessWidget { label: context.l10n.keyboardShortcuts, icon: Icons.keyboard_alt_outlined, ), + BusyMaxMenuEntry( + value: ScheduleToolbarMenuAction.reportIssue, + label: context.l10n.reportAnIssue, + icon: YaruIcons.warning, + ), BusyMaxMenuEntry( value: ScheduleToolbarMenuAction.about, label: context.l10n.aboutBusyMax, diff --git a/lib/src/features/schedule/presentation/schedule_workspace.dart b/lib/src/features/schedule/presentation/schedule_workspace.dart index d6cf013..08ce764 100644 --- a/lib/src/features/schedule/presentation/schedule_workspace.dart +++ b/lib/src/features/schedule/presentation/schedule_workspace.dart @@ -18,6 +18,7 @@ import '../../../app/busymax_surface_colors.dart'; import '../../../core/logging/redacting_logger.dart'; import '../../../features/accounts/data/accounts_repository.dart'; import '../../../features/calendar/data/calendar_repository.dart'; +import '../../../features/feedback/presentation/feedback_dialog.dart'; import '../../../features/sync/sync_auth_error.dart'; import '../../../l10n/l10n.dart'; import '../../../l10n/localized_formatters.dart'; @@ -712,6 +713,8 @@ class _ScheduleWorkspaceState extends ConsumerState { _handleHeaderBarAction(BusyMaxHeaderBarAction.settings); case ScheduleToolbarMenuAction.keyboardShortcuts: _handleHeaderBarAction(BusyMaxHeaderBarAction.keyboardShortcuts); + case ScheduleToolbarMenuAction.reportIssue: + _handleHeaderBarAction(BusyMaxHeaderBarAction.reportIssue); case ScheduleToolbarMenuAction.about: _handleHeaderBarAction(BusyMaxHeaderBarAction.aboutBusyMax); } @@ -845,13 +848,18 @@ class _ScheduleWorkspaceState extends ConsumerState { headerBarService: ref.read(linuxHeaderBarServiceProvider), ), ); + case BusyMaxHeaderBarAction.reportIssue: + unawaited( + showBusyMaxFeedbackDialog( + context, + submissionService: ref.read(feedbackSubmissionServiceProvider), + headerBarService: ref.read(linuxHeaderBarServiceProvider), + ), + ); case BusyMaxHeaderBarAction.aboutBusyMax: unawaited( showBusyMaxAboutDialog( context, - feedbackSubmissionService: ref.read( - feedbackSubmissionServiceProvider, - ), headerBarService: ref.read(linuxHeaderBarServiceProvider), ), ); diff --git a/lib/src/features/settings/presentation/settings_screen.dart b/lib/src/features/settings/presentation/settings_screen.dart index b8be22b..7b11ee5 100644 --- a/lib/src/features/settings/presentation/settings_screen.dart +++ b/lib/src/features/settings/presentation/settings_screen.dart @@ -24,6 +24,7 @@ import '../../../task_providers/task_provider.dart'; import '../../accounts/data/accounts_repository.dart'; import '../../auth/data/auth_repository.dart'; import '../../diagnostics/presentation/diagnostics_screen.dart'; +import '../../feedback/presentation/feedback_dialog.dart'; import '../../sync/sync_auth_error.dart'; import '../../tasks/presentation/desktop_date_time_fields.dart'; import 'account_removal_dialog.dart'; @@ -384,13 +385,20 @@ class _SettingsScreenState extends ConsumerState { ); return; } + if (action == BusyMaxHeaderBarAction.reportIssue) { + unawaited( + showBusyMaxFeedbackDialog( + context, + submissionService: ref.read(feedbackSubmissionServiceProvider), + headerBarService: ref.read(linuxHeaderBarServiceProvider), + ), + ); + return; + } if (action == BusyMaxHeaderBarAction.aboutBusyMax) { unawaited( showBusyMaxAboutDialog( context, - feedbackSubmissionService: ref.read( - feedbackSubmissionServiceProvider, - ), headerBarService: ref.read(linuxHeaderBarServiceProvider), ), ); diff --git a/lib/src/platform/linux_header_bar_service.dart b/lib/src/platform/linux_header_bar_service.dart index e6bf50d..d66f598 100644 --- a/lib/src/platform/linux_header_bar_service.dart +++ b/lib/src/platform/linux_header_bar_service.dart @@ -26,6 +26,7 @@ enum BusyMaxHeaderBarAction { refresh, settings, keyboardShortcuts, + reportIssue, aboutBusyMax, } @@ -81,6 +82,7 @@ class BusyMaxHeaderBarLabels { required this.back, required this.settings, required this.keyboardShortcuts, + required this.reportIssue, required this.aboutBusyMax, }); @@ -102,6 +104,7 @@ class BusyMaxHeaderBarLabels { final String back; final String settings; final String keyboardShortcuts; + final String reportIssue; final String aboutBusyMax; Map toJson() { @@ -124,6 +127,7 @@ class BusyMaxHeaderBarLabels { 'back': back, 'settings': settings, 'keyboardShortcuts': keyboardShortcuts, + 'reportIssue': reportIssue, 'aboutBusyMax': aboutBusyMax, }; } @@ -150,6 +154,7 @@ class BusyMaxHeaderBarLabels { back == other.back && settings == other.settings && keyboardShortcuts == other.keyboardShortcuts && + reportIssue == other.reportIssue && aboutBusyMax == other.aboutBusyMax; } @@ -173,6 +178,7 @@ class BusyMaxHeaderBarLabels { back, settings, keyboardShortcuts, + reportIssue, aboutBusyMax, ); } @@ -669,6 +675,7 @@ class LinuxHeaderBarService { 'refresh' => BusyMaxHeaderBarAction.refresh, 'settings' => BusyMaxHeaderBarAction.settings, 'keyboardShortcuts' => BusyMaxHeaderBarAction.keyboardShortcuts, + 'reportIssue' => BusyMaxHeaderBarAction.reportIssue, 'aboutBusyMax' => BusyMaxHeaderBarAction.aboutBusyMax, _ => null, }; diff --git a/linux/runner/my_application.cc b/linux/runner/my_application.cc index ab96d1c..b210fcb 100644 --- a/linux/runner/my_application.cc +++ b/linux/runner/my_application.cc @@ -180,6 +180,7 @@ struct _MyApplication { 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_search_query; gboolean hide_on_close; @@ -2795,6 +2796,8 @@ static void rebuild_header_settings_menu_model(MyApplication* self) { g_menu_append(menu, self->header_settings_label, "header.settings"); g_menu_append(menu, self->header_keyboard_shortcuts_label, "header.keyboard-shortcuts"); + g_menu_append(menu, self->header_report_issue_label, + "header.report-issue"); g_menu_append(menu, self->header_about_label, "header.about"); set_header_menu_button_model(self->settings_menu_button, G_MENU_MODEL(menu), &self->settings_menu); @@ -2887,6 +2890,8 @@ static void initialize_header_menu_actions(MyApplication* self) { 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 = @@ -3430,6 +3435,7 @@ static void set_header_localized_labels(MyApplication* self, FlValue* args) { 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"); set_button_label_and_tooltip(self->today_button, today, today); @@ -3451,6 +3457,7 @@ static void set_header_localized_labels(MyApplication* self, FlValue* args) { 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); } @@ -5080,6 +5087,7 @@ static void my_application_dispose(GObject* object) { 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_search_query, g_free); g_clear_pointer(&self->dart_entrypoint_arguments, g_strfreev); @@ -5186,6 +5194,7 @@ static void my_application_init(MyApplication* self) { 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_search_query = g_strdup(""); self->header_search_active = FALSE; diff --git a/test/app/about_dialog_test.dart b/test/app/about_dialog_test.dart index 370d93d..8142a0f 100644 --- a/test/app/about_dialog_test.dart +++ b/test/app/about_dialog_test.dart @@ -26,10 +26,15 @@ void main() { await tester.pumpAndSettle(); expect(find.text('BusyMax'), findsOneWidget); - expect(find.text('ToDo and Calendar'), findsOneWidget); + expect(find.text('Calendar and tasks'), findsOneWidget); + expect(find.text('License'), findsOneWidget); + expect(find.text('Apache License 2.0'), findsOneWidget); expect(find.text('Website'), findsOneWidget); - expect(find.text('Send feedback'), findsOneWidget); - expect(find.text('Report an issue'), findsOneWidget); + expect(find.text('https://busystack.org'), findsOneWidget); + expect(find.text('Source code'), findsOneWidget); + 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.byType(YaruDialogTitleBar), findsOneWidget); expect(find.byType(YaruWindowControl), findsOneWidget); @@ -114,7 +119,7 @@ void main() { await tester.pumpAndSettle(); expect(tester.takeException(), isNull); - expect(find.text('Report an issue').hitTestable(), findsOneWidget); + expect(find.text('Source code').hitTestable(), findsOneWidget); expect(closeButton.hitTestable(), findsOneWidget); expect(tester.getTopLeft(closeButton), closePosition); }, @@ -312,13 +317,15 @@ void main() { }); } - test('about links point to BusyStack repository', () { + test('about links match the BusyStack product metadata', () { final source = File( 'lib/src/app/busymax_about_dialog.dart', ).readAsStringSync(); + expect(source, contains('https://busystack.org')); expect(source, contains('https://github.com/busystack/busymax')); - expect(source, contains('https://github.com/busystack/busymax/issues')); + expect(source, contains('https://www.apache.org/licenses/LICENSE-2.0')); + expect(source, isNot(contains('/issues'))); expect(source, isNot(contains('https://github.com/albertgee/busymax'))); }); diff --git a/test/app/native_ui_audit_test.dart b/test/app/native_ui_audit_test.dart index 271d79a..c7243c7 100644 --- a/test/app/native_ui_audit_test.dart +++ b/test/app/native_ui_audit_test.dart @@ -653,6 +653,23 @@ void main() { 'g_menu_append(menu, self->header_settings_label, "header.settings")', ), ); + expect( + source, + contains( + 'g_menu_append(menu, self->header_report_issue_label,\n' + ' "header.report-issue")', + ), + ); + expect( + source.indexOf('self->header_report_issue_label'), + lessThan(source.indexOf('self->header_about_label')), + ); + expect( + source, + contains( + 'create_header_bridge_action(self, "report-issue", "reportIssue")', + ), + ); expect( source, contains('create_header_bridge_action(self, "about", "aboutBusyMax")'), diff --git a/test/features/feedback/presentation/feedback_dialog_test.dart b/test/features/feedback/presentation/feedback_dialog_test.dart index 8492d84..a302f54 100644 --- a/test/features/feedback/presentation/feedback_dialog_test.dart +++ b/test/features/feedback/presentation/feedback_dialog_test.dart @@ -42,6 +42,7 @@ void main() { }); await _pumpDialog(tester, service); + expect(find.text('Report an issue'), findsOneWidget); for (final key in const [ 'feedback-subject', 'feedback-message', diff --git a/test/features/schedule/presentation/schedule_toolbar_test.dart b/test/features/schedule/presentation/schedule_toolbar_test.dart index 8c4cb24..68a6abf 100644 --- a/test/features/schedule/presentation/schedule_toolbar_test.dart +++ b/test/features/schedule/presentation/schedule_toolbar_test.dart @@ -211,12 +211,19 @@ void main() { await tester.pumpAndSettle(); expect( find.byWidgetPredicate((widget) => widget is PopupMenuItem), - findsNWidgets(3), + findsNWidgets(4), ); expect(find.byType(YaruRadio), findsNothing); + expect(find.text('Report an issue'), findsOneWidget); await tester.tap(find.text('Settings')); await tester.pumpAndSettle(); expect(selectedMenuAction, ScheduleToolbarMenuAction.settings); + + await tester.tap(find.byTooltip('Main Menu')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Report an issue')); + await tester.pumpAndSettle(); + expect(selectedMenuAction, ScheduleToolbarMenuAction.reportIssue); }); testWidgets('compact fallback moves refresh into the main menu', ( @@ -260,7 +267,7 @@ void main() { await tester.pumpAndSettle(); expect( find.byWidgetPredicate((widget) => widget is PopupMenuItem), - findsNWidgets(4), + findsNWidgets(5), ); await tester.tap(find.text('Refresh all')); await tester.pumpAndSettle(); diff --git a/test/platform/linux_header_bar_configuration_synchronizer_test.dart b/test/platform/linux_header_bar_configuration_synchronizer_test.dart index 3f5a420..d3152a0 100644 --- a/test/platform/linux_header_bar_configuration_synchronizer_test.dart +++ b/test/platform/linux_header_bar_configuration_synchronizer_test.dart @@ -119,6 +119,7 @@ BusyMaxHeaderBarConfiguration _configuration({required bool dark}) { back: 'Back', settings: 'Settings', keyboardShortcuts: 'Keyboard shortcuts', + reportIssue: 'Report an issue', aboutBusyMax: 'About BusyMax', ), sidebarWidth: 300, diff --git a/test/platform/linux_header_bar_service_test.dart b/test/platform/linux_header_bar_service_test.dart index 4e1ed3a..c74484e 100644 --- a/test/platform/linux_header_bar_service_test.dart +++ b/test/platform/linux_header_bar_service_test.dart @@ -81,6 +81,7 @@ void main() { back: 'Back', settings: 'Settings', keyboardShortcuts: 'Keyboard Shortcuts', + reportIssue: 'Report an issue', aboutBusyMax: 'About BusyMax', ), ); @@ -138,6 +139,7 @@ void main() { 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[2].arguments, 300); expect(calls[3].arguments, 'rtl'); @@ -725,12 +727,13 @@ void main() { final session = service.claimSession(); addTearDown(session.dispose); - final nextAction = session.actions.take(6).toList(); + 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, [ @@ -739,6 +742,7 @@ void main() { BusyMaxHeaderBarAction.continueSetup, BusyMaxHeaderBarAction.settings, BusyMaxHeaderBarAction.keyboardShortcuts, + BusyMaxHeaderBarAction.reportIssue, BusyMaxHeaderBarAction.aboutBusyMax, ]); }); From e65e251c8ce251f99f36b121d474addd59998c91 Mon Sep 17 00:00:00 2001 From: albert Date: Wed, 29 Jul 2026 20:47:23 -0700 Subject: [PATCH 50/73] Ensure synchronous hide of GtkPopover before destruction to release modal grab and pointer state. --- linux/runner/my_application.cc | 7 +++++++ test/app/native_ui_audit_test.dart | 3 +++ 2 files changed, 10 insertions(+) diff --git a/linux/runner/my_application.cc b/linux/runner/my_application.cc index b210fcb..d740648 100644 --- a/linux/runner/my_application.cc +++ b/linux/runner/my_application.cc @@ -1337,6 +1337,13 @@ static void native_menu_session_dispose(NativeMenuSession* session) { session->closed_signal_id); session->closed_signal_id = 0; } + // A GtkPopover "closed" signal is emitted when its hide transition + // starts, not when the widget is unmapped. Complete the hide synchronously + // before destruction so GTK releases the modal grab and pointer state + // before a subsequent menu is presented. + if (gtk_widget_get_visible(session->popover)) { + gtk_widget_hide(session->popover); + } gtk_widget_destroy(session->popover); g_clear_object(&session->popover); } diff --git a/test/app/native_ui_audit_test.dart b/test/app/native_ui_audit_test.dart index c7243c7..3af53b5 100644 --- a/test/app/native_ui_audit_test.dart +++ b/test/app/native_ui_audit_test.dart @@ -1301,15 +1301,18 @@ void main() { final destroyIndex = dispose.indexOf( 'gtk_widget_destroy(session->popover)', ); + final hideIndex = dispose.indexOf('gtk_widget_hide(session->popover)'); final clearActionsIndex = dispose.indexOf( 'g_clear_object(&session->action_group)', ); final respondIndex = dispose.indexOf('native_menu_session_respond('); final freeIndex = dispose.indexOf('g_free(session)'); expect(destroyIndex, isNonNegative); + expect(hideIndex, isNonNegative); expect(clearActionsIndex, isNonNegative); expect(respondIndex, isNonNegative); expect(freeIndex, isNonNegative); + expect(hideIndex, lessThan(destroyIndex)); expect(clearActionsIndex, lessThan(respondIndex)); expect(respondIndex, lessThan(freeIndex)); expect( From 4866db892b622082cfb6072140916a6a53860332 Mon Sep 17 00:00:00 2001 From: albert Date: Thu, 30 Jul 2026 01:18:09 -0700 Subject: [PATCH 51/73] Add keyboard shortcuts for various actions and enhance menu entry structure. Enhance header menu with shortcut and icon attributes. Refactor view mode presentation to include icons and shortcuts. Improve tooltip functionality for buttons with associated shortcuts. --- lib/src/app/busymax_app.dart | 14 + lib/src/app/busymax_design.dart | 31 +- lib/src/app/busymax_glyphs.dart | 70 ++++ .../busymax_keyboard_shortcuts_dialog.dart | 44 ++- lib/src/app/busymax_shortcuts.dart | 23 ++ .../schedule/presentation/mini_calendar.dart | 6 +- .../presentation/schedule_create_menu.dart | 5 + .../presentation/schedule_toolbar.dart | 49 ++- .../presentation/schedule_year_view.dart | 4 +- .../desktop_date_time_fields.dart | 2 + .../platform/linux_header_bar_service.dart | 76 +++- lib/src/platform/native_menu_service.dart | 6 + linux/runner/my_application.cc | 367 ++++++++++++++++-- test/app/busymax_menu_button_test.dart | 12 +- test/app/native_ui_audit_test.dart | 44 ++- .../schedule_create_menu_test.dart | 16 +- .../presentation/schedule_toolbar_test.dart | 32 +- .../presentation/schedule_views_test.dart | 42 +- ...chedule_workspace_task_mutations_test.dart | 4 +- .../desktop_date_time_fields_test.dart | 14 + .../linux_header_bar_service_test.dart | 13 + test/platform/native_menu_service_test.dart | 14 +- 22 files changed, 786 insertions(+), 102 deletions(-) diff --git a/lib/src/app/busymax_app.dart b/lib/src/app/busymax_app.dart index 11a442c..3b48976 100644 --- a/lib/src/app/busymax_app.dart +++ b/lib/src/app/busymax_app.dart @@ -254,6 +254,20 @@ class _BusyMaxAppState extends ConsumerState { 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, + createShortcut: BusyMaxShortcutLabels.create, + createEventShortcut: BusyMaxShortcutLabels.newEvent, + createTaskShortcut: BusyMaxShortcutLabels.newTask, + previousShortcut: BusyMaxShortcutLabels.previousPeriod, + nextShortcut: BusyMaxShortcutLabels.nextPeriod, + settingsShortcut: BusyMaxShortcutLabels.settings, + keyboardShortcutsShortcut: BusyMaxShortcutLabels.keyboardShortcuts, ); _headerBarConfigurationSynchronizer.schedule( BusyMaxHeaderBarConfiguration( diff --git a/lib/src/app/busymax_design.dart b/lib/src/app/busymax_design.dart index 7ece53e..20977df 100644 --- a/lib/src/app/busymax_design.dart +++ b/lib/src/app/busymax_design.dart @@ -9,6 +9,7 @@ import 'package:yaru/yaru.dart'; import '../l10n/l10n.dart'; import '../platform/native_menu_service.dart'; +import 'busymax_glyphs.dart'; import 'busymax_surface_colors.dart'; abstract final class BusyMaxSpacing { @@ -55,6 +56,11 @@ abstract final class BusyMaxFormLayout { static const double comboInlineMaxFraction = 0.46; } +abstract final class BusyMaxCalendarHeaderLayout { + static const int monthControlFlex = 3; + static const int yearControlFlex = 2; +} + /// BusyMax's single deliberate adjustment to Yaru's surface depth. /// /// Grouped cards need a little more separation from the application canvas. @@ -2296,6 +2302,7 @@ class BusyMaxMenuEntry { required this.label, this.icon, this.child, + this.shortcut, this.enabled = true, this.selected = false, this.tooltip, @@ -2306,6 +2313,7 @@ class BusyMaxMenuEntry { final String label; final IconData? icon; final Widget? child; + final String? shortcut; final bool enabled; final bool selected; final String? tooltip; @@ -2496,8 +2504,10 @@ List _nativeMenuEntries(List> entries) { for (final entry in entries) NativeMenuEntry( label: entry.label, + iconName: BusyMaxGlyphs.nativeMenuIconName(entry.icon), enabled: entry.enabled, selected: entry.selected, + shortcut: entry.shortcut, ), ]; } @@ -2668,10 +2678,21 @@ Widget _busyMaxFallbackMenuEntry( overflow: TextOverflow.ellipsis, style: foreground == null ? null : TextStyle(color: foreground), ); - final content = entry.icon == null && selectionIndicator == null + final shortcut = entry.shortcut == null || entry.shortcut!.isEmpty + ? null + : Directionality( + textDirection: TextDirection.ltr, + child: Text( + entry.shortcut!, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.labelSmall, + ), + ); + final content = + entry.icon == null && selectionIndicator == null && shortcut == null ? label : Row( - mainAxisSize: MainAxisSize.min, children: [ if (selectionIndicator != null) ...[ selectionIndicator, @@ -2681,7 +2702,11 @@ Widget _busyMaxFallbackMenuEntry( Icon(entry.icon, color: foreground), const SizedBox(width: BusyMaxSpacing.sm), ], - Flexible(child: label), + Expanded(child: label), + if (shortcut != null) ...[ + const SizedBox(width: BusyMaxSpacing.md), + shortcut, + ], ], ); if (entry.enabled || entry.tooltip == null) { diff --git a/lib/src/app/busymax_glyphs.dart b/lib/src/app/busymax_glyphs.dart index 1e22ae2..912ba00 100644 --- a/lib/src/app/busymax_glyphs.dart +++ b/lib/src/app/busymax_glyphs.dart @@ -9,6 +9,76 @@ import 'package:yaru/yaru.dart'; abstract final class BusyMaxGlyphs { const BusyMaxGlyphs._(); + /// Maps Flutter menu glyphs to equivalent freedesktop themed icons. + /// + /// GTK menu models consume themed-icon names rather than Flutter font + /// glyphs, so the native bridge uses this catalog to retain menu artwork. + static String? nativeMenuIconName(IconData? icon) { + if (icon == null) { + return null; + } + if (icon == YaruIcons.refresh || icon == Icons.refresh) { + return 'view-refresh-symbolic'; + } + if (icon == YaruIcons.trash || icon == Icons.delete_outline) { + return 'user-trash-symbolic'; + } + if (icon == Icons.open_in_browser_outlined) { + return 'external-link-symbolic'; + } + if (icon == Icons.edit_outlined) { + return 'document-edit-symbolic'; + } + if (icon == Icons.calendar_view_day_outlined || + icon == YaruIcons.calendar_day) { + return 'calendar-app-symbolic'; + } + if (icon == Icons.view_week_outlined) { + return 'calendar-week-symbolic'; + } + if (icon == Icons.calendar_view_month) { + return 'calendar-month-symbolic'; + } + if (icon == Icons.calendar_today_outlined || icon == Icons.event_outlined) { + return 'x-office-calendar-symbolic'; + } + if (icon == Icons.view_agenda_outlined) { + return 'calendar-agenda-symbolic'; + } + if (icon == YaruIcons.settings || icon == Icons.settings_outlined) { + return 'preferences-system-symbolic'; + } + if (icon == Icons.keyboard_alt_outlined || + icon == YaruIcons.keyboard_shortcuts) { + return 'input-keyboard-symbolic'; + } + if (icon == YaruIcons.warning) { + return 'dialog-warning-symbolic'; + } + if (icon == Icons.info_outline) { + return 'help-about-symbolic'; + } + if (icon == Icons.task_alt_outlined) { + return 'checkbox-checked-symbolic'; + } + if (icon == YaruIcons.user) { + return 'avatar-default-symbolic'; + } + if (icon == YaruIcons.desktop) { + return 'video-display-symbolic'; + } + if (icon == YaruIcons.bell) { + return 'preferences-system-notifications-symbolic'; + } + if (icon == YaruIcons.shield_warning) { + return 'security-high-symbolic'; + } + if (icon == YaruIcons.monitor) { + return 'diagnostics-symbolic'; + } + return null; + } + static IconData backFor(TextDirection direction) { return direction == TextDirection.rtl ? YaruIcons.arrow_right diff --git a/lib/src/app/busymax_keyboard_shortcuts_dialog.dart b/lib/src/app/busymax_keyboard_shortcuts_dialog.dart index 2642566..0db4e2c 100644 --- a/lib/src/app/busymax_keyboard_shortcuts_dialog.dart +++ b/lib/src/app/busymax_keyboard_shortcuts_dialog.dart @@ -79,18 +79,24 @@ class BusyMaxKeyboardShortcutsDialog extends StatelessWidget { title: l10n.shortcutNextPeriod, subtitle: l10n.shortcutNextPeriodDescription, leading: const Icon(Icons.arrow_forward), - trailing: const _KeyboardShortcutBadge('Shift+Right'), + trailing: const _KeyboardShortcutBadge( + BusyMaxShortcutLabels.nextPeriod, + ), ), BusyMaxActionRow( title: l10n.shortcutPreviousPeriod, subtitle: l10n.shortcutPreviousPeriodDescription, leading: const Icon(Icons.arrow_back), - trailing: const _KeyboardShortcutBadge('Shift+Left'), + trailing: const _KeyboardShortcutBadge( + BusyMaxShortcutLabels.previousPeriod, + ), ), BusyMaxActionRow( title: l10n.shortcutJumpToToday, leading: const Icon(Icons.today_outlined), - trailing: const _KeyboardShortcutBadge('Shift+T'), + trailing: const _KeyboardShortcutBadge( + BusyMaxShortcutLabels.today, + ), ), ], ), @@ -108,12 +114,16 @@ class BusyMaxKeyboardShortcutsDialog extends StatelessWidget { BusyMaxActionRow( title: l10n.newEvent, leading: const Icon(Icons.event_outlined), - trailing: const _KeyboardShortcutBadge('E'), + trailing: const _KeyboardShortcutBadge( + BusyMaxShortcutLabels.newEvent, + ), ), BusyMaxActionRow( title: l10n.newTask, leading: const Icon(Icons.task_alt_outlined), - trailing: const _KeyboardShortcutBadge('T'), + trailing: const _KeyboardShortcutBadge( + BusyMaxShortcutLabels.newTask, + ), ), BusyMaxActionRow( title: l10n.shortcutSaveItem, @@ -146,27 +156,37 @@ class BusyMaxKeyboardShortcutsDialog extends StatelessWidget { BusyMaxActionRow( title: l10n.shortcutDayView, leading: const Icon(Icons.calendar_view_day_outlined), - trailing: const _KeyboardShortcutBadge('1 / D'), + trailing: const _KeyboardShortcutBadge( + BusyMaxShortcutLabels.dayView, + ), ), BusyMaxActionRow( title: l10n.shortcutWeekView, leading: const Icon(Icons.view_week_outlined), - trailing: const _KeyboardShortcutBadge('2 / W'), + trailing: const _KeyboardShortcutBadge( + BusyMaxShortcutLabels.weekView, + ), ), BusyMaxActionRow( title: l10n.shortcutMonthView, leading: const Icon(Icons.calendar_view_month), - trailing: const _KeyboardShortcutBadge('3 / M'), + trailing: const _KeyboardShortcutBadge( + BusyMaxShortcutLabels.monthView, + ), ), BusyMaxActionRow( title: l10n.shortcutYearView, leading: const Icon(Icons.calendar_today_outlined), - trailing: const _KeyboardShortcutBadge('4 / Y'), + trailing: const _KeyboardShortcutBadge( + BusyMaxShortcutLabels.yearView, + ), ), BusyMaxActionRow( title: l10n.shortcutAgendaView, leading: const Icon(Icons.view_agenda_outlined), - trailing: const _KeyboardShortcutBadge('0 / A'), + trailing: const _KeyboardShortcutBadge( + BusyMaxShortcutLabels.agendaView, + ), ), ], ), @@ -178,7 +198,9 @@ class BusyMaxKeyboardShortcutsDialog extends StatelessWidget { title: l10n.compactAgendaRefresh, subtitle: l10n.shortcutRefreshCompactAgendaDescription, leading: const Icon(Icons.refresh), - trailing: const _KeyboardShortcutBadge('Ctrl+R'), + trailing: const _KeyboardShortcutBadge( + BusyMaxShortcutLabels.refreshCompactAgenda, + ), ), BusyMaxActionRow( title: l10n.compactAgendaHide, diff --git a/lib/src/app/busymax_shortcuts.dart b/lib/src/app/busymax_shortcuts.dart index 55cb1a6..b4d3563 100644 --- a/lib/src/app/busymax_shortcuts.dart +++ b/lib/src/app/busymax_shortcuts.dart @@ -1,6 +1,8 @@ import 'package:flutter/services.dart'; import 'package:flutter/widgets.dart'; +import '../schedule/schedule_view_mode.dart'; + abstract final class BusyMaxShortcutActivators { static const keyboardShortcuts = SingleActivator( LogicalKeyboardKey.slash, @@ -20,5 +22,26 @@ abstract final class BusyMaxShortcutLabels { static const settings = 'Ctrl+,'; static const search = 'Ctrl+F'; static const create = 'Ctrl+N'; + static const previousPeriod = 'Shift+Left'; + static const nextPeriod = 'Shift+Right'; + static const today = 'Shift+T'; + static const newEvent = 'E'; + static const newTask = 'T'; + static const dayView = '1 / D'; + static const weekView = '2 / W'; + static const monthView = '3 / M'; + static const yearView = '4 / Y'; + static const agendaView = '0 / A'; + static const refreshCompactAgenda = 'Ctrl+R'; static const dismiss = 'Esc'; + + static String forViewMode(ScheduleViewMode mode) { + return switch (mode) { + ScheduleViewMode.day => dayView, + ScheduleViewMode.week => weekView, + ScheduleViewMode.month => monthView, + ScheduleViewMode.year => yearView, + ScheduleViewMode.agenda => agendaView, + }; + } } diff --git a/lib/src/features/schedule/presentation/mini_calendar.dart b/lib/src/features/schedule/presentation/mini_calendar.dart index 085658e..427c6e2 100644 --- a/lib/src/features/schedule/presentation/mini_calendar.dart +++ b/lib/src/features/schedule/presentation/mini_calendar.dart @@ -13,8 +13,6 @@ import '../../../schedule/schedule_projection.dart'; import 'calendar_day_semantics.dart'; const _miniCalendarHeaderControlExtent = 28.0; -const _miniCalendarMonthControlFlex = 3; -const _miniCalendarYearControlFlex = 2; /// Sidebar calendar with local month paging and schedule-view shortcuts. class MiniCalendar extends StatefulWidget { @@ -81,7 +79,7 @@ class _MiniCalendarState extends State { Row( children: [ Expanded( - flex: _miniCalendarMonthControlFlex, + flex: BusyMaxCalendarHeaderLayout.monthControlFlex, child: _MiniCalendarStepper( label: localizedMonthHeading(locale, visibleMonth), previousTooltip: l10n.previousMonth, @@ -98,7 +96,7 @@ class _MiniCalendarState extends State { ), const SizedBox(width: BusyMaxSpacing.sm), Expanded( - flex: _miniCalendarYearControlFlex, + flex: BusyMaxCalendarHeaderLayout.yearControlFlex, child: _MiniCalendarStepper( label: '${visibleMonth.year}', previousTooltip: l10n.previousYear, diff --git a/lib/src/features/schedule/presentation/schedule_create_menu.dart b/lib/src/features/schedule/presentation/schedule_create_menu.dart index 477b253..4fb7a78 100644 --- a/lib/src/features/schedule/presentation/schedule_create_menu.dart +++ b/lib/src/features/schedule/presentation/schedule_create_menu.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import '../../../app/busymax_design.dart'; +import '../../../app/busymax_shortcuts.dart'; import '../../../l10n/l10n.dart'; enum ScheduleCreateChoice { event, task } @@ -41,12 +42,16 @@ Future showScheduleCreateMenu({ BusyMaxMenuEntry( value: ScheduleCreateChoice.event, label: context.l10n.createEventAtTime, + icon: Icons.event_outlined, enabled: canCreateEvent, + shortcut: BusyMaxShortcutLabels.newEvent, ), BusyMaxMenuEntry( value: ScheduleCreateChoice.task, label: context.l10n.createTaskAtDate, + icon: Icons.task_alt_outlined, enabled: canCreateTask, + shortcut: BusyMaxShortcutLabels.newTask, ), ], ); diff --git a/lib/src/features/schedule/presentation/schedule_toolbar.dart b/lib/src/features/schedule/presentation/schedule_toolbar.dart index ae1b1d3..2380f31 100644 --- a/lib/src/features/schedule/presentation/schedule_toolbar.dart +++ b/lib/src/features/schedule/presentation/schedule_toolbar.dart @@ -4,6 +4,7 @@ import 'package:yaru/yaru.dart'; import '../../../app/busymax_design.dart'; import '../../../app/busymax_glyphs.dart'; +import '../../../app/busymax_shortcuts.dart'; import '../../../l10n/l10n.dart'; import '../../../l10n/localized_formatters.dart'; import '../../../schedule/schedule_range.dart'; @@ -84,23 +85,33 @@ class ScheduleToolbar extends StatelessWidget { ), onPressed: onToggleSidebar, ), - BusyMaxPushButton.standard( - onPressed: onToday, - child: Text(context.l10n.today), + Tooltip( + message: _shortcutTooltip( + context.l10n.today, + BusyMaxShortcutLabels.today, + ), + child: BusyMaxPushButton.standard( + onPressed: onToday, + child: Text(context.l10n.today), + ), ), const SizedBox(width: BusyMaxSpacing.sm), if (showPaging) ...[ YaruIconButton( - tooltip: MaterialLocalizations.of( - context, - ).previousPageTooltip, + tooltip: _shortcutTooltip( + MaterialLocalizations.of(context).previousPageTooltip, + BusyMaxShortcutLabels.previousPeriod, + ), icon: Icon( BusyMaxGlyphs.previousFor(Directionality.of(context)), ), onPressed: onPrevious, ), YaruIconButton( - tooltip: MaterialLocalizations.of(context).nextPageTooltip, + tooltip: _shortcutTooltip( + MaterialLocalizations.of(context).nextPageTooltip, + BusyMaxShortcutLabels.nextPeriod, + ), icon: Icon(BusyMaxGlyphs.nextFor(Directionality.of(context))), onPressed: onNext, ), @@ -115,7 +126,10 @@ class ScheduleToolbar extends StatelessWidget { ), ), BusyMaxMenuButton( - tooltip: _modeLabel(context, mode), + tooltip: _shortcutTooltip( + _modeLabel(context, mode), + BusyMaxShortcutLabels.forViewMode(mode), + ), icon: Icon(_modeIcon(mode)), entries: [ for (final value in ScheduleViewMode.values) @@ -124,18 +138,25 @@ class ScheduleToolbar extends StatelessWidget { label: _modeLabel(context, value), icon: _modeIcon(value), selected: mode == value, + shortcut: BusyMaxShortcutLabels.forViewMode(value), ), ], onSelected: onModeChanged, ), if (onSearch != null) YaruIconButton( - tooltip: MaterialLocalizations.of(context).searchFieldLabel, + tooltip: _shortcutTooltip( + MaterialLocalizations.of(context).searchFieldLabel, + BusyMaxShortcutLabels.search, + ), icon: const Icon(YaruIcons.search), onPressed: onSearch, ), BusyMaxMenuButton<_ScheduleCreateAction>( - tooltip: context.l10n.create, + tooltip: _shortcutTooltip( + context.l10n.create, + BusyMaxShortcutLabels.create, + ), icon: const Icon(YaruIcons.plus), controller: createMenuController, enabled: canCreateEvent || canCreateTask, @@ -143,12 +164,16 @@ class ScheduleToolbar extends StatelessWidget { 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) { @@ -181,11 +206,13 @@ class ScheduleToolbar extends StatelessWidget { 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, @@ -244,3 +271,5 @@ String _modeLabel(BuildContext context, ScheduleViewMode mode) { ScheduleViewMode.agenda => context.l10n.viewAgenda, }; } + +String _shortcutTooltip(String label, String shortcut) => '$label ($shortcut)'; diff --git a/lib/src/features/schedule/presentation/schedule_year_view.dart b/lib/src/features/schedule/presentation/schedule_year_view.dart index 9f24c42..6c20bf1 100644 --- a/lib/src/features/schedule/presentation/schedule_year_view.dart +++ b/lib/src/features/schedule/presentation/schedule_year_view.dart @@ -85,8 +85,8 @@ int _columnCount(double width, {required bool compact}) { if (width >= 680) { return 3; } - if (width >= 460) { + if (width >= 520) { return 2; } - return compact && width >= 360 ? 2 : 1; + return 1; } diff --git a/lib/src/features/tasks/presentation/desktop_date_time_fields.dart b/lib/src/features/tasks/presentation/desktop_date_time_fields.dart index 4809dde..e365326 100644 --- a/lib/src/features/tasks/presentation/desktop_date_time_fields.dart +++ b/lib/src/features/tasks/presentation/desktop_date_time_fields.dart @@ -439,6 +439,7 @@ class _DesktopDateValueDialogState extends State<_DesktopDateValueDialog> { child: Row( children: [ Expanded( + flex: BusyMaxCalendarHeaderLayout.monthControlFlex, child: _buildDateModeStepper( context: context, label: monthLabel, @@ -455,6 +456,7 @@ class _DesktopDateValueDialogState extends State<_DesktopDateValueDialog> { ), const SizedBox(width: BusyMaxSpacing.sm), Expanded( + flex: BusyMaxCalendarHeaderLayout.yearControlFlex, child: _buildDateModeStepper( context: context, label: '${_displayedMonth.year}', diff --git a/lib/src/platform/linux_header_bar_service.dart b/lib/src/platform/linux_header_bar_service.dart index d66f598..54a3e69 100644 --- a/lib/src/platform/linux_header_bar_service.dart +++ b/lib/src/platform/linux_header_bar_service.dart @@ -84,6 +84,20 @@ class BusyMaxHeaderBarLabels { required this.keyboardShortcuts, required this.reportIssue, required this.aboutBusyMax, + this.todayShortcut = '', + this.dayShortcut = '', + this.weekShortcut = '', + this.monthShortcut = '', + this.yearShortcut = '', + this.agendaShortcut = '', + this.searchShortcut = '', + this.createShortcut = '', + this.createEventShortcut = '', + this.createTaskShortcut = '', + this.previousShortcut = '', + this.nextShortcut = '', + this.settingsShortcut = '', + this.keyboardShortcutsShortcut = '', }); final String today; @@ -106,6 +120,20 @@ class BusyMaxHeaderBarLabels { 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 createShortcut; + final String createEventShortcut; + final String createTaskShortcut; + final String previousShortcut; + final String nextShortcut; + final String settingsShortcut; + final String keyboardShortcutsShortcut; Map toJson() { return { @@ -129,6 +157,20 @@ class BusyMaxHeaderBarLabels { 'keyboardShortcuts': keyboardShortcuts, 'reportIssue': reportIssue, 'aboutBusyMax': aboutBusyMax, + 'todayShortcut': todayShortcut, + 'dayShortcut': dayShortcut, + 'weekShortcut': weekShortcut, + 'monthShortcut': monthShortcut, + 'yearShortcut': yearShortcut, + 'agendaShortcut': agendaShortcut, + 'searchShortcut': searchShortcut, + 'createShortcut': createShortcut, + 'createEventShortcut': createEventShortcut, + 'createTaskShortcut': createTaskShortcut, + 'previousShortcut': previousShortcut, + 'nextShortcut': nextShortcut, + 'settingsShortcut': settingsShortcut, + 'keyboardShortcutsShortcut': keyboardShortcutsShortcut, }; } @@ -155,11 +197,25 @@ class BusyMaxHeaderBarLabels { settings == other.settings && keyboardShortcuts == other.keyboardShortcuts && reportIssue == other.reportIssue && - aboutBusyMax == other.aboutBusyMax; + aboutBusyMax == other.aboutBusyMax && + todayShortcut == other.todayShortcut && + dayShortcut == other.dayShortcut && + weekShortcut == other.weekShortcut && + monthShortcut == other.monthShortcut && + yearShortcut == other.yearShortcut && + agendaShortcut == other.agendaShortcut && + searchShortcut == other.searchShortcut && + createShortcut == other.createShortcut && + createEventShortcut == other.createEventShortcut && + createTaskShortcut == other.createTaskShortcut && + previousShortcut == other.previousShortcut && + nextShortcut == other.nextShortcut && + settingsShortcut == other.settingsShortcut && + keyboardShortcutsShortcut == other.keyboardShortcutsShortcut; } @override - int get hashCode => Object.hash( + int get hashCode => Object.hashAll([ today, day, week, @@ -180,7 +236,21 @@ class BusyMaxHeaderBarLabels { keyboardShortcuts, reportIssue, aboutBusyMax, - ); + todayShortcut, + dayShortcut, + weekShortcut, + monthShortcut, + yearShortcut, + agendaShortcut, + searchShortcut, + createShortcut, + createEventShortcut, + createTaskShortcut, + previousShortcut, + nextShortcut, + settingsShortcut, + keyboardShortcutsShortcut, + ]); } @immutable diff --git a/lib/src/platform/native_menu_service.dart b/lib/src/platform/native_menu_service.dart index 85ab41e..e365e1b 100644 --- a/lib/src/platform/native_menu_service.dart +++ b/lib/src/platform/native_menu_service.dart @@ -25,19 +25,25 @@ final class NativeMenuSession { final class NativeMenuEntry { const NativeMenuEntry({ required this.label, + this.iconName, this.enabled = true, this.selected = false, + this.shortcut, }); final String label; + final String? iconName; final bool enabled; final bool selected; + final String? shortcut; Map _toPlatformMap() { return { 'label': label, + if (iconName != null && iconName!.isNotEmpty) 'icon': iconName!, 'enabled': enabled, 'selected': selected, + if (shortcut != null && shortcut!.isNotEmpty) 'shortcut': shortcut!, }; } } diff --git a/linux/runner/my_application.cc b/linux/runner/my_application.cc index d740648..900bc72 100644 --- a/linux/runner/my_application.cc +++ b/linux/runner/my_application.cc @@ -73,6 +73,12 @@ constexpr char kDefaultModalBarrierColor[] = "rgba(0,0,0,0.25)"; constexpr char kHeaderControlStyleClass[] = "busymax-header-control"; constexpr char kHeaderOnboardingTextButtonStyleClass[] = "busymax-onboarding-text-button"; +constexpr char kMenuShortcutAttribute[] = "x-busymax-shortcut"; +constexpr char kMenuIconAttribute[] = "x-busymax-icon"; +constexpr char kModelButtonShortcutKey[] = + "busymax-model-button-shortcut"; +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"; @@ -160,7 +166,7 @@ struct _MyApplication { GtkWidget* previous_button; GtkWidget* next_button; GtkWidget* view_mode_button; - GtkWidget* view_mode_label; + GtkWidget* view_mode_icon; GtkWidget* view_mode_menu; GtkWidget* search_button; GtkWidget* create_button; @@ -182,6 +188,15 @@ struct _MyApplication { 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_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; @@ -214,6 +229,95 @@ static void style_header_menu_popover(GtkWidget* popover) { kHeaderMenuDepthStyleClass); } +static void add_model_button_presentation(GtkWidget* button, + const gchar* icon_name, + const gchar* shortcut) { + if (button == nullptr || !GTK_IS_MODEL_BUTTON(button) || + ((icon_name == nullptr || icon_name[0] == '\0') && + (shortcut == nullptr || shortcut[0] == '\0')) || + g_object_get_data(G_OBJECT(button), kModelButtonShortcutKey) != nullptr) { + return; + } + + GtkWidget* content = gtk_bin_get_child(GTK_BIN(button)); + if (content == nullptr || !GTK_IS_WIDGET(content)) { + return; + } + g_object_ref(content); + gtk_container_remove(GTK_CONTAINER(button), content); + + GtkWidget* row = + gtk_box_new(GTK_ORIENTATION_HORIZONTAL, kHeaderButtonSpacing); + if (icon_name != nullptr && icon_name[0] != '\0') { + GtkWidget* icon = + gtk_image_new_from_icon_name(icon_name, GTK_ICON_SIZE_MENU); + gtk_widget_set_valign(icon, GTK_ALIGN_CENTER); + gtk_box_pack_start(GTK_BOX(row), icon, FALSE, FALSE, 0); + } + gtk_widget_set_hexpand(content, TRUE); + gtk_box_pack_start(GTK_BOX(row), content, TRUE, TRUE, 0); + + if (shortcut != nullptr && shortcut[0] != '\0') { + GtkWidget* shortcut_label = gtk_label_new(shortcut); + gtk_widget_set_direction(shortcut_label, GTK_TEXT_DIR_LTR); + gtk_widget_set_halign(shortcut_label, GTK_ALIGN_END); + gtk_widget_set_valign(shortcut_label, GTK_ALIGN_CENTER); + gtk_label_set_xalign(GTK_LABEL(shortcut_label), 1.0); + gtk_style_context_add_class( + gtk_widget_get_style_context(shortcut_label), "dim-label"); + gtk_box_pack_end(GTK_BOX(row), shortcut_label, FALSE, FALSE, 0); + } + + gtk_container_add(GTK_CONTAINER(button), row); + gtk_widget_show_all(row); + g_object_unref(content); + g_object_set_data(G_OBJECT(button), kModelButtonShortcutKey, + GINT_TO_POINTER(1)); +} + +struct ModelMenuShortcutDecoration { + GMenuModel* model; + gint item_index; +}; + +static void decorate_model_menu_shortcuts_cb(GtkWidget* widget, + gpointer user_data) { + auto* decoration = static_cast(user_data); + if (GTK_IS_MODEL_BUTTON(widget)) { + if (decoration->item_index < + g_menu_model_get_n_items(decoration->model)) { + g_autoptr(GVariant) value = g_menu_model_get_item_attribute_value( + decoration->model, decoration->item_index, kMenuShortcutAttribute, + G_VARIANT_TYPE_STRING); + g_autoptr(GVariant) icon_value = + g_menu_model_get_item_attribute_value( + decoration->model, decoration->item_index, kMenuIconAttribute, + G_VARIANT_TYPE_STRING); + add_model_button_presentation( + widget, + icon_value != nullptr ? g_variant_get_string(icon_value, nullptr) + : nullptr, + value != nullptr ? g_variant_get_string(value, nullptr) : nullptr); + } + decoration->item_index++; + return; + } + if (GTK_IS_CONTAINER(widget)) { + gtk_container_foreach(GTK_CONTAINER(widget), + decorate_model_menu_shortcuts_cb, user_data); + } +} + +static void decorate_model_menu_shortcuts(GtkWidget* popover, + GMenuModel* model) { + if (popover == nullptr || !GTK_IS_CONTAINER(popover) || model == nullptr) { + return; + } + ModelMenuShortcutDecoration decoration = {model, 0}; + gtk_container_foreach(GTK_CONTAINER(popover), + decorate_model_menu_shortcuts_cb, &decoration); +} + static GdkPixbuf* load_application_icon_at_size(gint size) { g_autofree gchar* executable_path = g_file_read_link("/proc/self/exe", nullptr); @@ -1597,15 +1701,20 @@ static void show_native_menu(NativeMenuHandlerData* data, for (size_t index = 0; index < fl_value_get_length(entries); index++) { FlValue* entry = fl_value_get_list_value(entries, index); const gchar* label = fl_lookup_string_arg(entry, "label"); + FlValue* icon = fl_value_lookup_string(entry, "icon"); + FlValue* shortcut = fl_value_lookup_string(entry, "shortcut"); gboolean enabled = TRUE; gboolean selected = FALSE; if (label == nullptr || + (icon != nullptr && fl_value_get_type(icon) != FL_VALUE_TYPE_STRING) || + (shortcut != nullptr && + fl_value_get_type(shortcut) != FL_VALUE_TYPE_STRING) || !fl_lookup_optional_bool_arg(entry, "enabled", TRUE, &enabled) || !fl_lookup_optional_bool_arg(entry, "selected", FALSE, &selected)) { respond_native_menu_argument_error( method_call, - "each entry must contain a label and optional boolean enabled and " - "selected values."); + "each entry must contain a label, optional string icon and shortcut, " + "and optional boolean enabled and selected values."); return; } if (selected) { @@ -1656,10 +1765,20 @@ static void show_native_menu(NativeMenuHandlerData* data, for (size_t index = 0; index < fl_value_get_length(entries); index++) { FlValue* entry = fl_value_get_list_value(entries, index); const gchar* label = fl_lookup_string_arg(entry, "label"); + const gchar* icon_name = fl_lookup_string_arg(entry, "icon"); + const gchar* shortcut = fl_lookup_string_arg(entry, "shortcut"); gboolean enabled = TRUE; fl_lookup_optional_bool_arg(entry, "enabled", TRUE, &enabled); g_autoptr(GMenuItem) item = g_menu_item_new(label, nullptr); + 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); + g_menu_item_set_attribute(item, kMenuIconAttribute, "s", icon_name); + } + if (shortcut != nullptr && shortcut[0] != '\0') { + g_menu_item_set_attribute(item, kMenuShortcutAttribute, "s", shortcut); + } if (selected_entry_count == 1) { g_autofree gchar* target = g_strdup_printf("%zu", index); g_menu_item_set_action_and_target_value( @@ -1689,6 +1808,8 @@ static void show_native_menu(NativeMenuHandlerData* data, G_MENU_MODEL(session->model)); g_object_ref_sink(session->popover); style_native_popover(session->popover); + decorate_model_menu_shortcuts(session->popover, + G_MENU_MODEL(session->model)); gtk_popover_set_pointing_to(GTK_POPOVER(session->popover), &anchor); gtk_popover_set_position(GTK_POPOVER(session->popover), preferred_position); @@ -2723,6 +2844,9 @@ static void close_header_menu_button(GtkWidget* menu_button) { 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) { @@ -2752,15 +2876,58 @@ static const gchar* header_view_mode_label(MyApplication* self, return ""; } -static void update_header_view_mode_label(MyApplication* self) { - if (self->view_mode_label == nullptr || - !GTK_IS_LABEL(self->view_mode_label)) { +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"; - gtk_label_set_text(GTK_LABEL(self->view_mode_label), - header_view_mode_label(self, mode)); + 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, @@ -2785,13 +2952,41 @@ static void set_header_menu_button_model(GtkWidget* button, track_widget_pointer(tracked_popover, GTK_WIDGET(popover)); style_header_menu_popover(GTK_WIDGET(popover)); gtk_popover_set_position(popover, GTK_POS_BOTTOM); + decorate_model_menu_shortcuts(GTK_WIDGET(popover), model); +} + +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); + g_menu_item_set_attribute(item, kMenuIconAttribute, "s", icon_name); + } + if (shortcut != nullptr && shortcut[0] != '\0') { + g_menu_item_set_attribute(item, kMenuShortcutAttribute, "s", shortcut); + } + g_menu_append_item(menu, item); } static void append_header_view_mode_item(GMenu* menu, const gchar* label, - const gchar* mode) { + 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); + g_menu_item_set_attribute(item, kMenuIconAttribute, "s", icon_name); + } + if (shortcut != nullptr && shortcut[0] != '\0') { + g_menu_item_set_attribute(item, kMenuShortcutAttribute, "s", shortcut); + } g_menu_append_item(menu, item); } @@ -2800,12 +2995,18 @@ static void rebuild_header_settings_menu_model(MyApplication* self) { return; } g_autoptr(GMenu) menu = g_menu_new(); - g_menu_append(menu, self->header_settings_label, "header.settings"); - g_menu_append(menu, self->header_keyboard_shortcuts_label, - "header.keyboard-shortcuts"); - g_menu_append(menu, self->header_report_issue_label, - "header.report-issue"); - g_menu_append(menu, self->header_about_label, "header.about"); + 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); } @@ -2815,14 +3016,24 @@ static void rebuild_header_view_mode_menu_model(MyApplication* self) { return; } g_autoptr(GMenu) menu = g_menu_new(); - append_header_view_mode_item(menu, self->header_day_label, "day"); - append_header_view_mode_item(menu, self->header_week_label, "week"); - append_header_view_mode_item(menu, self->header_month_label, "month"); - append_header_view_mode_item(menu, self->header_year_label, "year"); - append_header_view_mode_item(menu, self->header_agenda_label, "agenda"); + 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_label(self); + update_header_view_mode_presentation(self); } static void rebuild_header_create_menu_model(MyApplication* self) { @@ -2830,9 +3041,12 @@ static void rebuild_header_create_menu_model(MyApplication* self) { return; } g_autoptr(GMenu) menu = g_menu_new(); - g_menu_append(menu, self->header_create_event_label, - "header.create-event"); - g_menu_append(menu, self->header_create_task_label, "header.create-task"); + 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); } @@ -2984,6 +3198,22 @@ static void set_widget_tooltip(GtkWidget* widget, const gchar* tooltip) { } } +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) { @@ -3065,7 +3295,7 @@ static void set_header_view_mode_labels(MyApplication* self, 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_label(self); + update_header_view_mode_presentation(self); } static void set_header_title(MyApplication* self, const gchar* title) { @@ -3147,7 +3377,7 @@ static void set_header_view_mode(MyApplication* self, const gchar* mode) { g_simple_action_set_state(self->header_view_mode_menu_action, state); } } - update_header_view_mode_label(self); + update_header_view_mode_presentation(self); } static void update_header_title_box_geometry(MyApplication* self) { @@ -3444,22 +3674,62 @@ static void set_header_localized_labels(MyApplication* self, FlValue* args) { 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"); - - set_button_label_and_tooltip(self->today_button, today, today); + 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* create_shortcut = + fl_lookup_string_arg(args, "createShortcut"); + 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_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_button_label_and_tooltip(self->today_button, today, nullptr); + 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(self->search_button, search); + 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); + set_widget_tooltip_with_shortcut(self->create_button, create, + create_shortcut); 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(self->previous_button, previous); - set_widget_tooltip(self->next_button, next); + set_widget_tooltip_with_shortcut(self->previous_button, previous, + previous_shortcut); + set_widget_tooltip_with_shortcut(self->next_button, next, next_shortcut); set_widget_tooltip(self->sidebar_collapsed_toggle_button, sidebar); replace_header_label(&self->header_settings_label, settings); replace_header_label(&self->header_keyboard_shortcuts_label, @@ -3669,13 +3939,14 @@ static GtkWidget* create_busymax_titlebar(MyApplication* self) { GtkWidget* view_mode_button_box = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, kHeaderButtonSpacing); - track_widget_pointer(&self->view_mode_label, gtk_label_new("")); - gtk_label_set_ellipsize(GTK_LABEL(self->view_mode_label), - PANGO_ELLIPSIZE_END); + 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_label, - TRUE, TRUE, 0); + 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), @@ -5067,7 +5338,7 @@ static void my_application_dispose(GObject* object) { 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_label); + 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); @@ -5096,6 +5367,15 @@ static void my_application_dispose(GObject* object) { 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_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->dart_entrypoint_arguments, g_strfreev); G_OBJECT_CLASS(my_application_parent_class)->dispose(object); @@ -5181,7 +5461,7 @@ static void my_application_init(MyApplication* self) { self->previous_button = nullptr; self->next_button = nullptr; self->view_mode_button = nullptr; - self->view_mode_label = nullptr; + self->view_mode_icon = nullptr; self->view_mode_menu = nullptr; self->search_button = nullptr; self->create_button = nullptr; @@ -5203,6 +5483,15 @@ static void my_application_init(MyApplication* self) { 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 / D"); + self->header_week_shortcut = g_strdup("2 / W"); + self->header_month_shortcut = g_strdup("3 / M"); + self->header_year_shortcut = g_strdup("4 / Y"); + self->header_agenda_shortcut = g_strdup("0 / A"); + self->header_create_event_shortcut = g_strdup("E"); + self->header_create_task_shortcut = g_strdup("T"); + self->header_settings_shortcut = g_strdup("Ctrl+,"); + self->header_keyboard_shortcuts_shortcut = g_strdup("Ctrl+/"); self->header_search_query = g_strdup(""); self->header_search_active = FALSE; self->header_navigation_visible = TRUE; diff --git a/test/app/busymax_menu_button_test.dart b/test/app/busymax_menu_button_test.dart index 1076619..eccc9c9 100644 --- a/test/app/busymax_menu_button_test.dart +++ b/test/app/busymax_menu_button_test.dart @@ -59,6 +59,7 @@ void main() { value: 'refresh', label: 'Refresh calendar', icon: YaruIcons.refresh, + shortcut: 'Ctrl+R', ), BusyMaxMenuEntry( value: 'open', @@ -92,6 +93,7 @@ void main() { await tester.pumpAndSettle(); expect(find.text('Refresh calendar'), findsOneWidget); + expect(find.text('Ctrl+R'), findsOneWidget); expect(find.text('Open in provider'), findsOneWidget); trigger = tester.widget(triggerFinder); expect(Theme.of(tester.element(triggerFinder)).hoverColor, inheritedHover); @@ -201,7 +203,9 @@ void main() { BusyMaxMenuEntry( value: 'refresh', label: 'Refresh calendar', + icon: YaruIcons.refresh, selected: true, + shortcut: 'Ctrl+R', ), BusyMaxMenuEntry(value: 'open', label: 'Open in provider'), ], @@ -231,7 +235,13 @@ void main() { ); expect(anchor, triggerRect); expect(arguments['entries'], [ - {'label': 'Refresh calendar', 'enabled': true, 'selected': true}, + { + 'label': 'Refresh calendar', + 'icon': 'view-refresh-symbolic', + 'enabled': true, + 'selected': true, + 'shortcut': 'Ctrl+R', + }, {'label': 'Open in provider', 'enabled': true, 'selected': false}, ]); }); diff --git a/test/app/native_ui_audit_test.dart b/test/app/native_ui_audit_test.dart index 3af53b5..e6c0e3b 100644 --- a/test/app/native_ui_audit_test.dart +++ b/test/app/native_ui_audit_test.dart @@ -650,14 +650,13 @@ void main() { expect( source, contains( - 'g_menu_append(menu, self->header_settings_label, "header.settings")', + 'append_header_action_item(menu, self->header_settings_label,', ), ); expect( source, contains( - 'g_menu_append(menu, self->header_report_issue_label,\n' - ' "header.report-issue")', + 'append_header_action_item(menu, self->header_report_issue_label,', ), ); expect( @@ -1217,25 +1216,46 @@ void main() { expect( source, contains( - 'append_header_view_mode_item(menu, self->header_year_label, "year")', + '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( - 'g_menu_append(menu, self->header_create_event_label,\n' - ' "header.create-event")', + 'append_header_action_item(menu, self->header_create_event_label,', ), ); expect( source, contains( - 'g_menu_append(menu, self->header_create_task_label, ' - '"header.create-task")', + 'append_header_action_item(menu, self->header_create_task_label,', ), ); + expect(source, contains('kMenuShortcutAttribute')); + expect(source, contains('kMenuIconAttribute')); + expect(source, contains('g_menu_item_set_icon(item, icon)')); + expect(source, contains('gtk_image_new_from_icon_name(icon_name')); + expect(source, 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"'))); @@ -1281,7 +1301,15 @@ void main() { expect(nativeMenu, contains('gtk_popover_set_pointing_to(')); expect(nativeMenu, contains('gtk_popover_set_modal(')); expect(nativeMenu, contains('style_native_popover(session->popover)')); + expect( + nativeMenu, + contains('decorate_model_menu_shortcuts(session->popover'), + ); expect(nativeMenu, contains('g_simple_action_set_enabled(')); + expect(nativeMenu, contains('g_themed_icon_new(icon_name)')); + expect(nativeMenu, contains('g_menu_item_set_icon(item, icon)')); + expect(nativeMenu, contains('kMenuIconAttribute')); + expect(runner, contains('add_model_button_presentation(')); expect(nativeMenu, contains('g_simple_action_new_stateful(')); expect(nativeMenu, contains('g_object_ref(G_OBJECT(method_call))')); expect( diff --git a/test/features/schedule/presentation/schedule_create_menu_test.dart b/test/features/schedule/presentation/schedule_create_menu_test.dart index 1403cdd..dc8f6d4 100644 --- a/test/features/schedule/presentation/schedule_create_menu_test.dart +++ b/test/features/schedule/presentation/schedule_create_menu_test.dart @@ -76,8 +76,20 @@ void main() { 'height': 0.0, }); expect(arguments['entries'], [ - {'label': 'Event', 'enabled': true, 'selected': false}, - {'label': 'Task', 'enabled': true, 'selected': false}, + { + 'label': 'Event', + 'icon': 'x-office-calendar-symbolic', + 'enabled': true, + 'selected': false, + 'shortcut': 'E', + }, + { + 'label': 'Task', + 'icon': 'checkbox-checked-symbolic', + 'enabled': true, + 'selected': false, + 'shortcut': 'T', + }, ]); expect(arguments['focusFirst'], isFalse); expect(arguments['preferredPosition'], 'bottom'); diff --git a/test/features/schedule/presentation/schedule_toolbar_test.dart b/test/features/schedule/presentation/schedule_toolbar_test.dart index 68a6abf..0ff147b 100644 --- a/test/features/schedule/presentation/schedule_toolbar_test.dart +++ b/test/features/schedule/presentation/schedule_toolbar_test.dart @@ -112,15 +112,27 @@ void main() { ), ); - await tester.tap(find.byTooltip('Create')); + await tester.tap(find.byTooltip('Create (Ctrl+N)')); await tester.pumpAndSettle(); expect(events, 0); expect(tasks, 1); expect(nativeCall?.method, 'show'); expect((nativeCall?.arguments as Map)['entries'], [ - {'label': 'Event', 'enabled': true, 'selected': false}, - {'label': 'Task', 'enabled': true, 'selected': false}, + { + 'label': 'Event', + 'icon': 'x-office-calendar-symbolic', + 'enabled': true, + 'selected': false, + 'shortcut': 'E', + }, + { + 'label': 'Task', + 'icon': 'checkbox-checked-symbolic', + 'enabled': true, + 'selected': false, + 'shortcut': 'T', + }, ]); expect( find.byWidgetPredicate((widget) => widget is PopupMenuItem), @@ -168,11 +180,11 @@ void main() { ); await tester.tap(find.byTooltip('Toggle Sidebar')); - await tester.tap(find.byTooltip('Search')); + await tester.tap(find.byTooltip('Search (Ctrl+F)')); expect(sidebarToggles, 1); expect(searches, 1); - await tester.tap(find.byTooltip('Create')); + await tester.tap(find.byTooltip('Create (Ctrl+N)')); await tester.pumpAndSettle(); expect( find.byWidgetPredicate((widget) => widget is PopupMenuItem), @@ -186,7 +198,7 @@ void main() { expect(events, 1); expect(tasks, 0); - await tester.tap(find.byTooltip('Week')); + await tester.tap(find.byTooltip('Week (2 / W)')); await tester.pumpAndSettle(); expect( find.byWidgetPredicate((widget) => widget is PopupMenuItem), @@ -306,7 +318,7 @@ void main() { ), ); - await tester.tap(find.byTooltip('Create')); + await tester.tap(find.byTooltip('Create (Ctrl+N)')); await tester.pumpAndSettle(); expect( @@ -382,7 +394,7 @@ void main() { final trigger = tester.widget( find.ancestor( - of: find.byTooltip('Create'), + of: find.byTooltip('Create (Ctrl+N)'), matching: find.byType(YaruIconButton), ), ); @@ -573,12 +585,12 @@ void main() { final trigger = tester.widget( find.ancestor( - of: find.byTooltip('Create'), + of: find.byTooltip('Create (Ctrl+N)'), matching: find.byType(YaruIconButton), ), ); expect(trigger.onPressed, isNull); - await tester.tap(find.byTooltip('Create')); + await tester.tap(find.byTooltip('Create (Ctrl+N)')); await tester.pumpAndSettle(); expect(find.text('Event'), findsNothing); expect(find.text('Task'), findsNothing); diff --git a/test/features/schedule/presentation/schedule_views_test.dart b/test/features/schedule/presentation/schedule_views_test.dart index b3f03e8..544d463 100644 --- a/test/features/schedule/presentation/schedule_views_test.dart +++ b/test/features/schedule/presentation/schedule_views_test.dart @@ -37,6 +37,40 @@ void main() { expect(workspace, contains("ValueKey('schedule-week-planner')")); }); + testWidgets('year view uses one month column at narrow desktop widths', ( + tester, + ) async { + final selectedDate = DateTime(2026, 1, 15); + + await tester.pumpWidget( + localizedTestApp( + child: Scaffold( + body: SizedBox( + width: 500, + height: 720, + child: ScheduleYearView( + selectedDate: selectedDate, + items: const [], + firstWeekday: DateTime.monday, + onDaySelected: (_) {}, + onMonthSelected: (_) {}, + onWeekSelected: (_) {}, + onCreateAtDay: (_) {}, + ), + ), + ), + ), + ); + await tester.pump(); + + final months = find.byType(YearMonthMiniCalendar); + expect(months, findsNWidgets(DateTime.monthsPerYear)); + final januaryPosition = tester.getTopLeft(months.at(0)); + final februaryPosition = tester.getTopLeft(months.at(1)); + expect(februaryPosition.dx, januaryPosition.dx); + expect(februaryPosition.dy, greaterThan(januaryPosition.dy)); + }); + testWidgets('day view uses package planner with custom BusyMax items', ( tester, ) async { @@ -3231,15 +3265,13 @@ void main() { expect( headerBar, contains( - 'g_menu_append(menu, self->header_create_event_label,\n' - ' "header.create-event")', + 'append_header_action_item(menu, self->header_create_event_label,', ), ); expect( headerBar, contains( - 'g_menu_append(menu, self->header_create_task_label, ' - '"header.create-task")', + 'append_header_action_item(menu, self->header_create_task_label,', ), ); expect(headerBar, contains('gtk_menu_button_set_menu_model')); @@ -3258,7 +3290,7 @@ void main() { ); expect(sidebar, isNot(contains('context.l10n.create'))); expect(sidebar, isNot(contains('PushButton.filled'))); - expect(toolbar, contains('tooltip: context.l10n.create')); + expect(toolbar, contains('BusyMaxShortcutLabels.create')); expect(toolbar, contains('icon: const Icon(YaruIcons.plus)')); expect(toolbar, contains('tooltip: context.l10n.refreshAll')); }); 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 1c0f564..2fb8a53 100644 --- a/test/features/schedule/presentation/schedule_workspace_task_mutations_test.dart +++ b/test/features/schedule/presentation/schedule_workspace_task_mutations_test.dart @@ -47,7 +47,7 @@ void main() { expect(find.text('Created from Schedule'), findsNothing); - await tester.tap(find.byTooltip('Create')); + await tester.tap(find.byTooltip('Create (Ctrl+N)')); await tester.pumpAndSettle(); await tester.tap(find.text('Task')); await tester.pumpAndSettle(); @@ -79,7 +79,7 @@ void main() { initialTaskListId: _projectListId, ); - await tester.tap(find.byTooltip('Create')); + await tester.tap(find.byTooltip('Create (Ctrl+N)')); await tester.pumpAndSettle(); await tester.tap(find.text('Task')); await tester.pumpAndSettle(); diff --git a/test/features/tasks/presentation/desktop_date_time_fields_test.dart b/test/features/tasks/presentation/desktop_date_time_fields_test.dart index e740df1..71e1879 100644 --- a/test/features/tasks/presentation/desktop_date_time_fields_test.dart +++ b/test/features/tasks/presentation/desktop_date_time_fields_test.dart @@ -685,6 +685,20 @@ void main() { expect(find.byType(MiniCalendarGrid), findsOneWidget); expect(find.text('July'), findsOneWidget); expect(find.text('2026'), findsOneWidget); + final monthControlWidth = + tester.getRect(find.byTooltip('Next month')).right - + tester.getRect(find.byTooltip('Previous month')).left; + final yearControlWidth = + tester.getRect(find.byTooltip('Next year')).right - + tester.getRect(find.byTooltip('Previous year')).left; + expect( + monthControlWidth / yearControlWidth, + closeTo( + BusyMaxCalendarHeaderLayout.monthControlFlex / + BusyMaxCalendarHeaderLayout.yearControlFlex, + 0.01, + ), + ); expect( find.descendant( of: find.byType(MiniCalendarGrid), diff --git a/test/platform/linux_header_bar_service_test.dart b/test/platform/linux_header_bar_service_test.dart index c74484e..80e3b11 100644 --- a/test/platform/linux_header_bar_service_test.dart +++ b/test/platform/linux_header_bar_service_test.dart @@ -83,6 +83,11 @@ void main() { keyboardShortcuts: 'Keyboard Shortcuts', reportIssue: 'Report an issue', aboutBusyMax: 'About BusyMax', + todayShortcut: 'Shift+T', + dayShortcut: '1 / D', + createEventShortcut: 'E', + settingsShortcut: 'Ctrl+,', + keyboardShortcutsShortcut: 'Ctrl+/', ), ); await service.setSidebarWidth(300); @@ -141,6 +146,14 @@ void main() { ); 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 / D')); + expect(calls[1].arguments, containsPair('createEventShortcut', 'E')); + expect(calls[1].arguments, containsPair('settingsShortcut', 'Ctrl+,')); + expect( + calls[1].arguments, + containsPair('keyboardShortcutsShortcut', 'Ctrl+/'), + ); expect(calls[2].arguments, 300); expect(calls[3].arguments, 'rtl'); expect(calls[4].arguments, containsPair('visible', true)); diff --git a/test/platform/native_menu_service_test.dart b/test/platform/native_menu_service_test.dart index 37b2b5f..be91c7f 100644 --- a/test/platform/native_menu_service_test.dart +++ b/test/platform/native_menu_service_test.dart @@ -27,7 +27,11 @@ void main() { anchor: const Rect.fromLTWH(24, 36, 140, 34), entries: const [ NativeMenuEntry(label: 'Personal'), - NativeMenuEntry(label: 'Work'), + NativeMenuEntry( + label: 'Work', + iconName: 'folder-symbolic', + shortcut: 'Ctrl+W', + ), NativeMenuEntry(label: 'Archived', enabled: false), ], ); @@ -40,7 +44,13 @@ void main() { 'anchor': {'x': 24.0, 'y': 36.0, 'width': 140.0, 'height': 34.0}, 'entries': [ {'label': 'Personal', 'enabled': true, 'selected': false}, - {'label': 'Work', 'enabled': true, 'selected': false}, + { + 'label': 'Work', + 'icon': 'folder-symbolic', + 'enabled': true, + 'selected': false, + 'shortcut': 'Ctrl+W', + }, {'label': 'Archived', 'enabled': false, 'selected': false}, ], 'focusFirst': false, From 549158807203fd7d4252928c503fcb807543f5bd Mon Sep 17 00:00:00 2001 From: albert Date: Thu, 30 Jul 2026 14:09:28 -0700 Subject: [PATCH 52/73] Enhance calendar day semantics with unique tooltip keys and adjust date picker dimensions. Refactor date mode header to include step buttons for month and year navigation, improving user interaction and accessibility. --- .../presentation/calendar_day_semantics.dart | 7 +- .../desktop_date_time_fields.dart | 110 ++++++++---------- .../presentation/schedule_views_test.dart | 47 ++++++++ .../desktop_date_time_fields_test.dart | 64 +++++++++- 4 files changed, 162 insertions(+), 66 deletions(-) diff --git a/lib/src/features/schedule/presentation/calendar_day_semantics.dart b/lib/src/features/schedule/presentation/calendar_day_semantics.dart index 003d27c..13f71c5 100644 --- a/lib/src/features/schedule/presentation/calendar_day_semantics.dart +++ b/lib/src/features/schedule/presentation/calendar_day_semantics.dart @@ -28,7 +28,12 @@ class BusyMaxCalendarDaySemantics extends StatelessWidget { selected: selected, label: label, onTap: onTap, - child: Tooltip(message: label, excludeFromSemantics: true, child: child), + child: Tooltip( + key: ValueKey((day.year, day.month, day.day)), + message: label, + excludeFromSemantics: true, + child: child, + ), ); } } diff --git a/lib/src/features/tasks/presentation/desktop_date_time_fields.dart b/lib/src/features/tasks/presentation/desktop_date_time_fields.dart index e365326..6b07964 100644 --- a/lib/src/features/tasks/presentation/desktop_date_time_fields.dart +++ b/lib/src/features/tasks/presentation/desktop_date_time_fields.dart @@ -16,7 +16,7 @@ import 'package:yaru/yaru.dart'; const nativeDateTimePickerChannelName = 'busymax/native_date_time_picker'; const _nativeDateTimePicker = NativeDateTimePicker(); -const _dateTimePickerMaxWidth = 300.0; +const _dateTimePickerMaxWidth = 340.0; const _dateTimePickerContentMaxHeight = 320.0; const _dateTimePickerPopoverMinimumHeight = 300.0; const _dateTimePickerPopoverPadding = EdgeInsets.all(BusyMaxSpacing.lg); @@ -289,7 +289,7 @@ Future showBusyMaxDateValueDialog( anchorContext: anchorContext ?? context, semanticLabel: label, preferredWidth: _dateTimePickerMaxWidth, - minimumWidth: 280, + minimumWidth: _dateTimePickerMaxWidth, preferredMinimumHeight: _dateTimePickerPopoverMinimumHeight, builder: (context, arrowSide, arrowAlignment) => _DesktopDateValueDialog( label: label, @@ -428,6 +428,8 @@ class _DesktopDateValueDialogState extends State<_DesktopDateValueDialog> { Widget _buildDateModeHeader(BuildContext context) { final locale = Localizations.localeOf(context).toLanguageTag(); final monthLabel = DateFormat.MMMM(locale).format(_displayedMonth); + final colorScheme = Theme.of(context).colorScheme; + final direction = Directionality.of(context); return Padding( padding: const EdgeInsetsDirectional.fromSTEB( @@ -438,37 +440,55 @@ class _DesktopDateValueDialogState extends State<_DesktopDateValueDialog> { ), child: Row( children: [ + _stepButton( + context, + colorScheme: colorScheme, + tooltip: context.l10n.previousMonth, + icon: BusyMaxGlyphs.startFor(direction), + onPressed: () => _showMonth( + DateTime(_displayedMonth.year, _displayedMonth.month - 1), + ), + ), Expanded( flex: BusyMaxCalendarHeaderLayout.monthControlFlex, - child: _buildDateModeStepper( - context: context, - label: monthLabel, - previousTooltip: context.l10n.previousMonth, - nextTooltip: context.l10n.nextMonth, - onPrevious: () => _showMonth( - DateTime(_displayedMonth.year, _displayedMonth.month - 1), - ), - onNext: () => _showMonth( - DateTime(_displayedMonth.year, _displayedMonth.month + 1), - ), - onLabelPressed: null, + child: FittedBox( + fit: BoxFit.scaleDown, + child: _stepLabel(context, monthLabel, null), + ), + ), + _stepButton( + context, + colorScheme: colorScheme, + tooltip: context.l10n.nextMonth, + icon: BusyMaxGlyphs.endFor(direction), + onPressed: () => _showMonth( + DateTime(_displayedMonth.year, _displayedMonth.month + 1), ), ), const SizedBox(width: BusyMaxSpacing.sm), + _stepButton( + context, + colorScheme: colorScheme, + tooltip: context.l10n.previousYear, + icon: BusyMaxGlyphs.startFor(direction), + onPressed: () => _showMonth( + DateTime(_displayedMonth.year - 1, _displayedMonth.month), + ), + ), Expanded( flex: BusyMaxCalendarHeaderLayout.yearControlFlex, - child: _buildDateModeStepper( - context: context, - label: '${_displayedMonth.year}', - previousTooltip: context.l10n.previousYear, - nextTooltip: context.l10n.nextYear, - onPrevious: () => _showMonth( - DateTime(_displayedMonth.year - 1, _displayedMonth.month), - ), - onNext: () => _showMonth( - DateTime(_displayedMonth.year + 1, _displayedMonth.month), - ), - onLabelPressed: null, + child: FittedBox( + fit: BoxFit.scaleDown, + child: _stepLabel(context, '${_displayedMonth.year}', null), + ), + ), + _stepButton( + context, + colorScheme: colorScheme, + tooltip: context.l10n.nextYear, + icon: BusyMaxGlyphs.endFor(direction), + onPressed: () => _showMonth( + DateTime(_displayedMonth.year + 1, _displayedMonth.month), ), ), ], @@ -488,44 +508,6 @@ class _DesktopDateValueDialogState extends State<_DesktopDateValueDialog> { setState(() => _displayedMonth = adjusted); } - Widget _buildDateModeStepper({ - required BuildContext context, - required String label, - required String previousTooltip, - required String nextTooltip, - required VoidCallback onPrevious, - required VoidCallback onNext, - VoidCallback? onLabelPressed, - }) { - final colorScheme = Theme.of(context).colorScheme; - return Row( - children: [ - _stepButton( - context, - colorScheme: colorScheme, - tooltip: previousTooltip, - icon: BusyMaxGlyphs.startFor(Directionality.of(context)), - onPressed: onPrevious, - ), - const SizedBox(width: BusyMaxSpacing.xs), - Expanded( - child: FittedBox( - fit: BoxFit.scaleDown, - child: _stepLabel(context, label, onLabelPressed), - ), - ), - const SizedBox(width: BusyMaxSpacing.xs), - _stepButton( - context, - colorScheme: colorScheme, - tooltip: nextTooltip, - icon: BusyMaxGlyphs.endFor(Directionality.of(context)), - onPressed: onNext, - ), - ], - ); - } - Widget _stepButton( BuildContext context, { required ColorScheme colorScheme, diff --git a/test/features/schedule/presentation/schedule_views_test.dart b/test/features/schedule/presentation/schedule_views_test.dart index 544d463..e6eea67 100644 --- a/test/features/schedule/presentation/schedule_views_test.dart +++ b/test/features/schedule/presentation/schedule_views_test.dart @@ -3,6 +3,7 @@ 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/calendar_day_semantics.dart'; import 'package:busymax/src/features/schedule/presentation/schedule_agenda_view.dart'; import 'package:busymax/src/features/schedule/presentation/schedule_anchored_popover.dart'; import 'package:busymax/src/features/schedule/presentation/schedule_day_week_view.dart'; @@ -882,6 +883,52 @@ void main() { semantics.dispose(); }); + testWidgets('calendar date changes replace native tooltip state', ( + tester, + ) async { + var day = DateTime(2026, 1, 15); + late StateSetter updateDay; + + await tester.pumpWidget( + localizedTestApp( + child: Scaffold( + body: StatefulBuilder( + builder: (context, setState) { + updateDay = setState; + return Center( + child: BusyMaxCalendarDaySemantics( + day: day, + selected: false, + onTap: () {}, + child: const SizedBox.square(dimension: 32), + ), + ); + }, + ), + ), + ), + ); + + final mouse = await tester.createGesture(kind: PointerDeviceKind.mouse); + addTearDown(mouse.removePointer); + await mouse.addPointer(location: Offset.zero); + await mouse.moveTo( + tester.getCenter(find.byType(BusyMaxCalendarDaySemantics)), + ); + await tester.pump(const Duration(milliseconds: 200)); + + final originalState = tester.state( + find.byType(RawTooltip), + ); + updateDay(() => day = DateTime(2026, 2, 15)); + await tester.pump(); + final updatedState = tester.state(find.byType(RawTooltip)); + + expect(updatedState, isNot(same(originalState))); + expect(find.byType(Tooltip), findsOneWidget); + expect(tester.takeException(), isNull); + }); + testWidgets('month view avoids overflow in very short cells', (tester) async { final selectedDate = DateTime(2026, 1, 15); diff --git a/test/features/tasks/presentation/desktop_date_time_fields_test.dart b/test/features/tasks/presentation/desktop_date_time_fields_test.dart index 71e1879..0c7c572 100644 --- a/test/features/tasks/presentation/desktop_date_time_fields_test.dart +++ b/test/features/tasks/presentation/desktop_date_time_fields_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/app/busymax_surface_colors.dart'; @@ -691,14 +692,36 @@ void main() { final yearControlWidth = tester.getRect(find.byTooltip('Next year')).right - tester.getRect(find.byTooltip('Previous year')).left; + expect(yearControlWidth, lessThan(monthControlWidth)); + final monthLabelSpace = find.ancestor( + of: find.text('July'), + matching: find.byType(FittedBox), + ); + final yearLabelSpace = find.ancestor( + of: find.text('2026'), + matching: find.byType(FittedBox), + ); expect( - monthControlWidth / yearControlWidth, + tester.getRect(monthLabelSpace).width / + tester.getRect(yearLabelSpace).width, closeTo( BusyMaxCalendarHeaderLayout.monthControlFlex / BusyMaxCalendarHeaderLayout.yearControlFlex, 0.01, ), ); + expect( + tester.getRect(find.byTooltip('Previous year')).size, + tester.getRect(find.byTooltip('Previous month')).size, + ); + expect( + tester.getRect(find.byTooltip('Next year')).size, + tester.getRect(find.byTooltip('Next month')).size, + ); + expect( + tester.getRect(find.text('2026')).height, + closeTo(tester.getRect(find.text('July')).height, 0.01), + ); expect( find.descendant( of: find.byType(MiniCalendarGrid), @@ -742,6 +765,45 @@ void main() { expect(find.byType(BusyMaxContentPopoverSurface), findsOneWidget); }); + testWidgets('fallback date picker safely pages while a tooltip is open', ( + tester, + ) async { + await tester.pumpWidget( + localizedTestApp( + child: Scaffold( + body: DesktopDateValueRow( + label: 'Due date', + date: '2026-07-22', + onChanged: _ignoreString, + ), + ), + ), + ); + + await tester.tap(find.byIcon(YaruIcons.calendar)); + await tester.pumpAndSettle(); + + final previousMonth = find.byTooltip('Previous month'); + final nextYear = find.byTooltip('Next year'); + expect(find.byType(RawTooltip), findsWidgets); + final mouse = await tester.createGesture(kind: ui.PointerDeviceKind.mouse); + addTearDown(mouse.removePointer); + await mouse.addPointer(location: Offset.zero); + await mouse.moveTo(tester.getCenter(previousMonth)); + await tester.pump(); + await mouse.down(tester.getCenter(previousMonth)); + await mouse.up(); + await tester.pump(); + await mouse.moveTo(tester.getCenter(nextYear)); + await tester.pump(); + await mouse.down(tester.getCenter(nextYear)); + await mouse.up(); + await tester.pumpAndSettle(); + + expect(tester.takeException(), isNull); + expect(find.byType(BusyMaxContentPopoverSurface), findsOneWidget); + }); + testWidgets('fallback date picker stays open while paging years', ( tester, ) async { From 54919572f55a8e7a879ecb6ec60928a075cdb72a Mon Sep 17 00:00:00 2001 From: albert Date: Thu, 30 Jul 2026 16:53:35 -0700 Subject: [PATCH 53/73] Enhance header functionality by adding sidebar toggle shortcuts and improving title fitting logic. Update header labels and tooltips for better user experience. Bump version to 0.1.4 in metadata files. --- linux/io.busystack.busymax.metainfo.xml | 2 +- linux/runner/my_application.cc | 110 +++++++++++++++++++----- snap/snapcraft.yaml | 2 +- 3 files changed, 91 insertions(+), 23 deletions(-) diff --git a/linux/io.busystack.busymax.metainfo.xml b/linux/io.busystack.busymax.metainfo.xml index 1ff2a3c..87ec999 100644 --- a/linux/io.busystack.busymax.metainfo.xml +++ b/linux/io.busystack.busymax.metainfo.xml @@ -86,7 +86,7 @@ - +

Beta maintenance release.

diff --git a/linux/runner/my_application.cc b/linux/runner/my_application.cc index 900bc72..af9629e 100644 --- a/linux/runner/my_application.cc +++ b/linux/runner/my_application.cc @@ -177,11 +177,14 @@ struct _MyApplication { 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; @@ -193,6 +196,7 @@ struct _MyApplication { 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; @@ -211,6 +215,7 @@ struct _MyApplication { 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 void style_native_popover(GtkWidget* popover) { if (popover == nullptr || !GTK_IS_POPOVER(popover)) { @@ -3298,11 +3303,43 @@ static void set_header_view_mode_labels(MyApplication* self, 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 (self->header_title_label != nullptr && - GTK_IS_LABEL(self->header_title_label) && title != nullptr) { - gtk_label_set_text(GTK_LABEL(self->header_title_label), 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) { @@ -3345,6 +3382,8 @@ static void set_header_search_state(MyApplication* self, } } self->suppress_header_bar_actions = previous_suppression; + update_header_control_visibility(self); + update_header_title_fit(self); if (!active_changed) { return; @@ -3453,6 +3492,8 @@ static void header_bar_size_allocate_cb(GtkWidget*, 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) { @@ -3467,14 +3508,18 @@ static void update_header_control_visibility(MyApplication* self) { 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); + set_widget_visible(self->today_button, + schedule_controls_visible && + !self->header_search_active); set_widget_visible(self->previous_button, schedule_controls_visible && self->header_navigation_visible); set_widget_visible(self->next_button, schedule_controls_visible && self->header_navigation_visible); - set_widget_visible(self->header_view_box, schedule_controls_visible); + set_widget_visible(self->header_view_box, + schedule_controls_visible && + !self->header_search_active); set_widget_visible(self->search_button, schedule_controls_visible); set_widget_visible(self->create_button, schedule_controls_visible); set_widget_visible(self->refresh_button, schedule_controls_visible); @@ -3530,9 +3575,18 @@ static void set_header_onboarding_controls(MyApplication* self, FlValue* args) { 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); } @@ -3632,6 +3686,7 @@ static void set_header_bar_state(MyApplication* self, FlValue* args) { 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; @@ -3667,7 +3722,10 @@ static void set_header_localized_labels(MyApplication* self, FlValue* args) { 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* sidebar = fl_lookup_string_arg(args, "sidebar"); + 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 = @@ -3684,8 +3742,8 @@ static void set_header_localized_labels(MyApplication* self, FlValue* args) { fl_lookup_string_arg(args, "agendaShortcut"); const gchar* search_shortcut = fl_lookup_string_arg(args, "searchShortcut"); - const gchar* create_shortcut = - fl_lookup_string_arg(args, "createShortcut"); + 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 = @@ -3703,6 +3761,11 @@ static void set_header_localized_labels(MyApplication* self, FlValue* args) { 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, @@ -3721,8 +3784,7 @@ static void set_header_localized_labels(MyApplication* self, FlValue* args) { search != nullptr) { gtk_entry_set_placeholder_text(GTK_ENTRY(self->search_entry), search); } - set_widget_tooltip_with_shortcut(self->create_button, create, - create_shortcut); + 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); @@ -3730,7 +3792,7 @@ static void set_header_localized_labels(MyApplication* self, FlValue* args) { set_widget_tooltip_with_shortcut(self->previous_button, previous, previous_shortcut); set_widget_tooltip_with_shortcut(self->next_button, next, next_shortcut); - set_widget_tooltip(self->sidebar_collapsed_toggle_button, sidebar); + update_header_sidebar_presentation(self); replace_header_label(&self->header_settings_label, settings); replace_header_label(&self->header_keyboard_shortcuts_label, keyboard_shortcuts); @@ -3869,9 +3931,7 @@ static GtkWidget* create_busymax_titlebar(MyApplication* self) { 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_END); - gtk_label_set_max_width_chars(GTK_LABEL(self->header_title_label), - kHeaderCenterMaximumWidthChars); + 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); @@ -5356,11 +5416,14 @@ static void my_application_dispose(GObject* object) { 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_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); @@ -5372,6 +5435,7 @@ static void my_application_dispose(GObject* object) { 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); @@ -5472,26 +5536,30 @@ static void my_application_init(MyApplication* self) { 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 / D"); - self->header_week_shortcut = g_strdup("2 / W"); - self->header_month_shortcut = g_strdup("3 / M"); - self->header_year_shortcut = g_strdup("4 / Y"); - self->header_agenda_shortcut = g_strdup("0 / A"); + 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+,"); - self->header_keyboard_shortcuts_shortcut = g_strdup("Ctrl+/"); + 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; diff --git a/snap/snapcraft.yaml b/snap/snapcraft.yaml index afe5f18..9c0fc84 100644 --- a/snap/snapcraft.yaml +++ b/snap/snapcraft.yaml @@ -1,6 +1,6 @@ name: busymax title: BusyMax -version: "0.1.2" +version: "0.1.4" summary: Calendar and task manager description: | BusyMax is a Linux desktop calendar and task manager. From 361fbcde4619e5cbea797ed817455effc2a8a30d Mon Sep 17 00:00:00 2001 From: albert Date: Thu, 30 Jul 2026 16:53:45 -0700 Subject: [PATCH 54/73] Add localization support for sidebar panel visibility messages in multiple languages --- lib/l10n/app_ar.arb | 2 + lib/l10n/app_de.arb | 2 + lib/l10n/app_en.arb | 2 + lib/l10n/app_es.arb | 2 + lib/l10n/app_et.arb | 2 + lib/l10n/app_fa.arb | 2 + lib/l10n/app_fi.arb | 2 + lib/l10n/app_fr.arb | 2 + lib/l10n/app_hi.arb | 2 + lib/l10n/app_it.arb | 2 + lib/l10n/app_ja.arb | 2 + lib/l10n/app_ko.arb | 2 + lib/l10n/app_pt.arb | 2 + lib/l10n/app_ru.arb | 2 + lib/l10n/app_vi.arb | 2 + lib/l10n/app_zh.arb | 2 + lib/l10n/app_zh_Hans.arb | 2 + lib/l10n/app_zh_Hant.arb | 2 + lib/l10n/generated/app_localizations.dart | 12 ++ lib/l10n/generated/app_localizations_ar.dart | 6 + lib/l10n/generated/app_localizations_de.dart | 6 + lib/l10n/generated/app_localizations_en.dart | 6 + lib/l10n/generated/app_localizations_es.dart | 6 + lib/l10n/generated/app_localizations_et.dart | 6 + lib/l10n/generated/app_localizations_fa.dart | 6 + lib/l10n/generated/app_localizations_fi.dart | 6 + lib/l10n/generated/app_localizations_fr.dart | 6 + lib/l10n/generated/app_localizations_hi.dart | 6 + lib/l10n/generated/app_localizations_it.dart | 6 + lib/l10n/generated/app_localizations_ja.dart | 6 + lib/l10n/generated/app_localizations_ko.dart | 6 + lib/l10n/generated/app_localizations_pt.dart | 6 + lib/l10n/generated/app_localizations_ru.dart | 6 + lib/l10n/generated/app_localizations_vi.dart | 6 + lib/l10n/generated/app_localizations_zh.dart | 18 ++ lib/src/app/busymax_app.dart | 5 +- .../busymax_keyboard_shortcuts_dialog.dart | 14 +- lib/src/app/busymax_shortcuts.dart | 24 ++- .../presentation/schedule_task_chip.dart | 3 + .../presentation/schedule_toolbar.dart | 38 +++- .../presentation/schedule_workspace.dart | 194 ++++++++---------- .../platform/linux_header_bar_service.dart | 25 ++- pubspec.yaml | 2 +- test/app/busymax_dialogs_test.dart | 4 +- test/app/keyboard_shortcuts_dialog_test.dart | 28 +-- test/app/native_ui_audit_test.dart | 13 +- .../presentation/schedule_toolbar_test.dart | 106 +++++++--- .../presentation/schedule_views_test.dart | 62 +++++- .../schedule_workspace_states_test.dart | 19 ++ ...chedule_workspace_task_mutations_test.dart | 4 +- ...r_bar_configuration_synchronizer_test.dart | 3 +- .../linux_header_bar_service_test.dart | 42 +++- 52 files changed, 526 insertions(+), 216 deletions(-) diff --git a/lib/l10n/app_ar.arb b/lib/l10n/app_ar.arb index 1e2ed00..e6ababa 100644 --- a/lib/l10n/app_ar.arb +++ b/lib/l10n/app_ar.arb @@ -187,6 +187,8 @@ "feedbackServerError": "يتعذر على BusyStack قبول ملاحظاتك الآن. لم تُمسح ملاحظاتك؛ حاول مرة أخرى.", "feedbackSuccess": "تم إرسال الملاحظات. المرجع: \u2068{id}\u2069", "toggleSidebar": "إظهار الشريط الجانبي أو إخفاؤه", + "showSidebar": "إظهار اللوحة الجانبية", + "hideSidebar": "إخفاء اللوحة الجانبية", "accounts": "الحسابات", "currentAccount": "الحساب الحالي", "switchAccount": "تبديل الحساب", diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index 451331b..a7e2716 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -190,6 +190,8 @@ "feedbackServerError": "BusyStack kann Ihr Feedback derzeit nicht annehmen. Ihr Feedback wurde nicht gelöscht; versuchen Sie es erneut.", "feedbackSuccess": "Feedback gesendet. Referenz: {id}", "toggleSidebar": "Seitenleiste umschalten", + "showSidebar": "Seitenbereich anzeigen", + "hideSidebar": "Seitenbereich ausblenden", "accounts": "Konten", "currentAccount": "Aktuelles Konto", "switchAccount": "Konto wechseln", diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index dd39455..b49ee6d 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -194,6 +194,8 @@ "feedbackSuccess": "Feedback sent. Reference: {id}", "@feedbackSuccess": {"placeholders": {"id": {"type": "String"}}}, "toggleSidebar": "Toggle Sidebar", + "showSidebar": "Show sidebar panel", + "hideSidebar": "Hide sidebar panel", "accounts": "Accounts", "currentAccount": "Current account", "switchAccount": "Switch account", diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index 4012293..1cf0705 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -190,6 +190,8 @@ "feedbackServerError": "BusyStack no puede aceptar tus comentarios ahora. Tus comentarios no se han borrado; inténtalo de nuevo.", "feedbackSuccess": "Comentarios enviados. Referencia: {id}", "toggleSidebar": "Mostrar u ocultar la barra lateral", + "showSidebar": "Mostrar panel lateral", + "hideSidebar": "Ocultar panel lateral", "accounts": "Cuentas", "currentAccount": "Cuenta actual", "switchAccount": "Cambiar cuenta", diff --git a/lib/l10n/app_et.arb b/lib/l10n/app_et.arb index a558b14..848f0c7 100644 --- a/lib/l10n/app_et.arb +++ b/lib/l10n/app_et.arb @@ -194,6 +194,8 @@ "feedbackSuccess": "Tagasiside saadetud. Viide: {id}", "@feedbackSuccess": {"placeholders": {"id": {"type": "String"}}}, "toggleSidebar": "Kuva või peida külgriba", + "showSidebar": "Kuva külgpaneel", + "hideSidebar": "Peida külgpaneel", "accounts": "Kontod", "currentAccount": "Praegune konto", "switchAccount": "Vaheta kontot", diff --git a/lib/l10n/app_fa.arb b/lib/l10n/app_fa.arb index 72f6900..6f52eaa 100644 --- a/lib/l10n/app_fa.arb +++ b/lib/l10n/app_fa.arb @@ -187,6 +187,8 @@ "feedbackServerError": "BusyStack اکنون نمی‌تواند بازخورد شما را بپذیرد. بازخورد شما پاک نشده است؛ دوباره تلاش کنید.", "feedbackSuccess": "بازخورد ارسال شد. شناسهٔ پیگیری: \u2068{id}\u2069", "toggleSidebar": "نمایش یا پنهان کردن نوار کناری", + "showSidebar": "نمایش پنل کناری", + "hideSidebar": "پنهان کردن پنل کناری", "accounts": "حساب‌ها", "currentAccount": "حساب فعلی", "switchAccount": "تعویض حساب", diff --git a/lib/l10n/app_fi.arb b/lib/l10n/app_fi.arb index c7224dd..bfe86fa 100644 --- a/lib/l10n/app_fi.arb +++ b/lib/l10n/app_fi.arb @@ -187,6 +187,8 @@ "feedbackServerError": "BusyStack ei voi vastaanottaa palautettasi juuri nyt. Palautettasi ei ole tyhjennetty. Yritä uudelleen.", "feedbackSuccess": "Palaute lähetetty. Viite: {id}", "toggleSidebar": "Näytä tai piilota sivupalkki", + "showSidebar": "Näytä sivupaneeli", + "hideSidebar": "Piilota sivupaneeli", "accounts": "Tilit", "currentAccount": "Nykyinen tili", "switchAccount": "Vaihda tiliä", diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index 8c57074..dab2ebc 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -190,6 +190,8 @@ "feedbackServerError": "BusyStack ne peut pas accepter vos commentaires pour le moment. Vos commentaires n’ont pas été effacés ; réessayez.", "feedbackSuccess": "Commentaires envoyés. Référence : {id}", "toggleSidebar": "Afficher/masquer la barre latérale", + "showSidebar": "Afficher le panneau latéral", + "hideSidebar": "Masquer le panneau latéral", "accounts": "Comptes", "currentAccount": "Compte actuel", "switchAccount": "Changer de compte", diff --git a/lib/l10n/app_hi.arb b/lib/l10n/app_hi.arb index cf3ee9d..5b5d1cd 100644 --- a/lib/l10n/app_hi.arb +++ b/lib/l10n/app_hi.arb @@ -187,6 +187,8 @@ "feedbackServerError": "BusyStack अभी आपकी प्रतिक्रिया स्वीकार नहीं कर सका। आपकी प्रतिक्रिया हटाई नहीं गई है; फिर से कोशिश करें।", "feedbackSuccess": "प्रतिक्रिया भेज दी गई। संदर्भ: {id}", "toggleSidebar": "साइडबार दिखाएँ या छिपाएँ", + "showSidebar": "साइडबार पैनल दिखाएँ", + "hideSidebar": "साइडबार पैनल छिपाएँ", "accounts": "खाते", "currentAccount": "मौजूदा खाता", "switchAccount": "खाता बदलें", diff --git a/lib/l10n/app_it.arb b/lib/l10n/app_it.arb index ebaad93..feafd1c 100644 --- a/lib/l10n/app_it.arb +++ b/lib/l10n/app_it.arb @@ -187,6 +187,8 @@ "feedbackServerError": "BusyStack non può accettare il feedback in questo momento. Il feedback non è stato cancellato; riprova.", "feedbackSuccess": "Feedback inviato. Riferimento: {id}", "toggleSidebar": "Mostra o nascondi la barra laterale", + "showSidebar": "Mostra il pannello laterale", + "hideSidebar": "Nascondi il pannello laterale", "accounts": "Account", "currentAccount": "Account attuale", "switchAccount": "Cambia account", diff --git a/lib/l10n/app_ja.arb b/lib/l10n/app_ja.arb index 500ca8a..4328798 100644 --- a/lib/l10n/app_ja.arb +++ b/lib/l10n/app_ja.arb @@ -187,6 +187,8 @@ "feedbackServerError": "現在、BusyStack はフィードバックを受け付けられません。フィードバックは消去されていません。もう一度お試しください。", "feedbackSuccess": "フィードバックを送信しました。参照番号: {id}", "toggleSidebar": "サイドバーの表示を切り替え", + "showSidebar": "サイドバーパネルを表示", + "hideSidebar": "サイドバーパネルを非表示", "accounts": "アカウント", "currentAccount": "現在のアカウント", "switchAccount": "アカウントを切り替え", diff --git a/lib/l10n/app_ko.arb b/lib/l10n/app_ko.arb index 690cf26..e965586 100644 --- a/lib/l10n/app_ko.arb +++ b/lib/l10n/app_ko.arb @@ -187,6 +187,8 @@ "feedbackServerError": "현재 BusyStack에서 의견을 받을 수 없습니다. 의견은 지워지지 않았습니다. 다시 시도하세요.", "feedbackSuccess": "의견을 보냈습니다. 참조: {id}", "toggleSidebar": "사이드바 표시 전환", + "showSidebar": "사이드바 패널 표시", + "hideSidebar": "사이드바 패널 숨기기", "accounts": "계정", "currentAccount": "현재 계정", "switchAccount": "계정 전환", diff --git a/lib/l10n/app_pt.arb b/lib/l10n/app_pt.arb index a461982..45ccce6 100644 --- a/lib/l10n/app_pt.arb +++ b/lib/l10n/app_pt.arb @@ -187,6 +187,8 @@ "feedbackServerError": "O BusyStack não pode aceitar os seus comentários neste momento. Os seus comentários não foram apagados; tente novamente.", "feedbackSuccess": "Comentários enviados. Referência: {id}", "toggleSidebar": "Mostrar ou ocultar a barra lateral", + "showSidebar": "Mostrar painel lateral", + "hideSidebar": "Ocultar painel lateral", "accounts": "Contas", "currentAccount": "Conta atual", "switchAccount": "Mudar de conta", diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index 4194c03..ae2e94f 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -187,6 +187,8 @@ "feedbackServerError": "BusyStack сейчас не может принять ваш отзыв. Текст отзыва сохранён. Повторите попытку.", "feedbackSuccess": "Отзыв отправлен. Номер: {id}", "toggleSidebar": "Показать или скрыть боковую панель", + "showSidebar": "Показать боковую панель", + "hideSidebar": "Скрыть боковую панель", "accounts": "Аккаунты", "currentAccount": "Текущий аккаунт", "switchAccount": "Сменить аккаунт", diff --git a/lib/l10n/app_vi.arb b/lib/l10n/app_vi.arb index 81b726a..1e813c6 100644 --- a/lib/l10n/app_vi.arb +++ b/lib/l10n/app_vi.arb @@ -187,6 +187,8 @@ "feedbackServerError": "BusyStack hiện không thể nhận phản hồi của bạn. Phản hồi chưa bị xóa; hãy thử lại.", "feedbackSuccess": "Đã gửi phản hồi. Mã tham chiếu: {id}", "toggleSidebar": "Hiện hoặc ẩn thanh bên", + "showSidebar": "Hiện bảng bên", + "hideSidebar": "Ẩn bảng bên", "accounts": "Tài khoản", "currentAccount": "Tài khoản hiện tại", "switchAccount": "Chuyển tài khoản", diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 62bb7a7..489af3f 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -187,6 +187,8 @@ "feedbackServerError": "BusyStack 目前无法接收您的反馈。您的反馈尚未清除,请重试。", "feedbackSuccess": "反馈已发送。参考编号:{id}", "toggleSidebar": "显示或隐藏侧边栏", + "showSidebar": "显示侧边栏面板", + "hideSidebar": "隐藏侧边栏面板", "accounts": "帐户", "currentAccount": "当前帐户", "switchAccount": "切换帐户", diff --git a/lib/l10n/app_zh_Hans.arb b/lib/l10n/app_zh_Hans.arb index d192a3d..a086904 100644 --- a/lib/l10n/app_zh_Hans.arb +++ b/lib/l10n/app_zh_Hans.arb @@ -187,6 +187,8 @@ "feedbackServerError": "BusyStack 目前无法接收您的反馈。您的反馈尚未清除,请重试。", "feedbackSuccess": "反馈已发送。参考编号:{id}", "toggleSidebar": "显示或隐藏侧边栏", + "showSidebar": "显示侧边栏面板", + "hideSidebar": "隐藏侧边栏面板", "accounts": "帐户", "currentAccount": "当前帐户", "switchAccount": "切换帐户", diff --git a/lib/l10n/app_zh_Hant.arb b/lib/l10n/app_zh_Hant.arb index 6f23f83..519b41c 100644 --- a/lib/l10n/app_zh_Hant.arb +++ b/lib/l10n/app_zh_Hant.arb @@ -187,6 +187,8 @@ "feedbackServerError": "BusyStack 目前無法接收您的意見。您的意見尚未清除,請再試一次。", "feedbackSuccess": "意見已傳送。參考編號:{id}", "toggleSidebar": "顯示或隱藏側邊欄", + "showSidebar": "顯示側邊欄面板", + "hideSidebar": "隱藏側邊欄面板", "accounts": "帳戶", "currentAccount": "目前帳戶", "switchAccount": "切換帳戶", diff --git a/lib/l10n/generated/app_localizations.dart b/lib/l10n/generated/app_localizations.dart index 0713355..4db1bf6 100644 --- a/lib/l10n/generated/app_localizations.dart +++ b/lib/l10n/generated/app_localizations.dart @@ -1254,6 +1254,18 @@ abstract class AppLocalizations { /// **'Toggle Sidebar'** String get toggleSidebar; + /// No description provided for @showSidebar. + /// + /// In en, this message translates to: + /// **'Show sidebar panel'** + String get showSidebar; + + /// No description provided for @hideSidebar. + /// + /// In en, this message translates to: + /// **'Hide sidebar panel'** + String get hideSidebar; + /// No description provided for @accounts. /// /// 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 272f27a..9a7c994 100644 --- a/lib/l10n/generated/app_localizations_ar.dart +++ b/lib/l10n/generated/app_localizations_ar.dart @@ -643,6 +643,12 @@ class AppLocalizationsAr extends AppLocalizations { @override String get toggleSidebar => 'إظهار الشريط الجانبي أو إخفاؤه'; + @override + String get showSidebar => 'إظهار اللوحة الجانبية'; + + @override + String get hideSidebar => 'إخفاء اللوحة الجانبية'; + @override String get accounts => 'الحسابات'; diff --git a/lib/l10n/generated/app_localizations_de.dart b/lib/l10n/generated/app_localizations_de.dart index fba9ac8..984c448 100644 --- a/lib/l10n/generated/app_localizations_de.dart +++ b/lib/l10n/generated/app_localizations_de.dart @@ -635,6 +635,12 @@ class AppLocalizationsDe extends AppLocalizations { @override String get toggleSidebar => 'Seitenleiste umschalten'; + @override + String get showSidebar => 'Seitenbereich anzeigen'; + + @override + String get hideSidebar => 'Seitenbereich ausblenden'; + @override String get accounts => 'Konten'; diff --git a/lib/l10n/generated/app_localizations_en.dart b/lib/l10n/generated/app_localizations_en.dart index 5dee20b..c73fe6c 100644 --- a/lib/l10n/generated/app_localizations_en.dart +++ b/lib/l10n/generated/app_localizations_en.dart @@ -629,6 +629,12 @@ class AppLocalizationsEn extends AppLocalizations { @override String get toggleSidebar => 'Toggle Sidebar'; + @override + String get showSidebar => 'Show sidebar panel'; + + @override + String get hideSidebar => 'Hide sidebar panel'; + @override String get accounts => 'Accounts'; diff --git a/lib/l10n/generated/app_localizations_es.dart b/lib/l10n/generated/app_localizations_es.dart index f879b63..b9618bd 100644 --- a/lib/l10n/generated/app_localizations_es.dart +++ b/lib/l10n/generated/app_localizations_es.dart @@ -637,6 +637,12 @@ class AppLocalizationsEs extends AppLocalizations { @override String get toggleSidebar => 'Mostrar u ocultar la barra lateral'; + @override + String get showSidebar => 'Mostrar panel lateral'; + + @override + String get hideSidebar => 'Ocultar panel lateral'; + @override String get accounts => 'Cuentas'; diff --git a/lib/l10n/generated/app_localizations_et.dart b/lib/l10n/generated/app_localizations_et.dart index 1b37858..a28760d 100644 --- a/lib/l10n/generated/app_localizations_et.dart +++ b/lib/l10n/generated/app_localizations_et.dart @@ -633,6 +633,12 @@ class AppLocalizationsEt extends AppLocalizations { @override String get toggleSidebar => 'Kuva või peida külgriba'; + @override + String get showSidebar => 'Kuva külgpaneel'; + + @override + String get hideSidebar => 'Peida külgpaneel'; + @override String get accounts => 'Kontod'; diff --git a/lib/l10n/generated/app_localizations_fa.dart b/lib/l10n/generated/app_localizations_fa.dart index 1965df0..331150b 100644 --- a/lib/l10n/generated/app_localizations_fa.dart +++ b/lib/l10n/generated/app_localizations_fa.dart @@ -651,6 +651,12 @@ class AppLocalizationsFa extends AppLocalizations { @override String get toggleSidebar => 'نمایش یا پنهان کردن نوار کناری'; + @override + String get showSidebar => 'نمایش پنل کناری'; + + @override + String get hideSidebar => 'پنهان کردن پنل کناری'; + @override String get accounts => 'حساب‌ها'; diff --git a/lib/l10n/generated/app_localizations_fi.dart b/lib/l10n/generated/app_localizations_fi.dart index de1abec..28183d9 100644 --- a/lib/l10n/generated/app_localizations_fi.dart +++ b/lib/l10n/generated/app_localizations_fi.dart @@ -635,6 +635,12 @@ class AppLocalizationsFi extends AppLocalizations { @override String get toggleSidebar => 'Näytä tai piilota sivupalkki'; + @override + String get showSidebar => 'Näytä sivupaneeli'; + + @override + String get hideSidebar => 'Piilota sivupaneeli'; + @override String get accounts => 'Tilit'; diff --git a/lib/l10n/generated/app_localizations_fr.dart b/lib/l10n/generated/app_localizations_fr.dart index 825191c..05d8078 100644 --- a/lib/l10n/generated/app_localizations_fr.dart +++ b/lib/l10n/generated/app_localizations_fr.dart @@ -635,6 +635,12 @@ class AppLocalizationsFr extends AppLocalizations { @override String get toggleSidebar => 'Afficher/masquer la barre latérale'; + @override + String get showSidebar => 'Afficher le panneau latéral'; + + @override + String get hideSidebar => 'Masquer le panneau latéral'; + @override String get accounts => 'Comptes'; diff --git a/lib/l10n/generated/app_localizations_hi.dart b/lib/l10n/generated/app_localizations_hi.dart index 6ffd740..3836a4b 100644 --- a/lib/l10n/generated/app_localizations_hi.dart +++ b/lib/l10n/generated/app_localizations_hi.dart @@ -636,6 +636,12 @@ class AppLocalizationsHi extends AppLocalizations { @override String get toggleSidebar => 'साइडबार दिखाएँ या छिपाएँ'; + @override + String get showSidebar => 'साइडबार पैनल दिखाएँ'; + + @override + String get hideSidebar => 'साइडबार पैनल छिपाएँ'; + @override String get accounts => 'खाते'; diff --git a/lib/l10n/generated/app_localizations_it.dart b/lib/l10n/generated/app_localizations_it.dart index 63870c9..5caf694 100644 --- a/lib/l10n/generated/app_localizations_it.dart +++ b/lib/l10n/generated/app_localizations_it.dart @@ -636,6 +636,12 @@ class AppLocalizationsIt extends AppLocalizations { @override String get toggleSidebar => 'Mostra o nascondi la barra laterale'; + @override + String get showSidebar => 'Mostra il pannello laterale'; + + @override + String get hideSidebar => 'Nascondi il pannello laterale'; + @override String get accounts => 'Account'; diff --git a/lib/l10n/generated/app_localizations_ja.dart b/lib/l10n/generated/app_localizations_ja.dart index ea134cb..f70562e 100644 --- a/lib/l10n/generated/app_localizations_ja.dart +++ b/lib/l10n/generated/app_localizations_ja.dart @@ -619,6 +619,12 @@ class AppLocalizationsJa extends AppLocalizations { @override String get toggleSidebar => 'サイドバーの表示を切り替え'; + @override + String get showSidebar => 'サイドバーパネルを表示'; + + @override + String get hideSidebar => 'サイドバーパネルを非表示'; + @override String get accounts => 'アカウント'; diff --git a/lib/l10n/generated/app_localizations_ko.dart b/lib/l10n/generated/app_localizations_ko.dart index d429527..78517da 100644 --- a/lib/l10n/generated/app_localizations_ko.dart +++ b/lib/l10n/generated/app_localizations_ko.dart @@ -619,6 +619,12 @@ class AppLocalizationsKo extends AppLocalizations { @override String get toggleSidebar => '사이드바 표시 전환'; + @override + String get showSidebar => '사이드바 패널 표시'; + + @override + String get hideSidebar => '사이드바 패널 숨기기'; + @override String get accounts => '계정'; diff --git a/lib/l10n/generated/app_localizations_pt.dart b/lib/l10n/generated/app_localizations_pt.dart index e993d65..019cddd 100644 --- a/lib/l10n/generated/app_localizations_pt.dart +++ b/lib/l10n/generated/app_localizations_pt.dart @@ -636,6 +636,12 @@ class AppLocalizationsPt extends AppLocalizations { @override String get toggleSidebar => 'Mostrar ou ocultar a barra lateral'; + @override + String get showSidebar => 'Mostrar painel lateral'; + + @override + String get hideSidebar => 'Ocultar painel lateral'; + @override String get accounts => 'Contas'; diff --git a/lib/l10n/generated/app_localizations_ru.dart b/lib/l10n/generated/app_localizations_ru.dart index 6895e23..4b8d8c5 100644 --- a/lib/l10n/generated/app_localizations_ru.dart +++ b/lib/l10n/generated/app_localizations_ru.dart @@ -639,6 +639,12 @@ class AppLocalizationsRu extends AppLocalizations { @override String get toggleSidebar => 'Показать или скрыть боковую панель'; + @override + String get showSidebar => 'Показать боковую панель'; + + @override + String get hideSidebar => 'Скрыть боковую панель'; + @override String get accounts => 'Аккаунты'; diff --git a/lib/l10n/generated/app_localizations_vi.dart b/lib/l10n/generated/app_localizations_vi.dart index 4d89f91..1ce4a97 100644 --- a/lib/l10n/generated/app_localizations_vi.dart +++ b/lib/l10n/generated/app_localizations_vi.dart @@ -633,6 +633,12 @@ class AppLocalizationsVi extends AppLocalizations { @override String get toggleSidebar => 'Hiện hoặc ẩn thanh bên'; + @override + String get showSidebar => 'Hiện bảng bên'; + + @override + String get hideSidebar => 'Ẩn bảng bên'; + @override String get accounts => 'Tài khoản'; diff --git a/lib/l10n/generated/app_localizations_zh.dart b/lib/l10n/generated/app_localizations_zh.dart index dfb1449..544ab6b 100644 --- a/lib/l10n/generated/app_localizations_zh.dart +++ b/lib/l10n/generated/app_localizations_zh.dart @@ -611,6 +611,12 @@ class AppLocalizationsZh extends AppLocalizations { @override String get toggleSidebar => '显示或隐藏侧边栏'; + @override + String get showSidebar => '显示侧边栏面板'; + + @override + String get hideSidebar => '隐藏侧边栏面板'; + @override String get accounts => '帐户'; @@ -1875,6 +1881,12 @@ class AppLocalizationsZhHans extends AppLocalizationsZh { @override String get toggleSidebar => '显示或隐藏侧边栏'; + @override + String get showSidebar => '显示侧边栏面板'; + + @override + String get hideSidebar => '隐藏侧边栏面板'; + @override String get accounts => '帐户'; @@ -3139,6 +3151,12 @@ class AppLocalizationsZhHant extends AppLocalizationsZh { @override String get toggleSidebar => '顯示或隱藏側邊欄'; + @override + String get showSidebar => '顯示側邊欄面板'; + + @override + String get hideSidebar => '隱藏側邊欄面板'; + @override String get accounts => '帳戶'; diff --git a/lib/src/app/busymax_app.dart b/lib/src/app/busymax_app.dart index 3b48976..c1dbd9a 100644 --- a/lib/src/app/busymax_app.dart +++ b/lib/src/app/busymax_app.dart @@ -248,7 +248,8 @@ class _BusyMaxAppState extends ConsumerState { menu: l10n.mainMenu, previous: materialL10n.previousPageTooltip, next: materialL10n.nextPageTooltip, - sidebar: l10n.toggleSidebar, + showSidebarPanel: l10n.showSidebar, + hideSidebarPanel: l10n.hideSidebar, back: materialL10n.backButtonTooltip, settings: l10n.settings, keyboardShortcuts: l10n.keyboardShortcuts, @@ -261,7 +262,7 @@ class _BusyMaxAppState extends ConsumerState { yearShortcut: BusyMaxShortcutLabels.yearView, agendaShortcut: BusyMaxShortcutLabels.agendaView, searchShortcut: BusyMaxShortcutLabels.search, - createShortcut: BusyMaxShortcutLabels.create, + sidebarShortcut: BusyMaxShortcutLabels.sidebar, createEventShortcut: BusyMaxShortcutLabels.newEvent, createTaskShortcut: BusyMaxShortcutLabels.newTask, previousShortcut: BusyMaxShortcutLabels.previousPeriod, diff --git a/lib/src/app/busymax_keyboard_shortcuts_dialog.dart b/lib/src/app/busymax_keyboard_shortcuts_dialog.dart index 0db4e2c..cde0271 100644 --- a/lib/src/app/busymax_keyboard_shortcuts_dialog.dart +++ b/lib/src/app/busymax_keyboard_shortcuts_dialog.dart @@ -75,6 +75,13 @@ class BusyMaxKeyboardShortcutsDialog extends StatelessWidget { title: l10n.shortcutGroupNavigation, filled: true, children: [ + BusyMaxActionRow( + title: l10n.toggleSidebar, + leading: const Icon(Icons.vertical_split_outlined), + trailing: const _KeyboardShortcutBadge( + BusyMaxShortcutLabels.sidebar, + ), + ), BusyMaxActionRow( title: l10n.shortcutNextPeriod, subtitle: l10n.shortcutNextPeriodDescription, @@ -104,13 +111,6 @@ class BusyMaxKeyboardShortcutsDialog extends StatelessWidget { title: l10n.shortcutGroupCreateAndEdit, filled: true, children: [ - BusyMaxActionRow( - title: l10n.create, - leading: const Icon(Icons.add), - trailing: const _KeyboardShortcutBadge( - BusyMaxShortcutLabels.create, - ), - ), BusyMaxActionRow( title: l10n.newEvent, leading: const Icon(Icons.event_outlined), diff --git a/lib/src/app/busymax_shortcuts.dart b/lib/src/app/busymax_shortcuts.dart index b4d3563..3820e9d 100644 --- a/lib/src/app/busymax_shortcuts.dart +++ b/lib/src/app/busymax_shortcuts.dart @@ -5,33 +5,35 @@ import '../schedule/schedule_view_mode.dart'; abstract final class BusyMaxShortcutActivators { static const keyboardShortcuts = SingleActivator( - LogicalKeyboardKey.slash, + LogicalKeyboardKey.keyK, control: true, + alt: true, ); static const settings = SingleActivator( - LogicalKeyboardKey.comma, + LogicalKeyboardKey.keyS, control: true, + alt: true, ); static const search = SingleActivator(LogicalKeyboardKey.keyF, control: true); - static const create = SingleActivator(LogicalKeyboardKey.keyN, control: true); + static const sidebar = SingleActivator(LogicalKeyboardKey.f9); static const dismiss = SingleActivator(LogicalKeyboardKey.escape); } abstract final class BusyMaxShortcutLabels { - static const keyboardShortcuts = 'Ctrl+/'; - static const settings = 'Ctrl+,'; + static const keyboardShortcuts = 'Ctrl+Alt+K'; + static const settings = 'Ctrl+Alt+S'; static const search = 'Ctrl+F'; - static const create = 'Ctrl+N'; + static const sidebar = 'F9'; static const previousPeriod = 'Shift+Left'; static const nextPeriod = 'Shift+Right'; static const today = 'Shift+T'; static const newEvent = 'E'; static const newTask = 'T'; - static const dayView = '1 / D'; - static const weekView = '2 / W'; - static const monthView = '3 / M'; - static const yearView = '4 / Y'; - static const agendaView = '0 / A'; + static const dayView = '1'; + static const weekView = '2'; + static const monthView = '3'; + static const yearView = '4'; + static const agendaView = '5'; static const refreshCompactAgenda = 'Ctrl+R'; static const dismiss = 'Esc'; diff --git a/lib/src/features/schedule/presentation/schedule_task_chip.dart b/lib/src/features/schedule/presentation/schedule_task_chip.dart index e3ac2eb..bfd0007 100644 --- a/lib/src/features/schedule/presentation/schedule_task_chip.dart +++ b/lib/src/features/schedule/presentation/schedule_task_chip.dart @@ -62,6 +62,9 @@ class ScheduleTaskChip extends StatelessWidget { color: Colors.transparent, child: InkWell( borderRadius: BorderRadius.circular(BusyMaxRadius.sm), + mouseCursor: onTap == null + ? MouseCursor.defer + : SystemMouseCursors.click, onTapDown: onTap == null ? null : (details) => pointerDownPosition = details.globalPosition, diff --git a/lib/src/features/schedule/presentation/schedule_toolbar.dart b/lib/src/features/schedule/presentation/schedule_toolbar.dart index 2380f31..6866d9e 100644 --- a/lib/src/features/schedule/presentation/schedule_toolbar.dart +++ b/lib/src/features/schedule/presentation/schedule_toolbar.dart @@ -77,7 +77,12 @@ class ScheduleToolbar extends StatelessWidget { const SizedBox(width: BusyMaxSpacing.sm), if (canShowSidebar && onToggleSidebar != null) YaruIconButton( - tooltip: context.l10n.toggleSidebar, + tooltip: _shortcutTooltip( + sidebarVisible + ? context.l10n.hideSidebar + : context.l10n.showSidebar, + BusyMaxShortcutLabels.sidebar, + ), icon: Icon( sidebarVisible ? Icons.vertical_split @@ -118,11 +123,9 @@ class ScheduleToolbar extends StatelessWidget { const SizedBox(width: BusyMaxSpacing.sm), ], Expanded( - child: Text( + child: _fittingRangeTitle( + context, _rangeLabel(context, mode, range, selectedDate), - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: busyMaxHeaderTitleStyle(context), ), ), BusyMaxMenuButton( @@ -153,10 +156,7 @@ class ScheduleToolbar extends StatelessWidget { onPressed: onSearch, ), BusyMaxMenuButton<_ScheduleCreateAction>( - tooltip: _shortcutTooltip( - context.l10n.create, - BusyMaxShortcutLabels.create, - ), + tooltip: context.l10n.create, icon: const Icon(YaruIcons.plus), controller: createMenuController, enabled: canCreateEvent || canCreateTask, @@ -236,6 +236,26 @@ class ScheduleToolbar extends StatelessWidget { } } +Widget _fittingRangeTitle(BuildContext context, String title) { + final style = busyMaxHeaderTitleStyle(context); + return LayoutBuilder( + builder: (context, constraints) { + final painter = TextPainter( + text: TextSpan(text: title, style: style), + maxLines: 1, + textDirection: Directionality.of(context), + textScaler: MediaQuery.textScalerOf(context), + )..layout(); + final titleFits = painter.width <= constraints.maxWidth; + painter.dispose(); + if (!titleFits) { + return const SizedBox.shrink(); + } + return Text(title, maxLines: 1, style: style); + }, + ); +} + IconData _modeIcon(ScheduleViewMode mode) { return switch (mode) { ScheduleViewMode.day => Icons.calendar_view_day_outlined, diff --git a/lib/src/features/schedule/presentation/schedule_workspace.dart b/lib/src/features/schedule/presentation/schedule_workspace.dart index 08ce764..4268f27 100644 --- a/lib/src/features/schedule/presentation/schedule_workspace.dart +++ b/lib/src/features/schedule/presentation/schedule_workspace.dart @@ -54,7 +54,7 @@ import 'schedule_year_view.dart'; enum _ScheduleShortcut { search, - create, + sidebar, dismissSearch, previous, next, @@ -78,8 +78,8 @@ const _scheduleShortcuts = { BusyMaxShortcutActivators.search: _ScheduleShortcutIntent( _ScheduleShortcut.search, ), - BusyMaxShortcutActivators.create: _ScheduleShortcutIntent( - _ScheduleShortcut.create, + BusyMaxShortcutActivators.sidebar: _ScheduleShortcutIntent( + _ScheduleShortcut.sidebar, ), BusyMaxShortcutActivators.dismiss: _ScheduleShortcutIntent( _ScheduleShortcut.dismissSearch, @@ -102,43 +102,28 @@ const _scheduleShortcuts = { SingleActivator(LogicalKeyboardKey.numpad1): _ScheduleShortcutIntent( _ScheduleShortcut.day, ), - SingleActivator(LogicalKeyboardKey.keyD): _ScheduleShortcutIntent( - _ScheduleShortcut.day, - ), SingleActivator(LogicalKeyboardKey.digit2): _ScheduleShortcutIntent( _ScheduleShortcut.week, ), SingleActivator(LogicalKeyboardKey.numpad2): _ScheduleShortcutIntent( _ScheduleShortcut.week, ), - SingleActivator(LogicalKeyboardKey.keyW): _ScheduleShortcutIntent( - _ScheduleShortcut.week, - ), SingleActivator(LogicalKeyboardKey.digit3): _ScheduleShortcutIntent( _ScheduleShortcut.month, ), SingleActivator(LogicalKeyboardKey.numpad3): _ScheduleShortcutIntent( _ScheduleShortcut.month, ), - SingleActivator(LogicalKeyboardKey.keyM): _ScheduleShortcutIntent( - _ScheduleShortcut.month, - ), SingleActivator(LogicalKeyboardKey.digit4): _ScheduleShortcutIntent( _ScheduleShortcut.year, ), SingleActivator(LogicalKeyboardKey.numpad4): _ScheduleShortcutIntent( _ScheduleShortcut.year, ), - SingleActivator(LogicalKeyboardKey.keyY): _ScheduleShortcutIntent( - _ScheduleShortcut.year, - ), - SingleActivator(LogicalKeyboardKey.digit0): _ScheduleShortcutIntent( - _ScheduleShortcut.agenda, - ), - SingleActivator(LogicalKeyboardKey.numpad0): _ScheduleShortcutIntent( + SingleActivator(LogicalKeyboardKey.digit5): _ScheduleShortcutIntent( _ScheduleShortcut.agenda, ), - SingleActivator(LogicalKeyboardKey.keyA): _ScheduleShortcutIntent( + SingleActivator(LogicalKeyboardKey.numpad5): _ScheduleShortcutIntent( _ScheduleShortcut.agenda, ), }; @@ -399,64 +384,88 @@ class _ScheduleWorkspaceState extends ConsumerState { BusyMaxLayoutRules.showSidebar( MediaQuery.sizeOf(context).width, ); + Widget buildSidebar() { + return SizedBox( + width: BusyMaxSizes.sidebarWidth, + child: FutureBuilder>( + future: miniCalendarItemsFuture, + builder: (context, miniSnapshot) { + final miniCalendarItems = + ScheduleProjection.filterByScope( + miniSnapshot.data ?? const [], + _scope, + ); + return ScheduleSidebar( + selectedDate: _selectedDate, + firstWeekday: firstWeekday, + items: miniCalendarItems, + onDateSelected: _openDay, + onMonthSelected: _setMonth, + onYearSelected: _setYear, + onWeekSelected: _setWeek, + ); + }, + ), + ); + } + final main = Column( children: [ if (showFallbackHeader) ...[ - 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(), + if (_searchActive) + Padding( + padding: const EdgeInsets.symmetric( + horizontal: BusyMaxSpacing.md, + vertical: BusyMaxSpacing.sm, ), - ), - onCreateTask: () => unawaited( - _openNewTask( - accounts, - due: _day(_defaultSelectedDateStart()), + child: BusyMaxSearchField( + controller: _searchController, + autofocus: true, + focusRequest: _fallbackSearchFocusRequest, + hintText: MaterialLocalizations.of( + context, + ).searchFieldLabel, + onChanged: _setSearchQuery, + onClear: _clearSearchQuery, ), + ) + else + 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: canShowFallbackSidebar, + sidebarVisible: + canShowFallbackSidebar && !_sidebarCollapsed, + onToggleSidebar: () => _handleHeaderBarAction( + BusyMaxHeaderBarAction.sidebarToggle, + ), + onSearch: () => _handleHeaderBarAction( + BusyMaxHeaderBarAction.search, + ), + onMenuSelected: _handleFallbackToolbarMenu, ), - 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), - ], - if (_searchActive && _showFlutterHeaderFallback) ...[ - Padding( - padding: const EdgeInsets.symmetric( - horizontal: BusyMaxSpacing.md, - vertical: BusyMaxSpacing.sm, - ), - child: BusyMaxSearchField( - controller: _searchController, - autofocus: true, - focusRequest: _fallbackSearchFocusRequest, - hintText: MaterialLocalizations.of( - context, - ).searchFieldLabel, - onChanged: _setSearchQuery, - onClear: _clearSearchQuery, - ), - ), const Divider(height: 1), ], Expanded( @@ -576,29 +585,7 @@ class _ScheduleWorkspaceState extends ConsumerState { ? main : Row( children: [ - SizedBox( - width: BusyMaxSizes.sidebarWidth, - child: FutureBuilder>( - future: miniCalendarItemsFuture, - builder: (context, miniSnapshot) { - final miniCalendarItems = - ScheduleProjection.filterByScope( - miniSnapshot.data ?? - const [], - _scope, - ); - return ScheduleSidebar( - selectedDate: _selectedDate, - firstWeekday: firstWeekday, - items: miniCalendarItems, - onDateSelected: _openDay, - onMonthSelected: _setMonth, - onYearSelected: _setYear, - onWeekSelected: _setWeek, - ); - }, - ), - ), + buildSidebar(), Expanded(child: main), ], ); @@ -1254,8 +1241,7 @@ class _ScheduleWorkspaceState extends ConsumerState { } return switch (command) { _ScheduleShortcut.search => true, - _ScheduleShortcut.create => - _latestWritableSources.isNotEmpty || _latestCanCreateTask, + _ScheduleShortcut.sidebar => _latestCanShowSidebar, _ScheduleShortcut.dismissSearch => _searchActive, _ScheduleShortcut.newEvent => _canHandleScheduleShortcut() && _latestWritableSources.isNotEmpty, @@ -1279,8 +1265,8 @@ class _ScheduleWorkspaceState extends ConsumerState { setState(() => _searchActive = true); } _focusSearch(); - case _ScheduleShortcut.create: - _openCreateAtSelectedDate(); + case _ScheduleShortcut.sidebar: + setState(() => _sidebarCollapsed = !_sidebarCollapsed); case _ScheduleShortcut.dismissSearch: _closeSearch(); case _ScheduleShortcut.previous: @@ -1478,16 +1464,6 @@ class _ScheduleWorkspaceState extends ConsumerState { : context; } - void _openCreateAtSelectedDate() { - if (_nativeHeaderBarAvailable && _headerBarSession.isCurrent) { - unawaited(_headerBarSession.showCreateMenu()); - return; - } - if (_showFlutterHeaderFallback) { - _createMenuController.openForKeyboard(); - } - } - Future _openNewEvent( List sources, DateTime start, diff --git a/lib/src/platform/linux_header_bar_service.dart b/lib/src/platform/linux_header_bar_service.dart index 54a3e69..0e192d1 100644 --- a/lib/src/platform/linux_header_bar_service.dart +++ b/lib/src/platform/linux_header_bar_service.dart @@ -78,7 +78,8 @@ class BusyMaxHeaderBarLabels { required this.menu, required this.previous, required this.next, - required this.sidebar, + required this.showSidebarPanel, + required this.hideSidebarPanel, required this.back, required this.settings, required this.keyboardShortcuts, @@ -91,7 +92,7 @@ class BusyMaxHeaderBarLabels { this.yearShortcut = '', this.agendaShortcut = '', this.searchShortcut = '', - this.createShortcut = '', + this.sidebarShortcut = '', this.createEventShortcut = '', this.createTaskShortcut = '', this.previousShortcut = '', @@ -114,7 +115,8 @@ class BusyMaxHeaderBarLabels { final String menu; final String previous; final String next; - final String sidebar; + final String showSidebarPanel; + final String hideSidebarPanel; final String back; final String settings; final String keyboardShortcuts; @@ -127,7 +129,7 @@ class BusyMaxHeaderBarLabels { final String yearShortcut; final String agendaShortcut; final String searchShortcut; - final String createShortcut; + final String sidebarShortcut; final String createEventShortcut; final String createTaskShortcut; final String previousShortcut; @@ -151,7 +153,8 @@ class BusyMaxHeaderBarLabels { 'menu': menu, 'previous': previous, 'next': next, - 'sidebar': sidebar, + 'showSidebarPanel': showSidebarPanel, + 'hideSidebarPanel': hideSidebarPanel, 'back': back, 'settings': settings, 'keyboardShortcuts': keyboardShortcuts, @@ -164,7 +167,7 @@ class BusyMaxHeaderBarLabels { 'yearShortcut': yearShortcut, 'agendaShortcut': agendaShortcut, 'searchShortcut': searchShortcut, - 'createShortcut': createShortcut, + 'sidebarShortcut': sidebarShortcut, 'createEventShortcut': createEventShortcut, 'createTaskShortcut': createTaskShortcut, 'previousShortcut': previousShortcut, @@ -192,7 +195,8 @@ class BusyMaxHeaderBarLabels { menu == other.menu && previous == other.previous && next == other.next && - sidebar == other.sidebar && + showSidebarPanel == other.showSidebarPanel && + hideSidebarPanel == other.hideSidebarPanel && back == other.back && settings == other.settings && keyboardShortcuts == other.keyboardShortcuts && @@ -205,7 +209,7 @@ class BusyMaxHeaderBarLabels { yearShortcut == other.yearShortcut && agendaShortcut == other.agendaShortcut && searchShortcut == other.searchShortcut && - createShortcut == other.createShortcut && + sidebarShortcut == other.sidebarShortcut && createEventShortcut == other.createEventShortcut && createTaskShortcut == other.createTaskShortcut && previousShortcut == other.previousShortcut && @@ -230,7 +234,8 @@ class BusyMaxHeaderBarLabels { menu, previous, next, - sidebar, + showSidebarPanel, + hideSidebarPanel, back, settings, keyboardShortcuts, @@ -243,7 +248,7 @@ class BusyMaxHeaderBarLabels { yearShortcut, agendaShortcut, searchShortcut, - createShortcut, + sidebarShortcut, createEventShortcut, createTaskShortcut, previousShortcut, diff --git a/pubspec.yaml b/pubspec.yaml index c1fce85..d6b221d 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: busymax description: BusyMax calendar and task manager. publish_to: 'none' -version: 0.1.2 +version: 0.1.4 environment: sdk: ^3.12.0 diff --git a/test/app/busymax_dialogs_test.dart b/test/app/busymax_dialogs_test.dart index f561014..c19f7e4 100644 --- a/test/app/busymax_dialogs_test.dart +++ b/test/app/busymax_dialogs_test.dart @@ -599,9 +599,11 @@ void main() { ); await tester.pump(); - for (final key in [LogicalKeyboardKey.comma, LogicalKeyboardKey.slash]) { + for (final key in [LogicalKeyboardKey.keyS, LogicalKeyboardKey.keyK]) { await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft); + await tester.sendKeyDownEvent(LogicalKeyboardKey.altLeft); await tester.sendKeyEvent(key); + await tester.sendKeyUpEvent(LogicalKeyboardKey.altLeft); await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft); } diff --git a/test/app/keyboard_shortcuts_dialog_test.dart b/test/app/keyboard_shortcuts_dialog_test.dart index 522d3a5..c817b31 100644 --- a/test/app/keyboard_shortcuts_dialog_test.dart +++ b/test/app/keyboard_shortcuts_dialog_test.dart @@ -25,20 +25,22 @@ void main() { expect(find.text('Create and Edit'), findsOneWidget); expect(find.text('Task editing'), findsOneWidget); expect(find.text('Compact agenda'), findsOneWidget); - expect(find.text('Ctrl+/'), findsOneWidget); - expect(find.text('Ctrl+,'), findsOneWidget); + expect(find.text('Ctrl+Alt+K'), findsOneWidget); + expect(find.text('Ctrl+Alt+S'), findsOneWidget); expect(find.text('Ctrl+F'), findsOneWidget); - expect(find.text('Ctrl+N'), findsOneWidget); + expect(find.text('F9'), findsOneWidget); + expect(find.text('Ctrl+N'), findsNothing); expect(find.text('Shift+Right'), findsOneWidget); expect(find.text('Shift+Left'), findsOneWidget); expect(find.text('T'), findsOneWidget); expect(find.text('Shift+T'), findsOneWidget); expect(find.text('E'), findsOneWidget); - expect(find.text('1 / D'), findsOneWidget); - expect(find.text('2 / W'), findsOneWidget); - expect(find.text('3 / M'), findsOneWidget); - expect(find.text('4 / Y'), findsOneWidget); - expect(find.text('0 / A'), findsOneWidget); + expect(find.text('1'), findsOneWidget); + expect(find.text('2'), findsOneWidget); + expect(find.text('3'), findsOneWidget); + expect(find.text('4'), findsOneWidget); + expect(find.text('5'), findsOneWidget); + expect(find.text('0'), findsNothing); expect(find.text('Ctrl+S'), findsOneWidget); expect(find.text('Backspace / Delete'), findsOneWidget); expect(find.text('Ctrl+R'), findsOneWidget); @@ -77,9 +79,10 @@ void main() { ); final badgeEnds = [ - 'Ctrl+/', - 'Ctrl+,', + 'Ctrl+Alt+K', + 'Ctrl+Alt+S', 'Ctrl+F', + 'F9', ].map((label) => tester.getTopRight(find.text(label)).dx).toList(); expect(badgeEnds.every((end) => end == badgeEnds.first), isTrue); }); @@ -216,8 +219,9 @@ void main() { expect(app, contains('keyboardShortcuts: l10n.keyboardShortcuts')); expect(app, contains('BusyMaxShortcutActivators.keyboardShortcuts')); - expect(shortcuts, contains('LogicalKeyboardKey.slash')); - expect(shortcuts, contains('LogicalKeyboardKey.comma')); + 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"')); diff --git a/test/app/native_ui_audit_test.dart b/test/app/native_ui_audit_test.dart index e6c0e3b..a1100e0 100644 --- a/test/app/native_ui_audit_test.dart +++ b/test/app/native_ui_audit_test.dart @@ -916,7 +916,15 @@ void main() { 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, @@ -1227,10 +1235,7 @@ void main() { 'gtk_image_set_from_icon_name(GTK_IMAGE(self->view_mode_icon),', ), ); - expect( - source, - contains('header_view_mode_icon_name("week")'), - ); + 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";')); diff --git a/test/features/schedule/presentation/schedule_toolbar_test.dart b/test/features/schedule/presentation/schedule_toolbar_test.dart index 0ff147b..2775bcf 100644 --- a/test/features/schedule/presentation/schedule_toolbar_test.dart +++ b/test/features/schedule/presentation/schedule_toolbar_test.dart @@ -46,9 +46,9 @@ void main() { ), child: Scaffold( body: SizedBox( - width: 1000, + width: 1200, child: ScheduleToolbar( - mode: ScheduleViewMode.week, + mode: ScheduleViewMode.agenda, range: ScheduleRange.week(DateTime(2026, 7, 22)), selectedDate: DateTime(2026, 7, 22), onToday: () {}, @@ -67,7 +67,7 @@ void main() { ); final titleFinder = find.byWidgetPredicate( - (widget) => widget is Text && (widget.data?.contains('2026') ?? false), + (widget) => widget is Text && widget.data == 'Agenda', ); expect(titleFinder, findsOneWidget); final title = tester.widget(titleFinder); @@ -112,7 +112,7 @@ void main() { ), ); - await tester.tap(find.byTooltip('Create (Ctrl+N)')); + await tester.tap(find.byTooltip('Create')); await tester.pumpAndSettle(); expect(events, 0); @@ -140,21 +140,14 @@ void main() { ); }); - testWidgets('fallback toolbar exposes the complete shell command set', ( + testWidgets('fallback toolbar hides a range title that does not fit', ( tester, ) async { - var sidebarToggles = 0; - var searches = 0; - var events = 0; - var tasks = 0; - ScheduleViewMode? selectedMode; - ScheduleToolbarMenuAction? selectedMenuAction; - await tester.pumpWidget( localizedTestApp( child: Scaffold( body: SizedBox( - width: 1000, + width: 600, child: ScheduleToolbar( mode: ScheduleViewMode.week, range: ScheduleRange.week(DateTime(2026, 7, 22)), @@ -162,29 +155,85 @@ void main() { onToday: () {}, onPrevious: () {}, onNext: () {}, - onModeChanged: (value) => selectedMode = value, + onModeChanged: (_) {}, canCreateEvent: true, canCreateTask: true, - onCreateEvent: () => events++, - onCreateTask: () => tasks++, + onCreateEvent: () {}, + onCreateTask: () {}, onRefresh: () {}, - canShowSidebar: true, - sidebarVisible: true, - onToggleSidebar: () => sidebarToggles++, - onSearch: () => searches++, - onMenuSelected: (value) => selectedMenuAction = value, ), ), ), ), ); - await tester.tap(find.byTooltip('Toggle Sidebar')); + expect( + find.byWidgetPredicate( + (widget) => widget is Text && (widget.data?.contains('2026') ?? false), + ), + findsNothing, + ); + expect( + find.byWidgetPredicate( + (widget) => widget is Text && widget.overflow == TextOverflow.ellipsis, + ), + findsNothing, + ); + }); + + testWidgets('fallback toolbar exposes the complete shell command set', ( + tester, + ) async { + var sidebarToggles = 0; + var searches = 0; + var events = 0; + var tasks = 0; + var sidebarVisible = true; + ScheduleViewMode? selectedMode; + ScheduleToolbarMenuAction? selectedMenuAction; + + await tester.pumpWidget( + localizedTestApp( + child: Scaffold( + body: SizedBox( + width: 1000, + child: StatefulBuilder( + builder: (context, setToolbarState) => ScheduleToolbar( + mode: ScheduleViewMode.week, + range: ScheduleRange.week(DateTime(2026, 7, 22)), + selectedDate: DateTime(2026, 7, 22), + onToday: () {}, + onPrevious: () {}, + onNext: () {}, + onModeChanged: (value) => selectedMode = value, + canCreateEvent: true, + canCreateTask: true, + onCreateEvent: () => events++, + onCreateTask: () => tasks++, + onRefresh: () {}, + canShowSidebar: true, + sidebarVisible: sidebarVisible, + onToggleSidebar: () { + sidebarToggles++; + setToolbarState(() => sidebarVisible = !sidebarVisible); + }, + onSearch: () => searches++, + onMenuSelected: (value) => selectedMenuAction = value, + ), + ), + ), + ), + ), + ); + + await tester.tap(find.byTooltip('Hide sidebar panel (F9)')); + await tester.pump(); + expect(find.byTooltip('Show sidebar panel (F9)'), findsOneWidget); await tester.tap(find.byTooltip('Search (Ctrl+F)')); expect(sidebarToggles, 1); expect(searches, 1); - await tester.tap(find.byTooltip('Create (Ctrl+N)')); + await tester.tap(find.byTooltip('Create')); await tester.pumpAndSettle(); expect( find.byWidgetPredicate((widget) => widget is PopupMenuItem), @@ -198,7 +247,7 @@ void main() { expect(events, 1); expect(tasks, 0); - await tester.tap(find.byTooltip('Week (2 / W)')); + await tester.tap(find.byTooltip('Week (2)')); await tester.pumpAndSettle(); expect( find.byWidgetPredicate((widget) => widget is PopupMenuItem), @@ -208,6 +257,7 @@ void main() { find.byType(YaruRadio), findsNWidgets(ScheduleViewMode.values.length), ); + expect(find.text('Compact'), findsNothing); await tester.tap( find.ancestor( of: find.text('Month'), @@ -318,7 +368,7 @@ void main() { ), ); - await tester.tap(find.byTooltip('Create (Ctrl+N)')); + await tester.tap(find.byTooltip('Create')); await tester.pumpAndSettle(); expect( @@ -394,7 +444,7 @@ void main() { final trigger = tester.widget( find.ancestor( - of: find.byTooltip('Create (Ctrl+N)'), + of: find.byTooltip('Create'), matching: find.byType(YaruIconButton), ), ); @@ -585,12 +635,12 @@ void main() { final trigger = tester.widget( find.ancestor( - of: find.byTooltip('Create (Ctrl+N)'), + of: find.byTooltip('Create'), matching: find.byType(YaruIconButton), ), ); expect(trigger.onPressed, isNull); - await tester.tap(find.byTooltip('Create (Ctrl+N)')); + await tester.tap(find.byTooltip('Create')); await tester.pumpAndSettle(); expect(find.text('Event'), findsNothing); expect(find.text('Task'), findsNothing); diff --git a/test/features/schedule/presentation/schedule_views_test.dart b/test/features/schedule/presentation/schedule_views_test.dart index e6eea67..15810dd 100644 --- a/test/features/schedule/presentation/schedule_views_test.dart +++ b/test/features/schedule/presentation/schedule_views_test.dart @@ -550,6 +550,38 @@ void main() { expect(activationPosition, isNull); }); + testWidgets('interactive task chip uses the event click cursor', ( + tester, + ) async { + final task = _itemsFor( + DateTime(2026, 1, 15), + ).whereType().first; + + await tester.pumpWidget( + localizedTestApp( + child: Scaffold( + body: Center( + child: ScheduleItemChip( + item: task, + width: 180, + height: 54, + onTap: (_, [_]) {}, + ), + ), + ), + ), + ); + + final taskInkWell = find.descendant( + of: find.byType(ScheduleItemChip), + matching: find.byType(InkWell), + ); + expect( + tester.widget(taskInkWell).mouseCursor, + SystemMouseCursors.click, + ); + }); + testWidgets('same-slot day items render in a horizontal strip', ( tester, ) async { @@ -2324,6 +2356,7 @@ void main() { 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')); expect(source, contains('allAccountsSyncRunnerProvider')); expect(source, contains('context.l10n.allTasksRefreshed')); @@ -2344,7 +2377,10 @@ void main() { expect(source, contains('_ScheduleShortcutAction(this)')); expect(source, contains('route != null && !route.isCurrent')); expect(source, contains('BusyMaxShortcutActivators.search:')); - expect(source, contains('BusyMaxShortcutActivators.create:')); + expect(source, contains('BusyMaxShortcutActivators.sidebar:')); + expect(source, contains('_ScheduleShortcut.sidebar')); + expect(source, contains('_sidebarCollapsed = !_sidebarCollapsed')); + expect(source, isNot(contains('BusyMaxShortcutActivators.create:'))); expect(source, contains('LogicalKeyboardKey.arrowRight')); expect(source, contains('_next();')); expect(source, contains('LogicalKeyboardKey.arrowLeft')); @@ -2364,20 +2400,23 @@ void main() { ); expect(source, contains('_goToToday();')); expect(source, contains('LogicalKeyboardKey.digit1')); - expect(source, contains('LogicalKeyboardKey.keyD')); + expect(source, isNot(contains('LogicalKeyboardKey.keyD'))); expect(source, contains('_setMode(ScheduleViewMode.day)')); expect(source, contains('LogicalKeyboardKey.digit2')); - expect(source, contains('LogicalKeyboardKey.keyW')); + expect(source, isNot(contains('LogicalKeyboardKey.keyW'))); expect(source, contains('_setMode(ScheduleViewMode.week)')); expect(source, contains('LogicalKeyboardKey.digit3')); - expect(source, contains('LogicalKeyboardKey.keyM')); + expect(source, isNot(contains('LogicalKeyboardKey.keyM'))); expect(source, contains('_setMode(ScheduleViewMode.month)')); expect(source, contains('LogicalKeyboardKey.digit4')); - expect(source, contains('LogicalKeyboardKey.keyY')); + expect(source, isNot(contains('LogicalKeyboardKey.keyY'))); expect(source, contains('_setMode(ScheduleViewMode.year)')); - expect(source, contains('LogicalKeyboardKey.digit0')); - expect(source, contains('LogicalKeyboardKey.keyA')); + expect(source, contains('LogicalKeyboardKey.digit5')); + expect(source, isNot(contains('LogicalKeyboardKey.keyA'))); expect(source, contains('_setMode(ScheduleViewMode.agenda)')); + expect(source, isNot(contains('LogicalKeyboardKey.digit0'))); + expect(source, isNot(contains('LogicalKeyboardKey.numpad0'))); + expect(source, isNot(contains('ScheduleViewMode.compact'))); expect(source, contains('focusContext.widget is! EditableText')); }); @@ -3299,8 +3338,7 @@ void main() { expect(workspace, isNot(contains('FloatingActionButton('))); expect(workspace, contains('BusyMaxHeaderBarAction.createEvent')); expect(workspace, contains('BusyMaxHeaderBarAction.createTask')); - expect(workspace, contains('void _openCreateAtSelectedDate()')); - expect(workspace, contains('_createMenuController.openForKeyboard()')); + expect(workspace, isNot(contains('void _openCreateAtSelectedDate()'))); expect( headerService, isNot(contains("'create' => BusyMaxHeaderBarAction.create")), @@ -3337,7 +3375,8 @@ void main() { ); expect(sidebar, isNot(contains('context.l10n.create'))); expect(sidebar, isNot(contains('PushButton.filled'))); - expect(toolbar, contains('BusyMaxShortcutLabels.create')); + 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')); }); @@ -3585,7 +3624,8 @@ void main() { expect( source, contains( - 'showNoDateTasks: searchHasQuery || _mode != ScheduleViewMode.agenda', + 'showNoDateTasks: searchHasQuery || ' + '_mode != ScheduleViewMode.agenda', ), ); expect( diff --git a/test/features/schedule/presentation/schedule_workspace_states_test.dart b/test/features/schedule/presentation/schedule_workspace_states_test.dart index b256eb3..1371447 100644 --- a/test/features/schedule/presentation/schedule_workspace_states_test.dart +++ b/test/features/schedule/presentation/schedule_workspace_states_test.dart @@ -6,6 +6,7 @@ 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_workspace.dart'; import 'package:busymax/src/platform/linux_header_bar_service.dart'; import 'package:flutter/material.dart'; @@ -111,6 +112,24 @@ void main() { expect(find.byType(BusyMaxSearchField), findsNothing); }); + testWidgets('F9 hides and shows the schedule sidebar', (tester) async { + await _pumpWorkspace( + tester, + accountsFactory: () => Stream.value(const []), + ); + await tester.pumpAndSettle(); + + expect(find.byType(ScheduleSidebar), findsOneWidget); + + await tester.sendKeyEvent(LogicalKeyboardKey.f9); + await tester.pumpAndSettle(); + expect(find.byType(ScheduleSidebar), findsNothing); + + await tester.sendKeyEvent(LogicalKeyboardKey.f9); + await tester.pumpAndSettle(); + expect(find.byType(ScheduleSidebar), findsOneWidget); + }); + testWidgets( 'native search owns Linux entry state without a Flutter duplicate', (tester) async { 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 2fb8a53..1c0f564 100644 --- a/test/features/schedule/presentation/schedule_workspace_task_mutations_test.dart +++ b/test/features/schedule/presentation/schedule_workspace_task_mutations_test.dart @@ -47,7 +47,7 @@ void main() { expect(find.text('Created from Schedule'), findsNothing); - await tester.tap(find.byTooltip('Create (Ctrl+N)')); + await tester.tap(find.byTooltip('Create')); await tester.pumpAndSettle(); await tester.tap(find.text('Task')); await tester.pumpAndSettle(); @@ -79,7 +79,7 @@ void main() { initialTaskListId: _projectListId, ); - await tester.tap(find.byTooltip('Create (Ctrl+N)')); + await tester.tap(find.byTooltip('Create')); await tester.pumpAndSettle(); await tester.tap(find.text('Task')); await tester.pumpAndSettle(); diff --git a/test/platform/linux_header_bar_configuration_synchronizer_test.dart b/test/platform/linux_header_bar_configuration_synchronizer_test.dart index d3152a0..aa39d5b 100644 --- a/test/platform/linux_header_bar_configuration_synchronizer_test.dart +++ b/test/platform/linux_header_bar_configuration_synchronizer_test.dart @@ -115,7 +115,8 @@ BusyMaxHeaderBarConfiguration _configuration({required bool dark}) { menu: 'Menu', previous: 'Previous', next: 'Next', - sidebar: 'Sidebar', + showSidebarPanel: 'Show sidebar panel', + hideSidebarPanel: 'Hide sidebar panel', back: 'Back', settings: 'Settings', keyboardShortcuts: 'Keyboard shortcuts', diff --git a/test/platform/linux_header_bar_service_test.dart b/test/platform/linux_header_bar_service_test.dart index 80e3b11..f16c74e 100644 --- a/test/platform/linux_header_bar_service_test.dart +++ b/test/platform/linux_header_bar_service_test.dart @@ -77,17 +77,19 @@ void main() { menu: 'Menu', previous: 'Previous', next: 'Next', - sidebar: 'Toggle Sidebar', + 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 / D', + dayShortcut: '1', + sidebarShortcut: 'F9', createEventShortcut: 'E', - settingsShortcut: 'Ctrl+,', - keyboardShortcutsShortcut: 'Ctrl+/', + settingsShortcut: 'Ctrl+Alt+S', + keyboardShortcutsShortcut: 'Ctrl+Alt+K', ), ); await service.setSidebarWidth(300); @@ -137,7 +139,14 @@ void main() { 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('sidebar', 'Toggle Sidebar')); + 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( @@ -147,12 +156,13 @@ void main() { 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 / D')); + 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+,')); + expect(calls[1].arguments, containsPair('settingsShortcut', 'Ctrl+Alt+S')); expect( calls[1].arguments, - containsPair('keyboardShortcutsShortcut', 'Ctrl+/'), + containsPair('keyboardShortcutsShortcut', 'Ctrl+Alt+K'), ); expect(calls[2].arguments, 300); expect(calls[3].arguments, 'rtl'); @@ -620,6 +630,22 @@ void main() { ), ); expect(source, contains('kHeaderSearchEntryStyleClass')); + expect( + source, + contains( + 'set_widget_visible(self->today_button,\n' + ' schedule_controls_visible &&\n' + ' !self->header_search_active);', + ), + ); + expect( + source, + contains( + 'set_widget_visible(self->header_view_box,\n' + ' schedule_controls_visible &&\n' + ' !self->header_search_active);', + ), + ); expect(geometryCssStart, isNonNegative); expect(geometryCssEnd, greaterThan(geometryCssStart)); From 62b4021bb99e2425caa9a41834e5d2f7b3214119 Mon Sep 17 00:00:00 2001 From: albert Date: Thu, 30 Jul 2026 18:23:32 -0700 Subject: [PATCH 55/73] Add highlightWhenOpen property to BusyMaxMenuButton for improved menu visibility --- lib/src/app/busymax_design.dart | 4 +- .../presentation/schedule_sidebar.dart | 2 + linux/runner/my_application.cc | 22 +-------- test/app/busymax_menu_button_test.dart | 43 ++++++++++++++++ test/app/native_ui_audit_test.dart | 49 +++---------------- .../presentation/schedule_views_test.dart | 1 + .../linux_header_bar_service_test.dart | 2 +- 7 files changed, 57 insertions(+), 66 deletions(-) diff --git a/lib/src/app/busymax_design.dart b/lib/src/app/busymax_design.dart index 20977df..61ebab0 100644 --- a/lib/src/app/busymax_design.dart +++ b/lib/src/app/busymax_design.dart @@ -2811,6 +2811,7 @@ class BusyMaxMenuButton extends StatefulWidget { this.triggerBuilder, this.controller, this.enabled = true, + this.highlightWhenOpen = true, this.nativeMenuService = const NativeMenuService(), }); @@ -2821,6 +2822,7 @@ class BusyMaxMenuButton extends StatefulWidget { final BusyMaxMenuTriggerBuilder? triggerBuilder; final BusyMaxMenuController? controller; final bool enabled; + final bool highlightWhenOpen; final NativeMenuService nativeMenuService; @override @@ -2884,7 +2886,7 @@ class _BusyMaxMenuButtonState extends State> { tooltip: widget.tooltip, icon: widget.icon, focusNode: _triggerFocusNode, - isSelected: _menuOpen, + isSelected: widget.highlightWhenOpen && _menuOpen, onPressed: widget.enabled ? _toggleMenu : null, ), ); diff --git a/lib/src/features/schedule/presentation/schedule_sidebar.dart b/lib/src/features/schedule/presentation/schedule_sidebar.dart index 5027f93..fbf0b50 100644 --- a/lib/src/features/schedule/presentation/schedule_sidebar.dart +++ b/lib/src/features/schedule/presentation/schedule_sidebar.dart @@ -104,6 +104,7 @@ class _SourceRow extends ConsumerWidget { ), menuButton: BusyMaxMenuButton( tooltip: context.l10n.options, + highlightWhenOpen: false, onSelected: (value) { switch (value) { case 'refresh': @@ -502,6 +503,7 @@ class _TaskListScheduleRow extends ConsumerWidget { ), menuButton: BusyMaxMenuButton( tooltip: context.l10n.options, + highlightWhenOpen: false, onSelected: (value) { switch (value) { case 'refresh': diff --git a/linux/runner/my_application.cc b/linux/runner/my_application.cc index af9629e..7c06412 100644 --- a/linux/runner/my_application.cc +++ b/linux/runner/my_application.cc @@ -2161,25 +2161,6 @@ static void refresh_header_bar_css(MyApplication* self) { "}", kHeaderSearchEntryStyleClass) : g_strdup(""); - g_autofree gchar* native_menu_state_css = - !self->header_bar_high_contrast && - is_css_color_token(self->header_bar_menu_hover_color) - ? g_strdup_printf( - "popover.background.%s " - "modelbutton:hover:not(:disabled) {" - "background-color: %s;" - "background-image: none;" - "}" - "popover.background.%s " - "row:hover:not(:disabled) {" - "background-color: %s;" - "background-image: none;" - "}", - kNativePopoverStyleClass, - self->header_bar_menu_hover_color, - kNativePopoverStyleClass, - self->header_bar_menu_hover_color) - : g_strdup(""); g_autofree gchar* header_menu_shadow_css = use_legacy_yaru_compatibility ? g_strdup_printf( @@ -2446,7 +2427,6 @@ static void refresh_header_bar_css(MyApplication* self) { "}" "%s" "%s" - "%s" ".busymax-titlebar .%s," ".busymax-titlebar .%s:backdrop {" "background-color: %s;" @@ -2466,7 +2446,7 @@ static void refresh_header_bar_css(MyApplication* self) { kHeaderModalOpenStyleClass, kHeaderModalOpenStyleClass, kHeaderModalOpenStyleClass, kHeaderModalOpenStyleClass, kHeaderModalOpenStyleClass, kHeaderModalOpenStyleClass, - native_popover_css, native_menu_state_css, header_menu_shadow_css, + native_popover_css, header_menu_shadow_css, kHeaderModalBarrierStyleClass, kHeaderModalBarrierStyleClass, modal_barrier_color); diff --git a/test/app/busymax_menu_button_test.dart b/test/app/busymax_menu_button_test.dart index eccc9c9..6050f9b 100644 --- a/test/app/busymax_menu_button_test.dart +++ b/test/app/busymax_menu_button_test.dart @@ -177,6 +177,49 @@ void main() { expect(find.text('Open in provider'), findsNothing); }); + testWidgets('menu button can keep a neutral trigger while open', ( + tester, + ) async { + final controller = BusyMaxMenuController(); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler( + channel, + (_) async => throw MissingPluginException(), + ); + + await tester.pumpWidget( + localizedTestApp( + child: Scaffold( + body: BusyMaxMenuButton( + tooltip: 'Options', + controller: controller, + highlightWhenOpen: false, + nativeMenuService: const NativeMenuService(channel: channel), + entries: const [ + BusyMaxMenuEntry(value: 'refresh', label: 'Refresh'), + ], + onSelected: (_) {}, + ), + ), + ), + ); + + await tester.tap(find.byTooltip('Options')); + await tester.pumpAndSettle(); + + final trigger = tester.widget( + find.ancestor( + of: find.byTooltip('Options'), + matching: find.byType(YaruIconButton), + ), + ); + expect(controller.isOpen, isTrue); + expect(trigger.isSelected, isFalse); + + controller.close(); + await tester.pumpAndSettle(); + }); + testWidgets('menu button maps a native selected index to its domain value', ( tester, ) async { diff --git a/test/app/native_ui_audit_test.dart b/test/app/native_ui_audit_test.dart index a1100e0..1632bed 100644 --- a/test/app/native_ui_audit_test.dart +++ b/test/app/native_ui_audit_test.dart @@ -1310,6 +1310,8 @@ void main() { nativeMenu, contains('decorate_model_menu_shortcuts(session->popover'), ); + expect(runner, isNot(contains('modelbutton:hover'))); + expect(nativeMenu, isNot(contains('GTK_STATE_FLAG_PRELIGHT'))); expect(nativeMenu, contains('g_simple_action_set_enabled(')); expect(nativeMenu, contains('g_themed_icon_new(icon_name)')); expect(nativeMenu, contains('g_menu_item_set_icon(item, icon)')); @@ -1805,7 +1807,7 @@ void main() { 'g_autofree gchar* native_search_geometry_css =', ); final nativeSearchGeometryCssEnd = source.indexOf( - 'g_autofree gchar* native_menu_state_css =', + 'g_autofree gchar* header_menu_shadow_css =', nativeSearchGeometryCssStart, ); expect(nativeSearchGeometryCssStart, isNonNegative); @@ -1817,17 +1819,6 @@ void main() { nativeSearchGeometryCssStart, nativeSearchGeometryCssEnd, ); - final nativeMenuStateCssStart = nativeSearchGeometryCssEnd; - final nativeMenuStateCssEnd = source.indexOf( - 'g_autofree gchar* header_menu_shadow_css =', - nativeMenuStateCssStart, - ); - expect(nativeMenuStateCssStart, isNonNegative); - expect(nativeMenuStateCssEnd, greaterThan(nativeMenuStateCssStart)); - final nativeMenuStateCss = source.substring( - nativeMenuStateCssStart, - nativeMenuStateCssEnd, - ); final headerMenuShadowCssStart = source.indexOf( 'g_autofree gchar* header_menu_shadow_css =', ); @@ -2002,37 +1993,9 @@ void main() { expect(nativeSearchGeometryCss, isNot(contains('min-height'))); expect(nativeSearchGeometryCss, isNot(contains('#'))); expect(nativeSearchGeometryCss, isNot(contains('rgba('))); - expect(nativeMenuStateCss, contains('!self->header_bar_high_contrast')); - expect( - nativeMenuStateCss, - isNot(contains('use_legacy_yaru_compatibility')), - ); - expect( - nativeMenuStateCss, - contains('is_css_color_token(self->header_bar_menu_hover_color)'), - ); - expect( - nativeMenuStateCss, - contains( - '"popover.background.%s "\n' - ' ' - '"modelbutton:hover:not(:disabled) {"', - ), - ); - expect(nativeMenuStateCss, isNot(contains(':not(:backdrop)'))); - expect(nativeMenuStateCss, isNot(contains('modelbutton.flat'))); - expect(nativeMenuStateCss, contains('"background-color: %s;"')); - expect(nativeMenuStateCss, contains('"background-image: none;"')); - expect(nativeMenuStateCss, contains('self->header_bar_menu_hover_color')); - expect(nativeMenuStateCss, contains('kNativePopoverStyleClass')); - expect(nativeMenuStateCss, isNot(contains('border-radius'))); - expect(nativeMenuStateCss, isNot(contains('"border:'))); - expect(nativeMenuStateCss, isNot(contains('box-shadow'))); - expect(nativeMenuStateCss, isNot(contains('padding'))); - expect(nativeMenuStateCss, isNot(contains('margin'))); - expect(nativeMenuStateCss, isNot(contains('min-height'))); - expect(nativeMenuStateCss, isNot(contains('#'))); - expect(nativeMenuStateCss, isNot(contains('rgba('))); + expect(source, isNot(contains('native_menu_state_css'))); + expect(source, isNot(contains('modelbutton:hover'))); + expect(source, isNot(contains('row:hover:not(:disabled)'))); expect( headerMenuShadowCss, contains('"popover.background.%s.%s:not(:backdrop) {"'), diff --git a/test/features/schedule/presentation/schedule_views_test.dart b/test/features/schedule/presentation/schedule_views_test.dart index 15810dd..a157e2f 100644 --- a/test/features/schedule/presentation/schedule_views_test.dart +++ b/test/features/schedule/presentation/schedule_views_test.dart @@ -3304,6 +3304,7 @@ void main() { expect(sidebar, contains('visibilityButton: _SourceVisibilityButton')); expect(sidebar, contains('menuButton: BusyMaxMenuButton')); expect(sidebar, contains('tooltip: context.l10n.options')); + expect('highlightWhenOpen: false'.allMatches(sidebar), hasLength(2)); expect(sidebar, contains('value ? context.l10n.hide : context.l10n.show')); expect(sidebar, isNot(contains('tooltip: context.l10n.sourceCalendar'))); expect(sidebar, isNot(contains('tooltip: context.l10n.sourceTaskList'))); diff --git a/test/platform/linux_header_bar_service_test.dart b/test/platform/linux_header_bar_service_test.dart index f16c74e..e2b91fe 100644 --- a/test/platform/linux_header_bar_service_test.dart +++ b/test/platform/linux_header_bar_service_test.dart @@ -589,7 +589,7 @@ void main() { 'g_autofree gchar* native_search_geometry_css =', ); final geometryCssEnd = source.indexOf( - 'g_autofree gchar* native_menu_state_css =', + 'g_autofree gchar* header_menu_shadow_css =', geometryCssStart, ); From 43c4c3916fa1c527547adb0c7b65d888b730d117 Mon Sep 17 00:00:00 2001 From: albert Date: Thu, 30 Jul 2026 18:49:23 -0700 Subject: [PATCH 56/73] Refactor dialog handling in busymax_dialogs.dart to use Navigator for improved theme management and barrier color handling --- lib/src/app/busymax_dialogs.dart | 54 +++++++++++++++++++--- test/app/busymax_dialogs_test.dart | 72 ++++++++++++++++++++++++++++++ 2 files changed, 119 insertions(+), 7 deletions(-) diff --git a/lib/src/app/busymax_dialogs.dart b/lib/src/app/busymax_dialogs.dart index 9e41f66..647d704 100644 --- a/lib/src/app/busymax_dialogs.dart +++ b/lib/src/app/busymax_dialogs.dart @@ -81,16 +81,56 @@ Future _showBusyMaxFlutterDialog( Color? barrierColor, bool barrierDismissible = true, }) { - return showDialog( - context: context, - barrierColor: barrierColor ?? busyMaxModalBarrierColor(context), - barrierDismissible: barrierDismissible, - traversalEdgeBehavior: TraversalEdgeBehavior.closedLoop, - builder: (dialogContext) => - BusyMaxModalShortcutBoundary(child: builder(dialogContext)), + 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, + ), ); } +class _BusyMaxDialogRoute extends DialogRoute { + _BusyMaxDialogRoute({ + required super.context, + required WidgetBuilder builder, + required CapturedThemes themes, + required Color? fixedBarrierColor, + required Color initialBarrierColor, + required super.barrierDismissible, + }) : _fixedBarrierColor = fixedBarrierColor, + _initialBarrierColor = initialBarrierColor, + super( + builder: (dialogContext) => + BusyMaxModalShortcutBoundary(child: builder(dialogContext)), + themes: themes, + barrierColor: initialBarrierColor, + traversalEdgeBehavior: TraversalEdgeBehavior.closedLoop, + ); + + final Color? _fixedBarrierColor; + final Color _initialBarrierColor; + + /// Unlike [DialogRoute]'s constructor value, this getter is reevaluated + /// when the Navigator's inherited theme changes. + @override + Color? get barrierColor { + final fixedColor = _fixedBarrierColor; + if (fixedColor != null) { + return fixedColor; + } + final navigatorContext = navigator?.context; + return navigatorContext == null + ? _initialBarrierColor + : busyMaxModalBarrierColor(navigatorContext); + } +} + Future showBusyMaxModalEditorDialog( BuildContext context, { required WidgetBuilder builder, diff --git a/test/app/busymax_dialogs_test.dart b/test/app/busymax_dialogs_test.dart index c19f7e4..fe6c958 100644 --- a/test/app/busymax_dialogs_test.dart +++ b/test/app/busymax_dialogs_test.dart @@ -165,6 +165,78 @@ void main() { expect(barrierCalls.last.arguments, 0); }); + testWidgets('open modal barrier follows live theme changes', (tester) async { + const accent = Color(0xFF3584E4); + final lightTheme = BusyMaxYaruTheme.build( + brightness: Brightness.light, + accentColor: accent, + ); + final darkTheme = BusyMaxYaruTheme.build( + brightness: Brightness.dark, + accentColor: accent, + ); + final themeMode = ValueNotifier(ThemeMode.light); + addTearDown(themeMode.dispose); + late BuildContext hostContext; + + await tester.pumpWidget( + ValueListenableBuilder( + valueListenable: themeMode, + builder: (context, mode, child) { + return MaterialApp( + theme: lightTheme, + darkTheme: darkTheme, + themeMode: mode, + home: Builder( + builder: (context) { + hostContext = context; + return const SizedBox.shrink(); + }, + ), + ); + }, + ), + ); + + final result = showBusyMaxModalDialog( + hostContext, + builder: (context) => const Dialog(child: Text('Theme-aware dialog')), + ); + await tester.pumpAndSettle(); + + Color? currentBarrierColor() { + return tester + .widget(find.byType(AnimatedModalBarrier).last) + .color + .value; + } + + expect( + currentBarrierColor(), + lightTheme.extension()!.shade, + ); + + themeMode.value = ThemeMode.dark; + await tester.pumpAndSettle(); + + expect( + currentBarrierColor(), + darkTheme.extension()!.shade, + ); + + themeMode.value = ThemeMode.light; + await tester.pumpAndSettle(); + + expect( + currentBarrierColor(), + lightTheme.extension()!.shade, + ); + + Navigator.of(hostContext, rootNavigator: true).pop(); + await tester.pumpAndSettle(); + await result; + }); + testWidgets('confirmation scrolls in a short window at 2x text', ( tester, ) async { From 504f7582c5b59b92563e735c747d9da2807beb4a Mon Sep 17 00:00:00 2001 From: albert Date: Thu, 30 Jul 2026 19:19:30 -0700 Subject: [PATCH 57/73] Add native menu state CSS for improved hover effects in header bar --- linux/runner/my_application.cc | 22 +++++++++++++- test/app/native_ui_audit_test.dart | 49 ++++++++++++++++++++++++++---- 2 files changed, 64 insertions(+), 7 deletions(-) diff --git a/linux/runner/my_application.cc b/linux/runner/my_application.cc index 7c06412..af9629e 100644 --- a/linux/runner/my_application.cc +++ b/linux/runner/my_application.cc @@ -2161,6 +2161,25 @@ static void refresh_header_bar_css(MyApplication* self) { "}", kHeaderSearchEntryStyleClass) : g_strdup(""); + g_autofree gchar* native_menu_state_css = + !self->header_bar_high_contrast && + is_css_color_token(self->header_bar_menu_hover_color) + ? g_strdup_printf( + "popover.background.%s " + "modelbutton:hover:not(:disabled) {" + "background-color: %s;" + "background-image: none;" + "}" + "popover.background.%s " + "row:hover:not(:disabled) {" + "background-color: %s;" + "background-image: none;" + "}", + kNativePopoverStyleClass, + self->header_bar_menu_hover_color, + kNativePopoverStyleClass, + self->header_bar_menu_hover_color) + : g_strdup(""); g_autofree gchar* header_menu_shadow_css = use_legacy_yaru_compatibility ? g_strdup_printf( @@ -2427,6 +2446,7 @@ static void refresh_header_bar_css(MyApplication* self) { "}" "%s" "%s" + "%s" ".busymax-titlebar .%s," ".busymax-titlebar .%s:backdrop {" "background-color: %s;" @@ -2446,7 +2466,7 @@ static void refresh_header_bar_css(MyApplication* self) { kHeaderModalOpenStyleClass, kHeaderModalOpenStyleClass, kHeaderModalOpenStyleClass, kHeaderModalOpenStyleClass, kHeaderModalOpenStyleClass, kHeaderModalOpenStyleClass, - native_popover_css, header_menu_shadow_css, + native_popover_css, native_menu_state_css, header_menu_shadow_css, kHeaderModalBarrierStyleClass, kHeaderModalBarrierStyleClass, modal_barrier_color); diff --git a/test/app/native_ui_audit_test.dart b/test/app/native_ui_audit_test.dart index 1632bed..a1100e0 100644 --- a/test/app/native_ui_audit_test.dart +++ b/test/app/native_ui_audit_test.dart @@ -1310,8 +1310,6 @@ void main() { nativeMenu, contains('decorate_model_menu_shortcuts(session->popover'), ); - expect(runner, isNot(contains('modelbutton:hover'))); - expect(nativeMenu, isNot(contains('GTK_STATE_FLAG_PRELIGHT'))); expect(nativeMenu, contains('g_simple_action_set_enabled(')); expect(nativeMenu, contains('g_themed_icon_new(icon_name)')); expect(nativeMenu, contains('g_menu_item_set_icon(item, icon)')); @@ -1807,7 +1805,7 @@ void main() { 'g_autofree gchar* native_search_geometry_css =', ); final nativeSearchGeometryCssEnd = source.indexOf( - 'g_autofree gchar* header_menu_shadow_css =', + 'g_autofree gchar* native_menu_state_css =', nativeSearchGeometryCssStart, ); expect(nativeSearchGeometryCssStart, isNonNegative); @@ -1819,6 +1817,17 @@ void main() { nativeSearchGeometryCssStart, nativeSearchGeometryCssEnd, ); + final nativeMenuStateCssStart = nativeSearchGeometryCssEnd; + final nativeMenuStateCssEnd = source.indexOf( + 'g_autofree gchar* header_menu_shadow_css =', + nativeMenuStateCssStart, + ); + expect(nativeMenuStateCssStart, isNonNegative); + expect(nativeMenuStateCssEnd, greaterThan(nativeMenuStateCssStart)); + final nativeMenuStateCss = source.substring( + nativeMenuStateCssStart, + nativeMenuStateCssEnd, + ); final headerMenuShadowCssStart = source.indexOf( 'g_autofree gchar* header_menu_shadow_css =', ); @@ -1993,9 +2002,37 @@ void main() { expect(nativeSearchGeometryCss, isNot(contains('min-height'))); expect(nativeSearchGeometryCss, isNot(contains('#'))); expect(nativeSearchGeometryCss, isNot(contains('rgba('))); - expect(source, isNot(contains('native_menu_state_css'))); - expect(source, isNot(contains('modelbutton:hover'))); - expect(source, isNot(contains('row:hover:not(:disabled)'))); + expect(nativeMenuStateCss, contains('!self->header_bar_high_contrast')); + expect( + nativeMenuStateCss, + isNot(contains('use_legacy_yaru_compatibility')), + ); + expect( + nativeMenuStateCss, + contains('is_css_color_token(self->header_bar_menu_hover_color)'), + ); + expect( + nativeMenuStateCss, + contains( + '"popover.background.%s "\n' + ' ' + '"modelbutton:hover:not(:disabled) {"', + ), + ); + expect(nativeMenuStateCss, isNot(contains(':not(:backdrop)'))); + expect(nativeMenuStateCss, isNot(contains('modelbutton.flat'))); + expect(nativeMenuStateCss, contains('"background-color: %s;"')); + expect(nativeMenuStateCss, contains('"background-image: none;"')); + expect(nativeMenuStateCss, contains('self->header_bar_menu_hover_color')); + expect(nativeMenuStateCss, contains('kNativePopoverStyleClass')); + expect(nativeMenuStateCss, isNot(contains('border-radius'))); + expect(nativeMenuStateCss, isNot(contains('"border:'))); + expect(nativeMenuStateCss, isNot(contains('box-shadow'))); + expect(nativeMenuStateCss, isNot(contains('padding'))); + expect(nativeMenuStateCss, isNot(contains('margin'))); + expect(nativeMenuStateCss, isNot(contains('min-height'))); + expect(nativeMenuStateCss, isNot(contains('#'))); + expect(nativeMenuStateCss, isNot(contains('rgba('))); expect( headerMenuShadowCss, contains('"popover.background.%s.%s:not(:backdrop) {"'), From fa669061834fdc297b4c1f1d2617a23c3592ab6c Mon Sep 17 00:00:00 2001 From: albert Date: Thu, 30 Jul 2026 19:33:39 -0700 Subject: [PATCH 58/73] Add discardChangesAction localization for multiple languages. Update confirmLabel for discard changes dialogs to improve clarity --- lib/l10n/app_ar.arb | 1 + lib/l10n/app_de.arb | 1 + lib/l10n/app_en.arb | 4 +++ lib/l10n/app_es.arb | 1 + lib/l10n/app_et.arb | 1 + lib/l10n/app_fa.arb | 1 + lib/l10n/app_fi.arb | 1 + lib/l10n/app_fr.arb | 1 + lib/l10n/app_hi.arb | 1 + lib/l10n/app_it.arb | 1 + lib/l10n/app_ja.arb | 1 + lib/l10n/app_ko.arb | 1 + lib/l10n/app_pt.arb | 1 + lib/l10n/app_ru.arb | 1 + lib/l10n/app_vi.arb | 1 + lib/l10n/app_zh.arb | 1 + lib/l10n/app_zh_Hans.arb | 1 + lib/l10n/app_zh_Hant.arb | 1 + lib/l10n/generated/app_localizations.dart | 6 ++++ lib/l10n/generated/app_localizations_ar.dart | 3 ++ lib/l10n/generated/app_localizations_de.dart | 3 ++ lib/l10n/generated/app_localizations_en.dart | 3 ++ lib/l10n/generated/app_localizations_es.dart | 3 ++ lib/l10n/generated/app_localizations_et.dart | 3 ++ lib/l10n/generated/app_localizations_fa.dart | 3 ++ lib/l10n/generated/app_localizations_fi.dart | 3 ++ lib/l10n/generated/app_localizations_fr.dart | 3 ++ lib/l10n/generated/app_localizations_hi.dart | 3 ++ lib/l10n/generated/app_localizations_it.dart | 3 ++ lib/l10n/generated/app_localizations_ja.dart | 3 ++ lib/l10n/generated/app_localizations_ko.dart | 3 ++ lib/l10n/generated/app_localizations_pt.dart | 3 ++ lib/l10n/generated/app_localizations_ru.dart | 3 ++ lib/l10n/generated/app_localizations_vi.dart | 3 ++ lib/l10n/generated/app_localizations_zh.dart | 9 +++++ .../calendar/presentation/event_editor.dart | 2 +- .../presentation/feedback_dialog.dart | 2 +- .../presentation/schedule_workspace.dart | 2 +- .../presentation/task_details_editor.dart | 4 +-- .../tasks/presentation/task_details_pane.dart | 2 +- test/app/localization_audit_test.dart | 21 +++++++++++ .../presentation/feedback_dialog_test.dart | 36 +++++++++++++++++++ 42 files changed, 144 insertions(+), 6 deletions(-) diff --git a/lib/l10n/app_ar.arb b/lib/l10n/app_ar.arb index e6ababa..cbaa7b0 100644 --- a/lib/l10n/app_ar.arb +++ b/lib/l10n/app_ar.arb @@ -359,6 +359,7 @@ "pendingOpAttempts": "المحاولات=\u2068{count}\u2069", "retry": "إعادة المحاولة", "discard": "تجاهل", + "discardChangesAction": "تجاهل التغييرات", "discardChanges": "تجاهل التغييرات؟", "discardChangesConfirmation": "سيؤدي ذلك إلى تجاهل التعديلات غير المحفوظة على هذه المهمة.", "retryCompleted": "اكتملت إعادة المحاولة.", diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index a7e2716..804258c 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -365,6 +365,7 @@ "pendingOpAttempts": "Versuche={count}", "retry": "Erneut versuchen", "discard": "Verwerfen", + "discardChangesAction": "Verwerfen", "discardChanges": "Änderungen verwerfen?", "discardChangesConfirmation": "Dies verwirft ungespeicherte Änderungen an dieser Aufgabe.", "retryCompleted": "Erneuter Versuch abgeschlossen.", diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index b49ee6d..ff0a6c0 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -384,6 +384,10 @@ "@pendingOpAttempts": {"placeholders": {"count": {"type": "int"}}}, "retry": "Retry", "discard": "Discard", + "discardChangesAction": "Discard", + "@discardChangesAction": { + "description": "Destructive confirmation button that throws away unsaved edits. Translate it distinctly from Cancel." + }, "discardChanges": "Discard changes?", "discardChangesConfirmation": "This discards unsaved edits to this task.", "retryCompleted": "Retry completed.", diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index 1cf0705..eaa9412 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -365,6 +365,7 @@ "pendingOpAttempts": "intentos={count}", "retry": "Reintentar", "discard": "Descartar", + "discardChangesAction": "Descartar", "discardChanges": "¿Descartar cambios?", "discardChangesConfirmation": "Esto descarta las ediciones no guardadas de esta tarea.", "retryCompleted": "Reintento completado.", diff --git a/lib/l10n/app_et.arb b/lib/l10n/app_et.arb index 848f0c7..16ab28c 100644 --- a/lib/l10n/app_et.arb +++ b/lib/l10n/app_et.arb @@ -384,6 +384,7 @@ "@pendingOpAttempts": {"placeholders": {"count": {"type": "int"}}}, "retry": "Proovi uuesti", "discard": "Hülga", + "discardChangesAction": "Hülga", "discardChanges": "Kas hüljata muudatused?", "discardChangesConfirmation": "See hülgab ülesande salvestamata muudatused.", "retryCompleted": "Uuesti proovimine lõpetatud.", diff --git a/lib/l10n/app_fa.arb b/lib/l10n/app_fa.arb index 6f52eaa..c812170 100644 --- a/lib/l10n/app_fa.arb +++ b/lib/l10n/app_fa.arb @@ -359,6 +359,7 @@ "pendingOpAttempts": "تلاش‌ها=\u2068{count}\u2069", "retry": "تلاش دوباره", "discard": "کنار گذاشتن", + "discardChangesAction": "ذخیره نشود", "discardChanges": "تغییرات کنار گذاشته شوند؟", "discardChangesConfirmation": "با این کار ویرایش‌های ذخیره‌نشدهٔ این کار کنار گذاشته می‌شوند.", "retryCompleted": "تلاش دوباره کامل شد.", diff --git a/lib/l10n/app_fi.arb b/lib/l10n/app_fi.arb index bfe86fa..006e0de 100644 --- a/lib/l10n/app_fi.arb +++ b/lib/l10n/app_fi.arb @@ -359,6 +359,7 @@ "pendingOpAttempts": "yritykset={count}", "retry": "Yritä uudelleen", "discard": "Hylkää", + "discardChangesAction": "Hylkää", "discardChanges": "Hylätäänkö muutokset?", "discardChangesConfirmation": "Tämä hylkää tehtävän tallentamattomat muutokset.", "retryCompleted": "Uudelleenyritys suoritettu.", diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index dab2ebc..bae2300 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -365,6 +365,7 @@ "pendingOpAttempts": "tentatives={count}", "retry": "Réessayer", "discard": "Abandonner", + "discardChangesAction": "Abandonner", "discardChanges": "Abandonner les modifications ?", "discardChangesConfirmation": "Les modifications non enregistrées apportées à cette tâche seront perdues.", "retryCompleted": "Nouvelle tentative terminée.", diff --git a/lib/l10n/app_hi.arb b/lib/l10n/app_hi.arb index 5b5d1cd..883045a 100644 --- a/lib/l10n/app_hi.arb +++ b/lib/l10n/app_hi.arb @@ -359,6 +359,7 @@ "pendingOpAttempts": "प्रयास={count}", "retry": "फिर से कोशिश करें", "discard": "खारिज करें", + "discardChangesAction": "बदलाव छोड़ें", "discardChanges": "बदलाव खारिज करें?", "discardChangesConfirmation": "इससे इस कार्य में किए गए सहेजे न गए बदलाव खारिज हो जाएँगे।", "retryCompleted": "दोबारा प्रयास पूरा हुआ।", diff --git a/lib/l10n/app_it.arb b/lib/l10n/app_it.arb index feafd1c..e69a9d3 100644 --- a/lib/l10n/app_it.arb +++ b/lib/l10n/app_it.arb @@ -359,6 +359,7 @@ "pendingOpAttempts": "tentativi={count}", "retry": "Riprova", "discard": "Scarta", + "discardChangesAction": "Scarta", "discardChanges": "Scartare le modifiche?", "discardChangesConfirmation": "Questa azione scarta le modifiche non salvate dell’attività.", "retryCompleted": "Nuovo tentativo completato.", diff --git a/lib/l10n/app_ja.arb b/lib/l10n/app_ja.arb index 4328798..eb93d36 100644 --- a/lib/l10n/app_ja.arb +++ b/lib/l10n/app_ja.arb @@ -359,6 +359,7 @@ "pendingOpAttempts": "試行回数={count}", "retry": "再試行", "discard": "破棄", + "discardChangesAction": "破棄", "discardChanges": "変更を破棄しますか?", "discardChangesConfirmation": "このタスクの未保存の編集内容を破棄します。", "retryCompleted": "再試行が完了しました。", diff --git a/lib/l10n/app_ko.arb b/lib/l10n/app_ko.arb index e965586..ffbd472 100644 --- a/lib/l10n/app_ko.arb +++ b/lib/l10n/app_ko.arb @@ -359,6 +359,7 @@ "pendingOpAttempts": "시도={count}", "retry": "다시 시도", "discard": "버리기", + "discardChangesAction": "변경 사항 버리기", "discardChanges": "변경 사항을 버릴까요?", "discardChangesConfirmation": "이 할 일에서 저장하지 않은 편집 내용을 버립니다.", "retryCompleted": "다시 시도했습니다.", diff --git a/lib/l10n/app_pt.arb b/lib/l10n/app_pt.arb index 45ccce6..e56c577 100644 --- a/lib/l10n/app_pt.arb +++ b/lib/l10n/app_pt.arb @@ -359,6 +359,7 @@ "pendingOpAttempts": "tentativas={count}", "retry": "Tentar novamente", "discard": "Descartar", + "discardChangesAction": "Descartar", "discardChanges": "Descartar alterações?", "discardChangesConfirmation": "Esta ação descarta as alterações não guardadas nesta tarefa.", "retryCompleted": "Nova tentativa concluída.", diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index ae2e94f..4479367 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -359,6 +359,7 @@ "pendingOpAttempts": "попытки={count}", "retry": "Повторить", "discard": "Отменить", + "discardChangesAction": "Не сохранять", "discardChanges": "Отменить изменения?", "discardChangesConfirmation": "Несохранённые изменения этой задачи будут отменены.", "retryCompleted": "Повторная попытка завершена.", diff --git a/lib/l10n/app_vi.arb b/lib/l10n/app_vi.arb index 1e813c6..2ca8c02 100644 --- a/lib/l10n/app_vi.arb +++ b/lib/l10n/app_vi.arb @@ -359,6 +359,7 @@ "pendingOpAttempts": "số_lần_thử={count}", "retry": "Thử lại", "discard": "Hủy bỏ", + "discardChangesAction": "Không lưu", "discardChanges": "Hủy bỏ thay đổi?", "discardChangesConfirmation": "Thao tác này sẽ hủy bỏ các chỉnh sửa chưa lưu đối với công việc.", "retryCompleted": "Đã thử lại.", diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 489af3f..0d2dd64 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -359,6 +359,7 @@ "pendingOpAttempts": "尝试次数={count}", "retry": "重试", "discard": "舍弃", + "discardChangesAction": "舍弃", "discardChanges": "舍弃更改?", "discardChangesConfirmation": "这将舍弃对此任务所做的未保存编辑。", "retryCompleted": "重试完成。", diff --git a/lib/l10n/app_zh_Hans.arb b/lib/l10n/app_zh_Hans.arb index a086904..d2812f2 100644 --- a/lib/l10n/app_zh_Hans.arb +++ b/lib/l10n/app_zh_Hans.arb @@ -359,6 +359,7 @@ "pendingOpAttempts": "尝试次数={count}", "retry": "重试", "discard": "舍弃", + "discardChangesAction": "舍弃", "discardChanges": "舍弃更改?", "discardChangesConfirmation": "这将舍弃对此任务所做的未保存编辑。", "retryCompleted": "重试完成。", diff --git a/lib/l10n/app_zh_Hant.arb b/lib/l10n/app_zh_Hant.arb index 519b41c..1139d09 100644 --- a/lib/l10n/app_zh_Hant.arb +++ b/lib/l10n/app_zh_Hant.arb @@ -359,6 +359,7 @@ "pendingOpAttempts": "嘗試次數={count}", "retry": "再試一次", "discard": "捨棄", + "discardChangesAction": "捨棄", "discardChanges": "要捨棄變更嗎?", "discardChangesConfirmation": "這會捨棄此待辦事項中尚未儲存的編輯內容。", "retryCompleted": "重試完成。", diff --git a/lib/l10n/generated/app_localizations.dart b/lib/l10n/generated/app_localizations.dart index 4db1bf6..8889a34 100644 --- a/lib/l10n/generated/app_localizations.dart +++ b/lib/l10n/generated/app_localizations.dart @@ -2286,6 +2286,12 @@ abstract class AppLocalizations { /// **'Discard'** String get discard; + /// Destructive confirmation button that throws away unsaved edits. Translate it distinctly from Cancel. + /// + /// In en, this message translates to: + /// **'Discard'** + String get discardChangesAction; + /// No description provided for @discardChanges. /// /// 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 9a7c994..dfc8593 100644 --- a/lib/l10n/generated/app_localizations_ar.dart +++ b/lib/l10n/generated/app_localizations_ar.dart @@ -1194,6 +1194,9 @@ class AppLocalizationsAr extends AppLocalizations { @override String get discard => 'تجاهل'; + @override + String get discardChangesAction => 'تجاهل التغييرات'; + @override String get discardChanges => 'تجاهل التغييرات؟'; diff --git a/lib/l10n/generated/app_localizations_de.dart b/lib/l10n/generated/app_localizations_de.dart index 984c448..1f913cd 100644 --- a/lib/l10n/generated/app_localizations_de.dart +++ b/lib/l10n/generated/app_localizations_de.dart @@ -1199,6 +1199,9 @@ class AppLocalizationsDe extends AppLocalizations { @override String get discard => 'Verwerfen'; + @override + String get discardChangesAction => 'Verwerfen'; + @override String get discardChanges => 'Änderungen verwerfen?'; diff --git a/lib/l10n/generated/app_localizations_en.dart b/lib/l10n/generated/app_localizations_en.dart index c73fe6c..5da3621 100644 --- a/lib/l10n/generated/app_localizations_en.dart +++ b/lib/l10n/generated/app_localizations_en.dart @@ -1183,6 +1183,9 @@ class AppLocalizationsEn extends AppLocalizations { @override String get discard => 'Discard'; + @override + String get discardChangesAction => 'Discard'; + @override String get discardChanges => 'Discard changes?'; diff --git a/lib/l10n/generated/app_localizations_es.dart b/lib/l10n/generated/app_localizations_es.dart index b9618bd..f969674 100644 --- a/lib/l10n/generated/app_localizations_es.dart +++ b/lib/l10n/generated/app_localizations_es.dart @@ -1200,6 +1200,9 @@ class AppLocalizationsEs extends AppLocalizations { @override String get discard => 'Descartar'; + @override + String get discardChangesAction => 'Descartar'; + @override String get discardChanges => '¿Descartar cambios?'; diff --git a/lib/l10n/generated/app_localizations_et.dart b/lib/l10n/generated/app_localizations_et.dart index a28760d..288e8a5 100644 --- a/lib/l10n/generated/app_localizations_et.dart +++ b/lib/l10n/generated/app_localizations_et.dart @@ -1192,6 +1192,9 @@ class AppLocalizationsEt extends AppLocalizations { @override String get discard => 'Hülga'; + @override + String get discardChangesAction => 'Hülga'; + @override String get discardChanges => 'Kas hüljata muudatused?'; diff --git a/lib/l10n/generated/app_localizations_fa.dart b/lib/l10n/generated/app_localizations_fa.dart index 331150b..e2e0ce9 100644 --- a/lib/l10n/generated/app_localizations_fa.dart +++ b/lib/l10n/generated/app_localizations_fa.dart @@ -1212,6 +1212,9 @@ class AppLocalizationsFa extends AppLocalizations { @override String get discard => 'کنار گذاشتن'; + @override + String get discardChangesAction => 'ذخیره نشود'; + @override String get discardChanges => 'تغییرات کنار گذاشته شوند؟'; diff --git a/lib/l10n/generated/app_localizations_fi.dart b/lib/l10n/generated/app_localizations_fi.dart index 28183d9..14b9056 100644 --- a/lib/l10n/generated/app_localizations_fi.dart +++ b/lib/l10n/generated/app_localizations_fi.dart @@ -1195,6 +1195,9 @@ class AppLocalizationsFi extends AppLocalizations { @override String get discard => 'Hylkää'; + @override + String get discardChangesAction => 'Hylkää'; + @override String get discardChanges => 'Hylätäänkö muutokset?'; diff --git a/lib/l10n/generated/app_localizations_fr.dart b/lib/l10n/generated/app_localizations_fr.dart index 05d8078..1e38f57 100644 --- a/lib/l10n/generated/app_localizations_fr.dart +++ b/lib/l10n/generated/app_localizations_fr.dart @@ -1197,6 +1197,9 @@ class AppLocalizationsFr extends AppLocalizations { @override String get discard => 'Abandonner'; + @override + String get discardChangesAction => 'Abandonner'; + @override String get discardChanges => 'Abandonner les modifications ?'; diff --git a/lib/l10n/generated/app_localizations_hi.dart b/lib/l10n/generated/app_localizations_hi.dart index 3836a4b..6ae4f75 100644 --- a/lib/l10n/generated/app_localizations_hi.dart +++ b/lib/l10n/generated/app_localizations_hi.dart @@ -1192,6 +1192,9 @@ class AppLocalizationsHi extends AppLocalizations { @override String get discard => 'खारिज करें'; + @override + String get discardChangesAction => 'बदलाव छोड़ें'; + @override String get discardChanges => 'बदलाव खारिज करें?'; diff --git a/lib/l10n/generated/app_localizations_it.dart b/lib/l10n/generated/app_localizations_it.dart index 5caf694..39c34ea 100644 --- a/lib/l10n/generated/app_localizations_it.dart +++ b/lib/l10n/generated/app_localizations_it.dart @@ -1199,6 +1199,9 @@ class AppLocalizationsIt extends AppLocalizations { @override String get discard => 'Scarta'; + @override + String get discardChangesAction => 'Scarta'; + @override String get discardChanges => 'Scartare le modifiche?'; diff --git a/lib/l10n/generated/app_localizations_ja.dart b/lib/l10n/generated/app_localizations_ja.dart index f70562e..325c71f 100644 --- a/lib/l10n/generated/app_localizations_ja.dart +++ b/lib/l10n/generated/app_localizations_ja.dart @@ -1165,6 +1165,9 @@ class AppLocalizationsJa extends AppLocalizations { @override String get discard => '破棄'; + @override + String get discardChangesAction => '破棄'; + @override String get discardChanges => '変更を破棄しますか?'; diff --git a/lib/l10n/generated/app_localizations_ko.dart b/lib/l10n/generated/app_localizations_ko.dart index 78517da..1c22812 100644 --- a/lib/l10n/generated/app_localizations_ko.dart +++ b/lib/l10n/generated/app_localizations_ko.dart @@ -1165,6 +1165,9 @@ class AppLocalizationsKo extends AppLocalizations { @override String get discard => '버리기'; + @override + String get discardChangesAction => '변경 사항 버리기'; + @override String get discardChanges => '변경 사항을 버릴까요?'; diff --git a/lib/l10n/generated/app_localizations_pt.dart b/lib/l10n/generated/app_localizations_pt.dart index 019cddd..4cbde97 100644 --- a/lib/l10n/generated/app_localizations_pt.dart +++ b/lib/l10n/generated/app_localizations_pt.dart @@ -1198,6 +1198,9 @@ class AppLocalizationsPt extends AppLocalizations { @override String get discard => 'Descartar'; + @override + String get discardChangesAction => 'Descartar'; + @override String get discardChanges => 'Descartar alterações?'; diff --git a/lib/l10n/generated/app_localizations_ru.dart b/lib/l10n/generated/app_localizations_ru.dart index 4b8d8c5..e0fd678 100644 --- a/lib/l10n/generated/app_localizations_ru.dart +++ b/lib/l10n/generated/app_localizations_ru.dart @@ -1198,6 +1198,9 @@ class AppLocalizationsRu extends AppLocalizations { @override String get discard => 'Отменить'; + @override + String get discardChangesAction => 'Не сохранять'; + @override String get discardChanges => 'Отменить изменения?'; diff --git a/lib/l10n/generated/app_localizations_vi.dart b/lib/l10n/generated/app_localizations_vi.dart index 1ce4a97..030c8c0 100644 --- a/lib/l10n/generated/app_localizations_vi.dart +++ b/lib/l10n/generated/app_localizations_vi.dart @@ -1191,6 +1191,9 @@ class AppLocalizationsVi extends AppLocalizations { @override String get discard => 'Hủy bỏ'; + @override + String get discardChangesAction => 'Không lưu'; + @override String get discardChanges => 'Hủy bỏ thay đổi?'; diff --git a/lib/l10n/generated/app_localizations_zh.dart b/lib/l10n/generated/app_localizations_zh.dart index 544ab6b..b4497bd 100644 --- a/lib/l10n/generated/app_localizations_zh.dart +++ b/lib/l10n/generated/app_localizations_zh.dart @@ -1156,6 +1156,9 @@ class AppLocalizationsZh extends AppLocalizations { @override String get discard => '舍弃'; + @override + String get discardChangesAction => '舍弃'; + @override String get discardChanges => '舍弃更改?'; @@ -2426,6 +2429,9 @@ class AppLocalizationsZhHans extends AppLocalizationsZh { @override String get discard => '舍弃'; + @override + String get discardChangesAction => '舍弃'; + @override String get discardChanges => '舍弃更改?'; @@ -3697,6 +3703,9 @@ class AppLocalizationsZhHant extends AppLocalizationsZh { @override String get discard => '捨棄'; + @override + String get discardChangesAction => '捨棄'; + @override String get discardChanges => '要捨棄變更嗎?'; diff --git a/lib/src/features/calendar/presentation/event_editor.dart b/lib/src/features/calendar/presentation/event_editor.dart index 098a82f..0779504 100644 --- a/lib/src/features/calendar/presentation/event_editor.dart +++ b/lib/src/features/calendar/presentation/event_editor.dart @@ -356,7 +356,7 @@ class _EventEditorState extends State { context, title: context.l10n.discardChanges, message: context.l10n.discardChangesConfirmation, - confirmLabel: context.l10n.discard, + confirmLabel: context.l10n.discardChangesAction, destructive: true, headerBarService: widget.headerBarService, ); diff --git a/lib/src/features/feedback/presentation/feedback_dialog.dart b/lib/src/features/feedback/presentation/feedback_dialog.dart index 8e1759f..f2e12d6 100644 --- a/lib/src/features/feedback/presentation/feedback_dialog.dart +++ b/lib/src/features/feedback/presentation/feedback_dialog.dart @@ -273,7 +273,7 @@ class _BusyMaxFeedbackDialogState extends State { context, title: context.l10n.discardChanges, message: context.l10n.discardChangesConfirmation, - confirmLabel: context.l10n.discard, + confirmLabel: context.l10n.discardChangesAction, destructive: true, barrierColor: Colors.transparent, headerBarService: widget.headerBarService, diff --git a/lib/src/features/schedule/presentation/schedule_workspace.dart b/lib/src/features/schedule/presentation/schedule_workspace.dart index 4268f27..fe97837 100644 --- a/lib/src/features/schedule/presentation/schedule_workspace.dart +++ b/lib/src/features/schedule/presentation/schedule_workspace.dart @@ -1816,7 +1816,7 @@ class _ScheduleWorkspaceState extends ConsumerState { context, title: context.l10n.discardChanges, message: context.l10n.discardChangesConfirmation, - confirmLabel: context.l10n.discard, + confirmLabel: context.l10n.discardChangesAction, destructive: true, barrierColor: Colors.transparent, headerBarService: ref.read(linuxHeaderBarServiceProvider), diff --git a/lib/src/features/tasks/presentation/task_details_editor.dart b/lib/src/features/tasks/presentation/task_details_editor.dart index 597997a..e4f210d 100644 --- a/lib/src/features/tasks/presentation/task_details_editor.dart +++ b/lib/src/features/tasks/presentation/task_details_editor.dart @@ -658,7 +658,7 @@ class _TaskDetailsEditorState extends State { context, title: context.l10n.discardChanges, message: context.l10n.discardChangesConfirmation, - confirmLabel: context.l10n.discard, + confirmLabel: context.l10n.discardChangesAction, destructive: true, barrierColor: widget.dialogBarrierColor, headerBarService: widget.headerBarService, @@ -681,7 +681,7 @@ class _TaskDetailsEditorState extends State { context, title: context.l10n.discardChanges, message: context.l10n.discardChangesConfirmation, - confirmLabel: context.l10n.discard, + confirmLabel: context.l10n.discardChangesAction, destructive: true, barrierColor: widget.dialogBarrierColor, headerBarService: widget.headerBarService, diff --git a/lib/src/features/tasks/presentation/task_details_pane.dart b/lib/src/features/tasks/presentation/task_details_pane.dart index ccc7166..130ed80 100644 --- a/lib/src/features/tasks/presentation/task_details_pane.dart +++ b/lib/src/features/tasks/presentation/task_details_pane.dart @@ -396,7 +396,7 @@ class _TaskDetailsPaneState extends ConsumerState { context, title: context.l10n.discardChanges, message: context.l10n.discardChangesConfirmation, - confirmLabel: context.l10n.discard, + confirmLabel: context.l10n.discardChangesAction, destructive: true, ); if (!mounted) { diff --git a/test/app/localization_audit_test.dart b/test/app/localization_audit_test.dart index 65de817..25d3558 100644 --- a/test/app/localization_audit_test.dart +++ b/test/app/localization_audit_test.dart @@ -66,6 +66,27 @@ void main() { expect(failures, isEmpty, reason: failures.join('\n')); }); + test('discard-changes action is distinct from cancel in every locale', () { + for (final locale in AppLocalizations.supportedLocales) { + final localizations = lookupAppLocalizations(locale); + expect( + localizations.discardChangesAction, + isNot(localizations.cancel), + reason: + '${locale.toLanguageTag()} must not translate Discard as Cancel', + ); + } + + expect( + lookupAppLocalizations(const Locale('ru')).discardChangesAction, + 'Не сохранять', + ); + expect( + lookupAppLocalizations(const Locale('vi')).discardChangesAction, + 'Không lưu', + ); + }); + test('Linux package metadata matches every translated catalog', () { final failures = _metadataTranslationFailures().toList(); diff --git a/test/features/feedback/presentation/feedback_dialog_test.dart b/test/features/feedback/presentation/feedback_dialog_test.dart index a302f54..f6d453e 100644 --- a/test/features/feedback/presentation/feedback_dialog_test.dart +++ b/test/features/feedback/presentation/feedback_dialog_test.dart @@ -254,6 +254,40 @@ void main() { expect(cancelCount, 1); }); + testWidgets('Russian discard action is distinct from cancel', (tester) async { + final service = _FakeFeedbackService((_) async { + return const FeedbackReceipt(id: 'unexpected'); + }); + var cancelCount = 0; + await _pumpDialog( + tester, + service, + locale: const Locale('ru'), + onCancel: () => cancelCount += 1, + ); + await tester.enterText( + find.byKey(const Key('feedback-subject')), + 'Черновик', + ); + + await tester.sendKeyEvent(LogicalKeyboardKey.escape); + await tester.pumpAndSettle(); + + final confirmation = find.byType(BusyMaxConfirmDialog); + expect( + find.descendant(of: confirmation, matching: find.text('Отмена')), + findsOneWidget, + ); + expect( + find.descendant(of: confirmation, matching: find.text('Не сохранять')), + findsOneWidget, + ); + + await tester.tap(find.text('Не сохранять')); + await tester.pumpAndSettle(); + expect(cancelCount, 1); + }); + testWidgets('clears the form and shows the server reference on success', ( tester, ) async { @@ -430,10 +464,12 @@ Future _pumpDialog( FeedbackSubmissionService service, { FeedbackSubmissionIdGenerator? submissionIdGenerator, String osVersion = 'Test Linux', + Locale locale = const Locale('en'), VoidCallback? onCancel, }) async { await tester.pumpWidget( localizedTestApp( + locale: locale, child: Scaffold( body: Center( child: SizedBox( From 622f6176cbeb135fee5af1c692fa246deebd64b5 Mon Sep 17 00:00:00 2001 From: albert Date: Thu, 30 Jul 2026 19:46:10 -0700 Subject: [PATCH 59/73] Add highlightSelectedDateOutsideMonth property to mini calendar for improved date selection visibility --- .../schedule/presentation/mini_calendar.dart | 17 +++++- .../presentation/schedule_views_test.dart | 61 ++++++++++++++++++- 2 files changed, 74 insertions(+), 4 deletions(-) diff --git a/lib/src/features/schedule/presentation/mini_calendar.dart b/lib/src/features/schedule/presentation/mini_calendar.dart index 427c6e2..7139eee 100644 --- a/lib/src/features/schedule/presentation/mini_calendar.dart +++ b/lib/src/features/schedule/presentation/mini_calendar.dart @@ -179,6 +179,7 @@ class YearMonthMiniCalendar extends StatelessWidget { displayedMonth: month, selectedDate: selectedDate, firstWeekday: firstWeekday, + highlightSelectedDateOutsideMonth: false, markerColorsByDay: markerColorsByDay, onDaySelected: onDaySelected, onWeekSelected: onWeekSelected, @@ -199,6 +200,7 @@ class MiniCalendarGrid extends StatelessWidget { required this.firstWeekday, required this.onDaySelected, this.markerColorsByDay = const {}, + this.highlightSelectedDateOutsideMonth = true, this.onWeekSelected, this.onDayDoubleTap, }); @@ -207,6 +209,7 @@ class MiniCalendarGrid extends StatelessWidget { final DateTime selectedDate; final int firstWeekday; final Map> markerColorsByDay; + final bool highlightSelectedDateOutsideMonth; final ValueChanged onDaySelected; final ValueChanged? onWeekSelected; final ValueChanged? onDayDoubleTap; @@ -271,6 +274,8 @@ class MiniCalendarGrid extends StatelessWidget { onWeekSelected: onWeekSelected, selectedDate: selectedDate, displayedMonth: month, + highlightSelectedDateOutsideMonth: + highlightSelectedDateOutsideMonth, markerColorsByDay: markerColorsByDay, onDaySelected: onDaySelected, onDayDoubleTap: onDayDoubleTap, @@ -306,6 +311,7 @@ class _MiniCalendarWeekRow extends StatelessWidget { required this.onWeekSelected, required this.selectedDate, required this.displayedMonth, + required this.highlightSelectedDateOutsideMonth, required this.markerColorsByDay, required this.onDaySelected, required this.onDayDoubleTap, @@ -316,6 +322,7 @@ class _MiniCalendarWeekRow extends StatelessWidget { final ValueChanged? onWeekSelected; final DateTime selectedDate; final DateTime displayedMonth; + final bool highlightSelectedDateOutsideMonth; final Map> markerColorsByDay; final ValueChanged onDaySelected; final ValueChanged? onDayDoubleTap; @@ -339,6 +346,8 @@ class _MiniCalendarWeekRow extends StatelessWidget { day: _addCalendarDays(weekStart, column), selectedDate: selectedDate, displayedMonth: displayedMonth, + highlightSelectedDateOutsideMonth: + highlightSelectedDateOutsideMonth, markerColorsByDay: markerColorsByDay, onSelected: onDaySelected, onDoubleTap: onDayDoubleTap, @@ -411,6 +420,7 @@ class _MiniCalendarDayButton extends StatefulWidget { required this.day, required this.selectedDate, required this.displayedMonth, + required this.highlightSelectedDateOutsideMonth, required this.markerColorsByDay, required this.onSelected, required this.onDoubleTap, @@ -419,6 +429,7 @@ class _MiniCalendarDayButton extends StatefulWidget { final DateTime day; final DateTime selectedDate; final DateTime displayedMonth; + final bool highlightSelectedDateOutsideMonth; final Map> markerColorsByDay; final ValueChanged onSelected; final ValueChanged? onDoubleTap; @@ -437,11 +448,13 @@ class _MiniCalendarDayButtonState extends State<_MiniCalendarDayButton> { final onSelected = widget.onSelected; final colorScheme = Theme.of(context).colorScheme; final surfaceColors = BusyMaxSurfaceColors.of(context); - final selected = _sameDay(day, selectedDate); - final today = _sameDay(day, DateTime.now()); final inDisplayedMonth = day.year == widget.displayedMonth.year && day.month == widget.displayedMonth.month; + final selected = + _sameDay(day, selectedDate) && + (inDisplayedMonth || widget.highlightSelectedDateOutsideMonth); + final today = _sameDay(day, DateTime.now()); final displayingCurrentMonth = widget.displayedMonth.year == DateTime.now().year && widget.displayedMonth.month == DateTime.now().month; diff --git a/test/features/schedule/presentation/schedule_views_test.dart b/test/features/schedule/presentation/schedule_views_test.dart index a157e2f..ec44a52 100644 --- a/test/features/schedule/presentation/schedule_views_test.dart +++ b/test/features/schedule/presentation/schedule_views_test.dart @@ -38,7 +38,7 @@ void main() { expect(workspace, contains("ValueKey('schedule-week-planner')")); }); - testWidgets('year view uses one month column at narrow desktop widths', ( + testWidgets('year view uses one month column in compact narrow layouts', ( tester, ) async { final selectedDate = DateTime(2026, 1, 15); @@ -47,7 +47,7 @@ void main() { localizedTestApp( child: Scaffold( body: SizedBox( - width: 500, + width: 420, height: 720, child: ScheduleYearView( selectedDate: selectedDate, @@ -57,6 +57,7 @@ void main() { onMonthSelected: (_) {}, onWeekSelected: (_) {}, onCreateAtDay: (_) {}, + compact: true, ), ), ), @@ -72,6 +73,62 @@ void main() { expect(februaryPosition.dy, greaterThan(januaryPosition.dy)); }); + testWidgets( + 'year view highlights a selected spillover date only in its own month', + (tester) async { + final semantics = tester.ensureSemantics(); + final selectedDate = DateTime(2026, 7, 30); + + await tester.pumpWidget( + localizedTestApp( + child: Scaffold( + body: SizedBox( + width: 1000, + height: 720, + child: ScheduleYearView( + selectedDate: selectedDate, + items: const [], + firstWeekday: DateTime.monday, + onDaySelected: (_) {}, + onMonthSelected: (_) {}, + onWeekSelected: (_) {}, + onCreateAtDay: (_) {}, + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + final months = find.byType(YearMonthMiniCalendar); + final selectedDateKey = ValueKey(( + selectedDate.year, + selectedDate.month, + selectedDate.day, + )); + final julyDate = find.descendant( + of: months.at(DateTime.july - 1), + matching: find.byKey(selectedDateKey), + ); + final augustSpilloverDate = find.descendant( + of: months.at(DateTime.august - 1), + matching: find.byKey(selectedDateKey), + ); + + expect(julyDate, findsOneWidget); + expect(augustSpilloverDate, findsOneWidget); + expect( + tester.getSemantics(julyDate).flagsCollection.isSelected, + ui.Tristate.isTrue, + ); + expect( + tester.getSemantics(augustSpilloverDate).flagsCollection.isSelected, + ui.Tristate.isFalse, + ); + semantics.dispose(); + }, + ); + testWidgets('day view uses package planner with custom BusyMax items', ( tester, ) async { From 1647d91eba6af4364b0605202a24604bdccc7e3d Mon Sep 17 00:00:00 2001 From: albert Date: Thu, 30 Jul 2026 19:48:43 -0700 Subject: [PATCH 60/73] Update Russian localization for discard changes prompts to enhance clarity --- lib/l10n/app_ru.arb | 4 ++-- lib/src/app/app_router.dart | 9 ++++++--- test/app/localization_audit_test.dart | 8 ++++++++ .../feedback/presentation/feedback_dialog_test.dart | 7 +++++++ 4 files changed, 23 insertions(+), 5 deletions(-) diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index 4479367..ed43d3b 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -360,8 +360,8 @@ "retry": "Повторить", "discard": "Отменить", "discardChangesAction": "Не сохранять", - "discardChanges": "Отменить изменения?", - "discardChangesConfirmation": "Несохранённые изменения этой задачи будут отменены.", + "discardChanges": "Не сохранять изменения?", + "discardChangesConfirmation": "Несохранённые изменения этой задачи будут потеряны.", "retryCompleted": "Повторная попытка завершена.", "discardPendingOperation": "Удалить заблокированную операцию?", "discardPendingOperationConfirmation": "Заблокированная локальная операция будет удалена. При следующей синхронизации данные будут заново загружены из Google Tasks.", diff --git a/lib/src/app/app_router.dart b/lib/src/app/app_router.dart index c0c848a..2891ffb 100644 --- a/lib/src/app/app_router.dart +++ b/lib/src/app/app_router.dart @@ -79,9 +79,12 @@ final appRouterProvider = Provider((ref) { ), GoRoute( path: '/settings', - builder: (context, state) => SettingsScreen( - initialPage: settingsPageFromRouteValue( - state.uri.queryParameters['page'], + pageBuilder: (context, state) => NoTransitionPage( + key: state.pageKey, + child: SettingsScreen( + initialPage: settingsPageFromRouteValue( + state.uri.queryParameters['page'], + ), ), ), ), diff --git a/test/app/localization_audit_test.dart b/test/app/localization_audit_test.dart index 25d3558..0069f9e 100644 --- a/test/app/localization_audit_test.dart +++ b/test/app/localization_audit_test.dart @@ -81,6 +81,14 @@ void main() { lookupAppLocalizations(const Locale('ru')).discardChangesAction, 'Не сохранять', ); + expect( + lookupAppLocalizations(const Locale('ru')).discardChanges, + 'Не сохранять изменения?', + ); + expect( + lookupAppLocalizations(const Locale('ru')).discardChangesConfirmation, + 'Несохранённые изменения этой задачи будут потеряны.', + ); expect( lookupAppLocalizations(const Locale('vi')).discardChangesAction, 'Không lưu', diff --git a/test/features/feedback/presentation/feedback_dialog_test.dart b/test/features/feedback/presentation/feedback_dialog_test.dart index f6d453e..8189912 100644 --- a/test/features/feedback/presentation/feedback_dialog_test.dart +++ b/test/features/feedback/presentation/feedback_dialog_test.dart @@ -274,6 +274,13 @@ void main() { await tester.pumpAndSettle(); final confirmation = find.byType(BusyMaxConfirmDialog); + expect( + find.descendant( + of: confirmation, + matching: find.text('Не сохранять изменения?'), + ), + findsOneWidget, + ); expect( find.descendant(of: confirmation, matching: find.text('Отмена')), findsOneWidget, From 1fa74184c68d0eb6c1b071abf01c1b19427c6876 Mon Sep 17 00:00:00 2001 From: albert Date: Thu, 30 Jul 2026 19:57:23 -0700 Subject: [PATCH 61/73] Update Russian localization for discard changes prompts to improve clarity --- lib/l10n/generated/app_localizations_ru.dart | 4 +-- .../auth/presentation/auth_routing_test.dart | 29 +++++++++++++++++++ 2 files changed, 31 insertions(+), 2 deletions(-) diff --git a/lib/l10n/generated/app_localizations_ru.dart b/lib/l10n/generated/app_localizations_ru.dart index e0fd678..c70bf7f 100644 --- a/lib/l10n/generated/app_localizations_ru.dart +++ b/lib/l10n/generated/app_localizations_ru.dart @@ -1202,11 +1202,11 @@ class AppLocalizationsRu extends AppLocalizations { String get discardChangesAction => 'Не сохранять'; @override - String get discardChanges => 'Отменить изменения?'; + String get discardChanges => 'Не сохранять изменения?'; @override String get discardChangesConfirmation => - 'Несохранённые изменения этой задачи будут отменены.'; + 'Несохранённые изменения этой задачи будут потеряны.'; @override String get retryCompleted => 'Повторная попытка завершена.'; diff --git a/test/features/auth/presentation/auth_routing_test.dart b/test/features/auth/presentation/auth_routing_test.dart index b8845d2..d2bb663 100644 --- a/test/features/auth/presentation/auth_routing_test.dart +++ b/test/features/auth/presentation/auth_routing_test.dart @@ -409,6 +409,35 @@ void main() { await _disposeApp(tester); }); + testWidgets('Settings enters and exits without a page transition', ( + tester, + ) async { + await _insertAccount( + database, + id: 'google:existing', + provider: TaskProvider.google, + ); + await _pumpApp(tester, database: database, oAuth: oAuth); + await tester.pumpAndSettle(); + + final router = GoRouter.of(tester.element(find.byType(ScheduleWorkspace))); + unawaited(router.push('/settings')); + await tester.pump(); + + final settings = find.byType(SettingsScreen); + expect(settings, findsOneWidget); + final settingsRoute = ModalRoute.of(tester.element(settings))!; + expect(settingsRoute.transitionDuration, Duration.zero); + expect(settingsRoute.reverseTransitionDuration, Duration.zero); + + router.pop(); + await tester.pump(); + + expect(settings, findsNothing); + expect(find.byType(ScheduleWorkspace), findsOneWidget); + await _disposeApp(tester); + }); + testWidgets( 'adding an account keeps Settings open during and after cancellation', (tester) async { From 977c44a98a767600d0dcc751c1d28e32cfdba9fc Mon Sep 17 00:00:00 2001 From: albert Date: Thu, 30 Jul 2026 20:26:04 -0700 Subject: [PATCH 62/73] Add tooltip theme customization for header bar to enhance visual consistency --- lib/src/app/busymax_app.dart | 10 ++ lib/src/app/busymax_design.dart | 32 ++++ lib/src/app/busymax_yaru_theme.dart | 15 +- .../platform/linux_header_bar_service.dart | 67 +++++++- linux/runner/my_application.cc | 145 +++++++++++++++++- test/app/high_contrast_theme_test.dart | 21 +-- test/app/native_ui_audit_test.dart | 49 ++++-- test/app/theme_localization_test.dart | 115 +++++++++++++- ...r_bar_configuration_synchronizer_test.dart | 10 ++ .../linux_header_bar_service_test.dart | 29 +++- 10 files changed, 462 insertions(+), 31 deletions(-) diff --git a/lib/src/app/busymax_app.dart b/lib/src/app/busymax_app.dart index c1dbd9a..a5f11da 100644 --- a/lib/src/app/busymax_app.dart +++ b/lib/src/app/busymax_app.dart @@ -55,6 +55,16 @@ BusyMaxHeaderBarTheme busyMaxHeaderBarThemeFor( 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, + ), ); } diff --git a/lib/src/app/busymax_design.dart b/lib/src/app/busymax_design.dart index 61ebab0..c73f45d 100644 --- a/lib/src/app/busymax_design.dart +++ b/lib/src/app/busymax_design.dart @@ -21,10 +21,13 @@ abstract final class BusyMaxSpacing { static const double lg = kYaruPagePadding; static const double xl = kYaruPagePadding * 1.5; static const double xxl = kYaruPagePadding * 2; + static const double tooltipHorizontal = 10; + static const double tooltipVertical = 6; } abstract final class BusyMaxRadius { static const double sm = kYaruButtonRadius; + static const double tooltip = kYaruButtonRadius; static const double md = kYaruContainerRadius; static const double lg = kYaruContainerRadius; static const double headerButton = kYaruButtonRadius; @@ -50,6 +53,7 @@ abstract final class BusyMaxSizes { static const double popoverActionIcon = iconSm; static const double popoverArrowWidth = 18; static const double popoverArrowHeight = 10; + static const double tooltipMinHeight = 30; } abstract final class BusyMaxFormLayout { @@ -79,11 +83,39 @@ abstract final class BusyMaxAlpha { static const double calendarGridDark = 0.06; static const double groupedRowLightHoverStrength = 0.50; static const double nativeHeaderMenuShadowOpacity = 0.30; + static const double tooltipBackground = 0.80; + 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. +/// This contract keeps their surface geometry and palette identical while +/// allowing both toolkits to retain native positioning and accessibility. +abstract final class BusyMaxTooltipStyle { + static final Color background = Colors.black.withValues( + alpha: BusyMaxAlpha.tooltipBackground, + ); + static const Color foreground = Colors.white; + static final Color border = Colors.white.withValues( + alpha: BusyMaxAlpha.tooltipBorder, + ); + static const EdgeInsets padding = EdgeInsets.symmetric( + horizontal: BusyMaxSpacing.tooltipHorizontal, + vertical: BusyMaxSpacing.tooltipVertical, + ); + static const BorderRadius borderRadius = BorderRadius.all( + Radius.circular(BusyMaxRadius.tooltip), + ); + static const BoxConstraints constraints = BoxConstraints( + minHeight: BusyMaxSizes.tooltipMinHeight, + ); } enum BusyMaxPopoverShadowRole { standard, details } diff --git a/lib/src/app/busymax_yaru_theme.dart b/lib/src/app/busymax_yaru_theme.dart index b5eda3c..22c05fc 100644 --- a/lib/src/app/busymax_yaru_theme.dart +++ b/lib/src/app/busymax_yaru_theme.dart @@ -192,6 +192,19 @@ class BusyMaxYaruTheme { borderRadius: BorderRadius.circular(BusyMaxRadius.md), ), ); + final tooltipTheme = base.tooltipTheme.copyWith( + decoration: BoxDecoration( + color: BusyMaxTooltipStyle.background, + border: Border.all(color: BusyMaxTooltipStyle.border), + borderRadius: BusyMaxTooltipStyle.borderRadius, + ), + textStyle: textTheme.bodyMedium?.copyWith( + color: BusyMaxTooltipStyle.foreground, + ), + padding: BusyMaxTooltipStyle.padding, + constraints: BusyMaxTooltipStyle.constraints, + waitDuration: BusyMaxMotion.tooltipWait, + ); return base.copyWith( brightness: brightness, @@ -366,7 +379,7 @@ class BusyMaxYaruTheme { fallback: textTheme.labelLarge, ), ), - tooltipTheme: base.tooltipTheme, + tooltipTheme: tooltipTheme, snackBarTheme: base.snackBarTheme.copyWith( contentTextStyle: normalizer.apply( base.snackBarTheme.contentTextStyle, diff --git a/lib/src/platform/linux_header_bar_service.dart b/lib/src/platform/linux_header_bar_service.dart index 0e192d1..ed1fc2c 100644 --- a/lib/src/platform/linux_header_bar_service.dart +++ b/lib/src/platform/linux_header_bar_service.dart @@ -258,6 +258,66 @@ class BusyMaxHeaderBarLabels { ]); } +@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({ @@ -274,6 +334,7 @@ class BusyMaxHeaderBarTheme { required this.dialogBackgroundColor, required this.dialogOutlineColor, required this.modalBarrierColor, + required this.tooltip, }); final bool preferDark; @@ -289,6 +350,7 @@ class BusyMaxHeaderBarTheme { final Color dialogBackgroundColor; final Color dialogOutlineColor; final Color modalBarrierColor; + final BusyMaxHeaderBarTooltipTheme tooltip; Map toJson() { return { @@ -305,6 +367,7 @@ class BusyMaxHeaderBarTheme { 'dialogBackgroundColor': busyMaxCssColor(dialogBackgroundColor), 'dialogOutlineColor': busyMaxCssColor(dialogOutlineColor), 'modalBarrierColor': busyMaxCssColor(modalBarrierColor), + 'tooltip': tooltip.toJson(), }; } @@ -324,7 +387,8 @@ class BusyMaxHeaderBarTheme { other.popoverShadowColor == popoverShadowColor && other.dialogBackgroundColor == dialogBackgroundColor && other.dialogOutlineColor == dialogOutlineColor && - other.modalBarrierColor == modalBarrierColor; + other.modalBarrierColor == modalBarrierColor && + other.tooltip == tooltip; } @override @@ -342,6 +406,7 @@ class BusyMaxHeaderBarTheme { dialogBackgroundColor, dialogOutlineColor, modalBarrierColor, + tooltip, ]); } diff --git a/linux/runner/my_application.cc b/linux/runner/my_application.cc index af9629e..75894e5 100644 --- a/linux/runner/my_application.cc +++ b/linux/runner/my_application.cc @@ -70,6 +70,14 @@ constexpr char kDefaultHeaderBarSidebarBorderColor[] = constexpr char kDefaultHeaderMenuShadowColor[] = "rgba(0,0,0,0.3)"; 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)"; +constexpr gdouble kDefaultTooltipRadius = 8.0; +constexpr gdouble kDefaultTooltipFontSize = 14.0; +constexpr gdouble kDefaultTooltipHorizontalPadding = 10.0; +constexpr gdouble kDefaultTooltipVerticalPadding = 6.0; +constexpr gdouble kDefaultTooltipMinimumHeight = 30.0; constexpr char kHeaderControlStyleClass[] = "busymax-header-control"; constexpr char kHeaderOnboardingTextButtonStyleClass[] = "busymax-onboarding-text-button"; @@ -131,6 +139,14 @@ struct _MyApplication { 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; @@ -414,6 +430,49 @@ static gboolean fl_lookup_int_arg(FlValue* args, return TRUE; } +static gboolean fl_lookup_double_arg(FlValue* args, + const gchar* key, + gdouble* value_out) { + if (args == nullptr || fl_value_get_type(args) != FL_VALUE_TYPE_MAP) { + return FALSE; + } + FlValue* value = fl_value_lookup_string(args, key); + if (value == nullptr) { + return FALSE; + } + if (fl_value_get_type(value) == FL_VALUE_TYPE_FLOAT) { + *value_out = fl_value_get_float(value); + return TRUE; + } + if (fl_value_get_type(value) == FL_VALUE_TYPE_INT) { + *value_out = static_cast(fl_value_get_int(value)); + return TRUE; + } + return FALSE; +} + +static void update_bounded_double_arg(FlValue* args, + const gchar* key, + gdouble minimum, + gdouble maximum, + gdouble* target) { + gdouble value = 0; + if (fl_lookup_double_arg(args, key, &value) && value >= minimum && + value <= maximum) { + *target = value; + } +} + +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_date(const gchar* value, guint* year, guint* month, @@ -2192,6 +2251,55 @@ static void refresh_header_bar_css(MyApplication* self) { css_color_or(self->header_bar_popover_shadow_color, kDefaultHeaderMenuShadowColor)) : 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); + g_autofree gchar* tooltip_css = g_strdup_printf( + "tooltip," + "tooltip.background {" + "margin: 0;" + "padding: 0;" + "min-height: %.2fpx;" + "}" + "tooltip.background {" + "background-color: %s;" + "background-image: none;" + "background-clip: padding-box;" + "border: 1px solid %s;" + "border-radius: %.2fpx;" + "}" + "tooltip decoration," + "tooltip.csd decoration {" + "background-color: transparent;" + "border-radius: %.2fpx;" + "box-shadow: none;" + "}" + "tooltip > box," + "tooltip.background > box {" + "margin: 0;" + "padding: 0;" + "min-height: 0;" + "}" + "tooltip * {" + "background-color: transparent;" + "color: %s;" + "}" + "tooltip label {" + "margin: 0;" + "padding: %.2fpx %.2fpx;" + "min-height: 0;" + "font-size: %.2fpx;" + "font-weight: 400;" + "}", + self->header_bar_tooltip_minimum_height, tooltip_background, + tooltip_border, self->header_bar_tooltip_radius, + self->header_bar_tooltip_radius, tooltip_foreground, + self->header_bar_tooltip_vertical_padding, + self->header_bar_tooltip_horizontal_padding, + 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 {" @@ -2304,6 +2412,7 @@ static void refresh_header_bar_css(MyApplication* self) { "%s" "%s" "%s" + "%s" "headerbar.busymax-flat-headerbar," "headerbar.busymax-flat-headerbar:backdrop {" "background-color: %s;" @@ -2454,7 +2563,7 @@ static void refresh_header_bar_css(MyApplication* self) { "}", window_background_color, yaru_window_decoration_css, native_dialog_css, native_time_zone_dialog_css, - native_search_geometry_css, + native_search_geometry_css, tooltip_css, background_color, foreground_color, sidebar_background_color, foreground_color, sidebar_border_color, foreground_color, foreground_color, kHeaderBackdropForegroundOpacity, @@ -2532,6 +2641,27 @@ static void set_header_bar_theme(MyApplication* self, FlValue* args) { 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); + } set_main_flutter_view_background(self); refresh_header_bar_css(self); } @@ -5415,6 +5545,9 @@ static void my_application_dispose(GObject* object) { 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); @@ -5490,6 +5623,16 @@ static void my_application_init(MyApplication* self) { self->header_bar_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 = + kDefaultTooltipHorizontalPadding; + self->header_bar_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; diff --git a/test/app/high_contrast_theme_test.dart b/test/app/high_contrast_theme_test.dart index 2881cd4..9c9ef42 100644 --- a/test/app/high_contrast_theme_test.dart +++ b/test/app/high_contrast_theme_test.dart @@ -84,16 +84,17 @@ void main() { surfaces.border, ); - final yaruTooltipTheme = switch (theme.brightness) { - Brightness.light => createYaruLightTheme( - primaryColor: theme.colorScheme.primary, - ).tooltipTheme, - Brightness.dark => createYaruDarkTheme( - primaryColor: theme.colorScheme.primary, - highContrast: true, - ).tooltipTheme, - }; - expect(theme.tooltipTheme, yaruTooltipTheme); + final tooltipDecoration = theme.tooltipTheme.decoration! as BoxDecoration; + final tooltipBorder = tooltipDecoration.border! as Border; + expect(tooltipDecoration.color, BusyMaxTooltipStyle.background); + expect(tooltipDecoration.borderRadius, BusyMaxTooltipStyle.borderRadius); + expect(tooltipBorder.top.color, BusyMaxTooltipStyle.border); + expect( + theme.tooltipTheme.textStyle?.color, + BusyMaxTooltipStyle.foreground, + ); + expect(theme.tooltipTheme.padding, BusyMaxTooltipStyle.padding); + expect(theme.tooltipTheme.constraints, BusyMaxTooltipStyle.constraints); } }); diff --git a/test/app/native_ui_audit_test.dart b/test/app/native_ui_audit_test.dart index a1100e0..cb79a7e 100644 --- a/test/app/native_ui_audit_test.dart +++ b/test/app/native_ui_audit_test.dart @@ -776,9 +776,15 @@ void main() { expect(source, isNot(contains('GtkWidget* sidebar_toggle_button;'))); expect(source, isNot(contains('self->sidebar_toggle_button'))); expect(source, isNot(contains('kHeaderButtonRadius'))); - expect(source, isNot(contains('tooltip.background'))); - expect(source, isNot(contains('"tooltip > box,"'))); - expect(source, isNot(contains('"tooltip label {"'))); + expect(source, contains('kDefaultTooltipBackground')); + expect(source, contains('kDefaultTooltipForeground')); + expect(source, contains('kDefaultTooltipBorder')); + expect(source, contains('kDefaultTooltipRadius')); + 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 label {"')); expect(source, isNot(contains('kHeaderTooltipVerticalPadding'))); expect(source, isNot(contains('kHeaderTooltipHorizontalPadding'))); expect(source, isNot(contains('kYaruGtk3TooltipVerticalPadding'))); @@ -1832,7 +1838,7 @@ void main() { 'g_autofree gchar* header_menu_shadow_css =', ); final headerMenuShadowCssEnd = source.indexOf( - 'g_autofree gchar* yaru_window_decoration_css =', + 'const gchar* tooltip_background =', headerMenuShadowCssStart, ); expect(headerMenuShadowCssStart, isNonNegative); @@ -1841,8 +1847,16 @@ void main() { headerMenuShadowCssStart, headerMenuShadowCssEnd, ); + final tooltipCssStart = headerMenuShadowCssEnd; + 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( - 'const gboolean use_legacy_yaru_compatibility =', + 'g_autofree gchar* yaru_window_decoration_css =', ); final yaruDecorationCssEnd = source.indexOf( 'GtkWidget* header_bar =', @@ -2047,6 +2061,18 @@ void main() { expect(headerMenuShadowCss, contains('kHeaderMenuDepthStyleClass')); expect(headerMenuShadowCss, isNot(contains('"border:'))); expect(headerMenuShadowCss, isNot(contains('border-radius'))); + 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 label {"')); + expect(tooltipCss, contains('"padding: %.2fpx %.2fpx;"')); + 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( @@ -2083,12 +2109,9 @@ void main() { ' "not(.maximized):not(.fullscreen) > decoration {"', ), ); - expect( - yaruDecorationCss, - contains('current_gtk_theme_uses_legacy_yaru_shadow()'), - ); + expect(source, contains('current_gtk_theme_uses_legacy_yaru_shadow()')); expect(yaruDecorationCss, contains('use_legacy_yaru_compatibility')); - expect(yaruDecorationCss, contains('!self->header_bar_high_contrast')); + expect(source, contains('!self->header_bar_high_contrast')); expect( yaruDecorationCss, contains('"box-shadow: 0 0 14px 2px rgba(0,0,6,0.03),"'), @@ -2208,7 +2231,7 @@ void main() { expect(headerCss, isNot(contains('alpha(currentColor, 0.30)'))); expect(headerCss, isNot(contains('#151515'))); expect(headerCss, isNot(contains('popover.busymax'))); - expect(headerCss, isNot(contains('tooltip.background'))); + expect(headerCss, contains('tooltip_css')); expect(headerCss, contains(':hover')); expect(headerCss, contains(':active')); expect(headerCss, contains(':checked')); @@ -2693,6 +2716,10 @@ bool _isAllowedFontSizeException(File file, String line) { if (file.path.endsWith('lib/src/app/busymax_yaru_theme.dart')) { return true; } + if (file.path.endsWith('lib/src/app/busymax_app.dart') && + line.contains('theme.tooltipTheme.textStyle?.fontSize')) { + return true; + } return file.path.endsWith( 'lib/src/features/tasks/presentation/desktop_date_time_fields.dart', ) && diff --git a/test/app/theme_localization_test.dart b/test/app/theme_localization_test.dart index 0c14057..150b76c 100644 --- a/test/app/theme_localization_test.dart +++ b/test/app/theme_localization_test.dart @@ -40,6 +40,20 @@ void main() { 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, + BusyMaxSpacing.tooltipHorizontal, + ); + expect(headerTheme.tooltip.verticalPadding, BusyMaxSpacing.tooltipVertical); + expect(headerTheme.tooltip.minimumHeight, BusyMaxSizes.tooltipMinHeight); }); test('builds with system accent and tokenized control surfaces', () { @@ -150,6 +164,74 @@ void main() { } }); + testWidgets('desktop tooltips use one explicit natural-width geometry', ( + tester, + ) async { + Future<({Rect surface, Rect text})> measureTooltip({ + required Brightness brightness, + required String message, + }) async { + final tooltipKey = GlobalKey(); + await tester.pumpWidget( + MaterialApp( + theme: _buildBusyMaxTheme(brightness: brightness), + home: Scaffold( + body: Center( + child: Tooltip( + key: tooltipKey, + message: message, + child: const SizedBox.square(dimension: 32), + ), + ), + ), + ), + ); + expect(tooltipKey.currentState!.ensureTooltipVisible(), isTrue); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 200)); + + final messageFinder = find.text(message); + expect(messageFinder, findsOneWidget); + final textRect = tester.getRect(messageFinder); + Rect? surfaceRect; + tester.element(messageFinder).visitAncestorElements((element) { + if (element.widget case final ConstrainedBox constrained + when constrained.constraints == BusyMaxTooltipStyle.constraints) { + final box = element.renderObject! as RenderBox; + surfaceRect = box.localToGlobal(Offset.zero) & box.size; + return false; + } + return true; + }); + expect(surfaceRect, isNotNull); + return (surface: surfaceRect!, text: textRect); + } + + for (final brightness in Brightness.values) { + final short = await measureTooltip( + brightness: brightness, + message: 'Main menu', + ); + final long = await measureTooltip( + brightness: brightness, + message: 'Show sidebar panel', + ); + + for (final measurement in [short, long]) { + expect(measurement.surface.height, BusyMaxSizes.tooltipMinHeight); + expect( + measurement.surface.width, + moreOrLessEquals( + measurement.text.width + + (BusyMaxSpacing.tooltipHorizontal + BusyMaxStroke.outline) * 2, + epsilon: 0.01, + ), + ); + } + expect(long.surface.width, greaterThan(short.surface.width)); + } + }); + test('semantic theme retains Yaru component geometry and interactions', () { final theme = _buildBusyMaxTheme(brightness: Brightness.light); final base = createYaruLightTheme(primaryColor: _testAccentColor); @@ -487,8 +569,25 @@ void main() { ); } final yaruLight = createYaruLightTheme(primaryColor: _testAccentColor); - expect(light.tooltipTheme, yaruLight.tooltipTheme); - expect(dark.tooltipTheme, yaruDark.tooltipTheme); + for (final theme in [light, dark]) { + final decoration = theme.tooltipTheme.decoration! as BoxDecoration; + final border = decoration.border! as Border; + expect(decoration.color, BusyMaxTooltipStyle.background); + expect(decoration.borderRadius, BusyMaxTooltipStyle.borderRadius); + expect(border.top.color, BusyMaxTooltipStyle.border); + expect( + theme.tooltipTheme.textStyle?.color, + BusyMaxTooltipStyle.foreground, + ); + expect(theme.tooltipTheme.padding, BusyMaxTooltipStyle.padding); + expect(theme.tooltipTheme.constraints, BusyMaxTooltipStyle.constraints); + expect(theme.tooltipTheme.waitDuration, BusyMaxMotion.tooltipWait); + } + expect( + light.tooltipTheme.waitDuration, + yaruLight.tooltipTheme.waitDuration, + ); + expect(dark.tooltipTheme.waitDuration, yaruDark.tooltipTheme.waitDuration); }); test('BusyMaxSurfaceColors copyWith preserves and overrides fields', () { @@ -706,9 +805,15 @@ void main() { family: gtkFamily, scale: scale, ); - // Yaru leaves tooltip textStyle unset, so Flutter resolves it from the - // already-normalized ambient TextTheme together with its inverse palette. - expect(theme.tooltipTheme.textStyle, base.tooltipTheme.textStyle); + expect( + theme.tooltipTheme.textStyle?.fontFamily, + theme.textTheme.bodyMedium?.fontFamily, + ); + expect( + theme.tooltipTheme.textStyle?.fontSize, + theme.textTheme.bodyMedium?.fontSize, + ); + expect(theme.tooltipTheme.textStyle?.color, BusyMaxTooltipStyle.foreground); _expectComponentStyleUsesTypography( theme.snackBarTheme.contentTextStyle, baseStyle: base.snackBarTheme.contentTextStyle, diff --git a/test/platform/linux_header_bar_configuration_synchronizer_test.dart b/test/platform/linux_header_bar_configuration_synchronizer_test.dart index aa39d5b..18dabf8 100644 --- a/test/platform/linux_header_bar_configuration_synchronizer_test.dart +++ b/test/platform/linux_header_bar_configuration_synchronizer_test.dart @@ -139,6 +139,16 @@ BusyMaxHeaderBarConfiguration _configuration({required bool dark}) { 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 index e2b91fe..e3f2b60 100644 --- a/test/platform/linux_header_bar_service_test.dart +++ b/test/platform/linux_header_bar_service_test.dart @@ -117,6 +117,16 @@ void main() { 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, + ), ), ); @@ -190,6 +200,16 @@ void main() { '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, + }, }), ); }); @@ -589,7 +609,7 @@ void main() { 'g_autofree gchar* native_search_geometry_css =', ); final geometryCssEnd = source.indexOf( - 'g_autofree gchar* header_menu_shadow_css =', + 'g_autofree gchar* native_menu_state_css =', geometryCssStart, ); @@ -732,7 +752,12 @@ void main() { expect(source, isNot(contains('popover.busymax-header-popover'))); expect(source, contains('kNativePopoverStyleClass')); expect(source, contains('style_header_menu_popover(GTK_WIDGET(popover))')); - expect(source, isNot(contains('tooltip.background'))); + 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'))); From c1010e17dcdf11d04f4ac964ff5cfd42fb9694e2 Mon Sep 17 00:00:00 2001 From: albert Date: Thu, 30 Jul 2026 20:37:46 -0700 Subject: [PATCH 63/73] Add shrinkToFitHeight property to mini calendar for flexible height adjustment --- .../schedule/presentation/mini_calendar.dart | 12 ++- .../desktop_date_time_fields.dart | 84 +++++++++++-------- .../desktop_date_time_fields_test.dart | 14 ++++ 3 files changed, 75 insertions(+), 35 deletions(-) diff --git a/lib/src/features/schedule/presentation/mini_calendar.dart b/lib/src/features/schedule/presentation/mini_calendar.dart index 7139eee..69a417d 100644 --- a/lib/src/features/schedule/presentation/mini_calendar.dart +++ b/lib/src/features/schedule/presentation/mini_calendar.dart @@ -201,6 +201,7 @@ class MiniCalendarGrid extends StatelessWidget { required this.onDaySelected, this.markerColorsByDay = const {}, this.highlightSelectedDateOutsideMonth = true, + this.shrinkToFitHeight = false, this.onWeekSelected, this.onDayDoubleTap, }); @@ -210,6 +211,7 @@ class MiniCalendarGrid extends StatelessWidget { final int firstWeekday; final Map> markerColorsByDay; final bool highlightSelectedDateOutsideMonth; + final bool shrinkToFitHeight; final ValueChanged onDaySelected; final ValueChanged? onWeekSelected; final ValueChanged? onDayDoubleTap; @@ -228,13 +230,19 @@ class MiniCalendarGrid extends StatelessWidget { maximumWeekNumberExtent, constraints.maxWidth / (DateTime.daysPerWeek + 1), ); - final dayExtent = + final naturalDayExtent = math.max(0.0, constraints.maxWidth - weekNumberExtent) / DateTime.daysPerWeek; const weekdayHeaderHeight = 18.0; + const fixedVerticalExtent = weekdayHeaderHeight + BusyMaxSpacing.xs; + final fittedDayExtent = + shrinkToFitHeight && constraints.hasBoundedHeight + ? math.max(0.0, constraints.maxHeight - fixedVerticalExtent) / 6 + : naturalDayExtent; + final dayExtent = math.min(naturalDayExtent, fittedDayExtent); return SizedBox( width: double.infinity, - height: weekdayHeaderHeight + BusyMaxSpacing.xs + dayExtent * 6, + height: fixedVerticalExtent + dayExtent * 6, child: Column( children: [ SizedBox( diff --git a/lib/src/features/tasks/presentation/desktop_date_time_fields.dart b/lib/src/features/tasks/presentation/desktop_date_time_fields.dart index 6b07964..a873463 100644 --- a/lib/src/features/tasks/presentation/desktop_date_time_fields.dart +++ b/lib/src/features/tasks/presentation/desktop_date_time_fields.dart @@ -18,6 +18,7 @@ const nativeDateTimePickerChannelName = 'busymax/native_date_time_picker'; const _nativeDateTimePicker = NativeDateTimePicker(); const _dateTimePickerMaxWidth = 340.0; const _dateTimePickerContentMaxHeight = 320.0; +const _dateTimePickerMinimumFittedContentHeight = 180.0; const _dateTimePickerPopoverMinimumHeight = 300.0; const _dateTimePickerPopoverPadding = EdgeInsets.all(BusyMaxSpacing.lg); const _timePickerMaxWidth = 260.0; @@ -366,45 +367,62 @@ class _DesktopDateValueDialogState extends State<_DesktopDateValueDialog> { return LayoutBuilder( builder: (context, constraints) { final contentHeight = _calculateDateTimePickerPopupHeight(constraints); - return BusyMaxContentPopoverSurface( - arrowSide: widget.arrowSide, - arrowAlignment: widget.arrowAlignment, - padding: _dateTimePickerPopoverPadding, - child: ScrollConfiguration( + Widget calendarGrid({bool shrinkToFitHeight = false}) { + return Padding( + padding: const EdgeInsetsDirectional.fromSTEB( + BusyMaxSpacing.headerInset, + BusyMaxSpacing.headerInset, + BusyMaxSpacing.headerInset, + BusyMaxSpacing.md, + ), + child: MiniCalendarGrid( + displayedMonth: _displayedMonth, + selectedDate: _selected, + firstWeekday: _firstWeekday(context), + shrinkToFitHeight: shrinkToFitHeight, + onDaySelected: (date) => _setSelectedDate( + date, + submit: + date.year == _displayedMonth.year && + date.month == _displayedMonth.month, + ), + ), + ); + } + + final content = Column( + mainAxisSize: MainAxisSize.min, + children: [_buildDateModeHeader(context), calendarGrid()], + ); + final Widget pickerContent; + if (contentHeight >= _dateTimePickerContentMaxHeight) { + pickerContent = content; + } else if (contentHeight >= _dateTimePickerMinimumFittedContentHeight) { + pickerContent = SizedBox( + height: contentHeight, + child: Column( + children: [ + _buildDateModeHeader(context), + Expanded(child: calendarGrid(shrinkToFitHeight: true)), + ], + ), + ); + } else { + pickerContent = ScrollConfiguration( behavior: ScrollConfiguration.of( context, ).copyWith(scrollbars: false), child: ConstrainedBox( constraints: BoxConstraints(maxHeight: contentHeight), - child: SingleChildScrollView( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - _buildDateModeHeader(context), - Padding( - padding: const EdgeInsetsDirectional.fromSTEB( - BusyMaxSpacing.headerInset, - BusyMaxSpacing.headerInset, - BusyMaxSpacing.headerInset, - BusyMaxSpacing.md, - ), - child: MiniCalendarGrid( - displayedMonth: _displayedMonth, - selectedDate: _selected, - firstWeekday: _firstWeekday(context), - onDaySelected: (date) => _setSelectedDate( - date, - submit: - date.year == _displayedMonth.year && - date.month == _displayedMonth.month, - ), - ), - ), - ], - ), - ), + child: SingleChildScrollView(child: content), ), - ), + ); + } + return BusyMaxContentPopoverSurface( + arrowSide: widget.arrowSide, + arrowAlignment: widget.arrowAlignment, + padding: _dateTimePickerPopoverPadding, + child: pickerContent, ); }, ); diff --git a/test/features/tasks/presentation/desktop_date_time_fields_test.dart b/test/features/tasks/presentation/desktop_date_time_fields_test.dart index 0c7c572..4302086 100644 --- a/test/features/tasks/presentation/desktop_date_time_fields_test.dart +++ b/test/features/tasks/presentation/desktop_date_time_fields_test.dart @@ -598,6 +598,20 @@ void main() { (triggerRect.center.dy - popoverRect.center.dy).abs(), greaterThan(0), ); + expect( + find.descendant( + of: find.byType(BusyMaxContentPopoverSurface), + matching: find.byType(SingleChildScrollView), + ), + findsNothing, + ); + expect( + find.descendant( + of: find.byType(BusyMaxContentPopoverSurface), + matching: find.byWidgetPredicate((widget) => widget is RawScrollbar), + ), + findsNothing, + ); await tester.sendKeyEvent(LogicalKeyboardKey.escape); await tester.pumpAndSettle(); From 58ee3a3425e991237e4b36f50dffdd1c65abeee8 Mon Sep 17 00:00:00 2001 From: albert Date: Thu, 30 Jul 2026 21:42:46 -0700 Subject: [PATCH 64/73] Enhance tooltip styling by adjusting padding and border properties for improved visual consistency --- linux/runner/my_application.cc | 45 +++++++++++++++++++----------- test/app/native_ui_audit_test.dart | 30 +++++++++++++++++--- 2 files changed, 55 insertions(+), 20 deletions(-) diff --git a/linux/runner/my_application.cc b/linux/runner/my_application.cc index 75894e5..7a277b3 100644 --- a/linux/runner/my_application.cc +++ b/linux/runner/my_application.cc @@ -78,6 +78,11 @@ constexpr gdouble kDefaultTooltipFontSize = 14.0; constexpr gdouble kDefaultTooltipHorizontalPadding = 10.0; 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 +// 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"; @@ -2257,18 +2262,31 @@ static void refresh_header_bar_css(MyApplication* self) { 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.background," + "tooltip box," + "tooltip.background box {" "margin: 0;" "padding: 0;" - "min-height: %.2fpx;" + "min-width: 0;" + "min-height: 0;" "}" "tooltip.background {" "background-color: %s;" "background-image: none;" "background-clip: padding-box;" - "border: 1px solid %s;" + "border: %.2fpx solid %s;" "border-radius: %.2fpx;" "}" "tooltip decoration," @@ -2277,29 +2295,24 @@ static void refresh_header_bar_css(MyApplication* self) { "border-radius: %.2fpx;" "box-shadow: none;" "}" - "tooltip > box," - "tooltip.background > box {" - "margin: 0;" - "padding: 0;" - "min-height: 0;" - "}" "tooltip * {" "background-color: transparent;" "color: %s;" "}" - "tooltip label {" + "tooltip label," + "tooltip.background label {" "margin: 0;" "padding: %.2fpx %.2fpx;" - "min-height: 0;" + "min-width: 0;" + "min-height: %.2fpx;" "font-size: %.2fpx;" "font-weight: 400;" "}", - self->header_bar_tooltip_minimum_height, tooltip_background, - tooltip_border, self->header_bar_tooltip_radius, + tooltip_background, kTooltipBorderWidth, tooltip_border, + self->header_bar_tooltip_radius, self->header_bar_tooltip_radius, tooltip_foreground, - self->header_bar_tooltip_vertical_padding, - self->header_bar_tooltip_horizontal_padding, - self->header_bar_tooltip_font_size); + 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 {" diff --git a/test/app/native_ui_audit_test.dart b/test/app/native_ui_audit_test.dart index cb79a7e..6fc7e17 100644 --- a/test/app/native_ui_audit_test.dart +++ b/test/app/native_ui_audit_test.dart @@ -780,11 +780,22 @@ void main() { 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 label {"')); + 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'))); @@ -2064,9 +2075,20 @@ void main() { 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 label {"')); + 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, From 8a5dfbfe623a0d71c40c9dcd10516073c798a690 Mon Sep 17 00:00:00 2001 From: albert Date: Fri, 31 Jul 2026 00:24:25 -0700 Subject: [PATCH 65/73] Refactor modal barrier management to separate visibility and shade depth handling for improved clarity and functionality --- lib/src/app/busymax_about_dialog.dart | 59 +++---- lib/src/app/busymax_design.dart | 1 + lib/src/app/busymax_dialogs.dart | 152 +++++++++++++----- .../platform/linux_header_bar_service.dart | 26 ++- linux/runner/my_application.cc | 50 ++++-- test/app/about_dialog_test.dart | 108 ++++++++----- test/app/busymax_dialogs_test.dart | 100 ++++++++---- test/app/modal_barrier_test.dart | 54 +++++++ test/app/native_ui_audit_test.dart | 8 +- .../presentation/event_editor_test.dart | 11 +- ...chedule_workspace_task_mutations_test.dart | 49 +++++- .../linux_header_bar_service_test.dart | 46 +++++- 12 files changed, 496 insertions(+), 168 deletions(-) diff --git a/lib/src/app/busymax_about_dialog.dart b/lib/src/app/busymax_about_dialog.dart index 0d5304f..0fa8cbc 100644 --- a/lib/src/app/busymax_about_dialog.dart +++ b/lib/src/app/busymax_about_dialog.dart @@ -10,6 +10,7 @@ import '../platform/linux_header_bar_service.dart'; import 'busymax_design.dart'; import 'busymax_dialog_identity.dart'; import 'busymax_dialogs.dart'; +import 'busymax_surface_colors.dart'; const _busyMaxWebsiteUrl = 'https://busystack.org'; const _busyMaxRepositoryUrl = 'https://github.com/busystack/busymax/'; @@ -53,16 +54,13 @@ class BusyMaxAboutDialog extends StatelessWidget { ), ), const SizedBox(height: BusyMaxSpacing.sm), - Align( - alignment: Alignment.center, - child: FutureBuilder( - future: PackageInfo.fromPlatform(), - builder: (context, snapshot) { - final info = snapshot.data; - final version = info == null ? '' : _formatVersion(info); - return _VersionTag(version: version); - }, - ), + FutureBuilder( + future: PackageInfo.fromPlatform(), + builder: (context, snapshot) { + final info = snapshot.data; + final version = info == null ? '' : _formatVersion(info); + return _VersionTag(version: version); + }, ), const SizedBox(height: BusyMaxSpacing.md), BusyMaxGroupedList( @@ -144,24 +142,31 @@ class _VersionTag extends StatelessWidget { return const SizedBox.shrink(); } final theme = Theme.of(context); - final colorScheme = theme.colorScheme; - return YaruTranslucentContainer( - opacity: 1, - border: const Border(), - borderRadius: const BorderRadius.all(Radius.circular(kYaruButtonRadius)), - color: colorScheme.primary, - child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: BusyMaxSpacing.sm, - vertical: BusyMaxSpacing.xs, + final colors = BusyMaxSurfaceColors.of(context); + return Center( + child: DecoratedBox( + decoration: BoxDecoration( + color: colors.control, + borderRadius: BorderRadius.circular(BusyMaxRadius.pill), + border: Border.all(color: colors.divider), ), - child: Text( - version, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: theme.textTheme.labelMedium?.copyWith( - color: colorScheme.onPrimary, - fontWeight: FontWeight.w600, + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: BusyMaxSpacing.md, + vertical: BusyMaxSpacing.xs, + ), + child: Directionality( + textDirection: TextDirection.ltr, + child: Text( + version, + maxLines: 1, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.center, + style: theme.textTheme.labelMedium?.copyWith( + color: colors.foreground, + fontWeight: FontWeight.w600, + ), + ), ), ), ), diff --git a/lib/src/app/busymax_design.dart b/lib/src/app/busymax_design.dart index c73f45d..11409d7 100644 --- a/lib/src/app/busymax_design.dart +++ b/lib/src/app/busymax_design.dart @@ -32,6 +32,7 @@ abstract final class BusyMaxRadius { static const double lg = kYaruContainerRadius; static const double headerButton = kYaruButtonRadius; static const double window = kYaruWindowRadius; + static const double pill = 999; } abstract final class BusyMaxSizes { diff --git a/lib/src/app/busymax_dialogs.dart b/lib/src/app/busymax_dialogs.dart index 647d704..1acfd79 100644 --- a/lib/src/app/busymax_dialogs.dart +++ b/lib/src/app/busymax_dialogs.dart @@ -28,7 +28,8 @@ class BusyMaxModalShortcutBoundary extends StatelessWidget { } } -final _modalDepths = Map.identity(); +final _modalStates = + Map.identity(); final _modalBarrierUpdateTails = Map>.identity(); @@ -44,6 +45,7 @@ Future showBusyMaxModalDialog( return _coordinateBusyMaxModal( context, headerBarService: effectiveHeaderBarService, + shadesHeader: barrierColor == null || barrierColor.a > 0, showSurface: () => _showBusyMaxFlutterDialog( context, builder: builder, @@ -56,19 +58,29 @@ Future showBusyMaxModalDialog( Future _coordinateBusyMaxModal( BuildContext context, { required LinuxHeaderBarService? headerBarService, + required bool shadesHeader, required Future Function() showSurface, }) async { final previousFocus = FocusManager.instance.primaryFocus; - await acquireBusyMaxModalBarrier(headerBarService); + await acquireBusyMaxModalBarrier( + headerBarService, + shadesHeader: shadesHeader, + ); if (!context.mounted) { - await releaseBusyMaxModalBarrier(headerBarService); + await releaseBusyMaxModalBarrier( + headerBarService, + shadesHeader: shadesHeader, + ); return null; } try { return await showSurface(); } finally { - await releaseBusyMaxModalBarrier(headerBarService); + await releaseBusyMaxModalBarrier( + headerBarService, + shadesHeader: shadesHeader, + ); if (previousFocus?.context != null && previousFocus!.canRequestFocus) { previousFocus.requestFocus(); } @@ -205,58 +217,108 @@ Future showBusyMaxConfirm( /// /// Every call must be paired with [releaseBusyMaxModalBarrier]. In-page modal /// surfaces should use this pair; route dialogs acquire it automatically. -Future acquireBusyMaxModalBarrier(LinuxHeaderBarService? service) async { +/// 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 depth = _modalDepths[service] ?? 0; - final nextDepth = depth + 1; - _modalDepths[service] = nextDepth; - final depthUpdate = _enqueueBusyMaxModalBarrierUpdate( + final state = _modalStates.putIfAbsent( service, - depth: nextDepth, + _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 { - await depthUpdate; + // 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) { - final remainingDepth = (_modalDepths[service] ?? 0) - 1; - if (remainingDepth > 0) { - _modalDepths[service] = remainingDepth; - } else { - _modalDepths.remove(service); - try { - // The platform may have applied the visibility change before its - // response failed. Restore the safe non-modal state, while preserving - // the original acquisition failure for the caller. - await _enqueueBusyMaxModalBarrierUpdate(service, depth: 0); - } on Object { - // Best-effort rollback cannot replace the causative exception. - } + 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]. -Future releaseBusyMaxModalBarrier(LinuxHeaderBarService? service) async { +/// +/// [shadesHeader] must match the value used by the corresponding acquire. +Future releaseBusyMaxModalBarrier( + LinuxHeaderBarService? service, { + bool shadesHeader = true, +}) async { if (service == null) { return; } - final depth = _modalDepths[service] ?? 0; - if (depth <= 1) { - _modalDepths.remove(service); - await _enqueueBusyMaxModalBarrierUpdate(service, depth: 0); + final state = _modalStates[service]; + if (state == null || state.modalDepth == 0) { return; } - final nextDepth = depth - 1; - _modalDepths[service] = nextDepth; - await _enqueueBusyMaxModalBarrierUpdate(service, depth: nextDepth); + 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 int depth, + required bool visible, + required int shadeDepth, }) { final previous = _modalBarrierUpdateTails[service] ?? Future.value(); final ready = previous.then( @@ -266,17 +328,27 @@ Future _enqueueBusyMaxModalBarrierUpdate( onError: (Object _, StackTrace _) {}, ); late final Future update; - update = ready.then((_) => service.setModalBarrierDepth(depth)).whenComplete( - () { - if (identical(_modalBarrierUpdateTails[service], update)) { - _modalBarrierUpdateTails.remove(service); - } - }, - ); + 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( diff --git a/lib/src/platform/linux_header_bar_service.dart b/lib/src/platform/linux_header_bar_service.dart index ed1fc2c..44e4f1c 100644 --- a/lib/src/platform/linux_header_bar_service.dart +++ b/lib/src/platform/linux_header_bar_service.dart @@ -554,7 +554,7 @@ class LinuxHeaderBarService { bool _available = false; bool _disposed = false; _BusyMaxOnboardingControlsState? _onboardingControls; - int? _modalBarrierDepth; + ({bool visible, int shadeDepth})? _modalBarrierState; double? _sidebarWidth; TextDirection? _textDirection; BusyMaxHeaderBarLabels? _labels; @@ -702,20 +702,32 @@ class LinuxHeaderBarService { } } - Future setModalBarrierDepth(int value) async { + Future setModalBarrierState({ + required bool visible, + required int shadeDepth, + }) async { if (!_available) { return; } - final depth = value < 0 ? 0 : value; - if (_modalBarrierDepth == depth) { + final effectiveShadeDepth = visible ? (shadeDepth < 0 ? 0 : shadeDepth) : 0; + final state = (visible: visible, shadeDepth: effectiveShadeDepth); + if (_modalBarrierState == state) { return; } - _modalBarrierDepth = depth; - await _invokeIfAvailable('setModalBarrierDepth', depth); + _modalBarrierState = state; + await _invokeIfAvailable('setModalBarrierState', { + 'visible': state.visible, + 'shadeDepth': state.shadeDepth, + }); } Future setModalBarrierVisible(bool value) { - return setModalBarrierDepth(value ? 1 : 0); + 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 { diff --git a/linux/runner/my_application.cc b/linux/runner/my_application.cc index 7a277b3..8b55014 100644 --- a/linux/runner/my_application.cc +++ b/linux/runner/my_application.cc @@ -157,7 +157,7 @@ struct _MyApplication { gboolean header_bar_can_show_sidebar; gboolean header_bar_sidebar_visible; gboolean header_bar_modal_barrier_visible; - gint header_bar_modal_barrier_depth; + gint header_bar_modal_barrier_shade_depth; gboolean header_bar_theme_received; GtkWindow* main_window; GtkWindow* header_focus_transient_window; @@ -2210,10 +2210,13 @@ static void refresh_header_bar_css(MyApplication* self) { 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_depth); + 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(); @@ -2719,13 +2722,23 @@ static void schedule_header_bar_focus_state_refresh(MyApplication* self) { g_object_ref(self), g_object_unref); } -static void set_header_bar_modal_barrier_depth(MyApplication* self, - gint depth) { - const gint effective_depth = std::max(0, depth); - const gboolean visible = effective_depth > 0; - self->header_bar_modal_barrier_depth = effective_depth; +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; - refresh_header_bar_css(self); + 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 = @@ -2750,7 +2763,14 @@ static void set_header_bar_modal_barrier_depth(MyApplication* self, static void set_header_bar_modal_barrier_visible(MyApplication* self, gboolean visible) { - set_header_bar_modal_barrier_depth(self, visible ? 1 : 0); + set_header_bar_modal_barrier_state(self, visible, visible ? 1 : 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); } static void clear_header_bar_pointer(MyApplication* self) { @@ -4336,6 +4356,16 @@ static void header_bar_method_call_cb(FlMethodChannel* channel, } 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); @@ -5651,8 +5681,8 @@ static void my_application_init(MyApplication* self) { 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->header_bar_modal_barrier_depth = 0; self->main_window = nullptr; self->header_focus_transient_window = nullptr; self->flutter_view = nullptr; diff --git a/test/app/about_dialog_test.dart b/test/app/about_dialog_test.dart index 8142a0f..b4aef80 100644 --- a/test/app/about_dialog_test.dart +++ b/test/app/about_dialog_test.dart @@ -194,10 +194,10 @@ void main() { ); expect( calls - .where((call) => call.method == 'setModalBarrierDepth') + .where((call) => call.method == 'setModalBarrierState') .single .arguments, - 1, + {'visible': true, 'shadeDepth': 1}, ); await tester.tap(find.byType(YaruWindowControl)); @@ -206,45 +206,78 @@ void main() { await result; expect(find.byType(BusyMaxAboutDialog), findsNothing); final barrierCalls = calls - .where((call) => call.method == 'setModalBarrierDepth') + .where((call) => call.method == 'setModalBarrierState') .toList(); expect(barrierCalls, hasLength(2)); - expect(barrierCalls.last.arguments, 0); + expect(barrierCalls.last.arguments, {'visible': false, 'shadeDepth': 0}); expect(tester.takeException(), isNull); }, ); for (final brightness in Brightness.values) { - testWidgets( - 'about version badge keeps accent identity and readable text in ' - '$brightness', - (tester) async { - const accent = Color(0xFF3584E4); - _setPackageInfo(version: '1.2.3', buildNumber: ''); - final theme = BusyMaxYaruTheme.build( - brightness: brightness, - accentColor: accent, - ); + testWidgets('about version badge uses the shared neutral outlined pill in ' + '$brightness', (tester) async { + const accent = Color(0xFF3584E4); + _setPackageInfo(version: '1.2.3', buildNumber: ''); + final theme = BusyMaxYaruTheme.build( + brightness: brightness, + accentColor: accent, + ); - await tester.pumpWidget( - localizedTestApp(theme: theme, child: const BusyMaxAboutDialog()), - ); - await tester.pumpAndSettle(); + await tester.pumpWidget( + localizedTestApp(theme: theme, child: const BusyMaxAboutDialog()), + ); + await tester.pumpAndSettle(); - expect(find.text('v1.2.3'), findsOneWidget); - expect(find.text('v1.2.3+'), findsNothing); - final badge = tester.widget( - find.byType(YaruTranslucentContainer), - ); - expect(badge.color, theme.colorScheme.primary); - expect(badge.opacity, 1); - expect((badge.border! as Border).dimensions, EdgeInsets.zero); - final versionText = tester.widget(find.text('v1.2.3')); - final textColor = versionText.style!.color!; - expect(textColor, theme.colorScheme.onPrimary); - expect(versionText.style?.fontWeight, FontWeight.w600); - }, - ); + expect(find.text('v1.2.3'), findsOneWidget); + expect(find.text('v1.2.3+'), findsNothing); + final colors = theme.extension()!; + final badgeFinder = find.byWidgetPredicate( + (widget) => + widget is DecoratedBox && + widget.decoration is BoxDecoration && + (widget.decoration as BoxDecoration).borderRadius == + BorderRadius.circular(BusyMaxRadius.pill), + ); + final badge = tester.widget(badgeFinder); + final decoration = badge.decoration as BoxDecoration; + expect(decoration.color, colors.control); + expect( + decoration.borderRadius, + BorderRadius.circular(BusyMaxRadius.pill), + ); + expect((decoration.border! as Border).top.color, colors.divider); + expect( + find.ancestor( + of: find.text('v1.2.3'), + matching: find.byWidgetPredicate( + (widget) => + widget is Padding && + widget.padding == + const EdgeInsets.symmetric( + horizontal: BusyMaxSpacing.md, + vertical: BusyMaxSpacing.xs, + ), + ), + ), + findsOneWidget, + ); + expect( + find.descendant( + of: badgeFinder, + matching: find.byWidgetPredicate( + (widget) => + widget is Directionality && + widget.textDirection == TextDirection.ltr, + ), + ), + findsOneWidget, + ); + final versionText = tester.widget(find.text('v1.2.3')); + final textColor = versionText.style!.color!; + expect(textColor, colors.foreground); + expect(versionText.style?.fontWeight, FontWeight.w600); + }); } for (final (brightness, dialogColor, popoverColor) in const [ @@ -343,14 +376,9 @@ void main() { expect(source, contains('headerBarService: headerBarService')); expect(dialogs, contains('acquireBusyMaxModalBarrier')); expect(dialogs, contains('releaseBusyMaxModalBarrier')); - expect( - dialogs, - contains('await acquireBusyMaxModalBarrier(headerBarService)'), - ); - expect( - dialogs, - contains('await releaseBusyMaxModalBarrier(headerBarService)'), - ); + 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('))); diff --git a/test/app/busymax_dialogs_test.dart b/test/app/busymax_dialogs_test.dart index fe6c958..bd53b0f 100644 --- a/test/app/busymax_dialogs_test.dart +++ b/test/app/busymax_dialogs_test.dart @@ -148,21 +148,21 @@ void main() { expect(find.byType(BusyMaxConfirmDialog), findsOneWidget); expect( - calls.where((call) => call.method == 'setModalBarrierDepth'), + calls.where((call) => call.method == 'setModalBarrierState'), hasLength(1), ); - expect(calls.last.arguments, 1); + expect(calls.last.arguments, {'visible': true, 'shadeDepth': 1}); await tester.tap(find.text('Remove')); await tester.pumpAndSettle(); expect(await result, isTrue); final barrierCalls = calls - .where((call) => call.method == 'setModalBarrierDepth') + .where((call) => call.method == 'setModalBarrierState') .toList(); expect(barrierCalls, hasLength(2)); - expect(barrierCalls.first.arguments, 1); - expect(barrierCalls.last.arguments, 0); + expect(barrierCalls.first.arguments, {'visible': true, 'shadeDepth': 1}); + expect(barrierCalls.last.arguments, {'visible': false, 'shadeDepth': 0}); }); testWidgets('open modal barrier follows live theme changes', (tester) async { @@ -290,7 +290,9 @@ void main() { expect(find.text('Discard changes'), findsOneWidget); }); - testWidgets('nested modals keep the native barrier active', (tester) async { + testWidgets('nested modals do not re-dim the native headerbar', ( + tester, + ) async { const channel = MethodChannel('busymax_test/nested_modal_barrier'); final calls = []; TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger @@ -328,15 +330,18 @@ void main() { 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 == 'setModalBarrierDepth') + .where((call) => call.method == 'setModalBarrierState') .map((call) => call.arguments), - [1, 2], + [ + {'visible': true, 'shadeDepth': 1}, + ], ); Navigator.of(hostContext, rootNavigator: true).pop(); @@ -344,9 +349,11 @@ void main() { await second; expect( calls - .where((call) => call.method == 'setModalBarrierDepth') + .where((call) => call.method == 'setModalBarrierState') .map((call) => call.arguments), - [1, 2, 1], + [ + {'visible': true, 'shadeDepth': 1}, + ], ); Navigator.of(hostContext, rootNavigator: true).pop(); @@ -354,9 +361,31 @@ void main() { await first; final barrierCalls = calls - .where((call) => call.method == 'setModalBarrierDepth') + .where((call) => call.method == 'setModalBarrierState') .toList(); - expect(barrierCalls.map((call) => call.arguments), [1, 2, 1, 0]); + 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', ( @@ -364,14 +393,18 @@ void main() { ) async { const channel = MethodChannel('busymax_test/serialized_modal_barrier'); final firstUpdate = Completer(); - final transitions = []; + final transitions = <({bool visible, int shadeDepth})>[]; TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger .setMockMethodCallHandler(channel, (call) async { if (call.method == 'initialize') { return true; } - if (call.method == 'setModalBarrierDepth') { - transitions.add(call.arguments! as int); + 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; } @@ -392,20 +425,23 @@ void main() { final acquire = acquireBusyMaxModalBarrier(service); await tester.pump(); - expect(transitions, [1]); + expect(transitions, [(visible: true, shadeDepth: 1)]); final release = releaseBusyMaxModalBarrier(service); await tester.pump(); expect( transitions, - [1], + [(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, [1, 0]); + expect(transitions, [ + (visible: true, shadeDepth: 1), + (visible: false, shadeDepth: 0), + ]); }); testWidgets('failed native barrier acquisition rolls back and can retry', ( @@ -420,14 +456,19 @@ void main() { ); expect( service.transitions, - [1, 0], + [(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, [1, 0, 1, 0]); + 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', ( @@ -474,10 +515,10 @@ void main() { expect(calls.first.method, 'initialize'); expect( calls - .where((call) => call.method == 'setModalBarrierDepth') + .where((call) => call.method == 'setModalBarrierState') .single .arguments, - 1, + {'visible': true, 'shadeDepth': 1}, ); await tester.tap(find.text('Cancel')); @@ -485,9 +526,9 @@ void main() { expect(await result, isFalse); final barrierCalls = calls - .where((call) => call.method == 'setModalBarrierDepth') + .where((call) => call.method == 'setModalBarrierState') .toList(); - expect(barrierCalls.last.arguments, 0); + expect(barrierCalls.last.arguments, {'visible': false, 'shadeDepth': 0}); }); testWidgets('editor dialog requires an explicit cancel action', ( @@ -690,13 +731,16 @@ class _ApplicationNavigationIntent extends Intent { class _FailingModalBarrierService extends LinuxHeaderBarService { _FailingModalBarrierService() : super(isLinux: false); - final transitions = []; + final transitions = <({bool visible, int shadeDepth})>[]; var _failNextShow = true; @override - Future setModalBarrierDepth(int value) async { - transitions.add(value); - if (value > 0 && _failNextShow) { + 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/modal_barrier_test.dart b/test/app/modal_barrier_test.dart index f3b42f5..b2f9c84 100644 --- a/test/app/modal_barrier_test.dart +++ b/test/app/modal_barrier_test.dart @@ -19,10 +19,64 @@ void main() { 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)'), + ); + }); + for (final (brightness, expectedAlpha) in [ (Brightness.light, 0.07), (Brightness.dark, 0.25), diff --git a/test/app/native_ui_audit_test.dart b/test/app/native_ui_audit_test.dart index 6fc7e17..bf7f7fb 100644 --- a/test/app/native_ui_audit_test.dart +++ b/test/app/native_ui_audit_test.dart @@ -975,6 +975,7 @@ void main() { 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, @@ -1511,12 +1512,16 @@ void main() { 'static gboolean refresh_header_bar_focus_state_cb(', ); final headerFocusEnd = runner.indexOf( - 'static void set_header_bar_modal_barrier_depth(', + 'static void set_header_bar_modal_barrier_visible(', headerFocusStart, ); expect(headerFocusStart, isNonNegative); expect(headerFocusEnd, greaterThan(headerFocusStart)); final headerFocus = runner.substring(headerFocusStart, headerFocusEnd); + expect( + headerFocus, + isNot(contains('self->header_bar_modal_barrier_visible ||')), + ); expect(headerFocus, contains('gtk_window_is_active(self->main_window)')); expect( headerFocus, @@ -1923,6 +1928,7 @@ void main() { 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)'))); diff --git a/test/features/calendar/presentation/event_editor_test.dart b/test/features/calendar/presentation/event_editor_test.dart index 2a1c82a..819bace 100644 --- a/test/features/calendar/presentation/event_editor_test.dart +++ b/test/features/calendar/presentation/event_editor_test.dart @@ -1409,14 +1409,9 @@ void main() { expect(editor, contains('showBusyMaxEventEditorDialog')); expect(editor, contains('showBusyMaxModalEditorDialog')); expect(editor, isNot(contains('showDialog'))); - expect( - dialogs, - contains('await acquireBusyMaxModalBarrier(headerBarService)'), - ); - expect( - dialogs, - contains('await releaseBusyMaxModalBarrier(headerBarService)'), - ); + expect(dialogs, contains('await acquireBusyMaxModalBarrier(')); + expect(dialogs, contains('await releaseBusyMaxModalBarrier(')); + expect(dialogs, contains('shadesHeader: shadesHeader')); expect( dialogs, contains('barrierColor ?? busyMaxModalBarrierColor(context)'), 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 1c0f564..02dfb9b 100644 --- a/test/features/schedule/presentation/schedule_workspace_task_mutations_test.dart +++ b/test/features/schedule/presentation/schedule_workspace_task_mutations_test.dart @@ -142,16 +142,22 @@ 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), + ]); await tester.enterText(find.byType(TextField).first, 'Unsaved route edit'); await tester.pump(); @@ -160,6 +166,18 @@ void main() { await tester.pumpAndSettle(); expect(find.text('Discard changes?'), findsOneWidget); + final modalBarrierColors = tester + .widgetList(find.byType(ModalBarrier)) + .map((barrier) => barrier.color) + .toList(); + 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); @@ -171,6 +189,10 @@ 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), + ]); }); } @@ -180,6 +202,7 @@ Future<_ScheduleHarness> _pumpScheduleWorkspace( String? initialTaskAccountId, String? initialTaskListId, String? initialTaskId, + LinuxHeaderBarService? headerBarService, }) async { final database = AppDatabase.memoryForTests(); addTearDown(database.close); @@ -237,9 +260,11 @@ Future<_ScheduleHarness> _pumpScheduleWorkspace( authState: accountAuthStateSignedIn, displayName: 'Schedule test', ); - final headerBarService = LinuxHeaderBarService(isLinux: false); - addTearDown(headerBarService.dispose); - + final effectiveHeaderBarService = + headerBarService ?? LinuxHeaderBarService(isLinux: false); + if (headerBarService == null) { + addTearDown(effectiveHeaderBarService.dispose); + } await tester.pumpWidget( ProviderScope( overrides: [ @@ -248,7 +273,9 @@ Future<_ScheduleHarness> _pumpScheduleWorkspace( activeAccountProvider.overrideWithValue(_accountId), localTimeZoneProvider.overrideWithValue('UTC'), localSettingsStoreProvider.overrideWithValue(_MemorySettingsStore()), - linuxHeaderBarServiceProvider.overrideWithValue(headerBarService), + linuxHeaderBarServiceProvider.overrideWithValue( + effectiveHeaderBarService, + ), taskListsRepositoryForAccountProvider.overrideWith((ref, accountId) { return TaskListsRepository(database: database, accountId: accountId); }), @@ -289,6 +316,20 @@ 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/platform/linux_header_bar_service_test.dart b/test/platform/linux_header_bar_service_test.dart index e3f2b60..7599781 100644 --- a/test/platform/linux_header_bar_service_test.dart +++ b/test/platform/linux_header_bar_service_test.dart @@ -139,10 +139,16 @@ void main() { 'setSidebarWidth', 'setTextDirection', 'setOnboardingControls', - 'setModalBarrierDepth', + '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')); @@ -183,7 +189,7 @@ void main() { calls[4].arguments, containsPair('contentWidth', busyMaxOnboardingContentMaxWidth), ); - expect(calls[5].arguments, 2); + expect(calls[5].arguments, {'visible': true, 'shadeDepth': 2}); expect( calls.last.arguments, equals({ @@ -245,10 +251,44 @@ void main() { await service.initialize(); expect(service.isAvailable, isTrue); - await service.setModalBarrierDepth(1); + 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 { From 1cbe42a16c7eb731c7eda848d4508a8b7c06db71 Mon Sep 17 00:00:00 2001 From: albert Date: Fri, 31 Jul 2026 23:35:42 -0700 Subject: [PATCH 66/73] Refactor native menu handling to improve structure and add support for custom styling --- linux/runner/my_application.cc | 363 ++++++++++++++++--------- test/app/busymax_menu_button_test.dart | 4 + test/app/native_ui_audit_test.dart | 146 +++++++--- 3 files changed, 348 insertions(+), 165 deletions(-) diff --git a/linux/runner/my_application.cc b/linux/runner/my_application.cc index 8b55014..6996efd 100644 --- a/linux/runner/my_application.cc +++ b/linux/runner/my_application.cc @@ -115,6 +115,8 @@ constexpr gint kNativeTimeZoneDialogContentHeight = 420; constexpr size_t kNativeTimeZoneResultLimit = 250; constexpr char kNativePopoverStyleClass[] = "busymax-native-popover"; constexpr char kHeaderMenuDepthStyleClass[] = "busymax-header-menu-depth"; +constexpr char kNativeMenuItemStyleClass[] = "busymax-native-menu-item"; +constexpr guint kNativeMenuContentPadding = 6; struct _MyApplication { GtkApplication parent_instance; @@ -1454,18 +1456,14 @@ static void register_native_dialogs_for_subwindow(FlView* view, g_object_unref); } -constexpr char kNativeMenuActionNamespace[] = "busymax-native-menu"; -constexpr char kNativeMenuActionIndexKey[] = "busymax-native-menu-index"; +constexpr char kNativeMenuItemIndexKey[] = "busymax-native-menu-index"; struct NativeMenuHandlerData; struct NativeMenuSession { NativeMenuHandlerData* owner; gint64 id; - size_t entry_count; GtkWidget* popover; - GMenu* model; - GSimpleActionGroup* action_group; FlMethodCall* method_call; gulong closed_signal_id; guint cleanup_source_id; @@ -1474,9 +1472,19 @@ struct NativeMenuSession { struct NativeMenuHandlerData { GtkWidget* view; + GtkWidget* input_layer; + GtkWidget* menu_layer; + GtkWidget* menu_button; NativeMenuSession* active; }; +struct NativeMenuHostWidgets { + GtkWidget* overlay; + GtkWidget* input_layer; + GtkWidget* menu_layer; + GtkWidget* menu_button; +}; + static void native_menu_session_respond(NativeMenuSession* session, gint selected_index) { if (session->method_call == nullptr) { @@ -1503,33 +1511,32 @@ static void native_menu_session_dispose(NativeMenuSession* session) { g_source_remove(session->cleanup_source_id); session->cleanup_source_id = 0; } - if (session->popover != nullptr) { if (session->closed_signal_id != 0) { g_signal_handler_disconnect(session->popover, session->closed_signal_id); session->closed_signal_id = 0; } - // A GtkPopover "closed" signal is emitted when its hide transition - // starts, not when the widget is unmapped. Complete the hide synchronously - // before destruction so GTK releases the modal grab and pointer state - // before a subsequent menu is presented. if (gtk_widget_get_visible(session->popover)) { gtk_widget_hide(session->popover); } + if (owner != nullptr && owner->menu_button != nullptr) { + gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(owner->menu_button), + FALSE); + gtk_menu_button_set_popover(GTK_MENU_BUTTON(owner->menu_button), + nullptr); + } gtk_widget_destroy(session->popover); g_clear_object(&session->popover); } - + if (owner != nullptr && owner->input_layer != nullptr) { + gtk_widget_hide(owner->input_layer); + } if (owner != nullptr && owner->view != nullptr) { - gtk_widget_insert_action_group(owner->view, kNativeMenuActionNamespace, - nullptr); if (gtk_widget_get_realized(owner->view)) { gtk_widget_grab_focus(owner->view); } } - g_clear_object(&session->model); - g_clear_object(&session->action_group); // Resolve the Dart future only after the native session is fully retired. // A resumed caller may immediately open another menu. native_menu_session_respond(session, session->pending_selected_index); @@ -1546,21 +1553,19 @@ static gboolean native_menu_cleanup_idle_cb(gpointer user_data) { static void native_menu_closed_cb(GtkPopover*, gpointer user_data) { auto* session = static_cast(user_data); if (session->cleanup_source_id == 0) { - // GtkModelButton can activate just before close begins. Deferring final - // cleanup avoids consuming the method response before the selected index is - // finalized. + // A button can activate immediately before the popover closes. Resolve the + // Dart result after GTK has released its pointer grab. session->cleanup_source_id = g_idle_add_full( G_PRIORITY_DEFAULT_IDLE, native_menu_cleanup_idle_cb, session, nullptr); } } -static void native_menu_action_activated_cb(GSimpleAction* action, - GVariant*, - gpointer user_data) { +static void native_menu_item_clicked_cb(GtkButton* button, + gpointer user_data) { auto* session = static_cast(user_data); const gint selected_index = GPOINTER_TO_INT( - g_object_get_data(G_OBJECT(action), kNativeMenuActionIndexKey)) - + g_object_get_data(G_OBJECT(button), kNativeMenuItemIndexKey)) - 1; session->pending_selected_index = selected_index; if (session->popover != nullptr) { @@ -1568,30 +1573,6 @@ static void native_menu_action_activated_cb(GSimpleAction* action, } } -static void native_menu_selection_activated_cb(GSimpleAction* action, - GVariant* parameter, - gpointer user_data) { - if (parameter == nullptr || - !g_variant_is_of_type(parameter, G_VARIANT_TYPE_STRING)) { - return; - } - const gchar* target = g_variant_get_string(parameter, nullptr); - gchar* end = nullptr; - const guint64 parsed = g_ascii_strtoull(target, &end, 10); - auto* session = static_cast(user_data); - if (target[0] == '\0' || end == nullptr || *end != '\0' || - parsed > static_cast(G_MAXINT) || - parsed >= session->entry_count) { - return; - } - - g_simple_action_set_state(action, parameter); - session->pending_selected_index = static_cast(parsed); - if (session->popover != nullptr) { - gtk_popover_popdown(GTK_POPOVER(session->popover)); - } -} - static gboolean native_menu_dismiss_active(NativeMenuHandlerData* data, gint64 session_id) { NativeMenuSession* session = data->active; @@ -1721,7 +1702,14 @@ static gboolean parse_native_menu_anchor(FlValue* args, static void show_native_menu(NativeMenuHandlerData* data, FlMethodCall* method_call, FlValue* args) { - if (data->view == nullptr || !gtk_widget_get_realized(data->view)) { + if (data->view == nullptr || data->input_layer == nullptr || + data->menu_layer == nullptr || data->menu_button == nullptr || + !gtk_widget_get_realized(data->view) || + !GTK_IS_FIXED(data->menu_layer) || + gtk_widget_get_parent(data->menu_button) != data->menu_layer || + gtk_widget_get_parent(data->menu_layer) != data->input_layer || + !GTK_IS_EVENT_BOX(data->input_layer) || + !GTK_IS_OVERLAY(gtk_widget_get_parent(data->input_layer))) { fl_method_call_respond_error(method_call, "unavailable", "The native menu host is unavailable.", nullptr, nullptr); @@ -1765,8 +1753,8 @@ static void show_native_menu(NativeMenuHandlerData* data, } size_t selected_entry_count = 0; - size_t selected_entry_index = 0; gboolean has_disabled_entry = FALSE; + gboolean has_icon = FALSE; for (size_t index = 0; index < fl_value_get_length(entries); index++) { FlValue* entry = fl_value_get_list_value(entries, index); const gchar* label = fl_lookup_string_arg(entry, "label"); @@ -1788,9 +1776,10 @@ static void show_native_menu(NativeMenuHandlerData* data, } if (selected) { selected_entry_count++; - selected_entry_index = index; } has_disabled_entry = has_disabled_entry || !enabled; + has_icon = has_icon || + (icon != nullptr && fl_value_get_string(icon)[0] != '\0'); } if (selected_entry_count > 1 || (selected_entry_count == 1 && has_disabled_entry)) { @@ -1808,89 +1797,120 @@ static void show_native_menu(NativeMenuHandlerData* data, auto* session = g_new0(NativeMenuSession, 1); session->owner = data; session->id = session_id; - session->entry_count = fl_value_get_length(entries); session->pending_selected_index = -1; session->method_call = FL_METHOD_CALL(g_object_ref(G_OBJECT(method_call))); - session->action_group = g_simple_action_group_new(); - session->model = g_menu_new(); data->active = session; - if (selected_entry_count == 1) { - g_autofree gchar* selected_target = - g_strdup_printf("%zu", selected_entry_index); - GSimpleAction* selection_action = g_simple_action_new_stateful( - "select", G_VARIANT_TYPE_STRING, - g_variant_new_string(selected_target)); - g_signal_connect(selection_action, "activate", - G_CALLBACK(native_menu_selection_activated_cb), session); - g_action_map_add_action(G_ACTION_MAP(session->action_group), - G_ACTION(selection_action)); - g_object_unref(selection_action); - } - - g_autofree gchar* selection_action_name = - g_strdup_printf("%s.select", kNativeMenuActionNamespace); + gtk_fixed_move(GTK_FIXED(data->menu_layer), data->menu_button, anchor.x, + anchor.y); + gtk_widget_set_size_request(data->menu_button, anchor.width, anchor.height); + gtk_widget_show(data->input_layer); + + session->popover = gtk_popover_new(data->menu_button); + g_object_ref_sink(session->popover); + gtk_menu_button_set_use_popover(GTK_MENU_BUTTON(data->menu_button), TRUE); + gtk_menu_button_set_direction( + GTK_MENU_BUTTON(data->menu_button), + preferred_position == GTK_POS_TOP ? GTK_ARROW_UP : GTK_ARROW_DOWN); + gtk_menu_button_set_popover(GTK_MENU_BUTTON(data->menu_button), + session->popover); + style_native_popover(session->popover); + GtkWidget* menu_box = gtk_box_new(GTK_ORIENTATION_VERTICAL, 0); + gtk_container_set_border_width(GTK_CONTAINER(menu_box), + kNativeMenuContentPadding); + gtk_widget_set_size_request(menu_box, anchor.width, -1); + gtk_container_add(GTK_CONTAINER(session->popover), menu_box); + + const GtkTextDirection menu_text_direction = + gtk_widget_get_direction(data->view) == GTK_TEXT_DIR_RTL + ? GTK_TEXT_DIR_RTL + : GTK_TEXT_DIR_LTR; + GSList* selection_group = nullptr; for (size_t index = 0; index < fl_value_get_length(entries); index++) { FlValue* entry = fl_value_get_list_value(entries, index); const gchar* label = fl_lookup_string_arg(entry, "label"); const gchar* icon_name = fl_lookup_string_arg(entry, "icon"); const gchar* shortcut = fl_lookup_string_arg(entry, "shortcut"); gboolean enabled = TRUE; + gboolean selected = FALSE; fl_lookup_optional_bool_arg(entry, "enabled", TRUE, &enabled); + fl_lookup_optional_bool_arg(entry, "selected", FALSE, &selected); - g_autoptr(GMenuItem) item = g_menu_item_new(label, nullptr); - if (icon_name != nullptr && icon_name[0] != '\0') { - g_autoptr(GIcon) icon = g_themed_icon_new(icon_name); - g_menu_item_set_icon(item, icon); - g_menu_item_set_attribute(item, kMenuIconAttribute, "s", icon_name); + GtkWidget* button = selected_entry_count == 1 + ? gtk_radio_button_new(selection_group) + : gtk_button_new(); + if (selected_entry_count == 1) { + selection_group = + gtk_radio_button_get_group(GTK_RADIO_BUTTON(button)); + gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(button), selected); + // Keep the native radio at the trailing edge while the row contents + // retain the application's text direction. + gtk_widget_set_direction( + button, menu_text_direction == GTK_TEXT_DIR_RTL ? GTK_TEXT_DIR_LTR + : GTK_TEXT_DIR_RTL); } - if (shortcut != nullptr && shortcut[0] != '\0') { - g_menu_item_set_attribute(item, kMenuShortcutAttribute, "s", shortcut); + gtk_button_set_relief(GTK_BUTTON(button), GTK_RELIEF_NONE); + gtk_widget_set_can_default(button, FALSE); + gtk_widget_set_hexpand(button, TRUE); + gtk_widget_set_sensitive(button, enabled); + gtk_style_context_add_class(gtk_widget_get_style_context(button), + GTK_STYLE_CLASS_FLAT); + gtk_style_context_add_class(gtk_widget_get_style_context(button), + kNativeMenuItemStyleClass); + g_object_set_data(G_OBJECT(button), kNativeMenuItemIndexKey, + GINT_TO_POINTER(static_cast(index) + 1)); + g_signal_connect(button, "clicked", + G_CALLBACK(native_menu_item_clicked_cb), session); + + GtkWidget* row = + gtk_box_new(GTK_ORIENTATION_HORIZONTAL, kHeaderButtonSpacing); + gtk_widget_set_direction(row, menu_text_direction); + gtk_widget_set_hexpand(row, TRUE); + if (has_icon) { + GtkWidget* icon = + icon_name != nullptr && icon_name[0] != '\0' + ? gtk_image_new_from_icon_name(icon_name, GTK_ICON_SIZE_MENU) + : gtk_image_new(); + gtk_widget_set_size_request(icon, 16, 16); + gtk_widget_set_valign(icon, GTK_ALIGN_CENTER); + gtk_box_pack_start(GTK_BOX(row), icon, FALSE, FALSE, 0); } - if (selected_entry_count == 1) { - g_autofree gchar* target = g_strdup_printf("%zu", index); - g_menu_item_set_action_and_target_value( - item, selection_action_name, g_variant_new_string(target)); - } else { - g_autofree gchar* action_name = g_strdup_printf("select-%zu", index); - GSimpleAction* action = g_simple_action_new(action_name, nullptr); - g_simple_action_set_enabled(action, enabled); - g_object_set_data(G_OBJECT(action), kNativeMenuActionIndexKey, - GINT_TO_POINTER(static_cast(index) + 1)); - g_signal_connect(action, "activate", - G_CALLBACK(native_menu_action_activated_cb), session); - g_action_map_add_action(G_ACTION_MAP(session->action_group), - G_ACTION(action)); - g_autofree gchar* detailed_action = - g_strdup_printf("%s.%s", kNativeMenuActionNamespace, action_name); - g_menu_item_set_detailed_action(item, detailed_action); - g_object_unref(action); + + GtkWidget* label_widget = gtk_label_new(label); + gtk_label_set_xalign(GTK_LABEL(label_widget), 0.0); + gtk_label_set_ellipsize(GTK_LABEL(label_widget), PANGO_ELLIPSIZE_END); + gtk_widget_set_hexpand(label_widget, TRUE); + gtk_widget_set_halign(label_widget, GTK_ALIGN_FILL); + gtk_widget_set_valign(label_widget, GTK_ALIGN_CENTER); + gtk_box_pack_start(GTK_BOX(row), label_widget, TRUE, TRUE, 0); + + if (shortcut != nullptr && shortcut[0] != '\0') { + GtkWidget* shortcut_widget = gtk_label_new(shortcut); + gtk_widget_set_direction(shortcut_widget, GTK_TEXT_DIR_LTR); + gtk_label_set_xalign(GTK_LABEL(shortcut_widget), 1.0); + gtk_widget_set_halign(shortcut_widget, GTK_ALIGN_END); + gtk_widget_set_valign(shortcut_widget, GTK_ALIGN_CENTER); + gtk_style_context_add_class( + gtk_widget_get_style_context(shortcut_widget), + GTK_STYLE_CLASS_DIM_LABEL); + gtk_box_pack_end(GTK_BOX(row), shortcut_widget, FALSE, FALSE, 0); } - g_menu_append_item(session->model, item); + + gtk_container_add(GTK_CONTAINER(button), row); + gtk_box_pack_start(GTK_BOX(menu_box), button, FALSE, FALSE, 0); } - gtk_widget_insert_action_group( - data->view, kNativeMenuActionNamespace, - G_ACTION_GROUP(session->action_group)); - session->popover = gtk_popover_new_from_model(data->view, - G_MENU_MODEL(session->model)); - g_object_ref_sink(session->popover); - style_native_popover(session->popover); - decorate_model_menu_shortcuts(session->popover, - G_MENU_MODEL(session->model)); - gtk_popover_set_pointing_to(GTK_POPOVER(session->popover), &anchor); - gtk_popover_set_position(GTK_POPOVER(session->popover), - preferred_position); + gtk_widget_show_all(menu_box); + gtk_popover_set_position(GTK_POPOVER(session->popover), preferred_position); gtk_popover_set_constrain_to(GTK_POPOVER(session->popover), GTK_POPOVER_CONSTRAINT_WINDOW); gtk_popover_set_modal(GTK_POPOVER(session->popover), TRUE); - session->closed_signal_id = - g_signal_connect(session->popover, "closed", G_CALLBACK(native_menu_closed_cb), - session); - // Use GTK's popover lifecycle so model-button pseudo states (hover) are - // dispatched correctly. - gtk_popover_popup(GTK_POPOVER(session->popover)); + session->closed_signal_id = g_signal_connect( + session->popover, "closed", G_CALLBACK(native_menu_closed_cb), session); + // This is the proven Local History path: let a mapped GtkMenuButton own the + // popup and its Wayland input lifecycle, just like the header-bar menus. + gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(data->menu_button), TRUE); if (focus_first) { gtk_widget_child_focus(session->popover, GTK_DIR_TAB_FORWARD); } @@ -1906,6 +1926,21 @@ static void native_menu_handler_data_free(gpointer user_data) { G_OBJECT(data->view), reinterpret_cast(&data->view)); } + if (data->input_layer != nullptr) { + g_object_remove_weak_pointer( + G_OBJECT(data->input_layer), + reinterpret_cast(&data->input_layer)); + } + if (data->menu_layer != nullptr) { + g_object_remove_weak_pointer( + G_OBJECT(data->menu_layer), + reinterpret_cast(&data->menu_layer)); + } + if (data->menu_button != nullptr) { + g_object_remove_weak_pointer( + G_OBJECT(data->menu_button), + reinterpret_cast(&data->menu_button)); + } g_free(data); } @@ -1931,28 +1966,78 @@ static void native_menu_method_call_cb(FlMethodChannel*, } } -static FlMethodChannel* create_native_menu_channel(FlView* view) { +static NativeMenuHostWidgets create_native_menu_host(FlView* view) { + NativeMenuHostWidgets host = {}; + host.overlay = gtk_overlay_new(); + gtk_container_add(GTK_CONTAINER(host.overlay), GTK_WIDGET(view)); + + host.input_layer = gtk_event_box_new(); + gtk_event_box_set_above_child(GTK_EVENT_BOX(host.input_layer), TRUE); + gtk_event_box_set_visible_window(GTK_EVENT_BOX(host.input_layer), FALSE); + gtk_widget_set_halign(host.input_layer, GTK_ALIGN_FILL); + gtk_widget_set_valign(host.input_layer, GTK_ALIGN_FILL); + gtk_overlay_add_overlay(GTK_OVERLAY(host.overlay), host.input_layer); + + host.menu_layer = gtk_fixed_new(); + gtk_container_add(GTK_CONTAINER(host.input_layer), host.menu_layer); + + host.menu_button = gtk_menu_button_new(); + gtk_widget_set_opacity(host.menu_button, 0); + gtk_widget_set_can_focus(host.menu_button, FALSE); + gtk_widget_set_focus_on_click(host.menu_button, FALSE); + gtk_widget_set_size_request(host.menu_button, 1, 1); + gtk_fixed_put(GTK_FIXED(host.menu_layer), host.menu_button, 0, 0); + + gtk_widget_show(host.menu_button); + gtk_widget_show(host.menu_layer); + // Keep the native input layer unmapped except while a modal menu is open so + // Flutter remains the content input owner at every other time. + gtk_widget_set_no_show_all(host.input_layer, TRUE); + gtk_widget_hide(host.input_layer); + gtk_widget_show(host.overlay); + return host; +} + +static FlMethodChannel* create_native_menu_channel( + FlView* view, + const NativeMenuHostWidgets& host) { 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)), kNativeMenuChannel, FL_METHOD_CODEC(codec)); auto* data = g_new0(NativeMenuHandlerData, 1); data->view = GTK_WIDGET(view); + data->input_layer = host.input_layer; + data->menu_layer = host.menu_layer; + data->menu_button = host.menu_button; g_object_add_weak_pointer(G_OBJECT(data->view), reinterpret_cast(&data->view)); + g_object_add_weak_pointer( + G_OBJECT(data->input_layer), + reinterpret_cast(&data->input_layer)); + g_object_add_weak_pointer( + G_OBJECT(data->menu_layer), + reinterpret_cast(&data->menu_layer)); + g_object_add_weak_pointer( + G_OBJECT(data->menu_button), + reinterpret_cast(&data->menu_button)); fl_method_channel_set_method_call_handler( channel, native_menu_method_call_cb, data, native_menu_handler_data_free); return channel; } -static void register_native_menus(MyApplication* self, FlView* view) { - self->native_menu_channel = create_native_menu_channel(view); +static void register_native_menus(MyApplication* self, + FlView* view, + const NativeMenuHostWidgets& host) { + self->native_menu_channel = create_native_menu_channel(view, host); } -static void register_native_menus_for_subwindow(FlView* view, - GtkWindow* window) { - FlMethodChannel* channel = create_native_menu_channel(view); +static void register_native_menus_for_subwindow( + FlView* view, + GtkWindow* window, + const NativeMenuHostWidgets& host) { + FlMethodChannel* channel = create_native_menu_channel(view, host); g_object_set_data_full(G_OBJECT(window), "busymax-native-menus", channel, g_object_unref); } @@ -2220,6 +2305,12 @@ static void refresh_header_bar_css(MyApplication* self) { const gboolean use_legacy_yaru_compatibility = !self->header_bar_high_contrast && current_gtk_theme_uses_legacy_yaru_shadow(); + g_autofree gchar* native_menu_geometry_css = g_strdup_printf( + "popover.background.%s .%s {" + "font-size: 0.92em;" + "padding: 2px 6px;" + "}", + kNativePopoverStyleClass, kNativeMenuItemStyleClass); g_autofree gchar* native_search_geometry_css = use_legacy_yaru_compatibility ? g_strdup_printf( @@ -2241,10 +2332,24 @@ static void refresh_header_bar_css(MyApplication* self) { "row:hover:not(:disabled) {" "background-color: %s;" "background-image: none;" + "}" + "popover.background.%s " + ".%s:hover:not(:disabled)," + "popover.background.%s " + ".%s:focus:not(:disabled) {" + "background-color: %s;" + "background-image: none;" + "border-color: transparent;" + "outline-width: 0;" "}", kNativePopoverStyleClass, self->header_bar_menu_hover_color, kNativePopoverStyleClass, + self->header_bar_menu_hover_color, + kNativePopoverStyleClass, + kNativeMenuItemStyleClass, + kNativePopoverStyleClass, + kNativeMenuItemStyleClass, self->header_bar_menu_hover_color) : g_strdup(""); g_autofree gchar* header_menu_shadow_css = @@ -2572,6 +2677,7 @@ static void refresh_header_bar_css(MyApplication* self) { "%s" "%s" "%s" + "%s" ".busymax-titlebar .%s," ".busymax-titlebar .%s:backdrop {" "background-color: %s;" @@ -2591,7 +2697,8 @@ static void refresh_header_bar_css(MyApplication* self) { kHeaderModalOpenStyleClass, kHeaderModalOpenStyleClass, kHeaderModalOpenStyleClass, kHeaderModalOpenStyleClass, kHeaderModalOpenStyleClass, kHeaderModalOpenStyleClass, - native_popover_css, native_menu_state_css, header_menu_shadow_css, + native_popover_css, native_menu_geometry_css, native_menu_state_css, + header_menu_shadow_css, kHeaderModalBarrierStyleClass, kHeaderModalBarrierStyleClass, modal_barrier_color); @@ -5391,7 +5498,18 @@ static void configure_compact_agenda_subwindow(FlPluginRegistry* registry) { register_compact_gtk_settings_channel(view, window); register_native_date_time_picker_for_subwindow(view, window); register_native_dialogs_for_subwindow(view, window); - register_native_menus_for_subwindow(view, window); + GtkWidget* view_parent = gtk_widget_get_parent(GTK_WIDGET(view)); + if (!GTK_IS_BIN(view_parent) || + gtk_bin_get_child(GTK_BIN(view_parent)) != GTK_WIDGET(view)) { + g_warning("Unable to install the native menu host in the compact window"); + return; + } + g_object_ref(view); + gtk_container_remove(GTK_CONTAINER(view_parent), GTK_WIDGET(view)); + NativeMenuHostWidgets native_menu_host = create_native_menu_host(view); + gtk_container_add(GTK_CONTAINER(view_parent), native_menu_host.overlay); + g_object_unref(view); + register_native_menus_for_subwindow(view, window, native_menu_host); } // Called when first Flutter frame received. @@ -5444,10 +5562,13 @@ static void my_application_activate(GApplication* application) { set_main_flutter_view_background(self); gtk_widget_show(GTK_WIDGET(view)); + NativeMenuHostWidgets native_menu_host = create_native_menu_host(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_box_pack_start(GTK_BOX(window_content), native_menu_host.overlay, TRUE, + TRUE, 0); gtk_widget_show(window_content); gtk_container_add(GTK_CONTAINER(window), window_content); @@ -5465,7 +5586,7 @@ static void my_application_activate(GApplication* application) { }); register_native_date_time_picker(self, view, window); register_native_dialogs(self, view, window); - register_native_menus(self, view); + register_native_menus(self, view, native_menu_host); register_window_channel(self, view); register_header_bar_channel(self, view); register_gtk_settings_channel(self, view); diff --git a/test/app/busymax_menu_button_test.dart b/test/app/busymax_menu_button_test.dart index 6050f9b..f0a53f4 100644 --- a/test/app/busymax_menu_button_test.dart +++ b/test/app/busymax_menu_button_test.dart @@ -154,6 +154,10 @@ void main() { hoveredPixel, _colorCloseTo(Color.alphaBlend(colors.controlHover, colors.popover)), ); + await mouse.moveTo(Offset.zero); + await tester.pumpAndSettle(); + final restoredPixel = await _capturePixel(tester, boundaryKey, hoverProbe); + expect(restoredPixel, idlePixel); controller.close(); await tester.pumpAndSettle(); diff --git a/test/app/native_ui_audit_test.dart b/test/app/native_ui_audit_test.dart index bf7f7fb..34898ef 100644 --- a/test/app/native_ui_audit_test.dart +++ b/test/app/native_ui_audit_test.dart @@ -880,7 +880,7 @@ void main() { 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(source, isNot(contains('"object-select-symbolic"'))); + 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'))); @@ -1091,7 +1091,7 @@ void main() { ); 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('gtk_event_box_new()'.allMatches(source).length, 2); 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'))); @@ -1297,12 +1297,12 @@ void main() { expect(source, isNot(contains('"openMenu"'))); }); - test('Linux content menus are native GTK model popovers', () { + test('Linux content menus use explicit native GTK popover buttons', () { 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 start = runner.indexOf('constexpr char kNativeMenuItemIndexKey'); final end = runner.indexOf('static void respond_success', start); expect(start, isNonNegative); @@ -1320,51 +1320,102 @@ void main() { final dispose = nativeMenu.substring(disposeStart, disposeEnd); expect(runner, contains('"busymax/native_menus"')); - expect(nativeMenu, contains('gtk_popover_new_from_model(')); - expect(nativeMenu, contains('gtk_popover_set_pointing_to(')); - expect(nativeMenu, contains('gtk_popover_set_modal(')); + expect(nativeMenu, contains('struct NativeMenuHostWidgets')); + expect(nativeMenu, contains('gtk_event_box_set_above_child(')); + expect(nativeMenu, contains('gtk_widget_show(data->input_layer)')); + expect( + nativeMenu, + contains('session->popover = gtk_popover_new(data->menu_button)'), + ); + expect(nativeMenu, isNot(contains('gtk_popover_new(data->view)'))); + expect(nativeMenu, contains('gtk_menu_button_set_popover(')); + expect( + nativeMenu, + contains( + 'gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(data->menu_button), TRUE)', + ), + ); expect(nativeMenu, contains('style_native_popover(session->popover)')); expect( nativeMenu, - contains('decorate_model_menu_shortcuts(session->popover'), - ); - expect(nativeMenu, contains('g_simple_action_set_enabled(')); - expect(nativeMenu, contains('g_themed_icon_new(icon_name)')); - expect(nativeMenu, contains('g_menu_item_set_icon(item, icon)')); - expect(nativeMenu, contains('kMenuIconAttribute')); - expect(runner, contains('add_model_button_presentation(')); - expect(nativeMenu, contains('g_simple_action_new_stateful(')); - expect(nativeMenu, contains('g_object_ref(G_OBJECT(method_call))')); + contains('gtk_container_set_border_width(GTK_CONTAINER(menu_box),'), + ); + expect(nativeMenu, contains('kNativeMenuContentPadding')); + expect(nativeMenu, contains('gtk_button_new()')); + expect( + nativeMenu, + contains('gtk_button_set_relief(GTK_BUTTON(button), GTK_RELIEF_NONE)'), + ); + expect(nativeMenu, contains('GTK_STYLE_CLASS_FLAT')); + expect(nativeMenu, contains('kNativeMenuItemStyleClass')); + expect(nativeMenu, contains('gtk_radio_button_new(selection_group)')); + expect(nativeMenu, contains('gtk_radio_button_get_group(')); + expect(nativeMenu, contains('gtk_toggle_button_set_active(')); expect( nativeMenu, - anyOf( - contains('gtk_popover_popup(session->popover)'), - contains('gtk_popover_popup(GTK_POPOVER(session->popover))'), + contains('gtk_widget_get_direction(data->view) == GTK_TEXT_DIR_RTL'), + ); + expect( + nativeMenu, + contains('gtk_widget_set_direction(\n button,'), + ); + expect( + nativeMenu, + contains('gtk_widget_set_direction(row, menu_text_direction)'), + ); + expect(nativeMenu, isNot(contains('gtk_drawing_area_new()'))); + expect(nativeMenu, isNot(contains('gtk_cell_renderer_render('))); + expect(nativeMenu, contains('native_menu_item_clicked_cb')); + expect(nativeMenu, contains('G_CALLBACK(native_menu_item_clicked_cb)')); + expect(nativeMenu, contains('gtk_widget_set_sensitive(button, enabled)')); + expect(nativeMenu, isNot(contains('"object-select-symbolic"'))); + expect(nativeMenu, contains('gtk_image_new_from_icon_name(')); + expect(nativeMenu, contains('gtk_label_new(shortcut)')); + expect(nativeMenu, contains('GTK_STYLE_CLASS_DIM_LABEL')); + expect(nativeMenu, contains('gtk_widget_show_all(menu_box)')); + expect(nativeMenu, contains('gtk_popover_set_modal(')); + expect(nativeMenu, isNot(contains('gtk_popover_popup('))); + expect( + nativeMenu, + contains( + 'gtk_widget_child_focus(session->popover, GTK_DIR_TAB_FORWARD)', ), ); - expect(nativeMenu, isNot(contains('gtk_widget_show(session->popover)'))); + expect(nativeMenu, contains('gtk_overlay_add_overlay(')); + expect( + nativeMenu, + isNot(contains('gtk_overlay_set_overlay_pass_through(')), + ); + expect(nativeMenu, contains('gtk_menu_button_new()')); + expect(nativeMenu, isNot(contains('gtk_menu_button_set_menu_model('))); + expect(nativeMenu, isNot(contains('gtk_menu_new_from_model('))); + expect(nativeMenu, isNot(contains('gtk_menu_popup_at_rect('))); + expect(nativeMenu, isNot(contains('gtk_popover_new_from_model('))); + 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, contains('g_object_ref(G_OBJECT(method_call))')); + expect(nativeMenu, isNot(contains('gtk_popover_bind_model('))); expect( nativeMenu, isNot(contains('gtk_widget_show_all(session->popover)')), ); - expect(nativeMenu, isNot(contains('gtk_popover_bind_model('))); - expect(nativeMenu, contains('gtk_widget_destroy(session->popover)')); + expect(dispose, contains('gtk_widget_hide(session->popover)')); + expect(dispose, contains('gtk_widget_hide(owner->input_layer)')); + expect(dispose, contains('gtk_menu_button_set_popover(')); + expect(dispose, contains('gtk_widget_destroy(session->popover)')); + final hideIndex = dispose.indexOf('gtk_widget_hide(session->popover)'); final destroyIndex = dispose.indexOf( 'gtk_widget_destroy(session->popover)', ); - final hideIndex = dispose.indexOf('gtk_widget_hide(session->popover)'); - final clearActionsIndex = dispose.indexOf( - 'g_clear_object(&session->action_group)', - ); final respondIndex = dispose.indexOf('native_menu_session_respond('); final freeIndex = dispose.indexOf('g_free(session)'); - expect(destroyIndex, isNonNegative); expect(hideIndex, isNonNegative); - expect(clearActionsIndex, isNonNegative); + expect(destroyIndex, isNonNegative); expect(respondIndex, isNonNegative); expect(freeIndex, isNonNegative); expect(hideIndex, lessThan(destroyIndex)); - expect(clearActionsIndex, lessThan(respondIndex)); + expect(destroyIndex, lessThan(respondIndex)); expect(respondIndex, lessThan(freeIndex)); expect( 'native_menu_session_respond('.allMatches(nativeMenu), @@ -1372,23 +1423,9 @@ void main() { ); expect( nativeMenu, - contains('g_signal_connect(session->popover, "closed"'), + contains('"closed", G_CALLBACK(native_menu_closed_cb)'), ); expect(nativeMenu, isNot(contains('"unmap"'))); - expect( - nativeMenu, - contains( - 'gtk_widget_child_focus(session->popover, GTK_DIR_TAB_FORWARD)', - ), - ); - expect( - nativeMenu, - isNot(contains('gtk_widget_grab_focus(session->popover)')), - ); - expect( - nativeMenu, - isNot(contains('gtk_widget_set_can_focus(session->popover')), - ); expect(nativeMenu, isNot(contains('gtk_dialog_run('))); expect(nativeMenu, isNot(contains('gtk_menu_new('))); expect(nativeMenu, isNot(contains('gtk_widget_override'))); @@ -2054,6 +2091,11 @@ void main() { expect(nativeMenuStateCss, isNot(contains('modelbutton.flat'))); expect(nativeMenuStateCss, contains('"background-color: %s;"')); expect(nativeMenuStateCss, contains('"background-image: none;"')); + expect(nativeMenuStateCss, contains('".%s:hover:not(:disabled),"')); + expect(nativeMenuStateCss, contains('".%s:focus:not(:disabled) {"')); + expect(nativeMenuStateCss, contains('"border-color: transparent;"')); + expect(nativeMenuStateCss, contains('"outline-width: 0;"')); + expect(nativeMenuStateCss, isNot(contains('"outline-style: none;"'))); expect(nativeMenuStateCss, contains('self->header_bar_menu_hover_color')); expect(nativeMenuStateCss, contains('kNativePopoverStyleClass')); expect(nativeMenuStateCss, isNot(contains('border-radius'))); @@ -2064,6 +2106,22 @@ void main() { expect(nativeMenuStateCss, isNot(contains('min-height'))); expect(nativeMenuStateCss, isNot(contains('#'))); expect(nativeMenuStateCss, isNot(contains('rgba('))); + expect( + source, + contains('constexpr guint kNativeMenuContentPadding = 6;'), + ); + expect(source, contains('g_autofree gchar* native_menu_geometry_css')); + expect(source, contains('"popover.background.%s .%s {"')); + expect(source, contains('"font-size: 0.92em;"')); + expect(source, contains('"padding: 2px 6px;"')); + expect(source, isNot(contains('kNativeMenuRadioLtrStyleClass'))); + expect(source, isNot(contains('kNativeMenuRadioRtlStyleClass'))); + expect( + source, + contains( + 'native_popover_css, native_menu_geometry_css, native_menu_state_css,', + ), + ); expect( headerMenuShadowCss, contains('"popover.background.%s.%s:not(:backdrop) {"'), From 771c81df3beada8d628c83dd06b613c37db1907f Mon Sep 17 00:00:00 2001 From: albert Date: Sat, 1 Aug 2026 18:29:59 -0700 Subject: [PATCH 67/73] Refactor native menu handling to improve action management and shortcut decoration --- linux/runner/my_application.cc | 283 +++++++++++------- test/app/native_ui_audit_test.dart | 82 ++--- .../tools/install_linux_dev_desktop_test.dart | 98 ++++++ 3 files changed, 308 insertions(+), 155 deletions(-) create mode 100644 test/tools/install_linux_dev_desktop_test.dart diff --git a/linux/runner/my_application.cc b/linux/runner/my_application.cc index 6996efd..9b37fb9 100644 --- a/linux/runner/my_application.cc +++ b/linux/runner/my_application.cc @@ -116,7 +116,6 @@ constexpr size_t kNativeTimeZoneResultLimit = 250; constexpr char kNativePopoverStyleClass[] = "busymax-native-popover"; constexpr char kHeaderMenuDepthStyleClass[] = "busymax-header-menu-depth"; constexpr char kNativeMenuItemStyleClass[] = "busymax-native-menu-item"; -constexpr guint kNativeMenuContentPadding = 6; struct _MyApplication { GtkApplication parent_instance; @@ -1456,15 +1455,21 @@ static void register_native_dialogs_for_subwindow(FlView* view, g_object_unref); } -constexpr char kNativeMenuItemIndexKey[] = "busymax-native-menu-index"; +constexpr char kNativeMenuActionNamespace[] = "busymax-native-menu"; +constexpr char kNativeMenuActionIndexKey[] = "busymax-native-menu-index"; struct NativeMenuHandlerData; struct NativeMenuSession { NativeMenuHandlerData* owner; gint64 id; + size_t entry_count; GtkWidget* popover; + GMenu* model; + GSimpleActionGroup* action_group; FlMethodCall* method_call; + GPtrArray* shortcut_labels; + GPtrArray* icon_names; gulong closed_signal_id; guint cleanup_source_id; gint pending_selected_index; @@ -1520,15 +1525,16 @@ static void native_menu_session_dispose(NativeMenuSession* session) { if (gtk_widget_get_visible(session->popover)) { gtk_widget_hide(session->popover); } - if (owner != nullptr && owner->menu_button != nullptr) { - gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(owner->menu_button), - FALSE); - gtk_menu_button_set_popover(GTK_MENU_BUTTON(owner->menu_button), - nullptr); - } - gtk_widget_destroy(session->popover); - g_clear_object(&session->popover); } + if (owner != nullptr && owner->menu_button != nullptr) { + gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(owner->menu_button), + FALSE); + gtk_menu_button_set_menu_model(GTK_MENU_BUTTON(owner->menu_button), + nullptr); + gtk_widget_insert_action_group(owner->menu_button, + kNativeMenuActionNamespace, nullptr); + } + g_clear_object(&session->popover); if (owner != nullptr && owner->input_layer != nullptr) { gtk_widget_hide(owner->input_layer); } @@ -1537,6 +1543,10 @@ static void native_menu_session_dispose(NativeMenuSession* session) { gtk_widget_grab_focus(owner->view); } } + g_clear_pointer(&session->shortcut_labels, g_ptr_array_unref); + g_clear_pointer(&session->icon_names, g_ptr_array_unref); + g_clear_object(&session->model); + g_clear_object(&session->action_group); // Resolve the Dart future only after the native session is fully retired. // A resumed caller may immediately open another menu. native_menu_session_respond(session, session->pending_selected_index); @@ -1560,14 +1570,38 @@ static void native_menu_closed_cb(GtkPopover*, gpointer user_data) { } } -static void native_menu_item_clicked_cb(GtkButton* button, - gpointer user_data) { +static void native_menu_action_activated_cb(GSimpleAction* action, + GVariant*, + gpointer user_data) { auto* session = static_cast(user_data); - const gint selected_index = + session->pending_selected_index = GPOINTER_TO_INT( - g_object_get_data(G_OBJECT(button), kNativeMenuItemIndexKey)) - + g_object_get_data(G_OBJECT(action), kNativeMenuActionIndexKey)) - 1; - session->pending_selected_index = selected_index; + if (session->popover != nullptr) { + gtk_popover_popdown(GTK_POPOVER(session->popover)); + } +} + +static void native_menu_selection_activated_cb(GSimpleAction* action, + GVariant* parameter, + gpointer user_data) { + if (parameter == nullptr || + !g_variant_is_of_type(parameter, G_VARIANT_TYPE_STRING)) { + return; + } + const gchar* target = g_variant_get_string(parameter, nullptr); + gchar* end = nullptr; + const guint64 parsed = g_ascii_strtoull(target, &end, 10); + auto* session = static_cast(user_data); + if (target[0] == '\0' || end == nullptr || *end != '\0' || + parsed > static_cast(G_MAXINT) || + parsed >= session->entry_count) { + return; + } + + g_simple_action_set_state(action, parameter); + session->pending_selected_index = static_cast(parsed); if (session->popover != nullptr) { gtk_popover_popdown(GTK_POPOVER(session->popover)); } @@ -1699,6 +1733,47 @@ static gboolean parse_native_menu_anchor(FlValue* args, return TRUE; } +struct NativeMenuShortcutDecoration { + GPtrArray* labels; + GPtrArray* icon_names; + guint index; +}; + +static void decorate_native_menu_shortcuts_cb(GtkWidget* widget, + gpointer user_data) { + auto* decoration = static_cast(user_data); + if (GTK_IS_MODEL_BUTTON(widget)) { + gtk_style_context_add_class(gtk_widget_get_style_context(widget), + kNativeMenuItemStyleClass); + if (decoration->index < decoration->labels->len) { + add_model_button_presentation( + widget, + static_cast( + g_ptr_array_index(decoration->icon_names, decoration->index)), + static_cast( + g_ptr_array_index(decoration->labels, decoration->index))); + } + decoration->index++; + return; + } + if (GTK_IS_CONTAINER(widget)) { + gtk_container_foreach(GTK_CONTAINER(widget), + decorate_native_menu_shortcuts_cb, user_data); + } +} + +static void decorate_native_menu_shortcuts(GtkWidget* popover, + GPtrArray* labels, + GPtrArray* icon_names) { + if (popover == nullptr || !GTK_IS_CONTAINER(popover) || labels == nullptr || + icon_names == nullptr) { + return; + } + NativeMenuShortcutDecoration decoration = {labels, icon_names, 0}; + gtk_container_foreach(GTK_CONTAINER(popover), + decorate_native_menu_shortcuts_cb, &decoration); +} + static void show_native_menu(NativeMenuHandlerData* data, FlMethodCall* method_call, FlValue* args) { @@ -1754,7 +1829,6 @@ static void show_native_menu(NativeMenuHandlerData* data, size_t selected_entry_count = 0; gboolean has_disabled_entry = FALSE; - gboolean has_icon = FALSE; for (size_t index = 0; index < fl_value_get_length(entries); index++) { FlValue* entry = fl_value_get_list_value(entries, index); const gchar* label = fl_lookup_string_arg(entry, "label"); @@ -1778,8 +1852,6 @@ static void show_native_menu(NativeMenuHandlerData* data, selected_entry_count++; } has_disabled_entry = has_disabled_entry || !enabled; - has_icon = has_icon || - (icon != nullptr && fl_value_get_string(icon)[0] != '\0'); } if (selected_entry_count > 1 || (selected_entry_count == 1 && has_disabled_entry)) { @@ -1797,115 +1869,116 @@ static void show_native_menu(NativeMenuHandlerData* data, auto* session = g_new0(NativeMenuSession, 1); session->owner = data; session->id = session_id; + session->entry_count = fl_value_get_length(entries); session->pending_selected_index = -1; session->method_call = FL_METHOD_CALL(g_object_ref(G_OBJECT(method_call))); + session->action_group = g_simple_action_group_new(); + session->model = g_menu_new(); + session->shortcut_labels = g_ptr_array_new_with_free_func(g_free); + session->icon_names = g_ptr_array_new_with_free_func(g_free); data->active = session; - gtk_fixed_move(GTK_FIXED(data->menu_layer), data->menu_button, anchor.x, - anchor.y); - gtk_widget_set_size_request(data->menu_button, anchor.width, anchor.height); - gtk_widget_show(data->input_layer); + GSimpleAction* selection_action = nullptr; + g_autofree gchar* detailed_selection_action = nullptr; + if (selected_entry_count == 1) { + g_autofree gchar* selected_target = nullptr; + for (size_t index = 0; index < fl_value_get_length(entries); index++) { + FlValue* entry = fl_value_get_list_value(entries, index); + gboolean selected = FALSE; + fl_lookup_optional_bool_arg(entry, "selected", FALSE, &selected); + if (selected) { + selected_target = g_strdup_printf("%zu", index); + break; + } + } + selection_action = g_simple_action_new_stateful( + "select", G_VARIANT_TYPE_STRING, + g_variant_new_string(selected_target != nullptr ? selected_target + : "")); + g_signal_connect(selection_action, "activate", + G_CALLBACK(native_menu_selection_activated_cb), session); + g_action_map_add_action(G_ACTION_MAP(session->action_group), + G_ACTION(selection_action)); + detailed_selection_action = + g_strdup_printf("%s.select", kNativeMenuActionNamespace); + } - session->popover = gtk_popover_new(data->menu_button); - g_object_ref_sink(session->popover); - gtk_menu_button_set_use_popover(GTK_MENU_BUTTON(data->menu_button), TRUE); - gtk_menu_button_set_direction( - GTK_MENU_BUTTON(data->menu_button), - preferred_position == GTK_POS_TOP ? GTK_ARROW_UP : GTK_ARROW_DOWN); - gtk_menu_button_set_popover(GTK_MENU_BUTTON(data->menu_button), - session->popover); - style_native_popover(session->popover); - GtkWidget* menu_box = gtk_box_new(GTK_ORIENTATION_VERTICAL, 0); - gtk_container_set_border_width(GTK_CONTAINER(menu_box), - kNativeMenuContentPadding); - gtk_widget_set_size_request(menu_box, anchor.width, -1); - gtk_container_add(GTK_CONTAINER(session->popover), menu_box); - - const GtkTextDirection menu_text_direction = - gtk_widget_get_direction(data->view) == GTK_TEXT_DIR_RTL - ? GTK_TEXT_DIR_RTL - : GTK_TEXT_DIR_LTR; - GSList* selection_group = nullptr; for (size_t index = 0; index < fl_value_get_length(entries); index++) { FlValue* entry = fl_value_get_list_value(entries, index); const gchar* label = fl_lookup_string_arg(entry, "label"); const gchar* icon_name = fl_lookup_string_arg(entry, "icon"); const gchar* shortcut = fl_lookup_string_arg(entry, "shortcut"); gboolean enabled = TRUE; - gboolean selected = FALSE; fl_lookup_optional_bool_arg(entry, "enabled", TRUE, &enabled); - fl_lookup_optional_bool_arg(entry, "selected", FALSE, &selected); - - GtkWidget* button = selected_entry_count == 1 - ? gtk_radio_button_new(selection_group) - : gtk_button_new(); - if (selected_entry_count == 1) { - selection_group = - gtk_radio_button_get_group(GTK_RADIO_BUTTON(button)); - gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(button), selected); - // Keep the native radio at the trailing edge while the row contents - // retain the application's text direction. - gtk_widget_set_direction( - button, menu_text_direction == GTK_TEXT_DIR_RTL ? GTK_TEXT_DIR_LTR - : GTK_TEXT_DIR_RTL); + + g_autoptr(GMenuItem) item = g_menu_item_new(label, nullptr); + if (selection_action != nullptr) { + g_autofree gchar* target = g_strdup_printf("%zu", index); + g_menu_item_set_action_and_target_value( + item, detailed_selection_action, g_variant_new_string(target)); + } else { + g_autofree gchar* action_name = g_strdup_printf("select-%zu", index); + GSimpleAction* action = g_simple_action_new(action_name, nullptr); + g_simple_action_set_enabled(action, enabled); + g_object_set_data(G_OBJECT(action), kNativeMenuActionIndexKey, + GINT_TO_POINTER(static_cast(index) + 1)); + g_signal_connect(action, "activate", + G_CALLBACK(native_menu_action_activated_cb), session); + g_action_map_add_action(G_ACTION_MAP(session->action_group), + G_ACTION(action)); + g_autofree gchar* detailed_action = + g_strdup_printf("%s.%s", kNativeMenuActionNamespace, action_name); + g_menu_item_set_detailed_action(item, detailed_action); + g_object_unref(action); } - gtk_button_set_relief(GTK_BUTTON(button), GTK_RELIEF_NONE); - gtk_widget_set_can_default(button, FALSE); - gtk_widget_set_hexpand(button, TRUE); - gtk_widget_set_sensitive(button, enabled); - gtk_style_context_add_class(gtk_widget_get_style_context(button), - GTK_STYLE_CLASS_FLAT); - gtk_style_context_add_class(gtk_widget_get_style_context(button), - kNativeMenuItemStyleClass); - g_object_set_data(G_OBJECT(button), kNativeMenuItemIndexKey, - GINT_TO_POINTER(static_cast(index) + 1)); - g_signal_connect(button, "clicked", - G_CALLBACK(native_menu_item_clicked_cb), session); - - GtkWidget* row = - gtk_box_new(GTK_ORIENTATION_HORIZONTAL, kHeaderButtonSpacing); - gtk_widget_set_direction(row, menu_text_direction); - gtk_widget_set_hexpand(row, TRUE); - if (has_icon) { - GtkWidget* icon = - icon_name != nullptr && icon_name[0] != '\0' - ? gtk_image_new_from_icon_name(icon_name, GTK_ICON_SIZE_MENU) - : gtk_image_new(); - gtk_widget_set_size_request(icon, 16, 16); - gtk_widget_set_valign(icon, GTK_ALIGN_CENTER); - gtk_box_pack_start(GTK_BOX(row), icon, FALSE, FALSE, 0); + 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); } + g_menu_append_item(session->model, item); + g_ptr_array_add(session->shortcut_labels, + g_strdup(shortcut != nullptr ? shortcut : "")); + g_ptr_array_add(session->icon_names, + g_strdup(icon_name != nullptr ? icon_name : "")); + } + if (selection_action != nullptr) { + g_object_unref(selection_action); + } - GtkWidget* label_widget = gtk_label_new(label); - gtk_label_set_xalign(GTK_LABEL(label_widget), 0.0); - gtk_label_set_ellipsize(GTK_LABEL(label_widget), PANGO_ELLIPSIZE_END); - gtk_widget_set_hexpand(label_widget, TRUE); - gtk_widget_set_halign(label_widget, GTK_ALIGN_FILL); - gtk_widget_set_valign(label_widget, GTK_ALIGN_CENTER); - gtk_box_pack_start(GTK_BOX(row), label_widget, TRUE, TRUE, 0); - - if (shortcut != nullptr && shortcut[0] != '\0') { - GtkWidget* shortcut_widget = gtk_label_new(shortcut); - gtk_widget_set_direction(shortcut_widget, GTK_TEXT_DIR_LTR); - gtk_label_set_xalign(GTK_LABEL(shortcut_widget), 1.0); - gtk_widget_set_halign(shortcut_widget, GTK_ALIGN_END); - gtk_widget_set_valign(shortcut_widget, GTK_ALIGN_CENTER); - gtk_style_context_add_class( - gtk_widget_get_style_context(shortcut_widget), - GTK_STYLE_CLASS_DIM_LABEL); - gtk_box_pack_end(GTK_BOX(row), shortcut_widget, FALSE, FALSE, 0); - } + gtk_fixed_move(GTK_FIXED(data->menu_layer), data->menu_button, anchor.x, + anchor.y); + gtk_widget_set_size_request(data->menu_button, anchor.width, anchor.height); + gtk_widget_show(data->input_layer); - gtk_container_add(GTK_CONTAINER(button), row); - gtk_box_pack_start(GTK_BOX(menu_box), button, FALSE, FALSE, 0); + gtk_menu_button_set_use_popover(GTK_MENU_BUTTON(data->menu_button), TRUE); + gtk_menu_button_set_direction( + GTK_MENU_BUTTON(data->menu_button), + preferred_position == GTK_POS_TOP ? GTK_ARROW_UP : GTK_ARROW_DOWN); + gtk_widget_insert_action_group( + data->menu_button, kNativeMenuActionNamespace, + G_ACTION_GROUP(session->action_group)); + gtk_menu_button_set_menu_model(GTK_MENU_BUTTON(data->menu_button), + G_MENU_MODEL(session->model)); + session->popover = + GTK_WIDGET(gtk_menu_button_get_popover(GTK_MENU_BUTTON(data->menu_button))); + if (session->popover == nullptr) { + fl_method_call_respond_error(method_call, "unavailable", + "GTK could not create the native menu.", + nullptr, nullptr); + g_clear_object(&session->method_call); + native_menu_session_dispose(session); + return; } - - gtk_widget_show_all(menu_box); + g_object_ref(session->popover); + style_native_popover(session->popover); gtk_popover_set_position(GTK_POPOVER(session->popover), preferred_position); gtk_popover_set_constrain_to(GTK_POPOVER(session->popover), GTK_POPOVER_CONSTRAINT_WINDOW); gtk_popover_set_modal(GTK_POPOVER(session->popover), TRUE); + decorate_native_menu_shortcuts(session->popover, + session->shortcut_labels, + session->icon_names); session->closed_signal_id = g_signal_connect( session->popover, "closed", G_CALLBACK(native_menu_closed_cb), session); // This is the proven Local History path: let a mapped GtkMenuButton own the diff --git a/test/app/native_ui_audit_test.dart b/test/app/native_ui_audit_test.dart index 34898ef..1fedad8 100644 --- a/test/app/native_ui_audit_test.dart +++ b/test/app/native_ui_audit_test.dart @@ -1297,12 +1297,12 @@ void main() { expect(source, isNot(contains('"openMenu"'))); }); - test('Linux content menus use explicit native GTK popover buttons', () { + test('Linux content menus use native GTK model buttons 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 kNativeMenuItemIndexKey'); + final start = runner.indexOf('constexpr char kNativeMenuActionNamespace'); final end = runner.indexOf('static void respond_success', start); expect(start, isNonNegative); @@ -1323,12 +1323,21 @@ void main() { expect(nativeMenu, contains('struct NativeMenuHostWidgets')); expect(nativeMenu, contains('gtk_event_box_set_above_child(')); expect(nativeMenu, contains('gtk_widget_show(data->input_layer)')); + expect(nativeMenu, contains('GMenu* model;')); + expect(nativeMenu, contains('GSimpleActionGroup* action_group;')); + expect(nativeMenu, contains('g_simple_action_new_stateful(')); + expect(nativeMenu, contains('g_menu_item_set_action_and_target_value(')); + expect(nativeMenu, contains('GTK_IS_MODEL_BUTTON(widget)')); + expect(nativeMenu, contains('gtk_menu_button_set_menu_model(')); + expect(nativeMenu, contains('gtk_menu_button_get_popover(')); expect( nativeMenu, - contains('session->popover = gtk_popover_new(data->menu_button)'), + contains( + 'gtk_widget_insert_action_group(\n' + ' data->menu_button, kNativeMenuActionNamespace', + ), ); expect(nativeMenu, isNot(contains('gtk_popover_new(data->view)'))); - expect(nativeMenu, contains('gtk_menu_button_set_popover(')); expect( nativeMenu, contains( @@ -1336,43 +1345,19 @@ void main() { ), ); expect(nativeMenu, contains('style_native_popover(session->popover)')); - expect( - nativeMenu, - contains('gtk_container_set_border_width(GTK_CONTAINER(menu_box),'), - ); - expect(nativeMenu, contains('kNativeMenuContentPadding')); - expect(nativeMenu, contains('gtk_button_new()')); - expect( - nativeMenu, - contains('gtk_button_set_relief(GTK_BUTTON(button), GTK_RELIEF_NONE)'), - ); - expect(nativeMenu, contains('GTK_STYLE_CLASS_FLAT')); expect(nativeMenu, contains('kNativeMenuItemStyleClass')); - expect(nativeMenu, contains('gtk_radio_button_new(selection_group)')); - expect(nativeMenu, contains('gtk_radio_button_get_group(')); - expect(nativeMenu, contains('gtk_toggle_button_set_active(')); - expect( - nativeMenu, - contains('gtk_widget_get_direction(data->view) == GTK_TEXT_DIR_RTL'), - ); - expect( - nativeMenu, - contains('gtk_widget_set_direction(\n button,'), - ); - expect( - nativeMenu, - contains('gtk_widget_set_direction(row, menu_text_direction)'), - ); + expect(nativeMenu, contains('add_model_button_presentation(')); + 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, contains('native_menu_item_clicked_cb')); - expect(nativeMenu, contains('G_CALLBACK(native_menu_item_clicked_cb)')); - expect(nativeMenu, contains('gtk_widget_set_sensitive(button, enabled)')); + expect(nativeMenu, isNot(contains('native_menu_item_clicked_cb'))); expect(nativeMenu, isNot(contains('"object-select-symbolic"'))); - expect(nativeMenu, contains('gtk_image_new_from_icon_name(')); - expect(nativeMenu, contains('gtk_label_new(shortcut)')); - expect(nativeMenu, contains('GTK_STYLE_CLASS_DIM_LABEL')); - expect(nativeMenu, contains('gtk_widget_show_all(menu_box)')); + expect(nativeMenu, isNot(contains('"radio-symbolic"'))); + expect(nativeMenu, isNot(contains('"radio-checked-symbolic"'))); expect(nativeMenu, contains('gtk_popover_set_modal(')); expect(nativeMenu, isNot(contains('gtk_popover_popup('))); expect( @@ -1387,10 +1372,10 @@ void main() { isNot(contains('gtk_overlay_set_overlay_pass_through(')), ); expect(nativeMenu, contains('gtk_menu_button_new()')); - expect(nativeMenu, isNot(contains('gtk_menu_button_set_menu_model('))); expect(nativeMenu, isNot(contains('gtk_menu_new_from_model('))); expect(nativeMenu, isNot(contains('gtk_menu_popup_at_rect('))); expect(nativeMenu, isNot(contains('gtk_popover_new_from_model('))); + expect(nativeMenu, isNot(contains('gtk_menu_button_set_popover('))); 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'))); @@ -1402,20 +1387,20 @@ void main() { ); expect(dispose, contains('gtk_widget_hide(session->popover)')); expect(dispose, contains('gtk_widget_hide(owner->input_layer)')); - expect(dispose, contains('gtk_menu_button_set_popover(')); - expect(dispose, contains('gtk_widget_destroy(session->popover)')); + expect(dispose, contains('gtk_menu_button_set_menu_model(')); + expect(dispose, contains('kNativeMenuActionNamespace, nullptr')); + expect(dispose, isNot(contains('gtk_widget_destroy(session->popover)'))); + expect(dispose, contains('g_clear_object(&session->popover)')); final hideIndex = dispose.indexOf('gtk_widget_hide(session->popover)'); - final destroyIndex = dispose.indexOf( - 'gtk_widget_destroy(session->popover)', - ); + final detachIndex = dispose.indexOf('gtk_menu_button_set_menu_model('); final respondIndex = dispose.indexOf('native_menu_session_respond('); final freeIndex = dispose.indexOf('g_free(session)'); expect(hideIndex, isNonNegative); - expect(destroyIndex, isNonNegative); + expect(detachIndex, isNonNegative); expect(respondIndex, isNonNegative); expect(freeIndex, isNonNegative); - expect(hideIndex, lessThan(destroyIndex)); - expect(destroyIndex, lessThan(respondIndex)); + expect(hideIndex, lessThan(detachIndex)); + expect(detachIndex, lessThan(respondIndex)); expect(respondIndex, lessThan(freeIndex)); expect( 'native_menu_session_respond('.allMatches(nativeMenu), @@ -2106,10 +2091,7 @@ void main() { expect(nativeMenuStateCss, isNot(contains('min-height'))); expect(nativeMenuStateCss, isNot(contains('#'))); expect(nativeMenuStateCss, isNot(contains('rgba('))); - expect( - source, - contains('constexpr guint kNativeMenuContentPadding = 6;'), - ); + expect(source, isNot(contains('kNativeMenuContentPadding'))); expect(source, contains('g_autofree gchar* native_menu_geometry_css')); expect(source, contains('"popover.background.%s .%s {"')); expect(source, contains('"font-size: 0.92em;"')); diff --git a/test/tools/install_linux_dev_desktop_test.dart b/test/tools/install_linux_dev_desktop_test.dart new file mode 100644 index 0000000..2782dcf --- /dev/null +++ b/test/tools/install_linux_dev_desktop_test.dart @@ -0,0 +1,98 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('install_linux_dev_desktop.sh', () { + test( + 'installs an idempotent Wayland desktop registration', + () async { + final dataHome = await Directory.systemTemp.createTemp( + 'busymax-dev-desktop-test-', + ); + addTearDown(() => dataHome.delete(recursive: true)); + + final environment = { + ...Platform.environment, + 'XDG_DATA_HOME': dataHome.path, + }; + final executable = File( + 'build/linux/x64/debug/bundle/busymax', + ).absolute.path; + + for (var attempt = 0; attempt < 2; attempt++) { + final result = await Process.run('bash', [ + 'tools/install_linux_dev_desktop.sh', + '--executable', + executable, + ], environment: environment); + expect(result.exitCode, 0, reason: _processFailure(result)); + } + + final desktop = File( + '${dataHome.path}/applications/io.busystack.busymax.desktop', + ); + final icon = File( + '${dataHome.path}/icons/hicolor/scalable/apps/' + 'io.busystack.busymax.svg', + ); + expect(desktop.existsSync(), isTrue); + expect(icon.existsSync(), isTrue); + + final desktopContents = desktop.readAsStringSync(); + expect(desktopContents, contains('Exec="$executable"')); + expect(desktopContents, contains('Icon=${icon.absolute.path}')); + expect( + desktopContents, + contains('StartupWMClass=io.busystack.busymax'), + ); + expect(desktopContents, contains('X-BusyMax-Development=true')); + expect( + icon.readAsBytesSync(), + File('assets/branding/busymax-logo.svg').readAsBytesSync(), + ); + + final uninstall = await Process.run('bash', [ + 'tools/install_linux_dev_desktop.sh', + '--uninstall', + ], environment: environment); + expect(uninstall.exitCode, 0, reason: _processFailure(uninstall)); + expect(desktop.existsSync(), isFalse); + expect(icon.existsSync(), isFalse); + }, + skip: !Platform.isLinux, + ); + + test( + 'does not overwrite a desktop entry it does not own', + () async { + final dataHome = await Directory.systemTemp.createTemp( + 'busymax-dev-desktop-unowned-test-', + ); + addTearDown(() => dataHome.delete(recursive: true)); + final desktop = File( + '${dataHome.path}/applications/io.busystack.busymax.desktop', + )..createSync(recursive: true); + desktop.writeAsStringSync('[Desktop Entry]\nName=Keep me\n'); + + final result = await Process.run( + 'bash', + ['tools/install_linux_dev_desktop.sh'], + environment: { + ...Platform.environment, + 'XDG_DATA_HOME': dataHome.path, + }, + ); + + expect(result.exitCode, isNot(0)); + expect(desktop.readAsStringSync(), '[Desktop Entry]\nName=Keep me\n'); + expect(result.stderr, contains('not owned by this helper')); + }, + skip: !Platform.isLinux, + ); + }); +} + +String _processFailure(ProcessResult result) { + return 'stdout:\n${result.stdout}\nstderr:\n${result.stderr}'; +} From bd5a889b1c664e58563c66ccd98eb4b983bbda8a Mon Sep 17 00:00:00 2001 From: albert Date: Sat, 1 Aug 2026 18:30:09 -0700 Subject: [PATCH 68/73] Add Linux development desktop integration script for BusyMax --- README.md | 14 +++ tools/install_linux_dev_desktop.sh | 140 +++++++++++++++++++++++++++++ 2 files changed, 154 insertions(+) create mode 100755 tools/install_linux_dev_desktop.sh diff --git a/README.md b/README.md index 7540778..20d46ce 100644 --- a/README.md +++ b/README.md @@ -85,6 +85,16 @@ It brings calendar events and tasks into a native-feeling Linux desktop interfac ## Run locally +Register the development launcher once so GNOME can associate BusyMax's native +Wayland windows with its desktop icon. The helper is idempotent and defaults to +the Flutter debug bundle: + +```bash +tools/install_linux_dev_desktop.sh +``` + +Then run BusyMax normally: + ```bash flutter run -d linux \ --dart-define=GOOGLE_OAUTH_CLIENT_ID= \ @@ -92,6 +102,10 @@ flutter run -d linux \ --dart-define=MICROSOFT_OAUTH_CLIENT_ID= ``` +Use `tools/install_linux_dev_desktop.sh --uninstall` to remove the development +launcher. Remove it before testing an installed Snap so the user-level launcher +does not take precedence; packaged Snaps register their own launcher. + ## Feedback submissions The native **Send feedback** form in the About dialog sends JSON to diff --git a/tools/install_linux_dev_desktop.sh b/tools/install_linux_dev_desktop.sh new file mode 100755 index 0000000..25e0192 --- /dev/null +++ b/tools/install_linux_dev_desktop.sh @@ -0,0 +1,140 @@ +#!/usr/bin/env bash +set -euo pipefail + +usage() { + cat <<'EOF' +Usage: tools/install_linux_dev_desktop.sh [options] + +Register this BusyMax checkout with the current user's Linux desktop so GNOME +can associate native Wayland windows with the BusyMax launcher and icon. + +Options: + --executable FILE Launcher target. Defaults to the Flutter debug bundle. + --uninstall Remove files previously installed by this helper. + -h, --help Show this help. +EOF +} + +fail() { + echo "error: $*" >&2 + exit 1 +} + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)" +PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)" +APP_ID="io.busystack.busymax" +DESKTOP_SOURCE="$PROJECT_ROOT/linux/${APP_ID}.desktop" +ICON_SOURCE="$PROJECT_ROOT/assets/branding/busymax-logo.svg" +EXECUTABLE="$PROJECT_ROOT/build/linux/x64/debug/bundle/busymax" +UNINSTALL=0 + +while [[ $# -gt 0 ]]; do + case "$1" in + --executable) + [[ $# -ge 2 ]] || fail "--executable requires a value" + EXECUTABLE="$2" + shift + ;; + --uninstall) + UNINSTALL=1 + ;; + -h|--help) + usage + exit 0 + ;; + *) + fail "unknown option: $1" + ;; + esac + shift +done + +[[ -f "$DESKTOP_SOURCE" ]] || fail "desktop entry not found: $DESKTOP_SOURCE" +[[ -f "$ICON_SOURCE" ]] || fail "icon not found: $ICON_SOURCE" + +if [[ "$EXECUTABLE" != /* ]]; then + EXECUTABLE="$PROJECT_ROOT/$EXECUTABLE" +fi +EXECUTABLE="$(realpath -m -- "$EXECUTABLE")" +case "$EXECUTABLE" in + *$'\n'*|*$'\r'*|*%*) + fail "executable path contains characters unsupported by desktop entries" + ;; +esac + +if [[ -n "${XDG_DATA_HOME:-}" ]]; then + DATA_HOME="$(realpath -m -- "$XDG_DATA_HOME")" +else + [[ -n "${HOME:-}" ]] || fail "HOME or XDG_DATA_HOME must be set" + DATA_HOME="$(realpath -m -- "$HOME/.local/share")" +fi + +DESKTOP_DEST="$DATA_HOME/applications/${APP_ID}.desktop" +ICON_DEST="$DATA_HOME/icons/hicolor/scalable/apps/${APP_ID}.svg" +OWNERSHIP_MARKER="X-BusyMax-Development=true" + +refresh_desktop_database() { + if command -v update-desktop-database >/dev/null 2>&1; then + update-desktop-database "$DATA_HOME/applications" + fi +} + +if [[ "$UNINSTALL" == 1 ]]; then + if [[ ! -e "$DESKTOP_DEST" && ! -L "$DESKTOP_DEST" ]]; then + echo "BusyMax development desktop entry is not installed." + exit 0 + fi + [[ ! -L "$DESKTOP_DEST" ]] || + fail "refusing to remove symbolic link: $DESKTOP_DEST" + grep -Fxq "$OWNERSHIP_MARKER" "$DESKTOP_DEST" || + fail "refusing to remove desktop entry not owned by this helper: $DESKTOP_DEST" + + rm -f -- "$DESKTOP_DEST" "$ICON_DEST" + refresh_desktop_database + echo "Removed BusyMax development desktop registration." + exit 0 +fi + +if [[ -e "$DESKTOP_DEST" || -L "$DESKTOP_DEST" ]]; then + [[ ! -L "$DESKTOP_DEST" ]] || + fail "refusing to replace symbolic link: $DESKTOP_DEST" + grep -Fxq "$OWNERSHIP_MARKER" "$DESKTOP_DEST" || + fail "refusing to replace desktop entry not owned by this helper: $DESKTOP_DEST" +fi +[[ ! -L "$ICON_DEST" ]] || + fail "refusing to replace symbolic link: $ICON_DEST" + +TEMP_DIR="$(mktemp -d "${TMPDIR:-/tmp}/busymax-dev-desktop.XXXXXX")" +trap 'rm -rf -- "$TEMP_DIR"' EXIT +GENERATED_DESKTOP="$TEMP_DIR/${APP_ID}.desktop" + +awk -v executable="$EXECUTABLE" -v icon="$ICON_DEST" ' + /^Exec=/ { + print "Exec=\"" executable "\"" + next + } + /^Icon=/ { + print "Icon=" icon + next + } + /^X-BusyMax-Development=/ { next } + { print } + END { print "X-BusyMax-Development=true" } +' "$DESKTOP_SOURCE" > "$GENERATED_DESKTOP" + +if command -v desktop-file-validate >/dev/null 2>&1; then + desktop-file-validate "$GENERATED_DESKTOP" +fi + +install -Dm644 "$GENERATED_DESKTOP" "$DESKTOP_DEST" +install -Dm644 "$ICON_SOURCE" "$ICON_DEST" +refresh_desktop_database + +echo "Registered BusyMax for native Wayland desktop integration:" +echo " Desktop entry: $DESKTOP_DEST" +echo " Icon: $ICON_DEST" +echo " Executable: $EXECUTABLE" +if [[ ! -x "$EXECUTABLE" ]]; then + echo "The executable does not exist yet; Flutter will create it on first run." +fi +echo "Quit all BusyMax windows and relaunch the app to refresh its dock icon." From 326f1765adeb9dc02eb26f9e9819d78dc0b8f7b5 Mon Sep 17 00:00:00 2001 From: albert Date: Sat, 1 Aug 2026 23:44:58 -0700 Subject: [PATCH 69/73] Add Noto fonts to snapcraft configuration. Remove unused dependencies from pubspec.yaml and update README to reflect new tray shortcut feature --- pubspec.lock | 12 ++---------- pubspec.yaml | 3 --- snap/snapcraft.yaml | 1 + 3 files changed, 3 insertions(+), 13 deletions(-) diff --git a/pubspec.lock b/pubspec.lock index ce90cbf..64fa673 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -241,14 +241,6 @@ packages: url: "https://pub.dev" source: hosted version: "0.7.14" - desktop_multi_window: - dependency: "direct main" - description: - name: desktop_multi_window - sha256: "60ba38725b8887b60e44d15afdcf0c3813568b5da2ccaf1e7f6fd09a380a6e24" - url: "https://pub.dev" - source: hosted - version: "0.3.0" desktop_notifications: dependency: "direct main" description: @@ -909,7 +901,7 @@ packages: source: hosted version: "2.6.1" screen_retriever: - dependency: "direct main" + dependency: transitive description: name: screen_retriever sha256: "570dbc8e4f70bac451e0efc9c9bb19fa2d6799a11e6ef04f946d7886d2e23d0c" @@ -1258,7 +1250,7 @@ packages: source: hosted version: "6.3.0" window_manager: - dependency: "direct main" + dependency: transitive description: name: window_manager sha256: "7eb6d6c4164ec08e1bf978d6e733f3cebe792e2a23fb07cbca25c2872bfdbdcd" diff --git a/pubspec.yaml b/pubspec.yaml index d6b221d..3d74a7a 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -16,7 +16,6 @@ dependencies: crypto: ^3.0.0 cryptography: ^2.9.0 dbus: ^0.7.14 - desktop_multi_window: ^0.3.0 desktop_notifications: ^0.6.3 drift: ^2.33.0 file_selector: ^1.0.3 @@ -30,7 +29,6 @@ dependencies: package_info_plus: ^10.1.0 path: ^1.9.0 path_provider: ^2.1.0 - screen_retriever: ^0.2.0 sqlite3: ^3.3.0 sqlite3_flutter_libs: ^0.6.0 system_theme: ^3.3.0 @@ -38,7 +36,6 @@ dependencies: ubuntu_localizations: ^0.5.2+3 url_launcher: ^6.3.0 uuid: ^4.5.0 - window_manager: ^0.5.1 xdg_status_notifier_item: ^0.0.1 yaru: ^10.2.0 diff --git a/snap/snapcraft.yaml b/snap/snapcraft.yaml index 9c0fc84..82126bf 100644 --- a/snap/snapcraft.yaml +++ b/snap/snapcraft.yaml @@ -59,6 +59,7 @@ parts: plugin: dump source: build/linux/x64/release/bundle stage-packages: + - fonts-noto-core - libhandy-1-0 - libgweather-4-0t64 - liblzma5 From dd70edf2e586b8d92086760179d85a884ab05fa7 Mon Sep 17 00:00:00 2001 From: albert Date: Sat, 1 Aug 2026 23:45:53 -0700 Subject: [PATCH 70/73] Refactor main function to simplify window handling and remove unused code. Refactor tray service and command handling to improve agenda management and remove unused components. Remove unused desktop_multi_window plugin references and clean up related code --- README.md | 8 +- docs/beta_snap_release.md | 6 +- docs/screenshots/agenda_window_agenda.png | Bin 269516 -> 0 bytes .../agenda_window_agenda_event_details.png | Bin 264325 -> 0 bytes docs/screenshots/agenda_window_edit_event.png | Bin 264533 -> 0 bytes lib/l10n/app_ar.arb | 25 +- lib/l10n/app_de.arb | 25 +- lib/l10n/app_en.arb | 26 +- lib/l10n/app_es.arb | 25 +- lib/l10n/app_et.arb | 26 +- lib/l10n/app_fa.arb | 25 +- lib/l10n/app_fi.arb | 25 +- lib/l10n/app_fr.arb | 25 +- lib/l10n/app_hi.arb | 25 +- lib/l10n/app_it.arb | 25 +- lib/l10n/app_ja.arb | 25 +- lib/l10n/app_ko.arb | 25 +- lib/l10n/app_pt.arb | 25 +- lib/l10n/app_ru.arb | 25 +- lib/l10n/app_vi.arb | 25 +- lib/l10n/app_zh.arb | 25 +- lib/l10n/app_zh_Hans.arb | 25 +- lib/l10n/app_zh_Hant.arb | 25 +- lib/l10n/generated/app_localizations.dart | 134 +- lib/l10n/generated/app_localizations_ar.dart | 71 +- lib/l10n/generated/app_localizations_de.dart | 73 +- lib/l10n/generated/app_localizations_en.dart | 71 +- lib/l10n/generated/app_localizations_es.dart | 73 +- lib/l10n/generated/app_localizations_et.dart | 73 +- lib/l10n/generated/app_localizations_fa.dart | 72 +- lib/l10n/generated/app_localizations_fi.dart | 72 +- lib/l10n/generated/app_localizations_fr.dart | 73 +- lib/l10n/generated/app_localizations_hi.dart | 74 +- lib/l10n/generated/app_localizations_it.dart | 72 +- lib/l10n/generated/app_localizations_ja.dart | 69 +- lib/l10n/generated/app_localizations_ko.dart | 69 +- lib/l10n/generated/app_localizations_pt.dart | 72 +- lib/l10n/generated/app_localizations_ru.dart | 72 +- lib/l10n/generated/app_localizations_vi.dart | 72 +- lib/l10n/generated/app_localizations_zh.dart | 207 +-- lib/main.dart | 58 +- lib/src/app/app_bootstrap.dart | 7 - lib/src/app/busymax_app.dart | 45 +- .../busymax_keyboard_shortcuts_dialog.dart | 20 - lib/src/app/busymax_shortcuts.dart | 1 - lib/src/demo/demo_profile.dart | 2 +- .../compact_agenda_controller.dart | 120 -- .../application/compact_agenda_data.dart | 260 --- .../application/compact_agenda_sections.dart | 105 -- .../application/compact_agenda_snapshot.dart | 300 ---- .../presentation/compact_agenda_app.dart | 348 ---- .../compact_agenda_formatting.dart | 97 -- .../presentation/compact_agenda_panel.dart | 1546 ----------------- .../presentation/schedule_empty_states.dart | 2 +- .../presentation/schedule_workspace.dart | 2 + lib/src/platform/busymax_tray_service.dart | 6 +- lib/src/platform/busymax_window_args.dart | 85 - .../compact_agenda_window_service.dart | 286 --- .../platform/main_window_command_bridge.dart | 186 -- .../platform/main_window_command_client.dart | 69 - lib/src/schedule/schedule_commands.dart | 1 + linux/flutter/generated_plugin_registrant.cc | 4 - linux/flutter/generated_plugins.cmake | 1 - linux/runner/my_application.cc | 510 ------ test/app/keyboard_shortcuts_dialog_test.dart | 14 +- test/app/native_ui_audit_test.dart | 177 +- test/app/theme_localization_test.dart | 132 +- test/demo/demo_profile_test.dart | 13 +- .../application/compact_agenda_data_test.dart | 183 -- .../compact_agenda_sections_test.dart | 218 --- .../compact_agenda_panel_test.dart | 489 ------ .../presentation/schedule_views_test.dart | 19 - test/platform/busymax_tray_service_test.dart | 4 +- test/platform/busymax_window_args_test.dart | 58 - .../compact_agenda_window_service_test.dart | 25 - .../main_window_command_bridge_test.dart | 94 - 76 files changed, 267 insertions(+), 7105 deletions(-) delete mode 100644 docs/screenshots/agenda_window_agenda.png delete mode 100644 docs/screenshots/agenda_window_agenda_event_details.png delete mode 100644 docs/screenshots/agenda_window_edit_event.png delete mode 100644 lib/src/features/schedule/application/compact_agenda_controller.dart delete mode 100644 lib/src/features/schedule/application/compact_agenda_data.dart delete mode 100644 lib/src/features/schedule/application/compact_agenda_sections.dart delete mode 100644 lib/src/features/schedule/application/compact_agenda_snapshot.dart delete mode 100644 lib/src/features/schedule/presentation/compact_agenda_app.dart delete mode 100644 lib/src/features/schedule/presentation/compact_agenda_formatting.dart delete mode 100644 lib/src/features/schedule/presentation/compact_agenda_panel.dart delete mode 100644 lib/src/platform/busymax_window_args.dart delete mode 100644 lib/src/platform/compact_agenda_window_service.dart delete mode 100644 lib/src/platform/main_window_command_bridge.dart delete mode 100644 lib/src/platform/main_window_command_client.dart delete mode 100644 test/features/schedule/application/compact_agenda_data_test.dart delete mode 100644 test/features/schedule/application/compact_agenda_sections_test.dart delete mode 100644 test/features/schedule/presentation/compact_agenda_panel_test.dart delete mode 100644 test/platform/busymax_window_args_test.dart delete mode 100644 test/platform/compact_agenda_window_service_test.dart delete mode 100644 test/platform/main_window_command_bridge_test.dart diff --git a/README.md b/README.md index 20d46ce..2dfc930 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ It brings calendar events and tasks into a native-feeling Linux desktop interfac - Calendar views for day, week, month, year, and agenda planning. - Task creation with lists, due dates, reminders, and repeat options. - Event editing with calendar selection, time controls, repeat rules, and reminders. -- Compact agenda window for quick access to upcoming work. +- Tray shortcut for opening the main Agenda view. - Integrations with Google Calendar, Google Tasks, Microsoft Calendar, and Microsoft To Do. ## Screenshots @@ -67,12 +67,6 @@ It brings calendar events and tasks into a native-feeling Linux desktop interfac BusyMax event editor

-

- BusyMax compact agenda window - BusyMax compact agenda event details - BusyMax compact agenda event editor -

- ## Prerequisites diff --git a/docs/beta_snap_release.md b/docs/beta_snap_release.md index 9c3e6ee..84c0fa8 100644 --- a/docs/beta_snap_release.md +++ b/docs/beta_snap_release.md @@ -114,12 +114,14 @@ snap run busymax Before upload, verify: -- Desktop search shows one BusyMax launcher; both main and Agenda windows open. +- Desktop search shows one BusyMax launcher; the tray Agenda action opens the + Agenda view in the main window. - Google and Microsoft sign-in complete successfully. - Tasks and events can be created, edited, completed, and deleted; a task created in Agenda appears immediately without manual refresh. - Accounts, settings, and data survive restart. -- Notifications and tray actions, including Agenda and Quit, work. +- Notifications and tray actions, including opening Agenda in the main window + and Quit, work. ## Upload To Beta diff --git a/docs/screenshots/agenda_window_agenda.png b/docs/screenshots/agenda_window_agenda.png deleted file mode 100644 index 6ec1ad3ec043276d2c17ad7e7fa0ad5b5180206e..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 269516 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%0AtY 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

DkBww|&Y46N1K zwJp@yF~HHxJS}r}9x1D&6bLAxFxAzz$oFEgIlh4!3WOZpY3Y6T2&)?g6H#tWeO&?K z)N4g=d7cmDq1R4PBRk)4Fhs{+X{RNI+N0&={52!dbRfVRJeH>A=@r_(e?12fe1Gg5 zdppMH$e{qp;VX=NrU{h1(3j14Uai(L+`haD>K zsgAe0X=-G?08vRK$j&qu_j3g3rH0m8I(uY*PQKP<>LGqJ`Ad-wXRdbWG zqH5(2nAzzN!~n?)bzxkSjFkoa%*F(&a+nbenN~n*elLhZ#dP?kGt}2LLYL0>(&Uv{ z1Jk)p-5Lw)okL!UjFcK_K+1FP=On zNC(;? z4jKFbCxq87#6*#CdSpyk?D_nv;}Jp{ChvtyL~z9`aIF~XgmWBcD#wl-+Tm3*;Ub~FIg z;9AQlcBxGFnvyLGaI*7$%4oOYc6_G#6YaXP(Ip$^zjjZl|Bbt|=CeX1g#QWpu?JmTnk%6?wvDYD*V1)cHqs5ducJHfxRv(q+Rk6!M)%%% zfbQYH$iVOVja%q9-hML&0^4YF>sqQTuaIO+Vt=Q-R!hl((qwa2aYi)Zz`Z~l1lRf9 zAN-WsFJ2Pz?C1aB<6O?;XPM$8kjsIhkB{LuE-v3jc-_yx{Aym?&v1ysO8RZPunPka6~2fz1{<+W73JzsAmTh9k)as$08Bh*!{l&oKPCvNS^^j1R|3Vr1+_f#>_DP~xQ*{_bTO>>TGsVW^gCXMpi!ZZI^B*@;A}LKGF$T$MlXN+BwaXpsSrKN=HX4nrkoABn|ADJ zVYr5~hNXmLfShoNbJmS6@FRc3RR0LgapnMpFwrRH2r|0E0YuhNWFdle3%`lM1FXX@s-*}cG_vG?|7$;0YgTyMPeTnfyO9&v$8EY#<6!%qi zz0tl=nG+u^DWxn2GF2OzBtw^}OJrt1qe;%kS%@p0lRBXqOw-zA6zA|e=+i|8UdWPA z&*Y$`=X{3|vEe|lY4aLcpI9R9IL{0%k4*FPnr5`3f+{MvGcr{r&VD5}#Rk5egPTd2 z)|Q0)~=T1?rLXs`Wj`ugYcuR zZ>s_(J|QCp1I5(QeucjL)fcFF^H!SYzykBHy_^+6b#SIRH&VR|1;Nk1_*!%@8mqviVY|p z0i4?zYl{R4t#z4n-a%m;WyCy~@>xOE5PbR^zKWIsa?X$zy=V2i8jvsZI)3`OFA4hk z-@fok8W^6SR}Q^G&piG(+gWs5`6yHdH9DgI{nhUyQ~&VufIaNU!6wYv(8je&PudQn zn#L;kXt%`3qM8acRN zg9Y7kKia6+Q2>fSpZFv@J-}sU6_w%?VdOA@47JW6J0Z+Pf7?CV7*PtWh7MBfiZGtng7(;ka zZ-O!!80bq2ahe>Qr9(eH&7U(Zf(~ePnq?!8hZ*3~LrGCcwA05%XJw?ItP?hKHY36Z z?n0pA5vWxh-qNzvS&)H%SXWh7BN-dY5+JxBbBANVkbq^*5DYh?nGlA) zTPT_ZibD)dPw?7}QhYfs>~VpDV6>3EIrU~?uksG@u>rw^JG?L6#~1X<^-hm2(8jh# z&Um0>@1xiMxRL#&DP?H2$ z=@@Ro!N)w;Q-KOZA`MjAyoaH;h#*W;lb2|2>O3WQ52HK-WvnM2eLp>L-<>8HP8s;l z2zeOJ0NOSEGD{hA&tp_x9bg0{BZN#fnxRm!7-GkcTm@^Y!Lpg^_LM3$WQwS1R-~wh zW<44dXh2{GQEGe?ez##-o&BvXuy$LQ+6%p&0_Vo>ze>=`wmIT}oeVd&v_N0^yZ@w$ z+D2M{)FwoIGKLI0(L2oNa!$%fQiejWG1r+(c NieMLlM`$?&Ar#2dO572=f$$0x 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 KMNUMnLSTY52G6ws diff --git a/docs/screenshots/agenda_window_edit_event.png b/docs/screenshots/agenda_window_edit_event.png deleted file mode 100644 index 1fd96cb9795dfa89cedc31acd21ed239de3b947d..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 264533 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 P00000NkvXXu0mjfkGr{M diff --git a/lib/l10n/app_ar.arb b/lib/l10n/app_ar.arb index cbaa7b0..6a96363 100644 --- a/lib/l10n/app_ar.arb +++ b/lib/l10n/app_ar.arb @@ -50,26 +50,8 @@ "scheduleSignInDescription": "سجّل الدخول لمزامنة التقويمات والمهام.", "scheduleNoSearchResults": "لا توجد أحداث أو مهام مطابقة", "scheduleNoSearchResultsDescription": "جرّب بحثًا مختلفًا أو امسح عوامل التصفية الحالية.", - "trayAgendaLoading": "جارٍ تحميل جدول الأعمال...", - "trayAgendaSignInRequired": "سجّل الدخول لإظهار جدول الأعمال.", - "trayAgendaNoSources": "لا توجد تقويمات أو قوائم مهام ظاهرة.", - "trayAgendaOpenBusyMax": "فتح التطبيق", - "trayAgendaRefresh": "تحديث", - "trayAgendaError": "جدول الأعمال غير متاح", - "compactAgendaTitle": "جدول الأعمال", - "compactAgendaSubtitle": "القادم", - "compactAgendaOverdue": "متأخرة", - "compactAgendaClear": "لا شيء حاليًا", - "compactAgendaOpenBusyMax": "فتح BusyMax", - "compactAgendaHide": "إخفاء", - "compactAgendaNewTask": "مهمة جديدة", - "compactAgendaRetry": "إعادة المحاولة", - "compactAgendaRefresh": "تحديث", - "compactAgendaAllDay": "طوال اليوم", - "compactAgendaDueToday": "مستحقة اليوم", - "compactAgendaDueTomorrow": "مستحقة غدًا", - "compactAgendaDueOn": "مستحقة في \u2068{date}\u2069", - "compactAgendaMoreOverdue": "تحميل المزيد من المهام المتأخرة", + "refresh": "تحديث", + "trayOpenBusyMax": "فتح BusyMax", "agendaLoadMoreOverdue": "تحميل المزيد من المهام المتأخرة", "agendaLoadMoreNoDate": "تحميل المزيد من المهام بلا تاريخ", "viewDay": "يوم", @@ -152,9 +134,6 @@ "shortcutGroupTaskEditing": "تعديل المهام", "shortcutCancelEditing": "إلغاء التعديل", "shortcutCancelEditingDescription": "إغلاق تعديل المهمة أو تفاصيلها", - "shortcutGroupCompactAgenda": "جدول الأعمال المصغّر", - "shortcutRefreshCompactAgendaDescription": "تحديث نافذة جدول الأعمال المصغّر", - "shortcutHideCompactAgendaDescription": "إخفاء نافذة جدول الأعمال المصغّر", "aboutBusyMax": "حول BusyMax", "aboutBusyMaxDescription": "التقويم والمهام", "license": "الترخيص", diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index 804258c..dd8a9bb 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -50,26 +50,8 @@ "scheduleSignInDescription": "Melden Sie sich an, um Kalender und Aufgaben zu synchronisieren.", "scheduleNoSearchResults": "Keine passenden Termine oder Aufgaben", "scheduleNoSearchResultsDescription": "Versuchen Sie es mit einer anderen Suche oder setzen Sie die aktuellen Filter zurück.", - "trayAgendaLoading": "Agenda wird geladen...", - "trayAgendaSignInRequired": "Melden Sie sich an, um die Agenda anzuzeigen.", - "trayAgendaNoSources": "Keine sichtbaren Kalender oder Aufgabenlisten.", - "trayAgendaOpenBusyMax": "App öffnen", - "trayAgendaRefresh": "Aktualisieren", - "trayAgendaError": "Agenda nicht verfügbar", - "compactAgendaTitle": "Agenda", - "compactAgendaSubtitle": "Anstehend", - "compactAgendaOverdue": "Überfällig", - "compactAgendaClear": "Im Moment frei", - "compactAgendaOpenBusyMax": "BusyMax öffnen", - "compactAgendaHide": "Ausblenden", - "compactAgendaNewTask": "Neue Aufgabe", - "compactAgendaRetry": "Erneut versuchen", - "compactAgendaRefresh": "Aktualisieren", - "compactAgendaAllDay": "Ganztägig", - "compactAgendaDueToday": "Heute fällig", - "compactAgendaDueTomorrow": "Morgen fällig", - "compactAgendaDueOn": "Fällig {date}", - "compactAgendaMoreOverdue": "Weitere überfällige Aufgaben laden", + "refresh": "Aktualisieren", + "trayOpenBusyMax": "BusyMax öffnen", "agendaLoadMoreOverdue": "Weitere überfällige Aufgaben laden", "agendaLoadMoreNoDate": "Weitere Aufgaben ohne Datum laden", "viewDay": "Tag", @@ -155,9 +137,6 @@ "shortcutGroupTaskEditing": "Aufgabenbearbeitung", "shortcutCancelEditing": "Bearbeitung abbrechen", "shortcutCancelEditingDescription": "Aufgabenbearbeitung oder Aufgabendetails schließen", - "shortcutGroupCompactAgenda": "Kompakte Agenda", - "shortcutRefreshCompactAgendaDescription": "Das kompakte Agenda-Fenster aktualisieren", - "shortcutHideCompactAgendaDescription": "Das kompakte Agenda-Fenster ausblenden", "aboutBusyMax": "Über BusyMax", "aboutBusyMaxDescription": "Kalender und Aufgaben", "license": "Lizenz", diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index ff0a6c0..db12d05 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -51,27 +51,8 @@ "scheduleSignInDescription": "Sign in to sync calendars and tasks.", "scheduleNoSearchResults": "No matching events or tasks", "scheduleNoSearchResultsDescription": "Try a different search or clear the current filters.", - "trayAgendaLoading": "Loading agenda...", - "trayAgendaSignInRequired": "Sign in to show agenda.", - "trayAgendaNoSources": "No visible calendars or task lists.", - "trayAgendaOpenBusyMax": "Open app", - "trayAgendaRefresh": "Refresh", - "trayAgendaError": "Agenda unavailable", - "compactAgendaTitle": "Agenda", - "compactAgendaSubtitle": "Upcoming", - "compactAgendaOverdue": "Overdue", - "compactAgendaClear": "Clear for now", - "compactAgendaOpenBusyMax": "Open BusyMax", - "compactAgendaHide": "Hide", - "compactAgendaNewTask": "New task", - "compactAgendaRetry": "Retry", - "compactAgendaRefresh": "Refresh", - "compactAgendaAllDay": "All day", - "compactAgendaDueToday": "Due today", - "compactAgendaDueTomorrow": "Due tomorrow", - "compactAgendaDueOn": "Due {date}", - "@compactAgendaDueOn": {"placeholders": {"date": {"type": "String"}}}, - "compactAgendaMoreOverdue": "Load more overdue tasks", + "refresh": "Refresh", + "trayOpenBusyMax": "Open BusyMax", "agendaLoadMoreOverdue": "Load more overdue tasks", "agendaLoadMoreNoDate": "Load more no-date tasks", "viewDay": "Day", @@ -158,9 +139,6 @@ "shortcutGroupTaskEditing": "Task editing", "shortcutCancelEditing": "Cancel editing", "shortcutCancelEditingDescription": "Close task editing or task details", - "shortcutGroupCompactAgenda": "Compact agenda", - "shortcutRefreshCompactAgendaDescription": "Refresh the compact agenda window", - "shortcutHideCompactAgendaDescription": "Hide the compact agenda window", "aboutBusyMax": "About BusyMax", "aboutBusyMaxDescription": "Calendar and tasks", "license": "License", diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index eaa9412..cfd3fef 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -50,26 +50,8 @@ "scheduleSignInDescription": "Inicia sesión para sincronizar calendarios y tareas.", "scheduleNoSearchResults": "No hay eventos ni tareas coincidentes", "scheduleNoSearchResultsDescription": "Prueba con otra búsqueda o borra los filtros actuales.", - "trayAgendaLoading": "Cargando agenda...", - "trayAgendaSignInRequired": "Inicia sesión para mostrar la agenda.", - "trayAgendaNoSources": "No hay calendarios ni listas de tareas visibles.", - "trayAgendaOpenBusyMax": "Abrir app", - "trayAgendaRefresh": "Actualizar", - "trayAgendaError": "Agenda no disponible", - "compactAgendaTitle": "Agenda", - "compactAgendaSubtitle": "Próximamente", - "compactAgendaOverdue": "Vencidas", - "compactAgendaClear": "Libre por ahora", - "compactAgendaOpenBusyMax": "Abrir BusyMax", - "compactAgendaHide": "Ocultar", - "compactAgendaNewTask": "Nueva tarea", - "compactAgendaRetry": "Reintentar", - "compactAgendaRefresh": "Actualizar", - "compactAgendaAllDay": "Todo el día", - "compactAgendaDueToday": "Vence hoy", - "compactAgendaDueTomorrow": "Vence mañana", - "compactAgendaDueOn": "Vence {date}", - "compactAgendaMoreOverdue": "Cargar más tareas vencidas", + "refresh": "Actualizar", + "trayOpenBusyMax": "Abrir BusyMax", "agendaLoadMoreOverdue": "Cargar más tareas vencidas", "agendaLoadMoreNoDate": "Cargar más tareas sin fecha", "viewDay": "Día", @@ -155,9 +137,6 @@ "shortcutGroupTaskEditing": "Edición de tareas", "shortcutCancelEditing": "Cancelar edición", "shortcutCancelEditingDescription": "Cerrar la edición o los detalles de la tarea", - "shortcutGroupCompactAgenda": "Agenda compacta", - "shortcutRefreshCompactAgendaDescription": "Actualizar la ventana de agenda compacta", - "shortcutHideCompactAgendaDescription": "Ocultar la ventana de agenda compacta", "aboutBusyMax": "Acerca de BusyMax", "aboutBusyMaxDescription": "Calendario y tareas", "license": "Licencia", diff --git a/lib/l10n/app_et.arb b/lib/l10n/app_et.arb index 16ab28c..6b952dc 100644 --- a/lib/l10n/app_et.arb +++ b/lib/l10n/app_et.arb @@ -51,27 +51,8 @@ "scheduleSignInDescription": "Kalendrite ja ülesannete sünkroonimiseks logige sisse.", "scheduleNoSearchResults": "Sobivaid sündmusi ega ülesandeid ei leitud", "scheduleNoSearchResultsDescription": "Proovige teistsugust otsingut või eemaldage praegused filtrid.", - "trayAgendaLoading": "Päevakava laadimine...", - "trayAgendaSignInRequired": "Päevakava kuvamiseks logige sisse.", - "trayAgendaNoSources": "Nähtavaid kalendreid ega ülesandeloendeid pole.", - "trayAgendaOpenBusyMax": "Ava rakendus", - "trayAgendaRefresh": "Värskenda", - "trayAgendaError": "Päevakava pole saadaval", - "compactAgendaTitle": "Päevakava", - "compactAgendaSubtitle": "Tulekul", - "compactAgendaOverdue": "Tähtaja ületanud", - "compactAgendaClear": "Praegu vaba", - "compactAgendaOpenBusyMax": "Ava BusyMax", - "compactAgendaHide": "Peida", - "compactAgendaNewTask": "Uus ülesanne", - "compactAgendaRetry": "Proovi uuesti", - "compactAgendaRefresh": "Värskenda", - "compactAgendaAllDay": "Kogu päev", - "compactAgendaDueToday": "Tähtaeg täna", - "compactAgendaDueTomorrow": "Tähtaeg homme", - "compactAgendaDueOn": "Tähtaeg {date}", - "@compactAgendaDueOn": {"placeholders": {"date": {"type": "String"}}}, - "compactAgendaMoreOverdue": "Laadi veel tähtaja ületanud ülesandeid", + "refresh": "Värskenda", + "trayOpenBusyMax": "Ava BusyMax", "agendaLoadMoreOverdue": "Laadi veel tähtaja ületanud ülesandeid", "agendaLoadMoreNoDate": "Laadi veel kuupäevata ülesandeid", "viewDay": "Päev", @@ -158,9 +139,6 @@ "shortcutGroupTaskEditing": "Ülesande muutmine", "shortcutCancelEditing": "Tühista muutmine", "shortcutCancelEditingDescription": "Sulge ülesande muutmine või ülesande üksikasjad", - "shortcutGroupCompactAgenda": "Kompaktne päevakava", - "shortcutRefreshCompactAgendaDescription": "Värskenda kompaktse päevakava akent", - "shortcutHideCompactAgendaDescription": "Peida kompaktse päevakava aken", "aboutBusyMax": "Teave BusyMaxi kohta", "aboutBusyMaxDescription": "Kalender ja ülesanded", "license": "Litsents", diff --git a/lib/l10n/app_fa.arb b/lib/l10n/app_fa.arb index c812170..7caa2aa 100644 --- a/lib/l10n/app_fa.arb +++ b/lib/l10n/app_fa.arb @@ -50,26 +50,8 @@ "scheduleSignInDescription": "برای همگام‌سازی تقویم‌ها و کارها وارد شوید.", "scheduleNoSearchResults": "هیچ رویداد یا کار منطبقی وجود ندارد", "scheduleNoSearchResultsDescription": "جست‌وجوی دیگری را امتحان کنید یا پالایه‌های فعلی را پاک کنید.", - "trayAgendaLoading": "در حال بارگیری برنامه...", - "trayAgendaSignInRequired": "برای نمایش برنامه وارد شوید.", - "trayAgendaNoSources": "هیچ تقویم یا فهرست کار قابل نمایشی وجود ندارد.", - "trayAgendaOpenBusyMax": "باز کردن برنامه", - "trayAgendaRefresh": "تازه‌سازی", - "trayAgendaError": "برنامه در دسترس نیست", - "compactAgendaTitle": "برنامه", - "compactAgendaSubtitle": "پیش رو", - "compactAgendaOverdue": "گذشته از موعد", - "compactAgendaClear": "فعلاً موردی نیست", - "compactAgendaOpenBusyMax": "باز کردن BusyMax", - "compactAgendaHide": "پنهان کردن", - "compactAgendaNewTask": "کار جدید", - "compactAgendaRetry": "تلاش دوباره", - "compactAgendaRefresh": "تازه‌سازی", - "compactAgendaAllDay": "تمام روز", - "compactAgendaDueToday": "سررسید امروز", - "compactAgendaDueTomorrow": "سررسید فردا", - "compactAgendaDueOn": "سررسید: \u2068{date}\u2069", - "compactAgendaMoreOverdue": "بارگیری کارهای عقب‌افتادهٔ بیشتر", + "refresh": "تازه‌سازی", + "trayOpenBusyMax": "باز کردن BusyMax", "agendaLoadMoreOverdue": "بارگیری کارهای عقب‌افتادهٔ بیشتر", "agendaLoadMoreNoDate": "بارگیری کارهای بدون تاریخ بیشتر", "viewDay": "روز", @@ -152,9 +134,6 @@ "shortcutGroupTaskEditing": "ویرایش کار", "shortcutCancelEditing": "لغو ویرایش", "shortcutCancelEditingDescription": "بستن ویرایش یا جزئیات کار", - "shortcutGroupCompactAgenda": "برنامهٔ فشرده", - "shortcutRefreshCompactAgendaDescription": "تازه‌سازی پنجرهٔ برنامهٔ فشرده", - "shortcutHideCompactAgendaDescription": "پنهان کردن پنجرهٔ برنامهٔ فشرده", "aboutBusyMax": "دربارهٔ BusyMax", "aboutBusyMaxDescription": "تقویم و کارها", "license": "مجوز", diff --git a/lib/l10n/app_fi.arb b/lib/l10n/app_fi.arb index 006e0de..900bd8d 100644 --- a/lib/l10n/app_fi.arb +++ b/lib/l10n/app_fi.arb @@ -50,26 +50,8 @@ "scheduleSignInDescription": "Kirjaudu sisään synkronoidaksesi kalenterit ja tehtävät.", "scheduleNoSearchResults": "Ei vastaavia tapahtumia tai tehtäviä", "scheduleNoSearchResultsDescription": "Kokeile toista hakua tai tyhjennä nykyiset suodattimet.", - "trayAgendaLoading": "Ladataan agendaa...", - "trayAgendaSignInRequired": "Kirjaudu sisään nähdäksesi agendan.", - "trayAgendaNoSources": "Ei näkyviä kalentereita tai tehtäväluetteloita.", - "trayAgendaOpenBusyMax": "Avaa sovellus", - "trayAgendaRefresh": "Päivitä", - "trayAgendaError": "Agenda ei ole käytettävissä", - "compactAgendaTitle": "Agenda", - "compactAgendaSubtitle": "Tulossa", - "compactAgendaOverdue": "Myöhässä", - "compactAgendaClear": "Ei mitään juuri nyt", - "compactAgendaOpenBusyMax": "Avaa BusyMax", - "compactAgendaHide": "Piilota", - "compactAgendaNewTask": "Uusi tehtävä", - "compactAgendaRetry": "Yritä uudelleen", - "compactAgendaRefresh": "Päivitä", - "compactAgendaAllDay": "Koko päivä", - "compactAgendaDueToday": "Erääntyy tänään", - "compactAgendaDueTomorrow": "Erääntyy huomenna", - "compactAgendaDueOn": "Erääntyy {date}", - "compactAgendaMoreOverdue": "Lataa lisää myöhässä olevia tehtäviä", + "refresh": "Päivitä", + "trayOpenBusyMax": "Avaa BusyMax", "agendaLoadMoreOverdue": "Lataa lisää myöhässä olevia tehtäviä", "agendaLoadMoreNoDate": "Lataa lisää päiväämättömiä tehtäviä", "viewDay": "Päivä", @@ -152,9 +134,6 @@ "shortcutGroupTaskEditing": "Tehtävien muokkaaminen", "shortcutCancelEditing": "Peruuta muokkaaminen", "shortcutCancelEditingDescription": "Sulje tehtävän muokkaus tai tehtävän tiedot", - "shortcutGroupCompactAgenda": "Kompakti agenda", - "shortcutRefreshCompactAgendaDescription": "Päivitä kompaktin agendan ikkuna", - "shortcutHideCompactAgendaDescription": "Piilota kompaktin agendan ikkuna", "aboutBusyMax": "Tietoja BusyMaxista", "aboutBusyMaxDescription": "Kalenteri ja tehtävät", "license": "Lisenssi", diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index bae2300..7d4d9a8 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -50,26 +50,8 @@ "scheduleSignInDescription": "Connectez-vous pour synchroniser vos calendriers et vos tâches.", "scheduleNoSearchResults": "Aucun événement ni aucune tâche ne correspond", "scheduleNoSearchResultsDescription": "Essayez une autre recherche ou effacez les filtres actuels.", - "trayAgendaLoading": "Chargement de l’agenda...", - "trayAgendaSignInRequired": "Connectez-vous pour afficher l’agenda.", - "trayAgendaNoSources": "Aucun calendrier ni liste de tâches visible.", - "trayAgendaOpenBusyMax": "Ouvrir l’app", - "trayAgendaRefresh": "Actualiser", - "trayAgendaError": "Agenda indisponible", - "compactAgendaTitle": "Agenda", - "compactAgendaSubtitle": "À venir", - "compactAgendaOverdue": "En retard", - "compactAgendaClear": "Libre pour le moment", - "compactAgendaOpenBusyMax": "Ouvrir BusyMax", - "compactAgendaHide": "Masquer", - "compactAgendaNewTask": "Nouvelle tâche", - "compactAgendaRetry": "Réessayer", - "compactAgendaRefresh": "Actualiser", - "compactAgendaAllDay": "Toute la journée", - "compactAgendaDueToday": "Échéance aujourd’hui", - "compactAgendaDueTomorrow": "Échéance demain", - "compactAgendaDueOn": "Échéance {date}", - "compactAgendaMoreOverdue": "Charger plus de tâches en retard", + "refresh": "Actualiser", + "trayOpenBusyMax": "Ouvrir BusyMax", "agendaLoadMoreOverdue": "Charger plus de tâches en retard", "agendaLoadMoreNoDate": "Charger plus de tâches sans date", "viewDay": "Jour", @@ -155,9 +137,6 @@ "shortcutGroupTaskEditing": "Modification des tâches", "shortcutCancelEditing": "Annuler la modification", "shortcutCancelEditingDescription": "Fermer la modification ou les détails de la tâche", - "shortcutGroupCompactAgenda": "Agenda compact", - "shortcutRefreshCompactAgendaDescription": "Actualiser la fenêtre d'agenda compact", - "shortcutHideCompactAgendaDescription": "Masquer la fenêtre d'agenda compact", "aboutBusyMax": "À propos de BusyMax", "aboutBusyMaxDescription": "Calendrier et tâches", "license": "Licence", diff --git a/lib/l10n/app_hi.arb b/lib/l10n/app_hi.arb index 883045a..a809900 100644 --- a/lib/l10n/app_hi.arb +++ b/lib/l10n/app_hi.arb @@ -50,26 +50,8 @@ "scheduleSignInDescription": "कैलेंडर और कार्य सिंक करने के लिए साइन इन करें।", "scheduleNoSearchResults": "कोई मिलता-जुलता ईवेंट या कार्य नहीं", "scheduleNoSearchResultsDescription": "कोई दूसरी खोज आज़माएँ या मौजूदा फ़िल्टर हटाएँ।", - "trayAgendaLoading": "कार्यसूची लोड हो रही है...", - "trayAgendaSignInRequired": "कार्यसूची दिखाने के लिए साइन इन करें।", - "trayAgendaNoSources": "कोई दिखाई देने वाला कैलेंडर या कार्य सूची नहीं।", - "trayAgendaOpenBusyMax": "ऐप खोलें", - "trayAgendaRefresh": "रीफ़्रेश करें", - "trayAgendaError": "कार्यसूची उपलब्ध नहीं है", - "compactAgendaTitle": "कार्यसूची", - "compactAgendaSubtitle": "आगामी", - "compactAgendaOverdue": "समय सीमा बीत चुकी", - "compactAgendaClear": "अभी कुछ नहीं", - "compactAgendaOpenBusyMax": "BusyMax खोलें", - "compactAgendaHide": "छिपाएँ", - "compactAgendaNewTask": "नया कार्य", - "compactAgendaRetry": "फिर से कोशिश करें", - "compactAgendaRefresh": "रीफ़्रेश करें", - "compactAgendaAllDay": "पूरे दिन", - "compactAgendaDueToday": "आज देय", - "compactAgendaDueTomorrow": "कल देय", - "compactAgendaDueOn": "{date} को देय", - "compactAgendaMoreOverdue": "समय-सीमा पार कर चुके अतिरिक्त कार्य लोड करें", + "refresh": "रीफ़्रेश करें", + "trayOpenBusyMax": "BusyMax खोलें", "agendaLoadMoreOverdue": "समय-सीमा पार कर चुके अतिरिक्त कार्य लोड करें", "agendaLoadMoreNoDate": "बिना तारीख वाले और कार्य लोड करें", "viewDay": "दिन", @@ -152,9 +134,6 @@ "shortcutGroupTaskEditing": "कार्य संपादन", "shortcutCancelEditing": "संपादन रद्द करें", "shortcutCancelEditingDescription": "कार्य संपादन या कार्य विवरण बंद करें", - "shortcutGroupCompactAgenda": "संक्षिप्त कार्यसूची", - "shortcutRefreshCompactAgendaDescription": "संक्षिप्त कार्यसूची विंडो रीफ़्रेश करें", - "shortcutHideCompactAgendaDescription": "संक्षिप्त कार्यसूची विंडो छिपाएँ", "aboutBusyMax": "BusyMax के बारे में", "aboutBusyMaxDescription": "कैलेंडर और कार्य", "license": "लाइसेंस", diff --git a/lib/l10n/app_it.arb b/lib/l10n/app_it.arb index e69a9d3..f486c22 100644 --- a/lib/l10n/app_it.arb +++ b/lib/l10n/app_it.arb @@ -50,26 +50,8 @@ "scheduleSignInDescription": "Accedi per sincronizzare calendari e attività.", "scheduleNoSearchResults": "Nessun evento o attività corrispondente", "scheduleNoSearchResultsDescription": "Prova una ricerca diversa o cancella i filtri attuali.", - "trayAgendaLoading": "Caricamento agenda...", - "trayAgendaSignInRequired": "Accedi per mostrare l’agenda.", - "trayAgendaNoSources": "Nessun calendario o elenco di attività visibile.", - "trayAgendaOpenBusyMax": "Apri applicazione", - "trayAgendaRefresh": "Aggiorna", - "trayAgendaError": "Agenda non disponibile", - "compactAgendaTitle": "Agenda", - "compactAgendaSubtitle": "In arrivo", - "compactAgendaOverdue": "Scadute", - "compactAgendaClear": "Nessun impegno per ora", - "compactAgendaOpenBusyMax": "Apri BusyMax", - "compactAgendaHide": "Nascondi", - "compactAgendaNewTask": "Nuova attività", - "compactAgendaRetry": "Riprova", - "compactAgendaRefresh": "Aggiorna", - "compactAgendaAllDay": "Tutto il giorno", - "compactAgendaDueToday": "Scadenza: oggi", - "compactAgendaDueTomorrow": "Scadenza: domani", - "compactAgendaDueOn": "Scadenza: {date}", - "compactAgendaMoreOverdue": "Carica altre attività scadute", + "refresh": "Aggiorna", + "trayOpenBusyMax": "Apri BusyMax", "agendaLoadMoreOverdue": "Carica altre attività scadute", "agendaLoadMoreNoDate": "Carica altre attività senza data", "viewDay": "Giorno", @@ -152,9 +134,6 @@ "shortcutGroupTaskEditing": "Modifica delle attività", "shortcutCancelEditing": "Annulla modifica", "shortcutCancelEditingDescription": "Chiudi la modifica o i dettagli dell’attività", - "shortcutGroupCompactAgenda": "Agenda compatta", - "shortcutRefreshCompactAgendaDescription": "Aggiorna la finestra dell’agenda compatta", - "shortcutHideCompactAgendaDescription": "Nascondi la finestra dell’agenda compatta", "aboutBusyMax": "Informazioni su BusyMax", "aboutBusyMaxDescription": "Calendario e attività", "license": "Licenza", diff --git a/lib/l10n/app_ja.arb b/lib/l10n/app_ja.arb index eb93d36..06b002e 100644 --- a/lib/l10n/app_ja.arb +++ b/lib/l10n/app_ja.arb @@ -50,26 +50,8 @@ "scheduleSignInDescription": "カレンダーとタスクを同期するにはサインインしてください。", "scheduleNoSearchResults": "一致する予定またはタスクはありません", "scheduleNoSearchResultsDescription": "別の条件で検索するか、現在のフィルターを解除してください。", - "trayAgendaLoading": "予定一覧を読み込んでいます...", - "trayAgendaSignInRequired": "予定一覧を表示するにはサインインしてください。", - "trayAgendaNoSources": "表示できるカレンダーまたはタスクリストがありません。", - "trayAgendaOpenBusyMax": "アプリを開く", - "trayAgendaRefresh": "更新", - "trayAgendaError": "予定一覧を利用できません", - "compactAgendaTitle": "予定一覧", - "compactAgendaSubtitle": "今後の予定", - "compactAgendaOverdue": "期限超過", - "compactAgendaClear": "今のところ予定なし", - "compactAgendaOpenBusyMax": "BusyMax を開く", - "compactAgendaHide": "非表示", - "compactAgendaNewTask": "新しいタスク", - "compactAgendaRetry": "再試行", - "compactAgendaRefresh": "更新", - "compactAgendaAllDay": "終日", - "compactAgendaDueToday": "今日が期限", - "compactAgendaDueTomorrow": "明日が期限", - "compactAgendaDueOn": "期限: {date}", - "compactAgendaMoreOverdue": "期限切れのタスクをさらに読み込む", + "refresh": "更新", + "trayOpenBusyMax": "BusyMax を開く", "agendaLoadMoreOverdue": "期限切れのタスクをさらに読み込む", "agendaLoadMoreNoDate": "日付のないタスクをさらに読み込む", "viewDay": "日", @@ -152,9 +134,6 @@ "shortcutGroupTaskEditing": "タスクの編集", "shortcutCancelEditing": "編集をキャンセル", "shortcutCancelEditingDescription": "タスクの編集または詳細を閉じる", - "shortcutGroupCompactAgenda": "コンパクト予定一覧", - "shortcutRefreshCompactAgendaDescription": "コンパクト予定一覧ウィンドウを更新", - "shortcutHideCompactAgendaDescription": "コンパクト予定一覧ウィンドウを非表示", "aboutBusyMax": "BusyMax について", "aboutBusyMaxDescription": "カレンダーとタスク", "license": "ライセンス", diff --git a/lib/l10n/app_ko.arb b/lib/l10n/app_ko.arb index ffbd472..183daaf 100644 --- a/lib/l10n/app_ko.arb +++ b/lib/l10n/app_ko.arb @@ -50,26 +50,8 @@ "scheduleSignInDescription": "캘린더와 할 일을 동기화하려면 로그인하세요.", "scheduleNoSearchResults": "일치하는 일정 또는 할 일이 없습니다", "scheduleNoSearchResultsDescription": "다른 검색어를 사용하거나 현재 필터를 지우세요.", - "trayAgendaLoading": "일정 목록을 불러오는 중...", - "trayAgendaSignInRequired": "일정 목록을 표시하려면 로그인하세요.", - "trayAgendaNoSources": "표시할 캘린더 또는 할 일 목록이 없습니다.", - "trayAgendaOpenBusyMax": "앱 열기", - "trayAgendaRefresh": "새로 고침", - "trayAgendaError": "일정 목록을 사용할 수 없습니다", - "compactAgendaTitle": "일정 목록", - "compactAgendaSubtitle": "예정", - "compactAgendaOverdue": "기한 지남", - "compactAgendaClear": "현재 예정 없음", - "compactAgendaOpenBusyMax": "BusyMax 열기", - "compactAgendaHide": "숨기기", - "compactAgendaNewTask": "새 할 일", - "compactAgendaRetry": "다시 시도", - "compactAgendaRefresh": "새로 고침", - "compactAgendaAllDay": "하루 종일", - "compactAgendaDueToday": "오늘 마감", - "compactAgendaDueTomorrow": "내일 마감", - "compactAgendaDueOn": "{date} 마감", - "compactAgendaMoreOverdue": "기한이 지난 할 일 더 불러오기", + "refresh": "새로 고침", + "trayOpenBusyMax": "BusyMax 열기", "agendaLoadMoreOverdue": "기한이 지난 할 일 더 불러오기", "agendaLoadMoreNoDate": "날짜 없는 할 일 더 불러오기", "viewDay": "일", @@ -152,9 +134,6 @@ "shortcutGroupTaskEditing": "할 일 편집", "shortcutCancelEditing": "편집 취소", "shortcutCancelEditingDescription": "할 일 편집 또는 할 일 세부 정보 닫기", - "shortcutGroupCompactAgenda": "간단 일정 목록", - "shortcutRefreshCompactAgendaDescription": "간단 일정 목록 창 새로 고침", - "shortcutHideCompactAgendaDescription": "간단 일정 목록 창 숨기기", "aboutBusyMax": "BusyMax 정보", "aboutBusyMaxDescription": "캘린더와 할 일", "license": "라이선스", diff --git a/lib/l10n/app_pt.arb b/lib/l10n/app_pt.arb index e56c577..d330459 100644 --- a/lib/l10n/app_pt.arb +++ b/lib/l10n/app_pt.arb @@ -50,26 +50,8 @@ "scheduleSignInDescription": "Inicie sessão para sincronizar calendários e tarefas.", "scheduleNoSearchResults": "Nenhum evento ou tarefa correspondente", "scheduleNoSearchResultsDescription": "Experimente outra pesquisa ou limpe os filtros atuais.", - "trayAgendaLoading": "A carregar agenda...", - "trayAgendaSignInRequired": "Inicie sessão para ver a agenda.", - "trayAgendaNoSources": "Sem calendários ou listas de tarefas visíveis.", - "trayAgendaOpenBusyMax": "Abrir aplicação", - "trayAgendaRefresh": "Atualizar", - "trayAgendaError": "Agenda indisponível", - "compactAgendaTitle": "Agenda", - "compactAgendaSubtitle": "Próximos", - "compactAgendaOverdue": "Em atraso", - "compactAgendaClear": "Livre por agora", - "compactAgendaOpenBusyMax": "Abrir o BusyMax", - "compactAgendaHide": "Ocultar", - "compactAgendaNewTask": "Nova tarefa", - "compactAgendaRetry": "Tentar novamente", - "compactAgendaRefresh": "Atualizar", - "compactAgendaAllDay": "Todo o dia", - "compactAgendaDueToday": "Prazo: hoje", - "compactAgendaDueTomorrow": "Prazo: amanhã", - "compactAgendaDueOn": "Prazo: {date}", - "compactAgendaMoreOverdue": "Carregar mais tarefas em atraso", + "refresh": "Atualizar", + "trayOpenBusyMax": "Abrir o BusyMax", "agendaLoadMoreOverdue": "Carregar mais tarefas em atraso", "agendaLoadMoreNoDate": "Carregar mais tarefas sem data", "viewDay": "Dia", @@ -152,9 +134,6 @@ "shortcutGroupTaskEditing": "Edição de tarefas", "shortcutCancelEditing": "Cancelar edição", "shortcutCancelEditingDescription": "Fechar a edição ou os detalhes da tarefa", - "shortcutGroupCompactAgenda": "Agenda compacta", - "shortcutRefreshCompactAgendaDescription": "Atualizar a janela da agenda compacta", - "shortcutHideCompactAgendaDescription": "Ocultar a janela da agenda compacta", "aboutBusyMax": "Acerca do BusyMax", "aboutBusyMaxDescription": "Calendário e tarefas", "license": "Licença", diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index ed43d3b..6184aa3 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -50,26 +50,8 @@ "scheduleSignInDescription": "Войдите, чтобы синхронизировать календари и задачи.", "scheduleNoSearchResults": "Подходящих событий или задач нет", "scheduleNoSearchResultsDescription": "Попробуйте изменить запрос или сбросить текущие фильтры.", - "trayAgendaLoading": "Загрузка расписания...", - "trayAgendaSignInRequired": "Войдите, чтобы просмотреть расписание.", - "trayAgendaNoSources": "Нет видимых календарей или списков задач.", - "trayAgendaOpenBusyMax": "Открыть приложение", - "trayAgendaRefresh": "Обновить", - "trayAgendaError": "Расписание недоступно", - "compactAgendaTitle": "Расписание", - "compactAgendaSubtitle": "Предстоящие", - "compactAgendaOverdue": "Просроченные", - "compactAgendaClear": "На ближайшее время ничего нет", - "compactAgendaOpenBusyMax": "Открыть BusyMax", - "compactAgendaHide": "Скрыть", - "compactAgendaNewTask": "Новая задача", - "compactAgendaRetry": "Повторить", - "compactAgendaRefresh": "Обновить", - "compactAgendaAllDay": "Весь день", - "compactAgendaDueToday": "Срок — сегодня", - "compactAgendaDueTomorrow": "Срок — завтра", - "compactAgendaDueOn": "Срок — {date}", - "compactAgendaMoreOverdue": "Загрузить ещё просроченные задачи", + "refresh": "Обновить", + "trayOpenBusyMax": "Открыть BusyMax", "agendaLoadMoreOverdue": "Загрузить ещё просроченные задачи", "agendaLoadMoreNoDate": "Загрузить ещё задачи без даты", "viewDay": "День", @@ -152,9 +134,6 @@ "shortcutGroupTaskEditing": "Редактирование задач", "shortcutCancelEditing": "Отменить редактирование", "shortcutCancelEditingDescription": "Выйти из режима редактирования задачи или закрыть сведения о ней", - "shortcutGroupCompactAgenda": "Компактное расписание", - "shortcutRefreshCompactAgendaDescription": "Обновить окно компактного расписания", - "shortcutHideCompactAgendaDescription": "Скрыть окно компактного расписания", "aboutBusyMax": "О приложении BusyMax", "aboutBusyMaxDescription": "Календарь и задачи", "license": "Лицензия", diff --git a/lib/l10n/app_vi.arb b/lib/l10n/app_vi.arb index 2ca8c02..1a0d6a5 100644 --- a/lib/l10n/app_vi.arb +++ b/lib/l10n/app_vi.arb @@ -50,26 +50,8 @@ "scheduleSignInDescription": "Đăng nhập để đồng bộ lịch và công việc.", "scheduleNoSearchResults": "Không có sự kiện hoặc công việc phù hợp", "scheduleNoSearchResultsDescription": "Thử tìm kiếm khác hoặc xóa các bộ lọc hiện tại.", - "trayAgendaLoading": "Đang tải lịch biểu...", - "trayAgendaSignInRequired": "Đăng nhập để hiển thị lịch biểu.", - "trayAgendaNoSources": "Không có lịch hoặc danh sách công việc nào đang hiển thị.", - "trayAgendaOpenBusyMax": "Mở ứng dụng", - "trayAgendaRefresh": "Làm mới", - "trayAgendaError": "Lịch biểu không khả dụng", - "compactAgendaTitle": "Lịch biểu", - "compactAgendaSubtitle": "Sắp tới", - "compactAgendaOverdue": "Quá hạn", - "compactAgendaClear": "Hiện chưa có lịch", - "compactAgendaOpenBusyMax": "Mở BusyMax", - "compactAgendaHide": "Ẩn", - "compactAgendaNewTask": "Công việc mới", - "compactAgendaRetry": "Thử lại", - "compactAgendaRefresh": "Làm mới", - "compactAgendaAllDay": "Cả ngày", - "compactAgendaDueToday": "Đến hạn hôm nay", - "compactAgendaDueTomorrow": "Đến hạn ngày mai", - "compactAgendaDueOn": "Đến hạn {date}", - "compactAgendaMoreOverdue": "Tải thêm công việc quá hạn", + "refresh": "Làm mới", + "trayOpenBusyMax": "Mở BusyMax", "agendaLoadMoreOverdue": "Tải thêm công việc quá hạn", "agendaLoadMoreNoDate": "Tải thêm công việc không có ngày", "viewDay": "Ngày", @@ -152,9 +134,6 @@ "shortcutGroupTaskEditing": "Chỉnh sửa công việc", "shortcutCancelEditing": "Hủy chỉnh sửa", "shortcutCancelEditingDescription": "Đóng phần chỉnh sửa hoặc chi tiết công việc", - "shortcutGroupCompactAgenda": "Lịch biểu thu gọn", - "shortcutRefreshCompactAgendaDescription": "Làm mới cửa sổ lịch biểu thu gọn", - "shortcutHideCompactAgendaDescription": "Ẩn cửa sổ lịch biểu thu gọn", "aboutBusyMax": "Giới thiệu BusyMax", "aboutBusyMaxDescription": "Lịch và công việc", "license": "Giấy phép", diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 0d2dd64..71eb8a8 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -50,26 +50,8 @@ "scheduleSignInDescription": "登录以同步日历和任务。", "scheduleNoSearchResults": "没有匹配的日程或任务", "scheduleNoSearchResultsDescription": "请尝试其他搜索内容或清除当前筛选条件。", - "trayAgendaLoading": "正在加载日程...", - "trayAgendaSignInRequired": "请登录以显示日程。", - "trayAgendaNoSources": "没有可见的日历或任务列表。", - "trayAgendaOpenBusyMax": "打开应用", - "trayAgendaRefresh": "刷新", - "trayAgendaError": "日程不可用", - "compactAgendaTitle": "日程", - "compactAgendaSubtitle": "接下来", - "compactAgendaOverdue": "已逾期", - "compactAgendaClear": "目前空闲", - "compactAgendaOpenBusyMax": "打开 BusyMax", - "compactAgendaHide": "隐藏", - "compactAgendaNewTask": "新建任务", - "compactAgendaRetry": "重试", - "compactAgendaRefresh": "刷新", - "compactAgendaAllDay": "全天", - "compactAgendaDueToday": "今天到期", - "compactAgendaDueTomorrow": "明天到期", - "compactAgendaDueOn": "{date} 到期", - "compactAgendaMoreOverdue": "加载更多逾期任务", + "refresh": "刷新", + "trayOpenBusyMax": "打开 BusyMax", "agendaLoadMoreOverdue": "加载更多逾期任务", "agendaLoadMoreNoDate": "加载更多无日期任务", "viewDay": "日", @@ -152,9 +134,6 @@ "shortcutGroupTaskEditing": "任务编辑", "shortcutCancelEditing": "取消编辑", "shortcutCancelEditingDescription": "关闭任务编辑或任务详情", - "shortcutGroupCompactAgenda": "紧凑日程", - "shortcutRefreshCompactAgendaDescription": "刷新紧凑日程窗口", - "shortcutHideCompactAgendaDescription": "隐藏紧凑日程窗口", "aboutBusyMax": "关于 BusyMax", "aboutBusyMaxDescription": "日历和任务", "license": "许可证", diff --git a/lib/l10n/app_zh_Hans.arb b/lib/l10n/app_zh_Hans.arb index d2812f2..75338a1 100644 --- a/lib/l10n/app_zh_Hans.arb +++ b/lib/l10n/app_zh_Hans.arb @@ -50,26 +50,8 @@ "scheduleSignInDescription": "登录以同步日历和任务。", "scheduleNoSearchResults": "没有匹配的日程或任务", "scheduleNoSearchResultsDescription": "请尝试其他搜索内容或清除当前筛选条件。", - "trayAgendaLoading": "正在加载日程...", - "trayAgendaSignInRequired": "请登录以显示日程。", - "trayAgendaNoSources": "没有可见的日历或任务列表。", - "trayAgendaOpenBusyMax": "打开应用", - "trayAgendaRefresh": "刷新", - "trayAgendaError": "日程不可用", - "compactAgendaTitle": "日程", - "compactAgendaSubtitle": "接下来", - "compactAgendaOverdue": "已逾期", - "compactAgendaClear": "目前空闲", - "compactAgendaOpenBusyMax": "打开 BusyMax", - "compactAgendaHide": "隐藏", - "compactAgendaNewTask": "新建任务", - "compactAgendaRetry": "重试", - "compactAgendaRefresh": "刷新", - "compactAgendaAllDay": "全天", - "compactAgendaDueToday": "今天到期", - "compactAgendaDueTomorrow": "明天到期", - "compactAgendaDueOn": "{date} 到期", - "compactAgendaMoreOverdue": "加载更多逾期任务", + "refresh": "刷新", + "trayOpenBusyMax": "打开 BusyMax", "agendaLoadMoreOverdue": "加载更多逾期任务", "agendaLoadMoreNoDate": "加载更多无日期任务", "viewDay": "日", @@ -152,9 +134,6 @@ "shortcutGroupTaskEditing": "任务编辑", "shortcutCancelEditing": "取消编辑", "shortcutCancelEditingDescription": "关闭任务编辑或任务详情", - "shortcutGroupCompactAgenda": "紧凑日程", - "shortcutRefreshCompactAgendaDescription": "刷新紧凑日程窗口", - "shortcutHideCompactAgendaDescription": "隐藏紧凑日程窗口", "aboutBusyMax": "关于 BusyMax", "aboutBusyMaxDescription": "日历和任务", "license": "许可证", diff --git a/lib/l10n/app_zh_Hant.arb b/lib/l10n/app_zh_Hant.arb index 1139d09..778d8cc 100644 --- a/lib/l10n/app_zh_Hant.arb +++ b/lib/l10n/app_zh_Hant.arb @@ -50,26 +50,8 @@ "scheduleSignInDescription": "登入以同步行事曆和待辦事項。", "scheduleNoSearchResults": "沒有相符的活動或待辦事項", "scheduleNoSearchResultsDescription": "請嘗試其他搜尋內容或清除目前的篩選條件。", - "trayAgendaLoading": "正在載入行程...", - "trayAgendaSignInRequired": "請登入以顯示行程。", - "trayAgendaNoSources": "沒有可見的行事曆或待辦清單。", - "trayAgendaOpenBusyMax": "開啟應用程式", - "trayAgendaRefresh": "重新整理", - "trayAgendaError": "無法使用行程", - "compactAgendaTitle": "行程", - "compactAgendaSubtitle": "接下來", - "compactAgendaOverdue": "已逾期", - "compactAgendaClear": "目前沒有安排", - "compactAgendaOpenBusyMax": "開啟 BusyMax", - "compactAgendaHide": "隱藏", - "compactAgendaNewTask": "新增待辦事項", - "compactAgendaRetry": "再試一次", - "compactAgendaRefresh": "重新整理", - "compactAgendaAllDay": "全天", - "compactAgendaDueToday": "今天到期", - "compactAgendaDueTomorrow": "明天到期", - "compactAgendaDueOn": "{date} 到期", - "compactAgendaMoreOverdue": "載入更多逾期待辦事項", + "refresh": "重新整理", + "trayOpenBusyMax": "開啟 BusyMax", "agendaLoadMoreOverdue": "載入更多逾期待辦事項", "agendaLoadMoreNoDate": "載入更多無日期待辦事項", "viewDay": "日", @@ -152,9 +134,6 @@ "shortcutGroupTaskEditing": "待辦事項編輯", "shortcutCancelEditing": "取消編輯", "shortcutCancelEditingDescription": "關閉待辦事項編輯或詳細資料", - "shortcutGroupCompactAgenda": "精簡行程", - "shortcutRefreshCompactAgendaDescription": "重新整理精簡行程視窗", - "shortcutHideCompactAgendaDescription": "隱藏精簡行程視窗", "aboutBusyMax": "關於 BusyMax", "aboutBusyMaxDescription": "行事曆與待辦事項", "license": "授權", diff --git a/lib/l10n/generated/app_localizations.dart b/lib/l10n/generated/app_localizations.dart index 8889a34..77b8b4c 100644 --- a/lib/l10n/generated/app_localizations.dart +++ b/lib/l10n/generated/app_localizations.dart @@ -432,125 +432,17 @@ abstract class AppLocalizations { /// **'Try a different search or clear the current filters.'** String get scheduleNoSearchResultsDescription; - /// No description provided for @trayAgendaLoading. - /// - /// In en, this message translates to: - /// **'Loading agenda...'** - String get trayAgendaLoading; - - /// No description provided for @trayAgendaSignInRequired. - /// - /// In en, this message translates to: - /// **'Sign in to show agenda.'** - String get trayAgendaSignInRequired; - - /// No description provided for @trayAgendaNoSources. - /// - /// In en, this message translates to: - /// **'No visible calendars or task lists.'** - String get trayAgendaNoSources; - - /// No description provided for @trayAgendaOpenBusyMax. - /// - /// In en, this message translates to: - /// **'Open app'** - String get trayAgendaOpenBusyMax; - - /// No description provided for @trayAgendaRefresh. + /// No description provided for @refresh. /// /// In en, this message translates to: /// **'Refresh'** - String get trayAgendaRefresh; - - /// No description provided for @trayAgendaError. - /// - /// In en, this message translates to: - /// **'Agenda unavailable'** - String get trayAgendaError; - - /// No description provided for @compactAgendaTitle. - /// - /// In en, this message translates to: - /// **'Agenda'** - String get compactAgendaTitle; + String get refresh; - /// No description provided for @compactAgendaSubtitle. - /// - /// In en, this message translates to: - /// **'Upcoming'** - String get compactAgendaSubtitle; - - /// No description provided for @compactAgendaOverdue. - /// - /// In en, this message translates to: - /// **'Overdue'** - String get compactAgendaOverdue; - - /// No description provided for @compactAgendaClear. - /// - /// In en, this message translates to: - /// **'Clear for now'** - String get compactAgendaClear; - - /// No description provided for @compactAgendaOpenBusyMax. + /// No description provided for @trayOpenBusyMax. /// /// In en, this message translates to: /// **'Open BusyMax'** - String get compactAgendaOpenBusyMax; - - /// No description provided for @compactAgendaHide. - /// - /// In en, this message translates to: - /// **'Hide'** - String get compactAgendaHide; - - /// No description provided for @compactAgendaNewTask. - /// - /// In en, this message translates to: - /// **'New task'** - String get compactAgendaNewTask; - - /// No description provided for @compactAgendaRetry. - /// - /// In en, this message translates to: - /// **'Retry'** - String get compactAgendaRetry; - - /// No description provided for @compactAgendaRefresh. - /// - /// In en, this message translates to: - /// **'Refresh'** - String get compactAgendaRefresh; - - /// No description provided for @compactAgendaAllDay. - /// - /// In en, this message translates to: - /// **'All day'** - String get compactAgendaAllDay; - - /// No description provided for @compactAgendaDueToday. - /// - /// In en, this message translates to: - /// **'Due today'** - String get compactAgendaDueToday; - - /// No description provided for @compactAgendaDueTomorrow. - /// - /// In en, this message translates to: - /// **'Due tomorrow'** - String get compactAgendaDueTomorrow; - - /// No description provided for @compactAgendaDueOn. - /// - /// In en, this message translates to: - /// **'Due {date}'** - String compactAgendaDueOn(String date); - - /// No description provided for @compactAgendaMoreOverdue. - /// - /// In en, this message translates to: - /// **'Load more overdue tasks'** - String get compactAgendaMoreOverdue; + String get trayOpenBusyMax; /// No description provided for @agendaLoadMoreOverdue. /// @@ -1044,24 +936,6 @@ abstract class AppLocalizations { /// **'Close task editing or task details'** String get shortcutCancelEditingDescription; - /// No description provided for @shortcutGroupCompactAgenda. - /// - /// In en, this message translates to: - /// **'Compact agenda'** - String get shortcutGroupCompactAgenda; - - /// No description provided for @shortcutRefreshCompactAgendaDescription. - /// - /// In en, this message translates to: - /// **'Refresh the compact agenda window'** - String get shortcutRefreshCompactAgendaDescription; - - /// No description provided for @shortcutHideCompactAgendaDescription. - /// - /// In en, this message translates to: - /// **'Hide the compact agenda window'** - String get shortcutHideCompactAgendaDescription; - /// No description provided for @aboutBusyMax. /// /// 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 dfc8593..f65376c 100644 --- a/lib/l10n/generated/app_localizations_ar.dart +++ b/lib/l10n/generated/app_localizations_ar.dart @@ -175,66 +175,10 @@ class AppLocalizationsAr extends AppLocalizations { 'جرّب بحثًا مختلفًا أو امسح عوامل التصفية الحالية.'; @override - String get trayAgendaLoading => 'جارٍ تحميل جدول الأعمال...'; + String get refresh => 'تحديث'; @override - String get trayAgendaSignInRequired => 'سجّل الدخول لإظهار جدول الأعمال.'; - - @override - String get trayAgendaNoSources => 'لا توجد تقويمات أو قوائم مهام ظاهرة.'; - - @override - String get trayAgendaOpenBusyMax => 'فتح التطبيق'; - - @override - String get trayAgendaRefresh => 'تحديث'; - - @override - String get trayAgendaError => 'جدول الأعمال غير متاح'; - - @override - String get compactAgendaTitle => 'جدول الأعمال'; - - @override - String get compactAgendaSubtitle => 'القادم'; - - @override - String get compactAgendaOverdue => 'متأخرة'; - - @override - String get compactAgendaClear => 'لا شيء حاليًا'; - - @override - String get compactAgendaOpenBusyMax => 'فتح BusyMax'; - - @override - String get compactAgendaHide => 'إخفاء'; - - @override - String get compactAgendaNewTask => 'مهمة جديدة'; - - @override - String get compactAgendaRetry => 'إعادة المحاولة'; - - @override - String get compactAgendaRefresh => 'تحديث'; - - @override - String get compactAgendaAllDay => 'طوال اليوم'; - - @override - String get compactAgendaDueToday => 'مستحقة اليوم'; - - @override - String get compactAgendaDueTomorrow => 'مستحقة غدًا'; - - @override - String compactAgendaDueOn(String date) { - return 'مستحقة في ⁨$date⁩'; - } - - @override - String get compactAgendaMoreOverdue => 'تحميل المزيد من المهام المتأخرة'; + String get trayOpenBusyMax => 'فتح BusyMax'; @override String get agendaLoadMoreOverdue => 'تحميل المزيد من المهام المتأخرة'; @@ -525,17 +469,6 @@ class AppLocalizationsAr extends AppLocalizations { String get shortcutCancelEditingDescription => 'إغلاق تعديل المهمة أو تفاصيلها'; - @override - String get shortcutGroupCompactAgenda => 'جدول الأعمال المصغّر'; - - @override - String get shortcutRefreshCompactAgendaDescription => - 'تحديث نافذة جدول الأعمال المصغّر'; - - @override - String get shortcutHideCompactAgendaDescription => - 'إخفاء نافذة جدول الأعمال المصغّر'; - @override String get aboutBusyMax => 'حول BusyMax'; diff --git a/lib/l10n/generated/app_localizations_de.dart b/lib/l10n/generated/app_localizations_de.dart index 1f913cd..0060526 100644 --- a/lib/l10n/generated/app_localizations_de.dart +++ b/lib/l10n/generated/app_localizations_de.dart @@ -175,68 +175,10 @@ class AppLocalizationsDe extends AppLocalizations { 'Versuchen Sie es mit einer anderen Suche oder setzen Sie die aktuellen Filter zurück.'; @override - String get trayAgendaLoading => 'Agenda wird geladen...'; + String get refresh => 'Aktualisieren'; @override - String get trayAgendaSignInRequired => - 'Melden Sie sich an, um die Agenda anzuzeigen.'; - - @override - String get trayAgendaNoSources => - 'Keine sichtbaren Kalender oder Aufgabenlisten.'; - - @override - String get trayAgendaOpenBusyMax => 'App öffnen'; - - @override - String get trayAgendaRefresh => 'Aktualisieren'; - - @override - String get trayAgendaError => 'Agenda nicht verfügbar'; - - @override - String get compactAgendaTitle => 'Agenda'; - - @override - String get compactAgendaSubtitle => 'Anstehend'; - - @override - String get compactAgendaOverdue => 'Überfällig'; - - @override - String get compactAgendaClear => 'Im Moment frei'; - - @override - String get compactAgendaOpenBusyMax => 'BusyMax öffnen'; - - @override - String get compactAgendaHide => 'Ausblenden'; - - @override - String get compactAgendaNewTask => 'Neue Aufgabe'; - - @override - String get compactAgendaRetry => 'Erneut versuchen'; - - @override - String get compactAgendaRefresh => 'Aktualisieren'; - - @override - String get compactAgendaAllDay => 'Ganztägig'; - - @override - String get compactAgendaDueToday => 'Heute fällig'; - - @override - String get compactAgendaDueTomorrow => 'Morgen fällig'; - - @override - String compactAgendaDueOn(String date) { - return 'Fällig $date'; - } - - @override - String get compactAgendaMoreOverdue => 'Weitere überfällige Aufgaben laden'; + String get trayOpenBusyMax => 'BusyMax öffnen'; @override String get agendaLoadMoreOverdue => 'Weitere überfällige Aufgaben laden'; @@ -515,17 +457,6 @@ class AppLocalizationsDe extends AppLocalizations { String get shortcutCancelEditingDescription => 'Aufgabenbearbeitung oder Aufgabendetails schließen'; - @override - String get shortcutGroupCompactAgenda => 'Kompakte Agenda'; - - @override - String get shortcutRefreshCompactAgendaDescription => - 'Das kompakte Agenda-Fenster aktualisieren'; - - @override - String get shortcutHideCompactAgendaDescription => - 'Das kompakte Agenda-Fenster ausblenden'; - @override String get aboutBusyMax => 'Über BusyMax'; diff --git a/lib/l10n/generated/app_localizations_en.dart b/lib/l10n/generated/app_localizations_en.dart index 5da3621..e858e70 100644 --- a/lib/l10n/generated/app_localizations_en.dart +++ b/lib/l10n/generated/app_localizations_en.dart @@ -174,66 +174,10 @@ class AppLocalizationsEn extends AppLocalizations { 'Try a different search or clear the current filters.'; @override - String get trayAgendaLoading => 'Loading agenda...'; + String get refresh => 'Refresh'; @override - String get trayAgendaSignInRequired => 'Sign in to show agenda.'; - - @override - String get trayAgendaNoSources => 'No visible calendars or task lists.'; - - @override - String get trayAgendaOpenBusyMax => 'Open app'; - - @override - String get trayAgendaRefresh => 'Refresh'; - - @override - String get trayAgendaError => 'Agenda unavailable'; - - @override - String get compactAgendaTitle => 'Agenda'; - - @override - String get compactAgendaSubtitle => 'Upcoming'; - - @override - String get compactAgendaOverdue => 'Overdue'; - - @override - String get compactAgendaClear => 'Clear for now'; - - @override - String get compactAgendaOpenBusyMax => 'Open BusyMax'; - - @override - String get compactAgendaHide => 'Hide'; - - @override - String get compactAgendaNewTask => 'New task'; - - @override - String get compactAgendaRetry => 'Retry'; - - @override - String get compactAgendaRefresh => 'Refresh'; - - @override - String get compactAgendaAllDay => 'All day'; - - @override - String get compactAgendaDueToday => 'Due today'; - - @override - String get compactAgendaDueTomorrow => 'Due tomorrow'; - - @override - String compactAgendaDueOn(String date) { - return 'Due $date'; - } - - @override - String get compactAgendaMoreOverdue => 'Load more overdue tasks'; + String get trayOpenBusyMax => 'Open BusyMax'; @override String get agendaLoadMoreOverdue => 'Load more overdue tasks'; @@ -512,17 +456,6 @@ class AppLocalizationsEn extends AppLocalizations { String get shortcutCancelEditingDescription => 'Close task editing or task details'; - @override - String get shortcutGroupCompactAgenda => 'Compact agenda'; - - @override - String get shortcutRefreshCompactAgendaDescription => - 'Refresh the compact agenda window'; - - @override - String get shortcutHideCompactAgendaDescription => - 'Hide the compact agenda window'; - @override String get aboutBusyMax => 'About BusyMax'; diff --git a/lib/l10n/generated/app_localizations_es.dart b/lib/l10n/generated/app_localizations_es.dart index f969674..6a0da5f 100644 --- a/lib/l10n/generated/app_localizations_es.dart +++ b/lib/l10n/generated/app_localizations_es.dart @@ -177,68 +177,10 @@ class AppLocalizationsEs extends AppLocalizations { 'Prueba con otra búsqueda o borra los filtros actuales.'; @override - String get trayAgendaLoading => 'Cargando agenda...'; + String get refresh => 'Actualizar'; @override - String get trayAgendaSignInRequired => - 'Inicia sesión para mostrar la agenda.'; - - @override - String get trayAgendaNoSources => - 'No hay calendarios ni listas de tareas visibles.'; - - @override - String get trayAgendaOpenBusyMax => 'Abrir app'; - - @override - String get trayAgendaRefresh => 'Actualizar'; - - @override - String get trayAgendaError => 'Agenda no disponible'; - - @override - String get compactAgendaTitle => 'Agenda'; - - @override - String get compactAgendaSubtitle => 'Próximamente'; - - @override - String get compactAgendaOverdue => 'Vencidas'; - - @override - String get compactAgendaClear => 'Libre por ahora'; - - @override - String get compactAgendaOpenBusyMax => 'Abrir BusyMax'; - - @override - String get compactAgendaHide => 'Ocultar'; - - @override - String get compactAgendaNewTask => 'Nueva tarea'; - - @override - String get compactAgendaRetry => 'Reintentar'; - - @override - String get compactAgendaRefresh => 'Actualizar'; - - @override - String get compactAgendaAllDay => 'Todo el día'; - - @override - String get compactAgendaDueToday => 'Vence hoy'; - - @override - String get compactAgendaDueTomorrow => 'Vence mañana'; - - @override - String compactAgendaDueOn(String date) { - return 'Vence $date'; - } - - @override - String get compactAgendaMoreOverdue => 'Cargar más tareas vencidas'; + String get trayOpenBusyMax => 'Abrir BusyMax'; @override String get agendaLoadMoreOverdue => 'Cargar más tareas vencidas'; @@ -517,17 +459,6 @@ class AppLocalizationsEs extends AppLocalizations { String get shortcutCancelEditingDescription => 'Cerrar la edición o los detalles de la tarea'; - @override - String get shortcutGroupCompactAgenda => 'Agenda compacta'; - - @override - String get shortcutRefreshCompactAgendaDescription => - 'Actualizar la ventana de agenda compacta'; - - @override - String get shortcutHideCompactAgendaDescription => - 'Ocultar la ventana de agenda compacta'; - @override String get aboutBusyMax => 'Acerca de BusyMax'; diff --git a/lib/l10n/generated/app_localizations_et.dart b/lib/l10n/generated/app_localizations_et.dart index 288e8a5..cba33c2 100644 --- a/lib/l10n/generated/app_localizations_et.dart +++ b/lib/l10n/generated/app_localizations_et.dart @@ -176,68 +176,10 @@ class AppLocalizationsEt extends AppLocalizations { 'Proovige teistsugust otsingut või eemaldage praegused filtrid.'; @override - String get trayAgendaLoading => 'Päevakava laadimine...'; + String get refresh => 'Värskenda'; @override - String get trayAgendaSignInRequired => 'Päevakava kuvamiseks logige sisse.'; - - @override - String get trayAgendaNoSources => - 'Nähtavaid kalendreid ega ülesandeloendeid pole.'; - - @override - String get trayAgendaOpenBusyMax => 'Ava rakendus'; - - @override - String get trayAgendaRefresh => 'Värskenda'; - - @override - String get trayAgendaError => 'Päevakava pole saadaval'; - - @override - String get compactAgendaTitle => 'Päevakava'; - - @override - String get compactAgendaSubtitle => 'Tulekul'; - - @override - String get compactAgendaOverdue => 'Tähtaja ületanud'; - - @override - String get compactAgendaClear => 'Praegu vaba'; - - @override - String get compactAgendaOpenBusyMax => 'Ava BusyMax'; - - @override - String get compactAgendaHide => 'Peida'; - - @override - String get compactAgendaNewTask => 'Uus ülesanne'; - - @override - String get compactAgendaRetry => 'Proovi uuesti'; - - @override - String get compactAgendaRefresh => 'Värskenda'; - - @override - String get compactAgendaAllDay => 'Kogu päev'; - - @override - String get compactAgendaDueToday => 'Tähtaeg täna'; - - @override - String get compactAgendaDueTomorrow => 'Tähtaeg homme'; - - @override - String compactAgendaDueOn(String date) { - return 'Tähtaeg $date'; - } - - @override - String get compactAgendaMoreOverdue => - 'Laadi veel tähtaja ületanud ülesandeid'; + String get trayOpenBusyMax => 'Ava BusyMax'; @override String get agendaLoadMoreOverdue => 'Laadi veel tähtaja ületanud ülesandeid'; @@ -516,17 +458,6 @@ class AppLocalizationsEt extends AppLocalizations { String get shortcutCancelEditingDescription => 'Sulge ülesande muutmine või ülesande üksikasjad'; - @override - String get shortcutGroupCompactAgenda => 'Kompaktne päevakava'; - - @override - String get shortcutRefreshCompactAgendaDescription => - 'Värskenda kompaktse päevakava akent'; - - @override - String get shortcutHideCompactAgendaDescription => - 'Peida kompaktse päevakava aken'; - @override String get aboutBusyMax => 'Teave BusyMaxi kohta'; diff --git a/lib/l10n/generated/app_localizations_fa.dart b/lib/l10n/generated/app_localizations_fa.dart index e2e0ce9..447985d 100644 --- a/lib/l10n/generated/app_localizations_fa.dart +++ b/lib/l10n/generated/app_localizations_fa.dart @@ -179,67 +179,10 @@ class AppLocalizationsFa extends AppLocalizations { 'جست‌وجوی دیگری را امتحان کنید یا پالایه‌های فعلی را پاک کنید.'; @override - String get trayAgendaLoading => 'در حال بارگیری برنامه...'; + String get refresh => 'تازه‌سازی'; @override - String get trayAgendaSignInRequired => 'برای نمایش برنامه وارد شوید.'; - - @override - String get trayAgendaNoSources => - 'هیچ تقویم یا فهرست کار قابل نمایشی وجود ندارد.'; - - @override - String get trayAgendaOpenBusyMax => 'باز کردن برنامه'; - - @override - String get trayAgendaRefresh => 'تازه‌سازی'; - - @override - String get trayAgendaError => 'برنامه در دسترس نیست'; - - @override - String get compactAgendaTitle => 'برنامه'; - - @override - String get compactAgendaSubtitle => 'پیش رو'; - - @override - String get compactAgendaOverdue => 'گذشته از موعد'; - - @override - String get compactAgendaClear => 'فعلاً موردی نیست'; - - @override - String get compactAgendaOpenBusyMax => 'باز کردن BusyMax'; - - @override - String get compactAgendaHide => 'پنهان کردن'; - - @override - String get compactAgendaNewTask => 'کار جدید'; - - @override - String get compactAgendaRetry => 'تلاش دوباره'; - - @override - String get compactAgendaRefresh => 'تازه‌سازی'; - - @override - String get compactAgendaAllDay => 'تمام روز'; - - @override - String get compactAgendaDueToday => 'سررسید امروز'; - - @override - String get compactAgendaDueTomorrow => 'سررسید فردا'; - - @override - String compactAgendaDueOn(String date) { - return 'سررسید: ⁨$date⁩'; - } - - @override - String get compactAgendaMoreOverdue => 'بارگیری کارهای عقب‌افتادهٔ بیشتر'; + String get trayOpenBusyMax => 'باز کردن BusyMax'; @override String get agendaLoadMoreOverdue => 'بارگیری کارهای عقب‌افتادهٔ بیشتر'; @@ -533,17 +476,6 @@ class AppLocalizationsFa extends AppLocalizations { @override String get shortcutCancelEditingDescription => 'بستن ویرایش یا جزئیات کار'; - @override - String get shortcutGroupCompactAgenda => 'برنامهٔ فشرده'; - - @override - String get shortcutRefreshCompactAgendaDescription => - 'تازه‌سازی پنجرهٔ برنامهٔ فشرده'; - - @override - String get shortcutHideCompactAgendaDescription => - 'پنهان کردن پنجرهٔ برنامهٔ فشرده'; - @override String get aboutBusyMax => 'دربارهٔ BusyMax'; diff --git a/lib/l10n/generated/app_localizations_fi.dart b/lib/l10n/generated/app_localizations_fi.dart index 14b9056..5f5a3ee 100644 --- a/lib/l10n/generated/app_localizations_fi.dart +++ b/lib/l10n/generated/app_localizations_fi.dart @@ -177,67 +177,10 @@ class AppLocalizationsFi extends AppLocalizations { 'Kokeile toista hakua tai tyhjennä nykyiset suodattimet.'; @override - String get trayAgendaLoading => 'Ladataan agendaa...'; + String get refresh => 'Päivitä'; @override - String get trayAgendaSignInRequired => 'Kirjaudu sisään nähdäksesi agendan.'; - - @override - String get trayAgendaNoSources => - 'Ei näkyviä kalentereita tai tehtäväluetteloita.'; - - @override - String get trayAgendaOpenBusyMax => 'Avaa sovellus'; - - @override - String get trayAgendaRefresh => 'Päivitä'; - - @override - String get trayAgendaError => 'Agenda ei ole käytettävissä'; - - @override - String get compactAgendaTitle => 'Agenda'; - - @override - String get compactAgendaSubtitle => 'Tulossa'; - - @override - String get compactAgendaOverdue => 'Myöhässä'; - - @override - String get compactAgendaClear => 'Ei mitään juuri nyt'; - - @override - String get compactAgendaOpenBusyMax => 'Avaa BusyMax'; - - @override - String get compactAgendaHide => 'Piilota'; - - @override - String get compactAgendaNewTask => 'Uusi tehtävä'; - - @override - String get compactAgendaRetry => 'Yritä uudelleen'; - - @override - String get compactAgendaRefresh => 'Päivitä'; - - @override - String get compactAgendaAllDay => 'Koko päivä'; - - @override - String get compactAgendaDueToday => 'Erääntyy tänään'; - - @override - String get compactAgendaDueTomorrow => 'Erääntyy huomenna'; - - @override - String compactAgendaDueOn(String date) { - return 'Erääntyy $date'; - } - - @override - String get compactAgendaMoreOverdue => 'Lataa lisää myöhässä olevia tehtäviä'; + String get trayOpenBusyMax => 'Avaa BusyMax'; @override String get agendaLoadMoreOverdue => 'Lataa lisää myöhässä olevia tehtäviä'; @@ -516,17 +459,6 @@ class AppLocalizationsFi extends AppLocalizations { String get shortcutCancelEditingDescription => 'Sulje tehtävän muokkaus tai tehtävän tiedot'; - @override - String get shortcutGroupCompactAgenda => 'Kompakti agenda'; - - @override - String get shortcutRefreshCompactAgendaDescription => - 'Päivitä kompaktin agendan ikkuna'; - - @override - String get shortcutHideCompactAgendaDescription => - 'Piilota kompaktin agendan ikkuna'; - @override String get aboutBusyMax => 'Tietoja BusyMaxista'; diff --git a/lib/l10n/generated/app_localizations_fr.dart b/lib/l10n/generated/app_localizations_fr.dart index 1e38f57..17b96a0 100644 --- a/lib/l10n/generated/app_localizations_fr.dart +++ b/lib/l10n/generated/app_localizations_fr.dart @@ -176,68 +176,10 @@ class AppLocalizationsFr extends AppLocalizations { 'Essayez une autre recherche ou effacez les filtres actuels.'; @override - String get trayAgendaLoading => 'Chargement de l’agenda...'; + String get refresh => 'Actualiser'; @override - String get trayAgendaSignInRequired => - 'Connectez-vous pour afficher l’agenda.'; - - @override - String get trayAgendaNoSources => - 'Aucun calendrier ni liste de tâches visible.'; - - @override - String get trayAgendaOpenBusyMax => 'Ouvrir l’app'; - - @override - String get trayAgendaRefresh => 'Actualiser'; - - @override - String get trayAgendaError => 'Agenda indisponible'; - - @override - String get compactAgendaTitle => 'Agenda'; - - @override - String get compactAgendaSubtitle => 'À venir'; - - @override - String get compactAgendaOverdue => 'En retard'; - - @override - String get compactAgendaClear => 'Libre pour le moment'; - - @override - String get compactAgendaOpenBusyMax => 'Ouvrir BusyMax'; - - @override - String get compactAgendaHide => 'Masquer'; - - @override - String get compactAgendaNewTask => 'Nouvelle tâche'; - - @override - String get compactAgendaRetry => 'Réessayer'; - - @override - String get compactAgendaRefresh => 'Actualiser'; - - @override - String get compactAgendaAllDay => 'Toute la journée'; - - @override - String get compactAgendaDueToday => 'Échéance aujourd’hui'; - - @override - String get compactAgendaDueTomorrow => 'Échéance demain'; - - @override - String compactAgendaDueOn(String date) { - return 'Échéance $date'; - } - - @override - String get compactAgendaMoreOverdue => 'Charger plus de tâches en retard'; + String get trayOpenBusyMax => 'Ouvrir BusyMax'; @override String get agendaLoadMoreOverdue => 'Charger plus de tâches en retard'; @@ -516,17 +458,6 @@ class AppLocalizationsFr extends AppLocalizations { String get shortcutCancelEditingDescription => 'Fermer la modification ou les détails de la tâche'; - @override - String get shortcutGroupCompactAgenda => 'Agenda compact'; - - @override - String get shortcutRefreshCompactAgendaDescription => - 'Actualiser la fenêtre d\'agenda compact'; - - @override - String get shortcutHideCompactAgendaDescription => - 'Masquer la fenêtre d\'agenda compact'; - @override String get aboutBusyMax => 'À propos de BusyMax'; diff --git a/lib/l10n/generated/app_localizations_hi.dart b/lib/l10n/generated/app_localizations_hi.dart index 6ae4f75..71cb781 100644 --- a/lib/l10n/generated/app_localizations_hi.dart +++ b/lib/l10n/generated/app_localizations_hi.dart @@ -177,69 +177,10 @@ class AppLocalizationsHi extends AppLocalizations { 'कोई दूसरी खोज आज़माएँ या मौजूदा फ़िल्टर हटाएँ।'; @override - String get trayAgendaLoading => 'कार्यसूची लोड हो रही है...'; + String get refresh => 'रीफ़्रेश करें'; @override - String get trayAgendaSignInRequired => - 'कार्यसूची दिखाने के लिए साइन इन करें।'; - - @override - String get trayAgendaNoSources => - 'कोई दिखाई देने वाला कैलेंडर या कार्य सूची नहीं।'; - - @override - String get trayAgendaOpenBusyMax => 'ऐप खोलें'; - - @override - String get trayAgendaRefresh => 'रीफ़्रेश करें'; - - @override - String get trayAgendaError => 'कार्यसूची उपलब्ध नहीं है'; - - @override - String get compactAgendaTitle => 'कार्यसूची'; - - @override - String get compactAgendaSubtitle => 'आगामी'; - - @override - String get compactAgendaOverdue => 'समय सीमा बीत चुकी'; - - @override - String get compactAgendaClear => 'अभी कुछ नहीं'; - - @override - String get compactAgendaOpenBusyMax => 'BusyMax खोलें'; - - @override - String get compactAgendaHide => 'छिपाएँ'; - - @override - String get compactAgendaNewTask => 'नया कार्य'; - - @override - String get compactAgendaRetry => 'फिर से कोशिश करें'; - - @override - String get compactAgendaRefresh => 'रीफ़्रेश करें'; - - @override - String get compactAgendaAllDay => 'पूरे दिन'; - - @override - String get compactAgendaDueToday => 'आज देय'; - - @override - String get compactAgendaDueTomorrow => 'कल देय'; - - @override - String compactAgendaDueOn(String date) { - return '$date को देय'; - } - - @override - String get compactAgendaMoreOverdue => - 'समय-सीमा पार कर चुके अतिरिक्त कार्य लोड करें'; + String get trayOpenBusyMax => 'BusyMax खोलें'; @override String get agendaLoadMoreOverdue => @@ -518,17 +459,6 @@ class AppLocalizationsHi extends AppLocalizations { String get shortcutCancelEditingDescription => 'कार्य संपादन या कार्य विवरण बंद करें'; - @override - String get shortcutGroupCompactAgenda => 'संक्षिप्त कार्यसूची'; - - @override - String get shortcutRefreshCompactAgendaDescription => - 'संक्षिप्त कार्यसूची विंडो रीफ़्रेश करें'; - - @override - String get shortcutHideCompactAgendaDescription => - 'संक्षिप्त कार्यसूची विंडो छिपाएँ'; - @override String get aboutBusyMax => 'BusyMax के बारे में'; diff --git a/lib/l10n/generated/app_localizations_it.dart b/lib/l10n/generated/app_localizations_it.dart index 39c34ea..426438b 100644 --- a/lib/l10n/generated/app_localizations_it.dart +++ b/lib/l10n/generated/app_localizations_it.dart @@ -178,67 +178,10 @@ class AppLocalizationsIt extends AppLocalizations { 'Prova una ricerca diversa o cancella i filtri attuali.'; @override - String get trayAgendaLoading => 'Caricamento agenda...'; + String get refresh => 'Aggiorna'; @override - String get trayAgendaSignInRequired => 'Accedi per mostrare l’agenda.'; - - @override - String get trayAgendaNoSources => - 'Nessun calendario o elenco di attività visibile.'; - - @override - String get trayAgendaOpenBusyMax => 'Apri applicazione'; - - @override - String get trayAgendaRefresh => 'Aggiorna'; - - @override - String get trayAgendaError => 'Agenda non disponibile'; - - @override - String get compactAgendaTitle => 'Agenda'; - - @override - String get compactAgendaSubtitle => 'In arrivo'; - - @override - String get compactAgendaOverdue => 'Scadute'; - - @override - String get compactAgendaClear => 'Nessun impegno per ora'; - - @override - String get compactAgendaOpenBusyMax => 'Apri BusyMax'; - - @override - String get compactAgendaHide => 'Nascondi'; - - @override - String get compactAgendaNewTask => 'Nuova attività'; - - @override - String get compactAgendaRetry => 'Riprova'; - - @override - String get compactAgendaRefresh => 'Aggiorna'; - - @override - String get compactAgendaAllDay => 'Tutto il giorno'; - - @override - String get compactAgendaDueToday => 'Scadenza: oggi'; - - @override - String get compactAgendaDueTomorrow => 'Scadenza: domani'; - - @override - String compactAgendaDueOn(String date) { - return 'Scadenza: $date'; - } - - @override - String get compactAgendaMoreOverdue => 'Carica altre attività scadute'; + String get trayOpenBusyMax => 'Apri BusyMax'; @override String get agendaLoadMoreOverdue => 'Carica altre attività scadute'; @@ -517,17 +460,6 @@ class AppLocalizationsIt extends AppLocalizations { String get shortcutCancelEditingDescription => 'Chiudi la modifica o i dettagli dell’attività'; - @override - String get shortcutGroupCompactAgenda => 'Agenda compatta'; - - @override - String get shortcutRefreshCompactAgendaDescription => - 'Aggiorna la finestra dell’agenda compatta'; - - @override - String get shortcutHideCompactAgendaDescription => - 'Nascondi la finestra dell’agenda compatta'; - @override String get aboutBusyMax => 'Informazioni su BusyMax'; diff --git a/lib/l10n/generated/app_localizations_ja.dart b/lib/l10n/generated/app_localizations_ja.dart index 325c71f..1da583e 100644 --- a/lib/l10n/generated/app_localizations_ja.dart +++ b/lib/l10n/generated/app_localizations_ja.dart @@ -172,66 +172,10 @@ class AppLocalizationsJa extends AppLocalizations { '別の条件で検索するか、現在のフィルターを解除してください。'; @override - String get trayAgendaLoading => '予定一覧を読み込んでいます...'; + String get refresh => '更新'; @override - String get trayAgendaSignInRequired => '予定一覧を表示するにはサインインしてください。'; - - @override - String get trayAgendaNoSources => '表示できるカレンダーまたはタスクリストがありません。'; - - @override - String get trayAgendaOpenBusyMax => 'アプリを開く'; - - @override - String get trayAgendaRefresh => '更新'; - - @override - String get trayAgendaError => '予定一覧を利用できません'; - - @override - String get compactAgendaTitle => '予定一覧'; - - @override - String get compactAgendaSubtitle => '今後の予定'; - - @override - String get compactAgendaOverdue => '期限超過'; - - @override - String get compactAgendaClear => '今のところ予定なし'; - - @override - String get compactAgendaOpenBusyMax => 'BusyMax を開く'; - - @override - String get compactAgendaHide => '非表示'; - - @override - String get compactAgendaNewTask => '新しいタスク'; - - @override - String get compactAgendaRetry => '再試行'; - - @override - String get compactAgendaRefresh => '更新'; - - @override - String get compactAgendaAllDay => '終日'; - - @override - String get compactAgendaDueToday => '今日が期限'; - - @override - String get compactAgendaDueTomorrow => '明日が期限'; - - @override - String compactAgendaDueOn(String date) { - return '期限: $date'; - } - - @override - String get compactAgendaMoreOverdue => '期限切れのタスクをさらに読み込む'; + String get trayOpenBusyMax => 'BusyMax を開く'; @override String get agendaLoadMoreOverdue => '期限切れのタスクをさらに読み込む'; @@ -507,15 +451,6 @@ class AppLocalizationsJa extends AppLocalizations { @override String get shortcutCancelEditingDescription => 'タスクの編集または詳細を閉じる'; - @override - String get shortcutGroupCompactAgenda => 'コンパクト予定一覧'; - - @override - String get shortcutRefreshCompactAgendaDescription => 'コンパクト予定一覧ウィンドウを更新'; - - @override - String get shortcutHideCompactAgendaDescription => 'コンパクト予定一覧ウィンドウを非表示'; - @override String get aboutBusyMax => 'BusyMax について'; diff --git a/lib/l10n/generated/app_localizations_ko.dart b/lib/l10n/generated/app_localizations_ko.dart index 1c22812..dee60a8 100644 --- a/lib/l10n/generated/app_localizations_ko.dart +++ b/lib/l10n/generated/app_localizations_ko.dart @@ -171,66 +171,10 @@ class AppLocalizationsKo extends AppLocalizations { String get scheduleNoSearchResultsDescription => '다른 검색어를 사용하거나 현재 필터를 지우세요.'; @override - String get trayAgendaLoading => '일정 목록을 불러오는 중...'; + String get refresh => '새로 고침'; @override - String get trayAgendaSignInRequired => '일정 목록을 표시하려면 로그인하세요.'; - - @override - String get trayAgendaNoSources => '표시할 캘린더 또는 할 일 목록이 없습니다.'; - - @override - String get trayAgendaOpenBusyMax => '앱 열기'; - - @override - String get trayAgendaRefresh => '새로 고침'; - - @override - String get trayAgendaError => '일정 목록을 사용할 수 없습니다'; - - @override - String get compactAgendaTitle => '일정 목록'; - - @override - String get compactAgendaSubtitle => '예정'; - - @override - String get compactAgendaOverdue => '기한 지남'; - - @override - String get compactAgendaClear => '현재 예정 없음'; - - @override - String get compactAgendaOpenBusyMax => 'BusyMax 열기'; - - @override - String get compactAgendaHide => '숨기기'; - - @override - String get compactAgendaNewTask => '새 할 일'; - - @override - String get compactAgendaRetry => '다시 시도'; - - @override - String get compactAgendaRefresh => '새로 고침'; - - @override - String get compactAgendaAllDay => '하루 종일'; - - @override - String get compactAgendaDueToday => '오늘 마감'; - - @override - String get compactAgendaDueTomorrow => '내일 마감'; - - @override - String compactAgendaDueOn(String date) { - return '$date 마감'; - } - - @override - String get compactAgendaMoreOverdue => '기한이 지난 할 일 더 불러오기'; + String get trayOpenBusyMax => 'BusyMax 열기'; @override String get agendaLoadMoreOverdue => '기한이 지난 할 일 더 불러오기'; @@ -507,15 +451,6 @@ class AppLocalizationsKo extends AppLocalizations { @override String get shortcutCancelEditingDescription => '할 일 편집 또는 할 일 세부 정보 닫기'; - @override - String get shortcutGroupCompactAgenda => '간단 일정 목록'; - - @override - String get shortcutRefreshCompactAgendaDescription => '간단 일정 목록 창 새로 고침'; - - @override - String get shortcutHideCompactAgendaDescription => '간단 일정 목록 창 숨기기'; - @override String get aboutBusyMax => 'BusyMax 정보'; diff --git a/lib/l10n/generated/app_localizations_pt.dart b/lib/l10n/generated/app_localizations_pt.dart index 4cbde97..14bbdea 100644 --- a/lib/l10n/generated/app_localizations_pt.dart +++ b/lib/l10n/generated/app_localizations_pt.dart @@ -178,67 +178,10 @@ class AppLocalizationsPt extends AppLocalizations { 'Experimente outra pesquisa ou limpe os filtros atuais.'; @override - String get trayAgendaLoading => 'A carregar agenda...'; + String get refresh => 'Atualizar'; @override - String get trayAgendaSignInRequired => 'Inicie sessão para ver a agenda.'; - - @override - String get trayAgendaNoSources => - 'Sem calendários ou listas de tarefas visíveis.'; - - @override - String get trayAgendaOpenBusyMax => 'Abrir aplicação'; - - @override - String get trayAgendaRefresh => 'Atualizar'; - - @override - String get trayAgendaError => 'Agenda indisponível'; - - @override - String get compactAgendaTitle => 'Agenda'; - - @override - String get compactAgendaSubtitle => 'Próximos'; - - @override - String get compactAgendaOverdue => 'Em atraso'; - - @override - String get compactAgendaClear => 'Livre por agora'; - - @override - String get compactAgendaOpenBusyMax => 'Abrir o BusyMax'; - - @override - String get compactAgendaHide => 'Ocultar'; - - @override - String get compactAgendaNewTask => 'Nova tarefa'; - - @override - String get compactAgendaRetry => 'Tentar novamente'; - - @override - String get compactAgendaRefresh => 'Atualizar'; - - @override - String get compactAgendaAllDay => 'Todo o dia'; - - @override - String get compactAgendaDueToday => 'Prazo: hoje'; - - @override - String get compactAgendaDueTomorrow => 'Prazo: amanhã'; - - @override - String compactAgendaDueOn(String date) { - return 'Prazo: $date'; - } - - @override - String get compactAgendaMoreOverdue => 'Carregar mais tarefas em atraso'; + String get trayOpenBusyMax => 'Abrir o BusyMax'; @override String get agendaLoadMoreOverdue => 'Carregar mais tarefas em atraso'; @@ -517,17 +460,6 @@ class AppLocalizationsPt extends AppLocalizations { String get shortcutCancelEditingDescription => 'Fechar a edição ou os detalhes da tarefa'; - @override - String get shortcutGroupCompactAgenda => 'Agenda compacta'; - - @override - String get shortcutRefreshCompactAgendaDescription => - 'Atualizar a janela da agenda compacta'; - - @override - String get shortcutHideCompactAgendaDescription => - 'Ocultar a janela da agenda compacta'; - @override String get aboutBusyMax => 'Acerca do BusyMax'; diff --git a/lib/l10n/generated/app_localizations_ru.dart b/lib/l10n/generated/app_localizations_ru.dart index c70bf7f..728ec35 100644 --- a/lib/l10n/generated/app_localizations_ru.dart +++ b/lib/l10n/generated/app_localizations_ru.dart @@ -174,67 +174,10 @@ class AppLocalizationsRu extends AppLocalizations { 'Попробуйте изменить запрос или сбросить текущие фильтры.'; @override - String get trayAgendaLoading => 'Загрузка расписания...'; + String get refresh => 'Обновить'; @override - String get trayAgendaSignInRequired => - 'Войдите, чтобы просмотреть расписание.'; - - @override - String get trayAgendaNoSources => 'Нет видимых календарей или списков задач.'; - - @override - String get trayAgendaOpenBusyMax => 'Открыть приложение'; - - @override - String get trayAgendaRefresh => 'Обновить'; - - @override - String get trayAgendaError => 'Расписание недоступно'; - - @override - String get compactAgendaTitle => 'Расписание'; - - @override - String get compactAgendaSubtitle => 'Предстоящие'; - - @override - String get compactAgendaOverdue => 'Просроченные'; - - @override - String get compactAgendaClear => 'На ближайшее время ничего нет'; - - @override - String get compactAgendaOpenBusyMax => 'Открыть BusyMax'; - - @override - String get compactAgendaHide => 'Скрыть'; - - @override - String get compactAgendaNewTask => 'Новая задача'; - - @override - String get compactAgendaRetry => 'Повторить'; - - @override - String get compactAgendaRefresh => 'Обновить'; - - @override - String get compactAgendaAllDay => 'Весь день'; - - @override - String get compactAgendaDueToday => 'Срок — сегодня'; - - @override - String get compactAgendaDueTomorrow => 'Срок — завтра'; - - @override - String compactAgendaDueOn(String date) { - return 'Срок — $date'; - } - - @override - String get compactAgendaMoreOverdue => 'Загрузить ещё просроченные задачи'; + String get trayOpenBusyMax => 'Открыть BusyMax'; @override String get agendaLoadMoreOverdue => 'Загрузить ещё просроченные задачи'; @@ -519,17 +462,6 @@ class AppLocalizationsRu extends AppLocalizations { String get shortcutCancelEditingDescription => 'Выйти из режима редактирования задачи или закрыть сведения о ней'; - @override - String get shortcutGroupCompactAgenda => 'Компактное расписание'; - - @override - String get shortcutRefreshCompactAgendaDescription => - 'Обновить окно компактного расписания'; - - @override - String get shortcutHideCompactAgendaDescription => - 'Скрыть окно компактного расписания'; - @override String get aboutBusyMax => 'О приложении BusyMax'; diff --git a/lib/l10n/generated/app_localizations_vi.dart b/lib/l10n/generated/app_localizations_vi.dart index 030c8c0..0abb69c 100644 --- a/lib/l10n/generated/app_localizations_vi.dart +++ b/lib/l10n/generated/app_localizations_vi.dart @@ -176,67 +176,10 @@ class AppLocalizationsVi extends AppLocalizations { 'Thử tìm kiếm khác hoặc xóa các bộ lọc hiện tại.'; @override - String get trayAgendaLoading => 'Đang tải lịch biểu...'; + String get refresh => 'Làm mới'; @override - String get trayAgendaSignInRequired => 'Đăng nhập để hiển thị lịch biểu.'; - - @override - String get trayAgendaNoSources => - 'Không có lịch hoặc danh sách công việc nào đang hiển thị.'; - - @override - String get trayAgendaOpenBusyMax => 'Mở ứng dụng'; - - @override - String get trayAgendaRefresh => 'Làm mới'; - - @override - String get trayAgendaError => 'Lịch biểu không khả dụng'; - - @override - String get compactAgendaTitle => 'Lịch biểu'; - - @override - String get compactAgendaSubtitle => 'Sắp tới'; - - @override - String get compactAgendaOverdue => 'Quá hạn'; - - @override - String get compactAgendaClear => 'Hiện chưa có lịch'; - - @override - String get compactAgendaOpenBusyMax => 'Mở BusyMax'; - - @override - String get compactAgendaHide => 'Ẩn'; - - @override - String get compactAgendaNewTask => 'Công việc mới'; - - @override - String get compactAgendaRetry => 'Thử lại'; - - @override - String get compactAgendaRefresh => 'Làm mới'; - - @override - String get compactAgendaAllDay => 'Cả ngày'; - - @override - String get compactAgendaDueToday => 'Đến hạn hôm nay'; - - @override - String get compactAgendaDueTomorrow => 'Đến hạn ngày mai'; - - @override - String compactAgendaDueOn(String date) { - return 'Đến hạn $date'; - } - - @override - String get compactAgendaMoreOverdue => 'Tải thêm công việc quá hạn'; + String get trayOpenBusyMax => 'Mở BusyMax'; @override String get agendaLoadMoreOverdue => 'Tải thêm công việc quá hạn'; @@ -515,17 +458,6 @@ class AppLocalizationsVi extends AppLocalizations { String get shortcutCancelEditingDescription => 'Đóng phần chỉnh sửa hoặc chi tiết công việc'; - @override - String get shortcutGroupCompactAgenda => 'Lịch biểu thu gọn'; - - @override - String get shortcutRefreshCompactAgendaDescription => - 'Làm mới cửa sổ lịch biểu thu gọn'; - - @override - String get shortcutHideCompactAgendaDescription => - 'Ẩn cửa sổ lịch biểu thu gọn'; - @override String get aboutBusyMax => 'Giới thiệu BusyMax'; diff --git a/lib/l10n/generated/app_localizations_zh.dart b/lib/l10n/generated/app_localizations_zh.dart index b4497bd..5d7f2bf 100644 --- a/lib/l10n/generated/app_localizations_zh.dart +++ b/lib/l10n/generated/app_localizations_zh.dart @@ -169,66 +169,10 @@ class AppLocalizationsZh extends AppLocalizations { String get scheduleNoSearchResultsDescription => '请尝试其他搜索内容或清除当前筛选条件。'; @override - String get trayAgendaLoading => '正在加载日程...'; + String get refresh => '刷新'; @override - String get trayAgendaSignInRequired => '请登录以显示日程。'; - - @override - String get trayAgendaNoSources => '没有可见的日历或任务列表。'; - - @override - String get trayAgendaOpenBusyMax => '打开应用'; - - @override - String get trayAgendaRefresh => '刷新'; - - @override - String get trayAgendaError => '日程不可用'; - - @override - String get compactAgendaTitle => '日程'; - - @override - String get compactAgendaSubtitle => '接下来'; - - @override - String get compactAgendaOverdue => '已逾期'; - - @override - String get compactAgendaClear => '目前空闲'; - - @override - String get compactAgendaOpenBusyMax => '打开 BusyMax'; - - @override - String get compactAgendaHide => '隐藏'; - - @override - String get compactAgendaNewTask => '新建任务'; - - @override - String get compactAgendaRetry => '重试'; - - @override - String get compactAgendaRefresh => '刷新'; - - @override - String get compactAgendaAllDay => '全天'; - - @override - String get compactAgendaDueToday => '今天到期'; - - @override - String get compactAgendaDueTomorrow => '明天到期'; - - @override - String compactAgendaDueOn(String date) { - return '$date 到期'; - } - - @override - String get compactAgendaMoreOverdue => '加载更多逾期任务'; + String get trayOpenBusyMax => '打开 BusyMax'; @override String get agendaLoadMoreOverdue => '加载更多逾期任务'; @@ -503,15 +447,6 @@ class AppLocalizationsZh extends AppLocalizations { @override String get shortcutCancelEditingDescription => '关闭任务编辑或任务详情'; - @override - String get shortcutGroupCompactAgenda => '紧凑日程'; - - @override - String get shortcutRefreshCompactAgendaDescription => '刷新紧凑日程窗口'; - - @override - String get shortcutHideCompactAgendaDescription => '隐藏紧凑日程窗口'; - @override String get aboutBusyMax => '关于 BusyMax'; @@ -1442,66 +1377,10 @@ class AppLocalizationsZhHans extends AppLocalizationsZh { String get scheduleNoSearchResultsDescription => '请尝试其他搜索内容或清除当前筛选条件。'; @override - String get trayAgendaLoading => '正在加载日程...'; - - @override - String get trayAgendaSignInRequired => '请登录以显示日程。'; - - @override - String get trayAgendaNoSources => '没有可见的日历或任务列表。'; - - @override - String get trayAgendaOpenBusyMax => '打开应用'; - - @override - String get trayAgendaRefresh => '刷新'; - - @override - String get trayAgendaError => '日程不可用'; - - @override - String get compactAgendaTitle => '日程'; - - @override - String get compactAgendaSubtitle => '接下来'; - - @override - String get compactAgendaOverdue => '已逾期'; - - @override - String get compactAgendaClear => '目前空闲'; - - @override - String get compactAgendaOpenBusyMax => '打开 BusyMax'; - - @override - String get compactAgendaHide => '隐藏'; + String get refresh => '刷新'; @override - String get compactAgendaNewTask => '新建任务'; - - @override - String get compactAgendaRetry => '重试'; - - @override - String get compactAgendaRefresh => '刷新'; - - @override - String get compactAgendaAllDay => '全天'; - - @override - String get compactAgendaDueToday => '今天到期'; - - @override - String get compactAgendaDueTomorrow => '明天到期'; - - @override - String compactAgendaDueOn(String date) { - return '$date 到期'; - } - - @override - String get compactAgendaMoreOverdue => '加载更多逾期任务'; + String get trayOpenBusyMax => '打开 BusyMax'; @override String get agendaLoadMoreOverdue => '加载更多逾期任务'; @@ -1776,15 +1655,6 @@ class AppLocalizationsZhHans extends AppLocalizationsZh { @override String get shortcutCancelEditingDescription => '关闭任务编辑或任务详情'; - @override - String get shortcutGroupCompactAgenda => '紧凑日程'; - - @override - String get shortcutRefreshCompactAgendaDescription => '刷新紧凑日程窗口'; - - @override - String get shortcutHideCompactAgendaDescription => '隐藏紧凑日程窗口'; - @override String get aboutBusyMax => '关于 BusyMax'; @@ -2715,66 +2585,10 @@ class AppLocalizationsZhHant extends AppLocalizationsZh { String get scheduleNoSearchResultsDescription => '請嘗試其他搜尋內容或清除目前的篩選條件。'; @override - String get trayAgendaLoading => '正在載入行程...'; - - @override - String get trayAgendaSignInRequired => '請登入以顯示行程。'; - - @override - String get trayAgendaNoSources => '沒有可見的行事曆或待辦清單。'; - - @override - String get trayAgendaOpenBusyMax => '開啟應用程式'; - - @override - String get trayAgendaRefresh => '重新整理'; - - @override - String get trayAgendaError => '無法使用行程'; - - @override - String get compactAgendaTitle => '行程'; - - @override - String get compactAgendaSubtitle => '接下來'; - - @override - String get compactAgendaOverdue => '已逾期'; - - @override - String get compactAgendaClear => '目前沒有安排'; - - @override - String get compactAgendaOpenBusyMax => '開啟 BusyMax'; - - @override - String get compactAgendaHide => '隱藏'; - - @override - String get compactAgendaNewTask => '新增待辦事項'; - - @override - String get compactAgendaRetry => '再試一次'; - - @override - String get compactAgendaRefresh => '重新整理'; + String get refresh => '重新整理'; @override - String get compactAgendaAllDay => '全天'; - - @override - String get compactAgendaDueToday => '今天到期'; - - @override - String get compactAgendaDueTomorrow => '明天到期'; - - @override - String compactAgendaDueOn(String date) { - return '$date 到期'; - } - - @override - String get compactAgendaMoreOverdue => '載入更多逾期待辦事項'; + String get trayOpenBusyMax => '開啟 BusyMax'; @override String get agendaLoadMoreOverdue => '載入更多逾期待辦事項'; @@ -3049,15 +2863,6 @@ class AppLocalizationsZhHant extends AppLocalizationsZh { @override String get shortcutCancelEditingDescription => '關閉待辦事項編輯或詳細資料'; - @override - String get shortcutGroupCompactAgenda => '精簡行程'; - - @override - String get shortcutRefreshCompactAgendaDescription => '重新整理精簡行程視窗'; - - @override - String get shortcutHideCompactAgendaDescription => '隱藏精簡行程視窗'; - @override String get aboutBusyMax => '關於 BusyMax'; diff --git a/lib/main.dart b/lib/main.dart index 34704bd..405f0f2 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,8 +1,6 @@ -import 'package:desktop_multi_window/desktop_multi_window.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:system_theme/system_theme.dart'; -import 'package:window_manager/window_manager.dart'; import 'src/app/app_bootstrap.dart'; import 'src/app/app_theme.dart'; @@ -10,18 +8,12 @@ import 'src/app/busymax_app.dart'; import 'src/config/build_config.dart'; import 'src/core/logging/redacting_logger.dart'; import 'src/demo/demo_profile.dart'; -import 'src/features/schedule/application/compact_agenda_data.dart'; -import 'src/features/schedule/presentation/compact_agenda_app.dart'; -import 'src/platform/busymax_window_args.dart'; import 'src/platform/gtk_font_service.dart'; import 'src/platform/linux_header_bar_service.dart'; -import 'src/platform/main_window_command_client.dart'; -Future main(List args) async { +Future main() async { final binding = WidgetsFlutterBinding.ensureInitialized(); binding.deferFirstFrame(); - final windowController = await WindowController.fromCurrentEngine(); - final windowArgs = BusyMaxWindowArgs.parse(windowController.arguments); final buildConfig = BuildConfig.fromEnvironment(); final LocalSettingsStore settingsStore; if (buildConfig.useFakeProviderData) { @@ -52,13 +44,11 @@ Future main(List args) async { final initialGtkFont = desktopSettings[1] as GtkFontSettings?; final initialGtkThemeColors = desktopSettings[2] as GtkThemeColors?; - if (windowArgs.kind == BusyMaxWindowKind.main) { - await _applyInitialNativeHeaderBarTheme( - settings: initialAppSettings, - gtkFont: initialGtkFont, - gtkThemeColors: initialGtkThemeColors, - ); - } + await _applyInitialNativeHeaderBarTheme( + settings: initialAppSettings, + gtkFont: initialGtkFont, + gtkThemeColors: initialGtkThemeColors, + ); final overrides = [ buildConfigProvider.overrideWithValue(buildConfig), @@ -72,35 +62,9 @@ Future main(List args) async { : null; final applicationOverrides = [...overrides, ...?demoProfile?.overrides]; - switch (windowArgs.kind) { - case BusyMaxWindowKind.main: - runApp( - ProviderScope( - overrides: applicationOverrides, - child: const BusyMaxApp(), - ), - ); - case BusyMaxWindowKind.compactAgenda: - await configureCompactAgendaNativeWindow(); - final compactOverrides = [...applicationOverrides]; - if (demoProfile == null) { - compactOverrides.add( - compactAgendaDataLoaderProvider.overrideWithValue( - (ref, query) => - const MainWindowCommandClient().compactAgendaSnapshot(query), - ), - ); - } - runApp( - ProviderScope( - overrides: compactOverrides, - child: BusyMaxCompactAgendaApp( - windowController: windowController, - windowArgs: windowArgs, - ), - ), - ); - } + runApp( + ProviderScope(overrides: applicationOverrides, child: const BusyMaxApp()), + ); binding.allowFirstFrame(); } @@ -135,7 +99,3 @@ Future _applyInitialNativeHeaderBarTheme({ headerBarService.dispose(); } } - -Future configureCompactAgendaNativeWindow() async { - await windowManager.ensureInitialized(); -} diff --git a/lib/src/app/app_bootstrap.dart b/lib/src/app/app_bootstrap.dart index d61d536..29d2dc4 100644 --- a/lib/src/app/app_bootstrap.dart +++ b/lib/src/app/app_bootstrap.dart @@ -36,7 +36,6 @@ import '../microsoft_calendar/microsoft_calendar_api_client.dart'; import '../microsoft_todo/api/microsoft_todo_api_client.dart'; import '../microsoft_todo/api/microsoft_todo_google_tasks_adapter.dart'; import '../microsoft_todo/oauth/microsoft_oauth_service.dart'; -import '../platform/compact_agenda_window_service.dart'; import '../platform/linux_window_service.dart'; import '../task_providers/task_provider.dart'; import '../schedule/schedule_commands.dart'; @@ -151,12 +150,6 @@ final linuxWindowServiceProvider = Provider( (ref) => const LinuxWindowService(), ); -final compactAgendaWindowServiceProvider = Provider( - (ref) { - return const CompactAgendaWindowService(); - }, -); - final authRepositoryProvider = Provider((ref) { return AuthRepository( oAuth: ref.watch(applicationOAuthGatewayProvider), diff --git a/lib/src/app/busymax_app.dart b/lib/src/app/busymax_app.dart index a5f11da..b9016db 100644 --- a/lib/src/app/busymax_app.dart +++ b/lib/src/app/busymax_app.dart @@ -10,8 +10,8 @@ import '../platform/gtk_font_service.dart'; import '../platform/linux_header_bar_configuration_synchronizer.dart'; import '../platform/linux_header_bar_service.dart'; import '../platform/linux_window_service.dart'; -import '../platform/main_window_command_bridge.dart'; import '../l10n/locale_resolution.dart'; +import '../schedule/schedule_commands.dart'; import 'app_bootstrap.dart'; import 'app_router.dart'; import 'busymax_keyboard_shortcuts_dialog.dart'; @@ -27,7 +27,6 @@ typedef BusyMaxTrayServiceFactory = required LinuxWindowService windowService, required BusyMaxTrayLabels labels, required Future Function() onOpenAgenda, - Future Function()? onBeforeQuit, }); BusyMaxHeaderBarTheme busyMaxHeaderBarThemeFor( @@ -84,6 +83,7 @@ class _BusyMaxAppState extends ConsumerState { bool? _lastTrayEnabled; bool _startMinimizedHandled = false; bool _settingsReady = false; + var _scheduleCommandSequence = 0; late final BusyMaxHeaderBarConfigurationSynchronizer _headerBarConfigurationSynchronizer; @@ -184,7 +184,7 @@ class _BusyMaxAppState extends ConsumerState { ref, settings, BusyMaxTrayLabels( - openBusyMax: l10n.compactAgendaOpenBusyMax, + openBusyMax: l10n.trayOpenBusyMax, agenda: l10n.viewAgenda, quitBusyMax: l10n.exit, ), @@ -224,11 +224,9 @@ class _BusyMaxAppState extends ConsumerState { }, ), }, - child: MainWindowCommandBridge( - child: ColoredBox( - color: BusyMaxSurfaceColors.of(context).window, - child: child ?? const SizedBox.shrink(), - ), + child: ColoredBox( + color: BusyMaxSurfaceColors.of(context).window, + child: child ?? const SizedBox.shrink(), ), ), ); @@ -303,16 +301,6 @@ class _BusyMaxAppState extends ConsumerState { } final windowService = ref.read(linuxWindowServiceProvider); - if (ref.read(buildConfigProvider).useFakeProviderData) { - _lastTrayEnabled = false; - _setHideOnClose(windowService, false); - final tray = _trayService; - if (tray != null) { - unawaited(tray.stop()); - } - return; - } - final trayEnabled = settings.showTrayIcon || settings.runInBackgroundWhenClosed || @@ -330,12 +318,10 @@ class _BusyMaxAppState extends ConsumerState { return; } _lastTrayEnabled = trayEnabled; - final compactAgendaWindows = ref.read(compactAgendaWindowServiceProvider); final tray = _trayService ??= _createTrayService( windowService: windowService, labels: labels, - onOpenAgenda: compactAgendaWindows.toggle, - onBeforeQuit: compactAgendaWindows.closeIfOpen, + onOpenAgenda: () => _openMainAgenda(ref, windowService), ); if (trayEnabled) { unawaited( @@ -355,7 +341,6 @@ class _BusyMaxAppState extends ConsumerState { required LinuxWindowService windowService, required BusyMaxTrayLabels labels, required Future Function() onOpenAgenda, - Future Function()? onBeforeQuit, }) { final factory = widget.trayServiceFactory; if (factory != null) { @@ -363,17 +348,29 @@ class _BusyMaxAppState extends ConsumerState { windowService: windowService, labels: labels, onOpenAgenda: onOpenAgenda, - onBeforeQuit: onBeforeQuit, ); } return BusyMaxTrayService( windowService: windowService, labels: labels, onOpenAgenda: onOpenAgenda, - onBeforeQuit: onBeforeQuit, ); } + Future _openMainAgenda( + WidgetRef ref, + LinuxWindowService windowService, + ) async { + await windowService.showWindow(); + ref + .read(scheduleWorkspaceCommandProvider.notifier) + .state = ScheduleWorkspaceCommand( + ScheduleWorkspaceCommandKind.agenda, + ++_scheduleCommandSequence, + ); + ref.read(appRouterProvider).go('/schedule'); + } + void _setHideOnClose(LinuxWindowService windowService, bool enabled) { if (_lastHideOnClose == enabled) { return; diff --git a/lib/src/app/busymax_keyboard_shortcuts_dialog.dart b/lib/src/app/busymax_keyboard_shortcuts_dialog.dart index cde0271..08583d4 100644 --- a/lib/src/app/busymax_keyboard_shortcuts_dialog.dart +++ b/lib/src/app/busymax_keyboard_shortcuts_dialog.dart @@ -190,26 +190,6 @@ class BusyMaxKeyboardShortcutsDialog extends StatelessWidget { ), ], ), - BusyMaxGroupedList( - title: l10n.shortcutGroupCompactAgenda, - filled: true, - children: [ - BusyMaxActionRow( - title: l10n.compactAgendaRefresh, - subtitle: l10n.shortcutRefreshCompactAgendaDescription, - leading: const Icon(Icons.refresh), - trailing: const _KeyboardShortcutBadge( - BusyMaxShortcutLabels.refreshCompactAgenda, - ), - ), - BusyMaxActionRow( - title: l10n.compactAgendaHide, - subtitle: l10n.shortcutHideCompactAgendaDescription, - leading: const Icon(Icons.visibility_off_outlined), - trailing: const _KeyboardShortcutBadge('Esc'), - ), - ], - ), ], ), ); diff --git a/lib/src/app/busymax_shortcuts.dart b/lib/src/app/busymax_shortcuts.dart index 3820e9d..135461d 100644 --- a/lib/src/app/busymax_shortcuts.dart +++ b/lib/src/app/busymax_shortcuts.dart @@ -34,7 +34,6 @@ abstract final class BusyMaxShortcutLabels { static const monthView = '3'; static const yearView = '4'; static const agendaView = '5'; - static const refreshCompactAgenda = 'Ctrl+R'; static const dismiss = 'Esc'; static String forViewMode(ScheduleViewMode mode) { diff --git a/lib/src/demo/demo_profile.dart b/lib/src/demo/demo_profile.dart index a92bc3e..92c9194 100644 --- a/lib/src/demo/demo_profile.dart +++ b/lib/src/demo/demo_profile.dart @@ -33,7 +33,7 @@ AppSettings busyMaxDemoSettings(BusyMaxDemoTheme theme) { notifyEventReminders: false, notifyTaskReminders: false, runInBackgroundWhenClosed: false, - showTrayIcon: false, + showTrayIcon: true, startMinimizedToTray: false, quitExitsCompletely: true, ); diff --git a/lib/src/features/schedule/application/compact_agenda_controller.dart b/lib/src/features/schedule/application/compact_agenda_controller.dart deleted file mode 100644 index 9dfd668..0000000 --- a/lib/src/features/schedule/application/compact_agenda_controller.dart +++ /dev/null @@ -1,120 +0,0 @@ -import 'package:flutter_riverpod/flutter_riverpod.dart'; - -import '../../../app/app_bootstrap.dart'; -import '../../../platform/main_window_command_client.dart'; -import '../../../schedule/schedule_item.dart'; -import '../../calendar/data/calendar_repository.dart'; -import '../../calendar/presentation/event_editor_draft.dart'; -import '../../tasks/data/tasks_repository.dart'; -import 'compact_agenda_data.dart'; - -final compactAgendaControllerProvider = Provider(( - ref, -) { - return CompactAgendaController(ref); -}); - -class CompactAgendaController { - const CompactAgendaController(this._ref); - - final Ref _ref; - - Future createTask({ - required String accountId, - required String taskListId, - required TaskCreateInput input, - }) async { - final repository = TasksRepository( - database: _ref.read(databaseProvider), - accountId: accountId, - ); - await repository.createTask(taskListId, input); - - await _requestTaskSync(accountId); - - _ref.invalidate(compactAgendaDataProvider); - _ref.invalidate(compactAgendaDataForQueryProvider); - } - - Future saveEvent(EventEditorDraft draft) async { - final repository = CalendarRepository( - database: _ref.read(databaseProvider), - localTimeZone: _ref.read(localTimeZoneProvider), - ); - await repository.updateLocalEvent(draft); - await _requestCalendarSync(draft.accountId); - - _ref.invalidate(compactAgendaDataProvider); - _ref.invalidate(compactAgendaDataForQueryProvider); - } - - Future deleteEvent(String eventId) async { - final repository = CalendarRepository( - database: _ref.read(databaseProvider), - localTimeZone: _ref.read(localTimeZoneProvider), - ); - final accountId = await repository.deleteLocalEvent(eventId); - await _requestCalendarSync(accountId); - - _ref.invalidate(compactAgendaDataProvider); - _ref.invalidate(compactAgendaDataForQueryProvider); - } - - Future deleteTask(TaskScheduleItem item) async { - final repository = TasksRepository( - database: _ref.read(databaseProvider), - accountId: item.accountId, - ); - await repository.deleteTask(item.sourceId, item.id); - await _requestTaskSync(item.accountId); - - _ref.invalidate(compactAgendaDataProvider); - _ref.invalidate(compactAgendaDataForQueryProvider); - } - - Future taskMutated(String accountId) async { - await _requestTaskSync(accountId); - _ref.invalidate(compactAgendaDataProvider); - _ref.invalidate(compactAgendaDataForQueryProvider); - } - - Future setTaskCompleted(TaskScheduleItem item, bool completed) async { - final fields = { - 'status': completed ? 'completed' : 'needsAction', - 'completed': completed ? DateTime.now().toUtc().toIso8601String() : null, - }; - - final repository = TasksRepository( - database: _ref.read(databaseProvider), - accountId: item.accountId, - ); - await repository.patchTask(item.sourceId, item.id, TaskPatchInput(fields)); - - await _requestTaskSync(item.accountId); - - _ref.invalidate(compactAgendaDataProvider); - _ref.invalidate(compactAgendaDataForQueryProvider); - } - - Future _requestTaskSync(String accountId) async { - try { - await const MainWindowCommandClient().requestTaskSync(accountId); - } on Object { - // The pending operation remains queued and will sync when the main engine - // is available. - } - } - - Future _requestCalendarSync(String accountId) async { - try { - await const MainWindowCommandClient().requestCalendarSync(accountId); - } on Object { - // The pending operation remains queued and will sync when the main engine - // is available. - } - } -} - -String compactAgendaTaskMutationKey(TaskScheduleItem item) { - return '${item.accountId}:${item.sourceId}:${item.id}'; -} diff --git a/lib/src/features/schedule/application/compact_agenda_data.dart b/lib/src/features/schedule/application/compact_agenda_data.dart deleted file mode 100644 index f79ba24..0000000 --- a/lib/src/features/schedule/application/compact_agenda_data.dart +++ /dev/null @@ -1,260 +0,0 @@ -import 'package:flutter_riverpod/flutter_riverpod.dart'; - -import '../../../app/app_bootstrap.dart'; -import '../../../schedule/schedule_filters.dart'; -import '../../../schedule/schedule_item.dart'; -import '../../../schedule/schedule_range.dart'; -import '../../../schedule/schedule_sorting.dart'; -import '../../../schedule/schedule_source_visibility.dart'; -import '../../task_lists/data/task_lists_repository.dart'; -import 'compact_agenda_sections.dart'; - -const compactAgendaInitialDays = 30; -const compactAgendaPageDays = 30; -const compactAgendaSqliteBusyRetryDelays = [ - Duration(milliseconds: 120), - Duration(milliseconds: 240), - Duration(milliseconds: 480), -]; - -typedef CompactAgendaDataLoader = - Future Function(Ref ref, CompactAgendaQuery query); -typedef CompactAgendaRetryDelay = Future Function(Duration duration); - -final compactAgendaDataLoaderProvider = Provider( - (ref) => loadCompactAgendaDataFromRepositories, -); - -final compactAgendaDataProvider = FutureProvider.autoDispose( - (ref) { - return ref.watch( - compactAgendaDataForQueryProvider(CompactAgendaQuery.initial).future, - ); - }, -); - -final compactAgendaDataForQueryProvider = FutureProvider.autoDispose - .family((ref, query) { - return loadCompactAgendaDataWithRetry(ref, query); - }); - -Future loadCompactAgendaDataWithRetry( - Ref ref, - CompactAgendaQuery query, { - CompactAgendaDataLoader? loader, - List retryDelays = compactAgendaSqliteBusyRetryDelays, - CompactAgendaRetryDelay delay = _compactAgendaDelay, -}) async { - final CompactAgendaDataLoader load = - loader ?? ref.read(compactAgendaDataLoaderProvider); - for (var attempt = 0; ; attempt += 1) { - try { - return await load(ref, query); - } on Object catch (error) { - if (!_isSqliteBusy(error) || attempt >= retryDelays.length) { - rethrow; - } - await delay(retryDelays[attempt]); - } - } -} - -Future loadCompactAgendaDataFromRepositories( - Ref ref, - CompactAgendaQuery query, -) async { - final now = DateTime.now(); - final today = DateTime(now.year, now.month, now.day); - final days = query.futureDays < 1 - ? compactAgendaInitialDays - : query.futureDays; - final end = today.add(Duration(days: days)); - final range = ScheduleRange(start: today, end: end); - - CompactAgendaData empty({ - required bool hasSignedInAccounts, - required bool hasSources, - }) { - return CompactAgendaData( - today: today, - range: range, - items: const [], - hasMoreOverdueTasks: false, - hasMoreNoDateTasks: false, - hasSignedInAccounts: hasSignedInAccounts, - hasSources: hasSources, - generatedAt: now, - canCreateEvents: false, - canCreateTasks: false, - ); - } - - final accounts = await ref - .read(accountsRepositoryProvider) - .listSignedInAccounts(); - if (accounts.isEmpty) { - return empty(hasSignedInAccounts: false, hasSources: false); - } - - final accountIds = accounts.map((account) => account.id).toSet(); - final calendarSources = await ref - .read(calendarRepositoryProvider) - .listVisibleSources(accountIds.toList()); - final taskLists = []; - for (final account in accounts) { - taskLists.addAll( - await ref - .read(taskListsRepositoryForAccountProvider(account.id)) - .listTaskLists(), - ); - } - - final visibility = ScheduleSourceVisibility.fromSources( - calendarSources: calendarSources, - taskLists: taskLists, - settings: ref.read(appSettingsControllerProvider), - ); - final hasSources = - visibility.visibleCalendarSourceIds.isNotEmpty || - visibility.visibleTaskListKeys.isNotEmpty; - if (!hasSources) { - return empty(hasSignedInAccounts: true, hasSources: false); - } - - final repository = ref.read(scheduleRepositoryProvider); - final datedItemsFuture = repository.listItems( - range: range, - filters: ScheduleFilters( - accountIds: accountIds, - sourceIds: visibility.visibleCalendarSourceIds, - taskListKeys: visibility.visibleTaskListKeys, - sourceFilterActive: true, - taskListFilterActive: true, - includeCalendarEvents: true, - includeTasks: true, - showCompletedTasks: false, - showNoDateTasks: false, - ), - ); - final overdueTasksFuture = repository.listOverdueTasks( - before: today, - limit: query.overdueLimit, - filters: ScheduleFilters( - accountIds: accountIds, - taskListKeys: visibility.visibleTaskListKeys, - taskListFilterActive: true, - includeTasks: true, - showCompletedTasks: false, - ), - ); - final noDateTasksFuture = repository.listNoDateTasks( - limit: query.noDateLimit, - filters: ScheduleFilters( - accountIds: accountIds, - taskListKeys: visibility.visibleTaskListKeys, - taskListFilterActive: true, - includeTasks: true, - showCompletedTasks: false, - ), - ); - final datedItems = await datedItemsFuture; - final overdueTasks = await overdueTasksFuture; - final noDateTasks = await noDateTasksFuture; - final rawItems = [...datedItems, ...overdueTasks.items, ...noDateTasks.items]; - - final items = rawItems.where((item) { - final start = item.start; - if (start == null) { - return item is TaskScheduleItem && !item.completed; - } - if (item is CalendarScheduleItem) { - return !start.isBefore(today) && start.isBefore(end); - } - if (item is TaskScheduleItem) { - return !item.completed && start.isBefore(end); - } - return false; - }).toList()..sort(compareScheduleItems); - - return CompactAgendaData( - today: today, - range: range, - items: items, - hasMoreOverdueTasks: overdueTasks.hasMore, - hasMoreNoDateTasks: noDateTasks.hasMore, - hasSignedInAccounts: true, - hasSources: true, - generatedAt: now, - canCreateEvents: calendarSources.any( - (source) => source.capabilities.canCreateEvents, - ), - canCreateTasks: visibility.visibleTaskListKeys.isNotEmpty, - ); -} - -class CompactAgendaData { - const CompactAgendaData({ - required this.today, - required this.range, - required this.items, - required this.hasMoreOverdueTasks, - required this.hasMoreNoDateTasks, - required this.hasSignedInAccounts, - required this.hasSources, - required this.generatedAt, - this.canCreateEvents = false, - this.canCreateTasks = false, - }); - - final DateTime today; - final ScheduleRange range; - final List items; - final bool hasMoreOverdueTasks; - final bool hasMoreNoDateTasks; - final bool hasSignedInAccounts; - final bool hasSources; - final DateTime generatedAt; - final bool canCreateEvents; - final bool canCreateTasks; -} - -class CompactAgendaQuery { - const CompactAgendaQuery({ - required this.futureDays, - required this.overdueLimit, - required this.noDateLimit, - }); - - static const initial = CompactAgendaQuery( - futureDays: compactAgendaInitialDays, - overdueLimit: compactAgendaInitialOverdueLimit, - noDateLimit: compactAgendaInitialNoDateLimit, - ); - - final int futureDays; - final int overdueLimit; - final int noDateLimit; - - @override - bool operator ==(Object other) { - return other is CompactAgendaQuery && - other.futureDays == futureDays && - other.overdueLimit == overdueLimit && - other.noDateLimit == noDateLimit; - } - - @override - int get hashCode => Object.hash(futureDays, overdueLimit, noDateLimit); -} - -Future _compactAgendaDelay(Duration duration) { - return Future.delayed(duration); -} - -bool _isSqliteBusy(Object error) { - final message = error.toString().toLowerCase(); - return message.contains('database is locked') || - message.contains('sqlite_busy') || - message.contains('sqlite exception(5)') || - message.contains('sqliteexception(5)'); -} diff --git a/lib/src/features/schedule/application/compact_agenda_sections.dart b/lib/src/features/schedule/application/compact_agenda_sections.dart deleted file mode 100644 index ead417d..0000000 --- a/lib/src/features/schedule/application/compact_agenda_sections.dart +++ /dev/null @@ -1,105 +0,0 @@ -import '../../../schedule/schedule_item.dart'; -import '../../../schedule/schedule_projection.dart'; -import '../../../schedule/schedule_sorting.dart'; - -const compactAgendaInitialOverdueLimit = 8; -const compactAgendaOverduePageSize = 8; -const compactAgendaInitialNoDateLimit = 8; -const compactAgendaNoDatePageSize = 8; - -enum CompactAgendaSectionKind { overdue, day, noDate } - -class CompactAgendaSection { - const CompactAgendaSection({ - required this.kind, - required this.items, - this.day, - this.hasMore = false, - }); - - final CompactAgendaSectionKind kind; - final DateTime? day; - final List items; - final bool hasMore; -} - -List buildCompactAgendaSections({ - required DateTime today, - required List items, - DateTime? end, - int overdueLimit = compactAgendaInitialOverdueLimit, - int noDateLimit = compactAgendaInitialNoDateLimit, - bool hasMoreOverdueTasks = false, - bool hasMoreNoDateTasks = false, -}) { - final rangeEnd = end == null ? null : ScheduleProjection.day(end); - final visibleOverdueLimit = overdueLimit < 1 - ? compactAgendaInitialOverdueLimit - : overdueLimit; - final visibleNoDateLimit = noDateLimit < 1 - ? compactAgendaInitialNoDateLimit - : noDateLimit; - final overdueTasks = items.whereType().where((item) { - final start = item.start; - return start != null && - !item.completed && - ScheduleProjection.day(start).isBefore(today); - }).toList()..sort(compareScheduleItems); - - final sections = []; - if (overdueTasks.isNotEmpty) { - sections.add( - CompactAgendaSection( - kind: CompactAgendaSectionKind.overdue, - items: overdueTasks.take(visibleOverdueLimit).toList(), - hasMore: - hasMoreOverdueTasks || overdueTasks.length > visibleOverdueLimit, - ), - ); - } - - final grouped = >{}; - final noDateTasks = []; - for (final item in items) { - if (item is TaskScheduleItem && item.completed) { - continue; - } - final start = item.start; - if (start == null) { - if (item is TaskScheduleItem) { - noDateTasks.add(item); - } - continue; - } - final day = ScheduleProjection.day(start); - if (day.isBefore(today) || (rangeEnd != null && !day.isBefore(rangeEnd))) { - continue; - } - grouped.putIfAbsent(day, () => []).add(item); - } - - final days = grouped.keys.toList()..sort(); - if (noDateTasks.isNotEmpty) { - noDateTasks.sort(compareScheduleItems); - sections.add( - CompactAgendaSection( - kind: CompactAgendaSectionKind.noDate, - items: noDateTasks.take(visibleNoDateLimit).toList(), - hasMore: hasMoreNoDateTasks || noDateTasks.length > visibleNoDateLimit, - ), - ); - } - - for (final day in days) { - final dayItems = grouped[day]!..sort(compareScheduleItems); - sections.add( - CompactAgendaSection( - kind: CompactAgendaSectionKind.day, - day: day, - items: dayItems, - ), - ); - } - - return sections; -} diff --git a/lib/src/features/schedule/application/compact_agenda_snapshot.dart b/lib/src/features/schedule/application/compact_agenda_snapshot.dart deleted file mode 100644 index 4b26fcd..0000000 --- a/lib/src/features/schedule/application/compact_agenda_snapshot.dart +++ /dev/null @@ -1,300 +0,0 @@ -import '../../../schedule/schedule_item.dart'; -import '../../../schedule/schedule_range.dart'; -import '../../../task_providers/task_provider.dart'; -import 'compact_agenda_data.dart'; -import 'compact_agenda_sections.dart'; - -Map encodeCompactAgendaQuery(CompactAgendaQuery query) { - return { - 'futureDays': query.futureDays, - 'overdueLimit': query.overdueLimit, - 'noDateLimit': query.noDateLimit, - }; -} - -CompactAgendaQuery decodeCompactAgendaQuery(Object? raw) { - if (raw is! Map) { - return CompactAgendaQuery.initial; - } - final map = raw.cast(); - return CompactAgendaQuery( - futureDays: _intValue(map, 'futureDays', compactAgendaInitialDays), - overdueLimit: _intValue( - map, - 'overdueLimit', - compactAgendaInitialOverdueLimit, - ), - noDateLimit: _intValue(map, 'noDateLimit', compactAgendaInitialNoDateLimit), - ); -} - -Map encodeCompactAgendaData(CompactAgendaData data) { - return { - 'today': data.today.toIso8601String(), - 'rangeStart': data.range.start.toIso8601String(), - 'rangeEnd': data.range.end.toIso8601String(), - 'items': data.items.map(encodeScheduleItem).toList(), - 'hasMoreOverdueTasks': data.hasMoreOverdueTasks, - 'hasMoreNoDateTasks': data.hasMoreNoDateTasks, - 'hasSignedInAccounts': data.hasSignedInAccounts, - 'hasSources': data.hasSources, - 'generatedAt': data.generatedAt.toIso8601String(), - 'canCreateEvents': data.canCreateEvents, - 'canCreateTasks': data.canCreateTasks, - }; -} - -CompactAgendaData decodeCompactAgendaData(Object? raw) { - final map = _mapValue(raw); - return CompactAgendaData( - today: _requiredDateTime(map, 'today'), - range: ScheduleRange( - start: _requiredDateTime(map, 'rangeStart'), - end: _requiredDateTime(map, 'rangeEnd'), - ), - items: _listValue(map['items']).map(decodeScheduleItem).toList(), - hasMoreOverdueTasks: _boolValue(map, 'hasMoreOverdueTasks'), - hasMoreNoDateTasks: _boolValue(map, 'hasMoreNoDateTasks'), - hasSignedInAccounts: _boolValue(map, 'hasSignedInAccounts'), - hasSources: _boolValue(map, 'hasSources'), - generatedAt: _requiredDateTime(map, 'generatedAt'), - canCreateEvents: _boolValue(map, 'canCreateEvents'), - canCreateTasks: _boolValue(map, 'canCreateTasks'), - ); -} - -Map encodeScheduleItem(ScheduleItem item) { - final common = { - 'kind': item is TaskScheduleItem ? 'task' : 'calendarEvent', - 'id': item.id, - 'accountId': item.accountId, - 'provider': item.provider.storageValue, - 'sourceId': item.sourceId, - 'title': item.title, - 'sourceName': item.sourceName, - 'accountDisplayName': item.accountDisplayName, - 'accountEmail': item.accountEmail, - 'start': item.start?.toIso8601String(), - 'end': item.end?.toIso8601String(), - 'allDay': item.allDay, - 'categories': item.categories, - }; - if (item is TaskScheduleItem) { - return { - ...common, - 'completed': item.completed, - 'notes': item.notes, - 'reminder': item.reminder?.toIso8601String(), - }; - } - final event = item as CalendarScheduleItem; - return { - ...common, - 'providerCalendarId': event.providerCalendarId, - 'providerRecurringEventId': event.providerRecurringEventId, - 'editorStart': event.editorStart?.toIso8601String(), - 'editorEnd': event.editorEnd?.toIso8601String(), - 'startTimeZone': event.startTimeZone, - 'endTimeZone': event.endTimeZone, - 'location': event.location, - 'description': event.description, - 'descriptionContentType': event.descriptionContentType, - 'descriptionHtml': event.descriptionHtml, - 'recurrence': event.recurrence, - 'attendees': event.attendees, - 'colorHex': event.colorHex, - 'reminderMinutesBeforeStart': event.reminderMinutesBeforeStart, - 'canEdit': event.capabilities.canEdit, - 'canDelete': event.capabilities.canDelete, - }; -} - -ScheduleItem decodeScheduleItem(Object? raw) { - final map = _mapValue(raw); - final kind = _requiredString(map, 'kind'); - final provider = TaskProviderParsing.fromStorageValue( - _optionalString(map, 'provider'), - ); - final common = _ScheduleItemCommon( - id: _requiredString(map, 'id'), - accountId: _requiredString(map, 'accountId'), - provider: provider, - sourceId: _requiredString(map, 'sourceId'), - title: _requiredString(map, 'title'), - sourceName: _optionalString(map, 'sourceName'), - accountDisplayName: _optionalString(map, 'accountDisplayName'), - accountEmail: _optionalString(map, 'accountEmail'), - start: _optionalDateTime(map, 'start'), - end: _optionalDateTime(map, 'end'), - allDay: _boolValue(map, 'allDay'), - categories: _stringListValue(map['categories']), - ); - if (kind == 'task') { - return TaskScheduleItem( - id: common.id, - accountId: common.accountId, - provider: common.provider, - sourceId: common.sourceId, - title: common.title, - completed: _boolValue(map, 'completed'), - allDay: common.allDay, - start: common.start, - end: common.end, - notes: _optionalString(map, 'notes'), - categories: common.categories, - reminder: _optionalDateTime(map, 'reminder'), - sourceName: common.sourceName, - accountDisplayName: common.accountDisplayName, - accountEmail: common.accountEmail, - ); - } - if (kind != 'calendarEvent') { - throw FormatException('Unsupported compact agenda item kind $kind.'); - } - return CalendarScheduleItem( - id: common.id, - accountId: common.accountId, - provider: common.provider, - sourceId: common.sourceId, - providerCalendarId: - _optionalString(map, 'providerCalendarId') ?? common.sourceId, - providerRecurringEventId: _optionalString(map, 'providerRecurringEventId'), - title: common.title, - allDay: common.allDay, - start: common.start, - end: common.end, - editorStart: _optionalDateTime(map, 'editorStart'), - editorEnd: _optionalDateTime(map, 'editorEnd'), - startTimeZone: _optionalString(map, 'startTimeZone'), - endTimeZone: _optionalString(map, 'endTimeZone'), - location: _optionalString(map, 'location'), - description: _optionalString(map, 'description'), - descriptionContentType: _optionalString(map, 'descriptionContentType'), - descriptionHtml: _optionalString(map, 'descriptionHtml'), - recurrence: map['recurrence'], - attendees: _mapListValue(map['attendees']), - colorHex: _optionalString(map, 'colorHex'), - categories: common.categories, - reminderMinutesBeforeStart: _intListValue( - map['reminderMinutesBeforeStart'], - ), - sourceName: common.sourceName, - accountDisplayName: common.accountDisplayName, - accountEmail: common.accountEmail, - capabilities: ScheduleItemCapabilities( - canEdit: _boolValue(map, 'canEdit'), - canDelete: _boolValue(map, 'canDelete'), - ), - ); -} - -class _ScheduleItemCommon { - const _ScheduleItemCommon({ - required this.id, - required this.accountId, - required this.provider, - required this.sourceId, - required this.title, - required this.sourceName, - required this.accountDisplayName, - required this.accountEmail, - required this.start, - required this.end, - required this.allDay, - required this.categories, - }); - - final String id; - final String accountId; - final TaskProvider provider; - final String sourceId; - final String title; - final String? sourceName; - final String? accountDisplayName; - final String? accountEmail; - final DateTime? start; - final DateTime? end; - final bool allDay; - final List categories; -} - -Map _mapValue(Object? value) { - if (value is! Map) { - throw const FormatException('Compact agenda snapshot is not a map.'); - } - return value.cast(); -} - -List _listValue(Object? value) { - if (value is! List) { - return const []; - } - return value.cast(); -} - -List _stringListValue(Object? value) { - return _listValue(value).map((item) => item.toString()).toList(); -} - -List _intListValue(Object? value) { - return _listValue(value) - .map((item) => item is int ? item : int.tryParse(item.toString())) - .nonNulls - .toList(); -} - -List> _mapListValue(Object? value) { - return [ - for (final item in _listValue(value)) - if (item is Map) Map.from(item), - ]; -} - -String _requiredString(Map map, String key) { - final value = map[key]; - if (value == null) { - throw FormatException('Compact agenda snapshot missing $key.'); - } - return value.toString(); -} - -String? _optionalString(Map map, String key) { - final value = map[key]; - if (value == null) { - return null; - } - final text = value.toString(); - return text.isEmpty ? null : text; -} - -DateTime _requiredDateTime(Map map, String key) { - final value = _optionalDateTime(map, key); - if (value == null) { - throw FormatException('Compact agenda snapshot missing $key.'); - } - return value; -} - -DateTime? _optionalDateTime(Map map, String key) { - final value = map[key]; - if (value == null) { - return null; - } - return DateTime.tryParse(value.toString()); -} - -bool _boolValue(Map map, String key) { - final value = map[key]; - if (value is bool) { - return value; - } - return value?.toString() == 'true'; -} - -int _intValue(Map map, String key, int fallback) { - final value = map[key]; - if (value is int) { - return value; - } - return int.tryParse(value?.toString() ?? '') ?? fallback; -} diff --git a/lib/src/features/schedule/presentation/compact_agenda_app.dart b/lib/src/features/schedule/presentation/compact_agenda_app.dart deleted file mode 100644 index f2dc6eb..0000000 --- a/lib/src/features/schedule/presentation/compact_agenda_app.dart +++ /dev/null @@ -1,348 +0,0 @@ -import 'dart:async'; -import 'dart:io'; - -import 'package:desktop_multi_window/desktop_multi_window.dart'; -import 'package:busymax/l10n/generated/app_localizations.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:logging/logging.dart'; -import 'package:system_theme/system_theme.dart'; -import 'package:ubuntu_localizations/ubuntu_localizations.dart'; -import 'package:window_manager/window_manager.dart'; - -import '../../../app/app_bootstrap.dart'; -import '../../../app/app_theme.dart'; -import '../../../app/busymax_design.dart'; -import '../../../app/system_accent.dart'; -import '../../../l10n/locale_resolution.dart'; -import '../../../platform/gtk_font_service.dart'; -import '../../../platform/busymax_window_args.dart'; -import '../application/compact_agenda_data.dart'; -import 'compact_agenda_panel.dart'; - -const _compactAgendaPanelWidth = 420.0; -const _compactAgendaPanelHeight = 680.0; -const _compactAgendaWindowSize = Size( - _compactAgendaPanelWidth + BusyMaxShadow.windowMargin * 2, - _compactAgendaPanelHeight + BusyMaxShadow.windowMargin * 2, -); -const _compactAgendaWindowChannel = MethodChannel( - 'io.busystack.busymax/compact_agenda_window', -); -final _compactAgendaWindowLogger = Logger('BusyMaxCompactAgendaWindow'); - -class BusyMaxCompactAgendaApp extends ConsumerStatefulWidget { - const BusyMaxCompactAgendaApp({ - required this.windowController, - required this.windowArgs, - super.key, - }); - - final WindowController windowController; - final BusyMaxWindowArgs windowArgs; - - @override - ConsumerState createState() => - _BusyMaxCompactAgendaAppState(); -} - -class _BusyMaxCompactAgendaAppState - extends ConsumerState - with WindowListener { - @override - void initState() { - super.initState(); - unawaited( - widget.windowController.setWindowMethodHandler(_handleWindowMethodCall), - ); - windowManager.addListener(this); - unawaited(windowManager.setPreventClose(true)); - WidgetsBinding.instance.addPostFrameCallback((_) { - if (mounted) { - unawaited(_show()); - } - }); - } - - @override - void dispose() { - unawaited(_clearWindowMethodHandler()); - windowManager.removeListener(this); - super.dispose(); - } - - Future _handleWindowMethodCall(MethodCall call) async { - switch (call.method) { - case 'busymax.compactAgenda.show': - await _show(call.arguments); - return true; - case 'busymax.compactAgenda.hide': - await widget.windowController.hide(); - return true; - case 'busymax.compactAgenda.toggle': - final visible = await windowManager.isVisible(); - final focused = await _isFocused(); - if (visible && focused) { - await widget.windowController.hide(); - } else { - await _show(call.arguments); - } - return true; - case 'busymax.compactAgenda.refresh': - ref.invalidate(compactAgendaDataProvider); - ref.invalidate(compactAgendaDataForQueryProvider); - return true; - case 'busymax.compactAgenda.destroy': - unawaited(_destroyWindow()); - return true; - } - - throw MissingPluginException('Not implemented: ${call.method}'); - } - - Future _destroyWindow() async { - await _clearWindowMethodHandler(); - try { - await windowManager.setPreventClose(false); - } on Object { - // The native window can already be gone during app shutdown. - } - try { - await windowManager.destroy(); - } on Object { - // Ignore stale secondary-window removal during main-process shutdown. - } - } - - Future _clearWindowMethodHandler() async { - try { - await widget.windowController.setWindowMethodHandler(null); - } on Object { - // The compact engine may already be unregistering during app shutdown. - } - } - - Future _show([Object? rawArgs]) async { - final position = _requestedPosition(rawArgs) ?? _initialRequestedPosition(); - _logPositioning('show requested', position); - final shownNatively = await _showNativeWindow(position); - if (!shownNatively) { - await _moveNearTrayArea(position); - await widget.windowController.show(); - unawaited(_focusNearTrayArea()); - } - ref.invalidate(compactAgendaDataProvider); - ref.invalidate(compactAgendaDataForQueryProvider); - } - - Future _showNativeWindow(Offset? position) async { - const attempts = 8; - const retryDelay = Duration(milliseconds: 60); - for (var attempt = 0; attempt < attempts; attempt += 1) { - try { - final result = await _compactAgendaWindowChannel.invokeMethod( - 'show', - _nativeWindowArguments(position), - ); - final succeeded = result ?? false; - _logPositioning( - 'native show completed native_position_succeeded=$succeeded', - position, - ); - return succeeded; - } on MissingPluginException { - if (attempt == attempts - 1) { - _logPositioning('native show unavailable', position, warning: true); - return false; - } - } on Object catch (error) { - if (attempt == attempts - 1) { - _logPositioning( - 'native show failed error=$error', - position, - warning: true, - ); - return false; - } - } - await Future.delayed(retryDelay); - } - return false; - } - - Future _moveNearTrayArea(Offset? requestedPosition) async { - try { - final position = requestedPosition ?? _initialRequestedPosition(); - if (position == null) { - await windowManager.setSize(_compactAgendaWindowSize); - await windowManager.setAlignment(Alignment.topRight); - _logPositioning('window_manager fallback aligned topRight', null); - return; - } - await windowManager.setBounds( - null, - position: position, - size: _compactAgendaWindowSize, - ); - _logPositioning('window_manager fallback setBounds succeeded', position); - } on Object catch (error) { - _logPositioning( - 'window_manager fallback failed error=$error', - requestedPosition, - warning: true, - ); - // Positioning is best-effort, especially on Wayland. - } - } - - Future _focusNearTrayArea() async { - try { - await windowManager.focus(); - } on Object { - // Positioning is best-effort, especially on Wayland. - } - } - - Offset? _initialRequestedPosition() { - final x = widget.windowArgs.requestedPositionX; - final y = widget.windowArgs.requestedPositionY; - if (x == null || y == null) { - return null; - } - return Offset(x, y); - } - - Offset? _requestedPosition(Object? rawArgs) { - if (rawArgs is! Map) { - return null; - } - final position = rawArgs['position']; - if (position is! Map) { - return null; - } - final x = position['x']; - final y = position['y']; - if (x is! num || y is! num) { - return null; - } - final dx = x.toDouble(); - final dy = y.toDouble(); - if (!dx.isFinite || !dy.isFinite) { - return null; - } - return Offset(dx, dy); - } - - Map _nativeWindowArguments(Offset? position) { - return { - if (position != null) ...{'x': position.dx, 'y': position.dy}, - 'width': _compactAgendaWindowSize.width, - 'height': _compactAgendaWindowSize.height, - }; - } - - void _logPositioning(String event, Offset? position, {bool warning = false}) { - final requested = position == null - ? 'requested_x= requested_y=' - : 'requested_x=${position.dx.round()} requested_y=${position.dy.round()}'; - final session = Platform.environment['XDG_SESSION_TYPE'] ?? ''; - final backend = Platform.environment['GDK_BACKEND'] ?? ''; - final message = - 'Compact agenda positioning: event="$event" $requested ' - 'final_width=${_compactAgendaWindowSize.width.round()} ' - 'final_height=${_compactAgendaWindowSize.height.round()} ' - 'session=$session gdk_backend=$backend'; - if (warning) { - _compactAgendaWindowLogger.warning(message); - } else { - _compactAgendaWindowLogger.fine(message); - } - } - - Future _isFocused() async { - try { - return await windowManager.isFocused(); - } on Object { - return false; - } - } - - @override - void onWindowClose() { - unawaited(widget.windowController.hide()); - } - - @override - Widget build(BuildContext context) { - final settings = ref.watch(appSettingsControllerProvider); - final ubuntuAccentColor = ref - .watch(ubuntuSystemAccentColorProvider) - .valueOrNull; - final gtkFont = ref.watch(gtkFontSettingsProvider).valueOrNull; - final gtkThemeColors = ref.watch(gtkThemeColorsProvider).valueOrNull; - - return SystemThemeBuilder( - builder: (context, systemColor) { - final accentColor = - gtkThemeColors?.accent ?? ubuntuAccentColor ?? systemColor.accent; - return MaterialApp( - onGenerateTitle: (context) { - final l10n = AppLocalizations.of(context); - return '${l10n.appTitle} — ${l10n.compactAgendaTitle}'; - }, - debugShowCheckedModeBanner: false, - theme: buildBusyMaxTheme( - brightness: Brightness.light, - accentColor: accentColor, - family: settings.themeFamily, - gtkFontFamily: gtkFont?.family, - gtkFontSize: gtkFont?.size, - gtkThemeColors: gtkThemeColors, - ), - darkTheme: buildBusyMaxTheme( - brightness: Brightness.dark, - accentColor: accentColor, - family: settings.themeFamily, - gtkFontFamily: gtkFont?.family, - gtkFontSize: gtkFont?.size, - gtkThemeColors: gtkThemeColors, - ), - highContrastTheme: buildBusyMaxTheme( - brightness: Brightness.light, - accentColor: accentColor, - family: settings.themeFamily, - gtkFontFamily: gtkFont?.family, - gtkFontSize: gtkFont?.size, - gtkThemeColors: gtkThemeColors, - highContrast: true, - ), - highContrastDarkTheme: buildBusyMaxTheme( - brightness: Brightness.dark, - accentColor: accentColor, - family: settings.themeFamily, - gtkFontFamily: gtkFont?.family, - gtkFontSize: gtkFont?.size, - gtkThemeColors: gtkThemeColors, - highContrast: true, - ), - themeMode: settings.themeMode, - locale: settings.locale, - localizationsDelegates: const [ - ...AppLocalizations.localizationsDelegates, - ...GlobalUbuntuLocalizations.delegates, - ], - localeListResolutionCallback: resolveBusyMaxLocales, - supportedLocales: AppLocalizations.supportedLocales, - home: const Scaffold( - backgroundColor: Colors.transparent, - body: Padding( - padding: EdgeInsets.all(BusyMaxShadow.windowMargin), - child: CompactAgendaPanel(), - ), - ), - ); - }, - ); - } -} diff --git a/lib/src/features/schedule/presentation/compact_agenda_formatting.dart b/lib/src/features/schedule/presentation/compact_agenda_formatting.dart deleted file mode 100644 index 82a3db5..0000000 --- a/lib/src/features/schedule/presentation/compact_agenda_formatting.dart +++ /dev/null @@ -1,97 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:intl/intl.dart'; - -import '../../../l10n/l10n.dart'; -import '../../../l10n/localized_formatters.dart'; -import '../../../schedule/schedule_item.dart'; -import '../../../schedule/schedule_projection.dart'; - -String compactAgendaDayLabel( - BuildContext context, { - required DateTime today, - required DateTime day, -}) { - final normalizedToday = ScheduleProjection.day(today); - final normalizedDay = ScheduleProjection.day(day); - if (normalizedDay == normalizedToday) { - return context.l10n.today; - } - if (normalizedDay == normalizedToday.add(const Duration(days: 1))) { - return context.l10n.tomorrow; - } - final locale = Localizations.localeOf(context).toString(); - return DateFormat.MMMEd(locale).format(normalizedDay); -} - -String compactAgendaTodaySubtitle(BuildContext context, DateTime today) { - final locale = Localizations.localeOf(context).toString(); - return localizedRangeLabel( - context.l10n.today, - DateFormat.MMMd(locale).format(today), - ); -} - -String compactAgendaItemMeta( - BuildContext context, - ScheduleItem item, { - required DateTime today, -}) { - if (item is TaskScheduleItem) { - return _taskDueLabel(context, item, today: today); - } - return _eventTimeLabel(context, item); -} - -String _eventTimeLabel(BuildContext context, ScheduleItem item) { - if (item.allDay) { - return context.l10n.compactAgendaAllDay; - } - final start = item.start; - if (start == null) { - return ''; - } - final end = item.end; - final startText = _formatTime(context, start); - if (end == null || - !end.isAfter(start) || - ScheduleProjection.day(end) != ScheduleProjection.day(start)) { - return startText; - } - return localizedRangeLabel(startText, _formatTime(context, end)); -} - -String _taskDueLabel( - BuildContext context, - TaskScheduleItem item, { - required DateTime today, -}) { - final start = item.start; - if (start == null) { - return ''; - } - final day = ScheduleProjection.day(start); - final normalizedToday = ScheduleProjection.day(today); - if (day == normalizedToday) { - return item.allDay - ? context.l10n.compactAgendaDueToday - : context.l10n.compactAgendaDueOn(_formatTime(context, start)); - } - if (day == normalizedToday.add(const Duration(days: 1))) { - return context.l10n.compactAgendaDueTomorrow; - } - if (day == normalizedToday.subtract(const Duration(days: 1))) { - return context.l10n.compactAgendaDueOn( - DateFormat.E(Localizations.localeOf(context).toString()).format(day), - ); - } - return context.l10n.compactAgendaDueOn( - DateFormat.MMMd(Localizations.localeOf(context).toString()).format(day), - ); -} - -String _formatTime(BuildContext context, DateTime value) { - return MaterialLocalizations.of(context).formatTimeOfDay( - TimeOfDay.fromDateTime(value), - alwaysUse24HourFormat: MediaQuery.alwaysUse24HourFormatOf(context), - ); -} diff --git a/lib/src/features/schedule/presentation/compact_agenda_panel.dart b/lib/src/features/schedule/presentation/compact_agenda_panel.dart deleted file mode 100644 index dc77e5f..0000000 --- a/lib/src/features/schedule/presentation/compact_agenda_panel.dart +++ /dev/null @@ -1,1546 +0,0 @@ -import 'dart:async'; - -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:window_manager/window_manager.dart'; -import 'package:yaru/yaru.dart'; - -import '../../../app/app_bootstrap.dart'; -import '../../../app/busymax_dialogs.dart'; -import '../../../app/busymax_design.dart'; -import '../../../app/busymax_yaru_theme.dart'; -import '../../../core/logging/redacting_logger.dart'; -import '../../../l10n/l10n.dart'; -import '../../../platform/main_window_command_client.dart'; -import '../../../schedule/schedule_item.dart'; -import '../../../schedule/schedule_projection.dart'; -import '../../../task_providers/task_provider.dart'; -import '../../calendar/data/calendar_repository.dart'; -import '../../calendar/presentation/event_editor.dart'; -import '../../calendar/presentation/event_editor_draft.dart'; -import '../../task_lists/data/task_lists_repository.dart'; -import '../../tasks/data/tasks_repository.dart'; -import '../../tasks/presentation/new_task_dialog.dart'; -import '../../tasks/presentation/task_details_pane.dart'; -import '../application/compact_agenda_controller.dart'; -import '../application/compact_agenda_data.dart'; -import '../application/compact_agenda_sections.dart'; -import 'compact_agenda_formatting.dart'; -import 'schedule_item_details_popover.dart'; -import 'schedule_item_exporter.dart'; - -typedef CompactAgendaTaskCompletionCallback = - Future Function(TaskScheduleItem item, bool completed); - -const _compactAgendaMinimumLayoutSize = Size(320, 480); - -class CompactAgendaPanel extends ConsumerStatefulWidget { - const CompactAgendaPanel({ - super.key, - this.data, - this.onOpenBusyMax, - this.onNewTask, - this.onRefresh, - this.onHide, - this.onOpenItem, - this.onTaskCompletionChanged, - }); - - final AsyncValue? data; - final Future Function()? onOpenBusyMax; - final Future Function()? onNewTask; - final Future Function()? onRefresh; - final Future Function()? onHide; - final Future Function(ScheduleItem item)? onOpenItem; - final CompactAgendaTaskCompletionCallback? onTaskCompletionChanged; - - @override - ConsumerState createState() => _CompactAgendaPanelState(); -} - -class _CompactAgendaPanelState extends ConsumerState { - final _mutatingTaskKeys = {}; - var _loadedDays = compactAgendaInitialDays; - var _overdueLimit = compactAgendaInitialOverdueLimit; - var _noDateLimit = compactAgendaInitialNoDateLimit; - var _loadMoreArmed = true; - DateTime? _lastLoadedRangeEnd; - CompactAgendaData? _lastAgendaData; - bool _bodyScrolledUnderHeader = false; - bool _bodyScrolledUnderFooter = false; - bool _creatingTask = false; - bool _creatingEvent = false; - TaskScheduleItem? _editingTask; - EventEditorDraft? _editingEventDraft; - - @override - Widget build(BuildContext context) { - final colors = BusyMaxSurfaceColors.of(context); - final colorScheme = Theme.of(context).colorScheme; - final AsyncValue watchedData = - widget.data ?? ref.watch(compactAgendaDataForQueryProvider(_query)); - final currentData = watchedData.valueOrNull; - if (currentData != null) { - _lastAgendaData = currentData; - if (_lastLoadedRangeEnd != currentData.range.end) { - _lastLoadedRangeEnd = currentData.range.end; - _loadMoreArmed = true; - } - } - final data = watchedData.isLoading && _lastAgendaData != null - ? AsyncData(_lastAgendaData!) - : watchedData; - return Shortcuts( - shortcuts: const { - SingleActivator(LogicalKeyboardKey.escape): - _DismissCompactAgendaIntent(), - SingleActivator(LogicalKeyboardKey.keyR, control: true): - _RefreshIntent(), - }, - child: Actions( - actions: { - _DismissCompactAgendaIntent: - CallbackAction<_DismissCompactAgendaIntent>( - onInvoke: (_) { - _dismissCompactAgenda(); - return null; - }, - ), - _RefreshIntent: CallbackAction<_RefreshIntent>( - onInvoke: (_) { - unawaited(_refresh()); - return null; - }, - ), - }, - child: Focus( - autofocus: true, - child: LayoutBuilder( - builder: (context, constraints) { - if (constraints.maxWidth < - _compactAgendaMinimumLayoutSize.width || - constraints.maxHeight < - _compactAgendaMinimumLayoutSize.height) { - return const SizedBox.expand(); - } - - final child = _editingTask != null - ? _CompactAgendaTaskEditorView( - item: _editingTask!, - onClose: _closeTaskEditor, - ) - : _creatingEvent || _editingEventDraft != null - ? _CompactAgendaEventEditorView( - initialDraft: _editingEventDraft, - categorySuggestionsByAccount: - _categorySuggestionsByAccount(), - onCancel: _closeEventEditor, - onSave: _saveEvent, - onDelete: _deleteEvent, - ) - : _creatingTask - ? _CompactAgendaNewTaskView( - onCancel: _closeNewTaskEditor, - onSubmitted: _createTask, - ) - : Column( - children: [ - _CompactAgendaHeader( - data: data.valueOrNull, - onRefresh: _refresh, - onHide: _hide, - ), - Expanded( - child: Stack( - children: [ - NotificationListener( - onNotification: - _handleScrollMetricsNotification, - child: NotificationListener( - onNotification: _handleScrollNotification, - child: ColoredBox( - color: colorScheme.surface, - child: _body(data), - ), - ), - ), - _CompactAgendaScrollShadow( - visible: _bodyScrolledUnderHeader, - below: true, - ), - _CompactAgendaScrollShadow( - visible: _bodyScrolledUnderFooter, - below: false, - ), - ], - ), - ), - _CompactAgendaBottomBar( - onNewEvent: data.valueOrNull?.canCreateEvents == true - ? _newEvent - : null, - onNewTask: data.valueOrNull?.canCreateTasks == true - ? _newTask - : null, - ), - ], - ); - - return DecoratedBox( - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(BusyMaxRadius.window), - boxShadow: BusyMaxShadow.windowShadowsFor(context), - ), - child: ClipRRect( - borderRadius: BorderRadius.circular(BusyMaxRadius.window), - clipBehavior: Clip.antiAlias, - child: DecoratedBox( - decoration: BoxDecoration( - color: colors.card, - border: Border.all(color: colors.border), - ), - child: child, - ), - ), - ); - }, - ), - ), - ), - ); - } - - Widget _body(AsyncValue data) { - return data.when( - loading: () => const _CompactAgendaLoadingState(), - error: (error, stackTrace) => _CompactAgendaMessageState( - icon: Icons.event_busy_outlined, - title: context.l10n.trayAgendaError, - message: redactForLog(error), - primaryLabel: context.l10n.compactAgendaRetry, - onPrimary: _refresh, - secondaryLabel: context.l10n.compactAgendaOpenBusyMax, - onSecondary: _openBusyMax, - ), - data: (agenda) { - if (!agenda.hasSignedInAccounts) { - _scheduleScrollChromeReset(); - return _CompactAgendaMessageState( - icon: Icons.login, - title: context.l10n.trayAgendaSignInRequired, - primaryLabel: context.l10n.compactAgendaOpenBusyMax, - onPrimary: _openBusyMax, - ); - } - if (!agenda.hasSources) { - _scheduleScrollChromeReset(); - return _CompactAgendaMessageState( - icon: Icons.event_busy_outlined, - title: context.l10n.trayAgendaNoSources, - primaryLabel: context.l10n.compactAgendaOpenBusyMax, - onPrimary: _openBusyMax, - ); - } - if (agenda.items.isEmpty) { - _scheduleScrollChromeReset(); - return _CompactAgendaMessageState( - icon: Icons.event_available, - title: context.l10n.compactAgendaClear, - message: context.l10n.noEventsOrTasks, - ); - } - return _sections(agenda); - }, - ); - } - - bool _handleScrollNotification(ScrollNotification notification) { - _updateScrollChrome(notification.metrics); - _maybeLoadMore(notification.metrics); - return false; - } - - bool _handleScrollMetricsNotification( - ScrollMetricsNotification notification, - ) { - _updateScrollChrome(notification.metrics); - return false; - } - - void _updateScrollChrome(ScrollMetrics metrics) { - final pixels = metrics.pixels.clamp(0.0, metrics.maxScrollExtent); - final scrolledFromTop = pixels > 0.5; - final canScrollFurtherDown = metrics.maxScrollExtent - pixels > 0.5; - _setScrollChrome( - header: scrolledFromTop, - footer: scrolledFromTop && canScrollFurtherDown, - ); - } - - void _scheduleScrollChromeReset() { - if (!_bodyScrolledUnderHeader && !_bodyScrolledUnderFooter) { - return; - } - WidgetsBinding.instance.addPostFrameCallback((_) { - if (mounted) { - _setScrollChrome(header: false, footer: false); - } - }); - } - - void _setScrollChrome({required bool header, required bool footer}) { - if (_bodyScrolledUnderHeader == header && - _bodyScrolledUnderFooter == footer) { - return; - } - setState(() { - _bodyScrolledUnderHeader = header; - _bodyScrolledUnderFooter = footer; - }); - } - - void _maybeLoadMore(ScrollMetrics metrics) { - if (widget.data != null || - !_loadMoreArmed || - metrics.axis != Axis.vertical || - metrics.extentAfter > 1) { - return; - } - final agenda = _lastAgendaData; - if (agenda == null || !agenda.hasSignedInAccounts || !agenda.hasSources) { - return; - } - _loadMoreArmed = false; - setState(() { - _loadedDays += compactAgendaPageDays; - }); - } - - Widget _sections(CompactAgendaData data) { - final sections = buildCompactAgendaSections( - today: data.today, - end: data.range.end, - items: data.items, - overdueLimit: _overdueLimit, - noDateLimit: _noDateLimit, - hasMoreOverdueTasks: data.hasMoreOverdueTasks, - hasMoreNoDateTasks: data.hasMoreNoDateTasks, - ); - return ListView.builder( - padding: const EdgeInsets.fromLTRB( - BusyMaxSpacing.md, - BusyMaxSpacing.sm, - BusyMaxSpacing.md, - BusyMaxSpacing.md, - ), - itemCount: sections.length, - itemBuilder: (context, index) { - final section = sections[index]; - return _CompactAgendaSectionView( - section: section, - today: data.today, - mutatingTaskKeys: _mutatingTaskKeys, - onOpenItem: _openItem, - onTaskCompletionChanged: _setTaskCompleted, - onLoadMoreOverdue: _loadMoreOverdue, - onLoadMoreNoDate: _loadMoreNoDate, - ); - }, - ); - } - - void _loadMoreOverdue() { - setState(() { - _overdueLimit += compactAgendaOverduePageSize; - }); - } - - void _loadMoreNoDate() { - setState(() { - _noDateLimit += compactAgendaNoDatePageSize; - }); - } - - Future _openBusyMax() async { - final callback = widget.onOpenBusyMax; - if (callback != null) { - await callback(); - return; - } - await const MainWindowCommandClient().openMain(); - await windowManager.hide(); - } - - Future _newTask() async { - final callback = widget.onNewTask; - if (callback != null) { - await callback(); - return; - } - setState(() { - _creatingTask = true; - _creatingEvent = false; - _editingTask = null; - _editingEventDraft = null; - _bodyScrolledUnderHeader = false; - _bodyScrolledUnderFooter = false; - }); - } - - Future _newEvent() async { - if (_lastAgendaData?.canCreateEvents != true) { - return; - } - setState(() { - _creatingEvent = true; - _creatingTask = false; - _editingTask = null; - _editingEventDraft = null; - _bodyScrolledUnderHeader = false; - _bodyScrolledUnderFooter = false; - }); - } - - void _closeNewTaskEditor() { - if (!_creatingTask) { - return; - } - setState(() { - _creatingTask = false; - }); - } - - Future _createTask(NewTaskDraft draft) async { - await ref - .read(compactAgendaControllerProvider) - .createTask( - accountId: draft.accountId, - taskListId: draft.taskListId, - input: draft.input, - ); - if (!mounted) { - return; - } - setState(() { - _creatingTask = false; - }); - _invalidateAgendaData(); - } - - void _openTaskEditor(TaskScheduleItem item) { - setState(() { - _editingTask = item; - _creatingTask = false; - _creatingEvent = false; - _editingEventDraft = null; - _bodyScrolledUnderHeader = false; - _bodyScrolledUnderFooter = false; - }); - } - - void _closeTaskEditor() { - if (_editingTask == null) { - return; - } - setState(() { - _editingTask = null; - }); - _invalidateAgendaData(); - } - - void _openEventEditor(CalendarScheduleItem item) { - if (!item.capabilities.canEdit) { - return; - } - setState(() { - _editingEventDraft = _eventDraftFromItem(item); - _creatingEvent = false; - _creatingTask = false; - _editingTask = null; - _bodyScrolledUnderHeader = false; - _bodyScrolledUnderFooter = false; - }); - } - - void _closeEventEditor() { - if (!_creatingEvent && _editingEventDraft == null) { - return; - } - setState(() { - _creatingEvent = false; - _editingEventDraft = null; - }); - _invalidateAgendaData(); - } - - Future _saveEvent(EventEditorDraft draft) async { - await ref.read(compactAgendaControllerProvider).saveEvent(draft); - if (!mounted) { - return; - } - setState(() { - _creatingEvent = false; - _editingEventDraft = null; - }); - _invalidateAgendaData(); - } - - Future _deleteEvent(String eventId) async { - await ref.read(compactAgendaControllerProvider).deleteEvent(eventId); - if (!mounted) { - return; - } - setState(() { - _creatingEvent = false; - _editingEventDraft = null; - }); - _invalidateAgendaData(); - } - - Future _refresh() async { - final callback = widget.onRefresh; - if (callback != null) { - await callback(); - return; - } - _invalidateAgendaData(); - } - - Future _hide() async { - final callback = widget.onHide; - if (callback != null) { - await callback(); - return; - } - await windowManager.hide(); - } - - void _dismissCompactAgenda() { - if (_editingTask != null) { - _closeTaskEditor(); - return; - } - if (_creatingEvent || _editingEventDraft != null) { - _closeEventEditor(); - return; - } - if (_creatingTask) { - _closeNewTaskEditor(); - return; - } - unawaited(_hide()); - } - - Future _openItem( - BuildContext anchorContext, - ScheduleItem item, [ - Offset? globalPosition, - ]) async { - final callback = widget.onOpenItem; - if (callback != null) { - await callback(item); - return; - } - final action = await showScheduleItemDetailsPopover( - context: context, - anchorContext: anchorContext, - item: item, - anchorPoint: globalPosition, - ); - if (!mounted || action == null) { - return; - } - switch (action) { - case ScheduleItemDetailsAction.export: - await _exportItem(item); - case ScheduleItemDetailsAction.edit: - if (!item.capabilities.canEdit) { - return; - } else if (item is TaskScheduleItem) { - _openTaskEditor(item); - } else if (item is CalendarScheduleItem) { - _openEventEditor(item); - } else { - await const MainWindowCommandClient().openScheduleItem(item); - await windowManager.hide(); - } - case ScheduleItemDetailsAction.delete: - if (item.capabilities.canDelete) { - await _deleteItem(item); - } - } - } - - Future _exportItem(ScheduleItem item) async { - try { - final file = await exportScheduleItemWithSaveDialog(item); - if (file == null || !mounted) { - return; - } - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(context.l10n.exportedFile(file.path))), - ); - } on Object catch (error) { - if (!mounted) { - return; - } - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(context.l10n.exportFailed(redactForLog(error)))), - ); - } - } - - Future _deleteItem(ScheduleItem item) async { - if (!item.capabilities.canDelete) { - return; - } - final confirmed = await showBusyMaxConfirm( - context, - title: item is CalendarScheduleItem - ? context.l10n.deleteEvent - : context.l10n.deleteTask, - message: item is TaskScheduleItem - ? context.l10n.deleteTaskConfirmation(item.title) - : 'Delete "${item.title}"?', - confirmLabel: context.l10n.delete, - destructive: true, - ); - if (!confirmed) { - return; - } - try { - if (item is CalendarScheduleItem) { - await _deleteEvent(item.id); - } else if (item is TaskScheduleItem) { - await ref.read(compactAgendaControllerProvider).deleteTask(item); - } - if (!mounted) { - return; - } - _invalidateAgendaData(); - } on Object catch (error) { - if (!mounted) { - return; - } - ScaffoldMessenger.of( - context, - ).showSnackBar(SnackBar(content: Text(redactForLog(error)))); - } - } - - Future _setTaskCompleted(TaskScheduleItem item, bool completed) async { - final key = compactAgendaTaskMutationKey(item); - if (_mutatingTaskKeys.contains(key)) { - return; - } - setState(() => _mutatingTaskKeys.add(key)); - try { - final callback = widget.onTaskCompletionChanged; - if (callback != null) { - await callback(item, completed); - } else { - await ref - .read(compactAgendaControllerProvider) - .setTaskCompleted(item, completed); - } - _invalidateAgendaData(); - } on Object catch (error) { - if (mounted) { - ScaffoldMessenger.of( - context, - ).showSnackBar(SnackBar(content: Text(redactForLog(error)))); - } - } finally { - if (mounted) { - setState(() => _mutatingTaskKeys.remove(key)); - } - } - } - - void _invalidateAgendaData() { - ref.invalidate(compactAgendaDataProvider); - ref.invalidate(compactAgendaDataForQueryProvider(_query)); - } - - CompactAgendaQuery get _query { - return CompactAgendaQuery( - futureDays: _loadedDays, - overdueLimit: _overdueLimit, - noDateLimit: _noDateLimit, - ); - } - - EventEditorDraft _eventDraftFromItem(CalendarScheduleItem item) { - return EventEditorDraft.existing( - eventId: item.id, - accountId: item.accountId, - sourceId: item.sourceId, - providerCalendarId: item.providerCalendarId, - providerRecurringEventId: item.providerRecurringEventId, - title: item.title, - allDay: item.allDay, - start: item.editorStart ?? item.start, - end: item.editorEnd ?? item.end, - startTimeZone: item.startTimeZone, - endTimeZone: item.endTimeZone, - location: item.location, - description: item.description, - descriptionContentType: item.descriptionContentType, - descriptionHtml: item.descriptionHtml, - recurrence: item.recurrence, - attendees: [ - for (final attendee in item.attendees) - EventAttendeeDraft.fromJson(attendee), - ], - reminders: _eventRemindersForEdit( - item.provider, - item.reminderMinutesBeforeStart, - ), - categories: item.categories, - ); - } - - Map> _categorySuggestionsByAccount() { - final byAccount = >{}; - for (final item in _lastAgendaData?.items ?? const []) { - if (item.categories.isEmpty) { - continue; - } - byAccount - .putIfAbsent(item.accountId, () => {}) - .addAll( - item.categories - .map((category) => category.trim()) - .where((category) => category.isNotEmpty), - ); - } - return { - for (final entry in byAccount.entries) - entry.key: (entry.value.toList()..sort()), - }; - } -} - -EventEditorDraft _newEventDraft(List sources) { - final source = sources.first; - final start = _defaultNewEventStart(); - return EventEditorDraft.newEvent( - accountId: source.accountId, - sourceId: source.id, - providerCalendarId: source.providerCalendarId, - start: start, - end: start.add(const Duration(hours: 1)), - ); -} - -DateTime _defaultNewEventStart() { - final now = DateTime.now(); - final base = DateTime(now.year, now.month, now.day, now.hour); - return now.minute < 30 - ? base.add(const Duration(minutes: 30)) - : base.add(const Duration(hours: 1)); -} - -List _editableSources( - List sources, -) { - final visibleEditable = [ - for (final source in sources) - if (!source.isDeleted && - !source.hidden && - source.selected && - source.capabilities.canCreateEvents) - source, - ]; - if (visibleEditable.isNotEmpty) { - return visibleEditable; - } - return [ - for (final source in sources) - if (!source.isDeleted && - !source.hidden && - source.capabilities.canCreateEvents) - source, - ]; -} - -Object? _eventRemindersForEdit(BusyProvider provider, List minutes) { - final normalized = [ - for (final value in minutes) - if (value > 0) value, - ]; - if (normalized.isEmpty) { - return null; - } - if (provider == TaskProvider.google) { - return { - 'useDefault': false, - 'overrides': [ - for (final minutes in normalized) - {'method': 'popup', 'minutes': minutes}, - ], - }; - } - return {'isReminderOn': true, 'reminderMinutesBeforeStart': normalized.first}; -} - -bool _listEquals(List a, List b) { - if (identical(a, b)) { - return true; - } - if (a.length != b.length) { - return false; - } - for (var index = 0; index < a.length; index += 1) { - if (a[index] != b[index]) { - return false; - } - } - return true; -} - -class _CompactAgendaHeader extends StatelessWidget { - const _CompactAgendaHeader({ - required this.data, - required this.onRefresh, - required this.onHide, - }); - - final CompactAgendaData? data; - final Future Function() onRefresh; - final Future Function() onHide; - - @override - Widget build(BuildContext context) { - final colors = BusyMaxSurfaceColors.of(context); - final subtitle = compactAgendaTodaySubtitle( - context, - data?.today ?? DateTime.now(), - ); - return DragToMoveArea( - child: Container( - height: 56, - key: const ValueKey('compactAgendaHeader'), - padding: const EdgeInsets.symmetric(horizontal: BusyMaxSpacing.md), - decoration: BoxDecoration(color: Theme.of(context).colorScheme.surface), - child: Row( - children: [ - Expanded( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - context.l10n.compactAgendaTitle, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.w700, - ), - ), - Text( - subtitle, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: colors.mutedForeground, - ), - ), - ], - ), - ), - _CompactHeaderButton( - tooltip: context.l10n.compactAgendaRefresh, - icon: Icons.refresh, - onPressed: () => unawaited(onRefresh()), - ), - const SizedBox(width: BusyMaxSpacing.xs), - YaruWindowControl( - type: YaruWindowControlType.close, - semanticLabel: context.l10n.compactAgendaHide, - onTap: () => unawaited(onHide()), - ), - ], - ), - ), - ); - } -} - -class _CompactHeaderButton extends StatelessWidget { - const _CompactHeaderButton({ - required this.tooltip, - required this.icon, - required this.onPressed, - }); - - final String tooltip; - final IconData icon; - final VoidCallback onPressed; - - @override - Widget build(BuildContext context) { - return BusyMaxHeaderIconButton( - tooltip: tooltip, - icon: Icon(icon), - iconSize: BusyMaxSizes.iconMd, - onPressed: onPressed, - foregroundColor: BusyMaxSurfaceColors.of(context).foreground, - backgroundColor: busyMaxSubtleButtonBackground(context), - ); - } -} - -class _CompactAgendaTaskEditorView extends ConsumerWidget { - const _CompactAgendaTaskEditorView({ - required this.item, - required this.onClose, - }); - - final TaskScheduleItem item; - final VoidCallback onClose; - - @override - Widget build(BuildContext context, WidgetRef ref) { - final database = ref.read(databaseProvider); - final controller = ref.read(compactAgendaControllerProvider); - return ColoredBox( - color: Theme.of(context).colorScheme.surface, - child: TaskDetailsPane( - accountId: item.accountId, - taskListId: item.sourceId, - taskId: item.id, - tasksRepositoryForAccount: (accountId) => - TasksRepository(database: database, accountId: accountId), - taskListsRepositoryForAccount: (accountId) => - TaskListsRepository(database: database, accountId: accountId), - onTaskMutationCommitted: controller.taskMutated, - onClose: onClose, - dialogBarrierColor: Colors.transparent, - ), - ); - } -} - -class _CompactAgendaEventEditorView extends ConsumerStatefulWidget { - const _CompactAgendaEventEditorView({ - required this.initialDraft, - required this.categorySuggestionsByAccount, - required this.onCancel, - required this.onSave, - required this.onDelete, - }); - - final EventEditorDraft? initialDraft; - final Map> categorySuggestionsByAccount; - final VoidCallback onCancel; - final Future Function(EventEditorDraft draft) onSave; - final Future Function(String eventId) onDelete; - - @override - ConsumerState<_CompactAgendaEventEditorView> createState() => - _CompactAgendaEventEditorViewState(); -} - -class _CompactAgendaEventEditorViewState - extends ConsumerState<_CompactAgendaEventEditorView> { - late final CalendarRepository _repository; - Stream>? _sourcesStream; - List? _sourceAccountIds; - - @override - void initState() { - super.initState(); - _repository = CalendarRepository( - database: ref.read(databaseProvider), - localTimeZone: ref.read(localTimeZoneProvider), - ); - } - - @override - Widget build(BuildContext context) { - final accounts = ref.watch(accountsStreamProvider); - return ColoredBox( - color: Theme.of(context).colorScheme.surface, - child: accounts.when( - loading: () => const _CompactAgendaLoadingState(), - error: (error, stackTrace) => _CompactAgendaMessageState( - icon: Icons.event_busy_outlined, - title: context.l10n.trayAgendaError, - message: redactForLog(error), - primaryLabel: context.l10n.cancel, - onPrimary: () async => widget.onCancel(), - ), - data: (accounts) { - if (accounts.isEmpty) { - return _CompactAgendaMessageState( - icon: Icons.login, - title: context.l10n.trayAgendaSignInRequired, - primaryLabel: context.l10n.cancel, - onPrimary: () async => widget.onCancel(), - ); - } - final accountIds = [for (final account in accounts) account.id]; - return StreamBuilder>( - stream: _watchSources(accountIds), - builder: (context, snapshot) { - if (snapshot.connectionState == ConnectionState.waiting && - !snapshot.hasData) { - return const _CompactAgendaLoadingState(); - } - final sources = _editableSources( - snapshot.data ?? const [], - ); - if (sources.isEmpty) { - return _CompactAgendaMessageState( - icon: Icons.event_busy_outlined, - title: context.l10n.trayAgendaNoSources, - primaryLabel: context.l10n.cancel, - onPrimary: () async => widget.onCancel(), - ); - } - final draft = widget.initialDraft ?? _newEventDraft(sources); - return EventEditor( - initialDraft: draft, - sources: sources, - categorySuggestionsByAccount: - widget.categorySuggestionsByAccount, - onCancel: widget.onCancel, - onSave: (draft) => unawaited(_save(draft)), - onDelete: draft.eventId == null - ? null - : (eventId) => unawaited(_delete(eventId)), - ); - }, - ); - }, - ), - ); - } - - Stream> _watchSources(List accountIds) { - if (_sourcesStream == null || - _sourceAccountIds == null || - !_listEquals(_sourceAccountIds!, accountIds)) { - _sourceAccountIds = accountIds; - _sourcesStream = _repository - .watchSourcesForAccounts(accountIds) - .asBroadcastStream(); - } - return _sourcesStream!; - } - - Future _save(EventEditorDraft draft) async { - try { - await widget.onSave(draft); - } on Object catch (error) { - if (!mounted) { - return; - } - ScaffoldMessenger.of( - context, - ).showSnackBar(SnackBar(content: Text(redactForLog(error)))); - } - } - - Future _delete(String eventId) async { - try { - await widget.onDelete(eventId); - } on Object catch (error) { - if (!mounted) { - return; - } - ScaffoldMessenger.of( - context, - ).showSnackBar(SnackBar(content: Text(redactForLog(error)))); - } - } -} - -class _CompactAgendaNewTaskView extends ConsumerWidget { - const _CompactAgendaNewTaskView({ - required this.onCancel, - required this.onSubmitted, - }); - - final VoidCallback onCancel; - final Future Function(NewTaskDraft draft) onSubmitted; - - @override - Widget build(BuildContext context, WidgetRef ref) { - final accounts = ref.watch(accountsStreamProvider); - return ColoredBox( - color: Theme.of(context).colorScheme.surface, - child: accounts.when( - loading: () => const _CompactAgendaLoadingState(), - error: (error, stackTrace) => _CompactAgendaMessageState( - icon: Icons.event_busy_outlined, - title: context.l10n.trayAgendaError, - message: redactForLog(error), - primaryLabel: context.l10n.cancel, - onPrimary: () async => onCancel(), - ), - data: (accounts) { - if (accounts.isEmpty) { - return _CompactAgendaMessageState( - icon: Icons.login, - title: context.l10n.trayAgendaSignInRequired, - primaryLabel: context.l10n.cancel, - onPrimary: () async => onCancel(), - ); - } - return NewTaskEditorPanel( - accounts: accounts, - categorySuggestionsForAccount: (accountId) => TasksRepository( - database: ref.read(databaseProvider), - accountId: accountId, - ).watchCategorySuggestions(), - onSubmitted: onSubmitted, - onCancel: onCancel, - ); - }, - ), - ); - } -} - -class _CompactAgendaLoadingState extends StatelessWidget { - const _CompactAgendaLoadingState(); - - @override - Widget build(BuildContext context) { - return Column( - children: [ - const LinearProgressIndicator(minHeight: 2), - Expanded( - child: ListView.separated( - padding: const EdgeInsets.all(BusyMaxSpacing.md), - itemCount: 4, - separatorBuilder: (_, _) => - const SizedBox(height: BusyMaxSpacing.sm), - itemBuilder: (context, index) => const _SkeletonRow(), - ), - ), - ], - ); - } -} - -class _SkeletonRow extends StatelessWidget { - const _SkeletonRow(); - - @override - Widget build(BuildContext context) { - final colors = BusyMaxSurfaceColors.of(context); - return Container( - height: 62, - padding: const EdgeInsets.all(BusyMaxSpacing.md), - decoration: BoxDecoration( - color: colors.control, - borderRadius: BorderRadius.circular(BusyMaxRadius.sm), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisAlignment: MainAxisAlignment.center, - children: [ - FractionallySizedBox( - widthFactor: 0.68, - child: Container(height: 10, color: colors.controlHover), - ), - const SizedBox(height: BusyMaxSpacing.sm), - FractionallySizedBox( - widthFactor: 0.42, - child: Container(height: 8, color: colors.controlHover), - ), - ], - ), - ); - } -} - -class _CompactAgendaMessageState extends StatelessWidget { - const _CompactAgendaMessageState({ - required this.icon, - required this.title, - this.message, - this.primaryLabel, - this.onPrimary, - this.secondaryLabel, - this.onSecondary, - }); - - final IconData icon; - final String title; - final String? message; - final String? primaryLabel; - final Future Function()? onPrimary; - final String? secondaryLabel; - final Future Function()? onSecondary; - - @override - Widget build(BuildContext context) { - final colors = BusyMaxSurfaceColors.of(context); - return LayoutBuilder( - builder: (context, constraints) { - final minHeight = constraints.maxHeight.isFinite - ? (constraints.maxHeight - BusyMaxSpacing.xl * 2) - .clamp(0.0, double.infinity) - .toDouble() - : 0.0; - return SingleChildScrollView( - padding: const EdgeInsets.all(BusyMaxSpacing.xl), - child: ConstrainedBox( - constraints: BoxConstraints(minHeight: minHeight), - child: Center( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(icon, size: 34, color: colors.mutedForeground), - const SizedBox(height: BusyMaxSpacing.md), - Text( - title, - textAlign: TextAlign.center, - style: Theme.of(context).textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.w700, - ), - ), - if (message != null && message!.isNotEmpty) ...[ - const SizedBox(height: BusyMaxSpacing.sm), - Text( - message!, - textAlign: TextAlign.center, - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: colors.mutedForeground, - ), - ), - ], - if (primaryLabel != null || secondaryLabel != null) ...[ - const SizedBox(height: BusyMaxSpacing.lg), - Wrap( - alignment: WrapAlignment.center, - spacing: BusyMaxSpacing.sm, - runSpacing: BusyMaxSpacing.sm, - children: [ - if (primaryLabel != null) - BusyMaxPushButton.suggested( - onPressed: onPrimary == null - ? null - : () => unawaited(onPrimary!()), - child: Text(primaryLabel!), - ), - if (secondaryLabel != null) - BusyMaxPushButton.standard( - onPressed: onSecondary == null - ? null - : () => unawaited(onSecondary!()), - child: Text(secondaryLabel!), - ), - ], - ), - ], - ], - ), - ), - ), - ); - }, - ); - } -} - -class _CompactAgendaSectionView extends StatelessWidget { - const _CompactAgendaSectionView({ - required this.section, - required this.today, - required this.mutatingTaskKeys, - required this.onOpenItem, - required this.onTaskCompletionChanged, - required this.onLoadMoreOverdue, - required this.onLoadMoreNoDate, - }); - - final CompactAgendaSection section; - final DateTime today; - final Set mutatingTaskKeys; - final Future Function( - BuildContext anchorContext, - ScheduleItem item, [ - Offset? globalPosition, - ]) - onOpenItem; - final CompactAgendaTaskCompletionCallback onTaskCompletionChanged; - final VoidCallback onLoadMoreOverdue; - final VoidCallback onLoadMoreNoDate; - - @override - Widget build(BuildContext context) { - final title = switch (section.kind) { - CompactAgendaSectionKind.overdue => context.l10n.compactAgendaOverdue, - CompactAgendaSectionKind.day => compactAgendaDayLabel( - context, - today: today, - day: section.day ?? today, - ), - CompactAgendaSectionKind.noDate => context.l10n.noDate, - }; - return BusyMaxGroupedList( - title: title, - filled: true, - children: [ - for (final item in section.items) - _CompactAgendaRow( - item: item, - today: today, - mutating: - item is TaskScheduleItem && - mutatingTaskKeys.contains(compactAgendaTaskMutationKey(item)), - onOpenItem: onOpenItem, - onTaskCompletionChanged: onTaskCompletionChanged, - ), - if (section.hasMore) - _MoreBucketRow( - title: switch (section.kind) { - CompactAgendaSectionKind.overdue => - context.l10n.agendaLoadMoreOverdue, - CompactAgendaSectionKind.noDate => - context.l10n.agendaLoadMoreNoDate, - CompactAgendaSectionKind.day => - context.l10n.agendaLoadMoreOverdue, - }, - onLoadMore: switch (section.kind) { - CompactAgendaSectionKind.overdue => onLoadMoreOverdue, - CompactAgendaSectionKind.noDate => onLoadMoreNoDate, - CompactAgendaSectionKind.day => onLoadMoreOverdue, - }, - ), - ], - ); - } -} - -class _CompactAgendaRow extends StatelessWidget { - const _CompactAgendaRow({ - required this.item, - required this.today, - required this.mutating, - required this.onOpenItem, - required this.onTaskCompletionChanged, - }); - - final ScheduleItem item; - final DateTime today; - final bool mutating; - final Future Function( - BuildContext anchorContext, - ScheduleItem item, [ - Offset? globalPosition, - ]) - onOpenItem; - final CompactAgendaTaskCompletionCallback onTaskCompletionChanged; - - @override - Widget build(BuildContext context) { - final task = item is TaskScheduleItem ? item as TaskScheduleItem : null; - return AnimatedOpacity( - opacity: mutating ? 0.48 : 1, - duration: const Duration(milliseconds: 120), - child: BusyMaxActionRow( - title: item.title, - titleWidget: _CompactAgendaRowTitle(item: item), - subtitleWidget: _CompactAgendaRowSubtitle(item: item, today: today), - leading: _CompactAgendaRowMarker(item: item), - trailing: task == null - ? null - : YaruCheckbox( - value: task.completed, - onChanged: mutating - ? null - : (value) => unawaited( - onTaskCompletionChanged(task, value ?? false), - ), - ), - enabled: !mutating, - onActivated: (rowContext, globalPosition) => - unawaited(onOpenItem(rowContext, item, globalPosition)), - ), - ); - } -} - -class _CompactAgendaRowMarker extends StatelessWidget { - const _CompactAgendaRowMarker({required this.item}); - - final ScheduleItem item; - - @override - Widget build(BuildContext context) { - final isTask = item.kind == ScheduleItemKind.task; - final color = isTask - ? BusyMaxSurfaceColors.of(context).mutedForeground - : ScheduleProjection.colorForItem( - item, - Theme.of(context).colorScheme.brightness, - ); - final icon = isTask ? YaruIcons.task_list : YaruIcons.calendar; - return Icon(icon, size: BusyMaxSizes.iconSm, color: color); - } -} - -class _CompactAgendaRowTitle extends StatelessWidget { - const _CompactAgendaRowTitle({required this.item}); - - final ScheduleItem item; - - @override - Widget build(BuildContext context) { - final task = item is TaskScheduleItem ? item as TaskScheduleItem : null; - return Text( - item.title, - maxLines: 2, - overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - fontWeight: FontWeight.w600, - decoration: task?.completed == true ? TextDecoration.lineThrough : null, - ), - ); - } -} - -class _CompactAgendaRowSubtitle extends StatelessWidget { - const _CompactAgendaRowSubtitle({required this.item, required this.today}); - - final ScheduleItem item; - final DateTime today; - - @override - Widget build(BuildContext context) { - final surfaceColors = BusyMaxSurfaceColors.of(context); - final source = ScheduleProjection.sourceLabelForScheduleItem(item); - final meta = compactAgendaItemMeta(context, item, today: today); - final event = item is CalendarScheduleItem - ? item as CalendarScheduleItem - : null; - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - Text( - [if (meta.isNotEmpty) meta, source].join(' - '), - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: Theme.of( - context, - ).textTheme.bodySmall?.copyWith(color: surfaceColors.mutedForeground), - ), - if (event?.location?.trim().isNotEmpty == true) - Text( - event!.location!.trim(), - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: surfaceColors.mutedForeground, - ), - ), - ], - ); - } -} - -class _MoreBucketRow extends StatelessWidget { - const _MoreBucketRow({required this.title, required this.onLoadMore}); - - final String title; - final VoidCallback onLoadMore; - - @override - Widget build(BuildContext context) { - return BusyMaxActionRow( - title: title, - leading: const Icon(YaruIcons.plus, size: BusyMaxSizes.iconSm), - onTap: onLoadMore, - ); - } -} - -class _CompactAgendaBottomBar extends StatelessWidget { - const _CompactAgendaBottomBar({ - required this.onNewEvent, - required this.onNewTask, - }); - - final Future Function()? onNewEvent; - final Future Function()? onNewTask; - - @override - Widget build(BuildContext context) { - return Container( - key: const ValueKey('compactAgendaFooter'), - padding: const EdgeInsets.all(BusyMaxSpacing.md), - decoration: BoxDecoration(color: Theme.of(context).colorScheme.surface), - child: Row( - children: [ - Expanded( - child: BusyMaxPushButton.standard( - onPressed: onNewEvent == null - ? null - : () => unawaited(onNewEvent!()), - child: Text(context.l10n.newEvent), - ), - ), - const SizedBox(width: BusyMaxSpacing.sm), - Expanded( - child: BusyMaxPushButton.standard( - onPressed: onNewTask == null - ? null - : () => unawaited(onNewTask!()), - child: Text(context.l10n.compactAgendaNewTask), - ), - ), - ], - ), - ); - } -} - -class _CompactAgendaScrollShadow extends StatelessWidget { - const _CompactAgendaScrollShadow({ - required this.visible, - required this.below, - }); - - final bool visible; - final bool below; - - @override - Widget build(BuildContext context) { - return IgnorePointer( - child: AnimatedOpacity( - key: ValueKey( - below - ? 'compactAgendaTopScrollShadow' - : 'compactAgendaBottomScrollShadow', - ), - opacity: visible ? 1 : 0, - duration: const Duration(milliseconds: 120), - child: Align( - alignment: below ? Alignment.topCenter : Alignment.bottomCenter, - child: DecoratedBox( - decoration: BoxDecoration( - boxShadow: BusyMaxShadow.edgeShadowsFor(context, below: below), - ), - child: const SizedBox(width: double.infinity, height: 1), - ), - ), - ), - ); - } -} - -class _DismissCompactAgendaIntent extends Intent { - const _DismissCompactAgendaIntent(); -} - -class _RefreshIntent extends Intent { - const _RefreshIntent(); -} diff --git a/lib/src/features/schedule/presentation/schedule_empty_states.dart b/lib/src/features/schedule/presentation/schedule_empty_states.dart index 6c43b4f..02f28da 100644 --- a/lib/src/features/schedule/presentation/schedule_empty_states.dart +++ b/lib/src/features/schedule/presentation/schedule_empty_states.dart @@ -64,7 +64,7 @@ class ScheduleNoSourcesState extends StatelessWidget { if (onRefresh != null) BusyMaxPushButton.standard( onPressed: onRefresh, - child: Text(context.l10n.trayAgendaRefresh), + child: Text(context.l10n.refresh), ), ], ); diff --git a/lib/src/features/schedule/presentation/schedule_workspace.dart b/lib/src/features/schedule/presentation/schedule_workspace.dart index fe97837..1ac1454 100644 --- a/lib/src/features/schedule/presentation/schedule_workspace.dart +++ b/lib/src/features/schedule/presentation/schedule_workspace.dart @@ -2096,6 +2096,8 @@ class _ScheduleWorkspaceState extends ConsumerState { switch (command.kind) { case ScheduleWorkspaceCommandKind.today: _goToToday(); + case ScheduleWorkspaceCommandKind.agenda: + _setMode(ScheduleViewMode.agenda); case ScheduleWorkspaceCommandKind.newEvent: unawaited(_openNewEvent(sources, _selectedDate)); case ScheduleWorkspaceCommandKind.newTask: diff --git a/lib/src/platform/busymax_tray_service.dart b/lib/src/platform/busymax_tray_service.dart index 37e53d3..6bc4df4 100644 --- a/lib/src/platform/busymax_tray_service.dart +++ b/lib/src/platform/busymax_tray_service.dart @@ -43,15 +43,12 @@ class BusyMaxTrayService { required LinuxWindowService windowService, required BusyMaxTrayLabels labels, required Future Function() onOpenAgenda, - Future Function()? onBeforeQuit, }) : _windowService = windowService, _labels = labels, - _onOpenAgenda = onOpenAgenda, - _onBeforeQuit = onBeforeQuit; + _onOpenAgenda = onOpenAgenda; final LinuxWindowService _windowService; final Future Function() _onOpenAgenda; - final Future Function()? _onBeforeQuit; BusyMaxTrayLabels _labels; final RedactingLogger _logger = RedactingLogger(Logger('BusyMaxTrayService')); @@ -200,7 +197,6 @@ class BusyMaxTrayService { Future _quit() async { _logger.fine('Tray quit requested: action=quitApp'); - await _onBeforeQuit?.call(); unawaited(_stopAfterQuitRequest()); await _windowService.quitApp(); } diff --git a/lib/src/platform/busymax_window_args.dart b/lib/src/platform/busymax_window_args.dart deleted file mode 100644 index a594408..0000000 --- a/lib/src/platform/busymax_window_args.dart +++ /dev/null @@ -1,85 +0,0 @@ -import 'dart:convert'; - -enum BusyMaxWindowKind { main, compactAgenda } - -class BusyMaxWindowArgs { - const BusyMaxWindowArgs({ - required this.kind, - required this.version, - this.requestedPositionX, - this.requestedPositionY, - }); - - final BusyMaxWindowKind kind; - final int version; - final double? requestedPositionX; - final double? requestedPositionY; - - static const currentVersion = 1; - - static const main = BusyMaxWindowArgs( - kind: BusyMaxWindowKind.main, - version: currentVersion, - ); - - static const compactAgenda = BusyMaxWindowArgs( - kind: BusyMaxWindowKind.compactAgenda, - version: currentVersion, - ); - - static BusyMaxWindowArgs compactAgendaAt({ - required double x, - required double y, - }) { - return BusyMaxWindowArgs( - kind: BusyMaxWindowKind.compactAgenda, - version: currentVersion, - requestedPositionX: x, - requestedPositionY: y, - ); - } - - String encode() { - return jsonEncode({ - 'app': 'BusyMax', - 'version': version, - 'kind': kind.name, - if (requestedPositionX != null && requestedPositionY != null) - 'position': {'x': requestedPositionX, 'y': requestedPositionY}, - }); - } - - static BusyMaxWindowArgs parse(String raw) { - if (raw.trim().isEmpty) { - return main; - } - - try { - final decoded = jsonDecode(raw); - if (decoded is! Map) { - return main; - } - if (decoded['app'] != 'BusyMax') { - return main; - } - final kind = decoded['kind']?.toString(); - if (kind == BusyMaxWindowKind.compactAgenda.name) { - final position = decoded['position']; - final x = position is Map ? position['x'] : null; - final y = position is Map ? position['y'] : null; - final parsedX = x is num ? x.toDouble() : null; - final parsedY = y is num ? y.toDouble() : null; - if (parsedX != null && - parsedY != null && - parsedX.isFinite && - parsedY.isFinite) { - return compactAgendaAt(x: parsedX, y: parsedY); - } - return compactAgenda; - } - return main; - } on Object { - return main; - } - } -} diff --git a/lib/src/platform/compact_agenda_window_service.dart b/lib/src/platform/compact_agenda_window_service.dart deleted file mode 100644 index 250c3f0..0000000 --- a/lib/src/platform/compact_agenda_window_service.dart +++ /dev/null @@ -1,286 +0,0 @@ -import 'dart:io'; - -import 'package:desktop_multi_window/desktop_multi_window.dart'; -import 'package:flutter/widgets.dart'; -import 'package:logging/logging.dart'; -import 'package:screen_retriever/screen_retriever.dart'; - -import 'busymax_window_args.dart'; - -const _compactAgendaWindowWidth = 420.0; -const _compactAgendaWindowHeight = 680.0; -const _compactAgendaWindowShadowMargin = 32.0; -const _compactAgendaPanelScreenGap = 6.0; -const _compactAgendaWindowFrameWidth = - _compactAgendaWindowWidth + _compactAgendaWindowShadowMargin * 2; -const _compactAgendaWindowFrameHeight = - _compactAgendaWindowHeight + _compactAgendaWindowShadowMargin * 2; - -@visibleForTesting -Offset compactAgendaTopRightWorkAreaPositionForTest(Rect workarea) { - return _topRightWorkAreaPlacement(workarea).finalPosition; -} - -class CompactAgendaWindowService { - const CompactAgendaWindowService(); - - static final Logger _logger = Logger('CompactAgendaWindowService'); - - Future toggle() async { - final position = await _preferredCompactAgendaPosition(); - _logPlacementRequest('toggle', position); - final controller = await _findCompactAgendaWindow(); - if (controller == null) { - await _createCompactAgendaWindow(position); - return; - } - await _invokeCompactMethod( - controller, - 'busymax.compactAgenda.toggle', - position, - ); - } - - Future show() async { - final position = await _preferredCompactAgendaPosition(); - _logPlacementRequest('show', position); - final controller = await _findCompactAgendaWindow(); - if (controller == null) { - await _createCompactAgendaWindow(position); - return; - } - await _invokeCompactMethod( - controller, - 'busymax.compactAgenda.show', - position, - ); - } - - Future hide() async { - final controller = await _findCompactAgendaWindow(); - if (controller == null) { - return; - } - await _invokeOrIgnore(controller, 'busymax.compactAgenda.hide'); - } - - Future closeIfOpen() async { - final controller = await _findCompactAgendaWindow(); - if (controller == null) { - return; - } - await _invokeOrIgnore(controller, 'busymax.compactAgenda.destroy'); - } - - Future _findCompactAgendaWindow() async { - final List controllers; - try { - controllers = await WindowController.getAll(); - } on Object { - return null; - } - for (final controller in controllers) { - final args = BusyMaxWindowArgs.parse(controller.arguments); - if (args.kind == BusyMaxWindowKind.compactAgenda) { - return controller; - } - } - return null; - } - - Future _createCompactAgendaWindow(Offset position) async { - _logger.fine( - 'Compact agenda create requested: final_x=${position.dx.round()} ' - 'final_y=${position.dy.round()} ${_sessionDescription()}', - ); - await WindowController.create( - WindowConfiguration( - arguments: BusyMaxWindowArgs.compactAgendaAt( - x: position.dx, - y: position.dy, - ).encode(), - hiddenAtLaunch: true, - ), - ); - } - - Future _invokeCompactMethod( - WindowController controller, - String method, - Offset position, - ) async { - const attempts = 12; - const retryDelay = Duration(milliseconds: 80); - for (var attempt = 0; attempt < attempts; attempt += 1) { - try { - await controller.invokeMethod( - method, - _positionMethodArguments(position), - ); - _logger.fine( - 'Compact agenda native position invocation succeeded: method=$method ' - 'final_x=${position.dx.round()} final_y=${position.dy.round()} ' - '${_sessionDescription()}', - ); - return; - } on Object catch (error) { - if (attempt == attempts - 1) { - _logger.warning( - 'Compact agenda native position invocation failed: method=$method ' - 'final_x=${position.dx.round()} final_y=${position.dy.round()} ' - 'error=$error ${_sessionDescription()}', - ); - return; - } - await Future.delayed(retryDelay); - } - } - } - - Future _invokeOrIgnore( - WindowController controller, - String method, - ) async { - try { - await controller.invokeMethod(method); - } on Object { - // Window may already be closing; nothing useful to do. - } - } - - Future _preferredCompactAgendaPosition() async { - try { - final primaryDisplay = await screenRetriever.getPrimaryDisplay(); - final displays = await screenRetriever.getAllDisplays(); - final cursor = await screenRetriever.getCursorScreenPoint(); - final display = displays.firstWhere((display) { - final frame = _visibleFrame(display); - return frame.contains(cursor); - }, orElse: () => primaryDisplay); - final placement = _topRightWorkAreaPlacement(_visibleFrame(display)); - _logger.fine( - 'Compact agenda monitor placement resolved: cursor_x=${cursor.dx.round()} ' - 'cursor_y=${cursor.dy.round()} workarea=${_displayGeometry(display)} ' - 'raw_x=${placement.rawPosition.dx.round()} ' - 'raw_y=${placement.rawPosition.dy.round()} ' - 'final_x=${placement.finalPosition.dx.round()} ' - 'final_y=${placement.finalPosition.dy.round()} ' - 'window_width=${_compactAgendaWindowFrameWidth.round()} ' - 'window_height=${_compactAgendaWindowFrameHeight.round()} ' - '${_sessionDescription()}', - ); - return placement.finalPosition; - } on Object catch (error) { - try { - final display = await screenRetriever.getPrimaryDisplay(); - final placement = _topRightWorkAreaPlacement(_visibleFrame(display)); - _logger.warning( - 'Compact agenda cursor placement fallback used: ' - 'workarea=${_displayGeometry(display)} ' - 'raw_x=${placement.rawPosition.dx.round()} ' - 'raw_y=${placement.rawPosition.dy.round()} ' - 'final_x=${placement.finalPosition.dx.round()} ' - 'final_y=${placement.finalPosition.dy.round()} ' - 'window_width=${_compactAgendaWindowFrameWidth.round()} ' - 'window_height=${_compactAgendaWindowFrameHeight.round()} ' - 'error=$error ${_sessionDescription()}', - ); - return placement.finalPosition; - } on Object catch (fallbackError) { - _logger.warning( - 'Compact agenda placement fallback failed: error=$fallbackError ' - '${_sessionDescription()}', - ); - return Offset.zero; - } - } - } - - Map _positionMethodArguments(Offset position) { - return { - 'position': {'x': position.dx, 'y': position.dy}, - }; - } - - Rect _visibleFrame(Display display) { - final visiblePosition = display.visiblePosition ?? Offset.zero; - final visibleSize = display.visibleSize ?? display.size; - return Rect.fromLTWH( - visiblePosition.dx, - visiblePosition.dy, - visibleSize.width, - visibleSize.height, - ); - } - - void _logPlacementRequest(String action, Offset position) { - _logger.fine( - 'Compact agenda placement requested: action=$action ' - 'final_x=${position.dx.round()} final_y=${position.dy.round()} ' - 'window_width=${_compactAgendaWindowFrameWidth.round()} ' - 'window_height=${_compactAgendaWindowFrameHeight.round()} ' - '${_sessionDescription()}', - ); - } - - String _displayGeometry(Display display) { - final frame = _visibleFrame(display); - return '${frame.left.round()},${frame.top.round()},' - '${frame.width.round()}x${frame.height.round()}'; - } - - static String _sessionDescription() { - final session = Platform.environment['XDG_SESSION_TYPE'] ?? ''; - final backend = Platform.environment['GDK_BACKEND'] ?? ''; - return 'session=$session gdk_backend=$backend'; - } -} - -_CompactAgendaPlacement _topRightWorkAreaPlacement(Rect workarea) { - final rawPosition = Offset( - workarea.right - - _compactAgendaWindowFrameWidth - - _compactAgendaPanelScreenGap, - workarea.top + _compactAgendaPanelScreenGap, - ); - return _CompactAgendaPlacement( - rawPosition: rawPosition, - finalPosition: _clampWindowPositionToWorkArea(rawPosition, workarea), - ); -} - -Offset _clampWindowPositionToWorkArea(Offset position, Rect workarea) { - return Offset( - _clampToVisibleFrame( - position.dx, - workarea.left + _compactAgendaPanelScreenGap, - workarea.right - - _compactAgendaWindowFrameWidth - - _compactAgendaPanelScreenGap, - ), - _clampToVisibleFrame( - position.dy, - workarea.top + _compactAgendaPanelScreenGap, - workarea.bottom - - _compactAgendaWindowFrameHeight - - _compactAgendaPanelScreenGap, - ), - ); -} - -double _clampToVisibleFrame(double value, double min, double max) { - if (max < min) { - return min; - } - return value.clamp(min, max).toDouble(); -} - -class _CompactAgendaPlacement { - const _CompactAgendaPlacement({ - required this.rawPosition, - required this.finalPosition, - }); - - final Offset rawPosition; - final Offset finalPosition; -} diff --git a/lib/src/platform/main_window_command_bridge.dart b/lib/src/platform/main_window_command_bridge.dart deleted file mode 100644 index 7108b84..0000000 --- a/lib/src/platform/main_window_command_bridge.dart +++ /dev/null @@ -1,186 +0,0 @@ -import 'dart:async'; - -import 'package:flutter/services.dart'; -import 'package:flutter/widgets.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; - -import '../app/app_bootstrap.dart'; -import '../app/app_router.dart'; -import '../features/schedule/application/compact_agenda_data.dart'; -import '../features/schedule/application/compact_agenda_snapshot.dart'; -import '../features/sync/sync_auth_error.dart'; -import '../schedule/schedule_commands.dart'; -import 'main_window_command_client.dart'; - -Future> loadFreshCompactAgendaSnapshot( - WidgetRef ref, - Object? rawArgs, -) async { - final query = decodeCompactAgendaQuery(rawArgs); - final data = await ref.refresh( - compactAgendaDataForQueryProvider(query).future, - ); - return encodeCompactAgendaData(data); -} - -class MainWindowCommandBridge extends ConsumerStatefulWidget { - const MainWindowCommandBridge({super.key, required this.child}); - - final Widget child; - - @override - ConsumerState createState() => - _MainWindowCommandBridgeState(); -} - -class _MainWindowCommandBridgeState - extends ConsumerState { - var _sequence = 0; - - @override - void initState() { - super.initState(); - unawaited(busyMaxMainWindowChannel.setMethodCallHandler(_handleMethodCall)); - } - - @override - void dispose() { - unawaited(busyMaxMainWindowChannel.setMethodCallHandler(null)); - super.dispose(); - } - - Future _handleMethodCall(MethodCall call) async { - switch (call.method) { - case 'busymax.main.open': - await ref.read(linuxWindowServiceProvider).showWindow(); - return true; - case 'busymax.main.openScheduleItem': - return _openScheduleItem(call.arguments); - case 'busymax.main.newTask': - return _newTask(); - case 'busymax.main.refreshAll': - await ref.read(allAccountsSyncRunnerProvider)(); - return true; - case 'busymax.main.compactAgendaSnapshot': - return _compactAgendaSnapshot(call.arguments); - case 'busymax.main.requestTaskSync': - return _requestTaskSync(call.arguments); - case 'busymax.main.requestCalendarSync': - return _requestCalendarSync(call.arguments); - } - - throw MissingPluginException('Not implemented: ${call.method}'); - } - - Future _openScheduleItem(Object? rawArgs) async { - if (rawArgs is! Map) { - return false; - } - final args = rawArgs.cast(); - final kind = args['kind']?.toString(); - final accountId = args['accountId']?.toString(); - final sourceId = args['sourceId']?.toString(); - final itemId = args['itemId']?.toString(); - final rawDate = args['date']?.toString(); - final date = rawDate == null ? null : DateTime.tryParse(rawDate); - - if (kind == null || - accountId == null || - sourceId == null || - itemId == null) { - return false; - } - - final commandKind = switch (kind) { - 'task' => ScheduleWorkspaceCommandKind.openTask, - 'calendarEvent' => ScheduleWorkspaceCommandKind.openCalendarEvent, - _ => null, - }; - if (commandKind == null) { - return false; - } - - await ref.read(linuxWindowServiceProvider).showWindow(); - ref - .read(scheduleWorkspaceCommandProvider.notifier) - .state = ScheduleWorkspaceCommand( - commandKind, - ++_sequence, - date: date, - accountId: accountId, - sourceId: sourceId, - itemId: itemId, - ); - ref.read(appRouterProvider).go('/schedule'); - return true; - } - - Future _newTask() async { - await ref.read(linuxWindowServiceProvider).showWindow(); - ref - .read(scheduleWorkspaceCommandProvider.notifier) - .state = ScheduleWorkspaceCommand( - ScheduleWorkspaceCommandKind.newTask, - ++_sequence, - ); - ref.read(appRouterProvider).go('/schedule'); - return true; - } - - Future> _compactAgendaSnapshot(Object? rawArgs) async { - return loadFreshCompactAgendaSnapshot(ref, rawArgs); - } - - Future _requestTaskSync(Object? rawArgs) async { - if (rawArgs is! Map) { - return false; - } - final accountId = rawArgs.cast()['accountId']?.toString(); - if (accountId == null || accountId.isEmpty) { - return false; - } - ref - .read(pendingMutationSyncRequesterForAccountProvider(accountId)) - .request(); - unawaited(ref.read(notificationSchedulerProvider).checkNow()); - return true; - } - - Future _requestCalendarSync(Object? rawArgs) async { - if (rawArgs is! Map) { - return false; - } - final accountId = rawArgs.cast()['accountId']?.toString(); - if (accountId == null || accountId.isEmpty) { - return false; - } - unawaited(_syncCalendarAccount(accountId)); - unawaited(ref.read(notificationSchedulerProvider).checkNow()); - return true; - } - - Future _syncCalendarAccount(String accountId) async { - try { - await ref - .read(accountSyncOperationsProvider) - .syncCalendar(accountId, full: false); - } on Object catch (error) { - if (isMissingOAuthTokenError(error)) { - try { - await ref - .read(authRepositoryProvider) - .markReconnectRequired(accountId); - } on Object { - // Preserve the original sync failure handling below. - } - } - // The local pending operation remains queued and a later refresh/sync can - // retry it. - } - } - - @override - Widget build(BuildContext context) { - return widget.child; - } -} diff --git a/lib/src/platform/main_window_command_client.dart b/lib/src/platform/main_window_command_client.dart deleted file mode 100644 index 073f307..0000000 --- a/lib/src/platform/main_window_command_client.dart +++ /dev/null @@ -1,69 +0,0 @@ -import 'package:desktop_multi_window/desktop_multi_window.dart'; - -import '../features/schedule/application/compact_agenda_data.dart'; -import '../features/schedule/application/compact_agenda_snapshot.dart'; -import '../schedule/schedule_item.dart'; -import '../schedule/schedule_projection.dart'; - -const busyMaxMainWindowChannelName = 'io.busystack.busymax/main-window'; - -const busyMaxMainWindowChannel = WindowMethodChannel( - busyMaxMainWindowChannelName, - mode: ChannelMode.unidirectional, -); - -class MainWindowCommandClient { - const MainWindowCommandClient(); - - Future openMain() async { - await busyMaxMainWindowChannel.invokeMethod('busymax.main.open'); - } - - Future openScheduleItem(ScheduleItem item) async { - final date = item.start == null - ? ScheduleProjection.day(DateTime.now()) - : ScheduleProjection.day(item.start!); - await busyMaxMainWindowChannel - .invokeMethod('busymax.main.openScheduleItem', { - 'kind': item is TaskScheduleItem ? 'task' : 'calendarEvent', - 'accountId': item.accountId, - 'sourceId': item.sourceId, - 'itemId': item.id, - 'date': date.toIso8601String(), - }); - } - - Future newTask() async { - await busyMaxMainWindowChannel.invokeMethod('busymax.main.newTask'); - } - - Future refreshAll() async { - await busyMaxMainWindowChannel.invokeMethod( - 'busymax.main.refreshAll', - ); - } - - Future compactAgendaSnapshot( - CompactAgendaQuery query, - ) async { - final response = await busyMaxMainWindowChannel.invokeMethod( - 'busymax.main.compactAgendaSnapshot', - encodeCompactAgendaQuery(query), - ); - return decodeCompactAgendaData(response); - } - - Future requestTaskSync(String accountId) async { - await busyMaxMainWindowChannel.invokeMethod( - 'busymax.main.requestTaskSync', - {'accountId': accountId}, - ); - } - - Future requestCalendarSync(String accountId) async { - await busyMaxMainWindowChannel.invokeMethod( - 'busymax.main.requestCalendarSync', - {'accountId': accountId}, - ); - } -} diff --git a/lib/src/schedule/schedule_commands.dart b/lib/src/schedule/schedule_commands.dart index 3e583aa..79423ba 100644 --- a/lib/src/schedule/schedule_commands.dart +++ b/lib/src/schedule/schedule_commands.dart @@ -2,6 +2,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; enum ScheduleWorkspaceCommandKind { today, + agenda, newEvent, newTask, openDate, diff --git a/linux/flutter/generated_plugin_registrant.cc b/linux/flutter/generated_plugin_registrant.cc index cf299bd..a246036 100644 --- a/linux/flutter/generated_plugin_registrant.cc +++ b/linux/flutter/generated_plugin_registrant.cc @@ -6,7 +6,6 @@ #include "generated_plugin_registrant.h" -#include #include #include #include @@ -17,9 +16,6 @@ #include void fl_register_plugins(FlPluginRegistry* registry) { - g_autoptr(FlPluginRegistrar) desktop_multi_window_registrar = - fl_plugin_registry_get_registrar_for_plugin(registry, "DesktopMultiWindowPlugin"); - desktop_multi_window_plugin_register_with_registrar(desktop_multi_window_registrar); g_autoptr(FlPluginRegistrar) file_selector_linux_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "FileSelectorPlugin"); file_selector_plugin_register_with_registrar(file_selector_linux_registrar); diff --git a/linux/flutter/generated_plugins.cmake b/linux/flutter/generated_plugins.cmake index 99551c6..a8c1e40 100644 --- a/linux/flutter/generated_plugins.cmake +++ b/linux/flutter/generated_plugins.cmake @@ -3,7 +3,6 @@ # list(APPEND FLUTTER_PLUGIN_LIST - desktop_multi_window file_selector_linux flutter_secure_storage_linux gtk diff --git a/linux/runner/my_application.cc b/linux/runner/my_application.cc index 9b37fb9..ef7d5c3 100644 --- a/linux/runner/my_application.cc +++ b/linux/runner/my_application.cc @@ -8,12 +8,7 @@ #include #include #include -#ifdef GDK_WINDOWING_X11 -#include -#endif - #include "flutter/generated_plugin_registrant.h" -#include "desktop_multi_window/desktop_multi_window_plugin.h" constexpr char kApplicationDisplayName[] = "BusyMax"; constexpr char kNativeDateTimePickerChannel[] = @@ -27,8 +22,6 @@ constexpr char kGtkFontSettingsEventChannel[] = "io.busystack.busymax/gtk_font_settings"; constexpr char kGtkThemeColorsEventChannel[] = "io.busystack.busymax/gtk_theme_colors"; -constexpr char kCompactAgendaWindowChannel[] = - "io.busystack.busymax/compact_agenda_window"; constexpr gint64 kHeaderBarStateSchemaVersion = 3; constexpr gint kHeaderButtonHeight = 34; constexpr gint kHeaderButtonSpacing = 6; @@ -47,21 +40,6 @@ constexpr gint kHeaderSidebarContentInset = kHeaderButtonSpacing; constexpr gint kHeaderMainContentStartInset = kHeaderSidebarContentInset; constexpr gint kMainWindowDefaultWidth = 1280; constexpr gint kMainWindowDefaultHeight = 720; -constexpr gint kCompactAgendaPanelWidth = 420; -constexpr gint kCompactAgendaPanelHeight = 680; -constexpr gint kCompactAgendaWindowShadowMargin = 32; -constexpr gint kCompactAgendaWindowWidth = - kCompactAgendaPanelWidth + kCompactAgendaWindowShadowMargin * 2; -constexpr gint kCompactAgendaWindowHeight = - kCompactAgendaPanelHeight + kCompactAgendaWindowShadowMargin * 2; -constexpr gint kCompactAgendaWindowMinWidth = - 360 + kCompactAgendaWindowShadowMargin * 2; -constexpr gint kCompactAgendaWindowMinHeight = - 520 + kCompactAgendaWindowShadowMargin * 2; -constexpr gint kCompactAgendaWindowMaxWidth = - 480 + kCompactAgendaWindowShadowMargin * 2; -constexpr gint kCompactAgendaWindowMaxHeight = - 840 + kCompactAgendaWindowShadowMargin * 2; constexpr char kDefaultWindowBackgroundColor[] = "#2C2C2C"; constexpr char kDefaultHeaderBarBackgroundColor[] = "#272727"; constexpr char kDefaultHeaderBarSidebarBackgroundColor[] = "#393939"; @@ -695,13 +673,6 @@ static void register_native_date_time_picker(MyApplication* self, create_native_date_time_picker_channel(view, window); } -static void register_native_date_time_picker_for_subwindow(FlView* view, - GtkWindow* window) { - FlMethodChannel* channel = create_native_date_time_picker_channel(view, window); - g_object_set_data_full(G_OBJECT(window), "busymax-native-date-time-picker", - channel, g_object_unref); -} - static void respond_bool(FlMethodCall* method_call, gboolean value) { g_autoptr(FlValue) result = fl_value_new_bool(value); fl_method_call_respond_success(method_call, result, nullptr); @@ -1447,14 +1418,6 @@ static void register_native_dialogs(MyApplication* self, create_native_dialog_channel(view, window, self); } -static void register_native_dialogs_for_subwindow(FlView* view, - GtkWindow* window) { - FlMethodChannel* channel = - create_native_dialog_channel(view, window, nullptr); - g_object_set_data_full(G_OBJECT(window), "busymax-native-dialogs", channel, - g_object_unref); -} - constexpr char kNativeMenuActionNamespace[] = "busymax-native-menu"; constexpr char kNativeMenuActionIndexKey[] = "busymax-native-menu-index"; @@ -2106,15 +2069,6 @@ static void register_native_menus(MyApplication* self, self->native_menu_channel = create_native_menu_channel(view, host); } -static void register_native_menus_for_subwindow( - FlView* view, - GtkWindow* window, - const NativeMenuHostWidgets& host) { - FlMethodChannel* channel = create_native_menu_channel(view, host); - g_object_set_data_full(G_OBJECT(window), "busymax-native-menus", channel, - g_object_unref); -} - static void respond_success(FlMethodCall* method_call) { g_autoptr(FlValue) result = fl_value_new_null(); fl_method_call_respond_success(method_call, result, nullptr); @@ -5053,197 +5007,6 @@ static void register_gtk_settings_channel(MyApplication* self, FlView* view) { gtk_theme_colors_cancel_cb, self, nullptr); } -struct CompactGtkSettingsBridge { - FlMethodChannel* settings_channel; - FlEventChannel* font_settings_event_channel; - FlEventChannel* theme_colors_event_channel; - gulong font_settings_signal_id; - gulong theme_name_signal_id; - gulong theme_dark_signal_id; - gboolean font_settings_listening; - gboolean theme_colors_listening; -}; - -static void compact_gtk_settings_bridge_disconnect_font( - CompactGtkSettingsBridge* bridge) { - if (bridge == nullptr || bridge->font_settings_signal_id == 0) { - return; - } - GtkSettings* settings = gtk_settings_get_default(); - if (settings != nullptr) { - g_signal_handler_disconnect(settings, bridge->font_settings_signal_id); - } - bridge->font_settings_signal_id = 0; -} - -static void compact_gtk_settings_bridge_disconnect_theme( - CompactGtkSettingsBridge* bridge) { - if (bridge == nullptr) { - return; - } - GtkSettings* settings = gtk_settings_get_default(); - if (settings != nullptr && bridge->theme_name_signal_id != 0) { - g_signal_handler_disconnect(settings, bridge->theme_name_signal_id); - } - if (settings != nullptr && bridge->theme_dark_signal_id != 0) { - g_signal_handler_disconnect(settings, bridge->theme_dark_signal_id); - } - bridge->theme_name_signal_id = 0; - bridge->theme_dark_signal_id = 0; -} - -static void compact_gtk_settings_bridge_free(gpointer data) { - CompactGtkSettingsBridge* bridge = - static_cast(data); - if (bridge == nullptr) { - return; - } - compact_gtk_settings_bridge_disconnect_font(bridge); - compact_gtk_settings_bridge_disconnect_theme(bridge); - g_clear_object(&bridge->settings_channel); - g_clear_object(&bridge->font_settings_event_channel); - g_clear_object(&bridge->theme_colors_event_channel); - g_free(bridge); -} - -static void compact_gtk_settings_send_font_event( - CompactGtkSettingsBridge* bridge) { - if (bridge == nullptr || !bridge->font_settings_listening || - bridge->font_settings_event_channel == nullptr) { - return; - } - g_autoptr(FlValue) result = get_gtk_font_settings(); - g_autoptr(GError) error = nullptr; - if (!fl_event_channel_send(bridge->font_settings_event_channel, result, - nullptr, &error)) { - const gchar* message = error != nullptr ? error->message : "unknown error"; - g_warning("Failed to send compact GTK font settings event: %s", message); - } -} - -static void compact_gtk_settings_send_theme_event( - CompactGtkSettingsBridge* bridge) { - if (bridge == nullptr || !bridge->theme_colors_listening || - bridge->theme_colors_event_channel == nullptr) { - return; - } - g_autoptr(FlValue) result = get_gtk_theme_colors(); - g_autoptr(GError) error = nullptr; - if (!fl_event_channel_send(bridge->theme_colors_event_channel, result, - nullptr, &error)) { - const gchar* message = error != nullptr ? error->message : "unknown error"; - g_warning("Failed to send compact GTK theme colors event: %s", message); - } -} - -static void compact_gtk_font_notify_cb(GObject* object, - GParamSpec* pspec, - gpointer user_data) { - compact_gtk_settings_send_font_event( - static_cast(user_data)); -} - -static void compact_gtk_theme_notify_cb(GObject* object, - GParamSpec* pspec, - gpointer user_data) { - compact_gtk_settings_send_theme_event( - static_cast(user_data)); -} - -static FlMethodErrorResponse* compact_gtk_font_settings_listen_cb( - FlEventChannel* channel, - FlValue* args, - gpointer user_data) { - CompactGtkSettingsBridge* bridge = - static_cast(user_data); - bridge->font_settings_listening = TRUE; - - GtkSettings* settings = gtk_settings_get_default(); - if (settings != nullptr && bridge->font_settings_signal_id == 0) { - bridge->font_settings_signal_id = - g_signal_connect(settings, "notify::gtk-font-name", - G_CALLBACK(compact_gtk_font_notify_cb), bridge); - } - - compact_gtk_settings_send_font_event(bridge); - return nullptr; -} - -static FlMethodErrorResponse* compact_gtk_font_settings_cancel_cb( - FlEventChannel* channel, - FlValue* args, - gpointer user_data) { - CompactGtkSettingsBridge* bridge = - static_cast(user_data); - bridge->font_settings_listening = FALSE; - compact_gtk_settings_bridge_disconnect_font(bridge); - return nullptr; -} - -static FlMethodErrorResponse* compact_gtk_theme_colors_listen_cb( - FlEventChannel* channel, - FlValue* args, - gpointer user_data) { - CompactGtkSettingsBridge* bridge = - static_cast(user_data); - bridge->theme_colors_listening = TRUE; - - GtkSettings* settings = gtk_settings_get_default(); - if (settings != nullptr && bridge->theme_name_signal_id == 0) { - bridge->theme_name_signal_id = - g_signal_connect(settings, "notify::gtk-theme-name", - G_CALLBACK(compact_gtk_theme_notify_cb), bridge); - } - if (settings != nullptr && bridge->theme_dark_signal_id == 0) { - bridge->theme_dark_signal_id = g_signal_connect( - settings, "notify::gtk-application-prefer-dark-theme", - G_CALLBACK(compact_gtk_theme_notify_cb), bridge); - } - - compact_gtk_settings_send_theme_event(bridge); - return nullptr; -} - -static FlMethodErrorResponse* compact_gtk_theme_colors_cancel_cb( - FlEventChannel* channel, - FlValue* args, - gpointer user_data) { - CompactGtkSettingsBridge* bridge = - static_cast(user_data); - bridge->theme_colors_listening = FALSE; - compact_gtk_settings_bridge_disconnect_theme(bridge); - return nullptr; -} - -static void register_compact_gtk_settings_channel(FlView* view, - GtkWindow* window) { - CompactGtkSettingsBridge* bridge = - g_new0(CompactGtkSettingsBridge, 1); - g_autoptr(FlStandardMethodCodec) codec = fl_standard_method_codec_new(); - FlBinaryMessenger* messenger = - fl_engine_get_binary_messenger(fl_view_get_engine(view)); - - bridge->settings_channel = fl_method_channel_new( - messenger, kGtkSettingsChannel, FL_METHOD_CODEC(codec)); - fl_method_channel_set_method_call_handler( - bridge->settings_channel, gtk_settings_method_call_cb, bridge, nullptr); - - bridge->font_settings_event_channel = fl_event_channel_new( - messenger, kGtkFontSettingsEventChannel, FL_METHOD_CODEC(codec)); - fl_event_channel_set_stream_handlers( - bridge->font_settings_event_channel, compact_gtk_font_settings_listen_cb, - compact_gtk_font_settings_cancel_cb, bridge, nullptr); - - bridge->theme_colors_event_channel = fl_event_channel_new( - messenger, kGtkThemeColorsEventChannel, FL_METHOD_CODEC(codec)); - fl_event_channel_set_stream_handlers( - bridge->theme_colors_event_channel, compact_gtk_theme_colors_listen_cb, - compact_gtk_theme_colors_cancel_cb, bridge, nullptr); - - g_object_set_data_full(G_OBJECT(window), "busymax-compact-gtk-settings", - bridge, compact_gtk_settings_bridge_free); -} - static gboolean window_delete_event_cb(GtkWidget* widget, GdkEvent* event, gpointer user_data) { @@ -5317,274 +5080,6 @@ static void register_window_channel(MyApplication* self, FlView* view) { self->window_channel, window_method_call_cb, self, nullptr); } -static void install_compact_agenda_window_css(GtkWindow* window) { - static const gchar* css = - "window#busymax-compact-agenda-window," - "window#busymax-compact-agenda-window:backdrop {" - "background-color: transparent;" - "background-image: none;" - "}" - "window#busymax-compact-agenda-window decoration," - "window#busymax-compact-agenda-window decoration:backdrop {" - "background-color: transparent;" - "background-image: none;" - "border: none;" - "}"; - - 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 compact Agenda CSS: %s", error->message); - g_object_unref(provider); - return; - } - - gtk_style_context_add_provider_for_screen( - gtk_window_get_screen(window), GTK_STYLE_PROVIDER(provider), - GTK_STYLE_PROVIDER_PRIORITY_APPLICATION); - g_object_unref(provider); -} - -static gboolean compact_agenda_number_arg(FlValue* args, - const gchar* key, - gdouble* value) { - if (args == nullptr || fl_value_get_type(args) != FL_VALUE_TYPE_MAP) { - return FALSE; - } - FlValue* arg = fl_value_lookup_string(args, key); - if (arg == nullptr) { - return FALSE; - } - - switch (fl_value_get_type(arg)) { - case FL_VALUE_TYPE_FLOAT: - *value = fl_value_get_float(arg); - return std::isfinite(*value); - case FL_VALUE_TYPE_INT: - *value = static_cast(fl_value_get_int(arg)); - return std::isfinite(*value); - default: - return FALSE; - } -} - -static gint compact_agenda_dimension_arg(FlValue* args, - const gchar* key, - gint fallback, - gint minimum, - gint maximum) { - gdouble value = fallback; - if (!compact_agenda_number_arg(args, key, &value)) { - return fallback; - } - if (value < minimum) { - return minimum; - } - if (value > maximum) { - return maximum; - } - return static_cast(value); -} - -static gboolean compact_agenda_position_arg(FlValue* args, - const gchar* key, - gint* value) { - gdouble parsed = 0; - if (!compact_agenda_number_arg(args, key, &parsed)) { - return FALSE; - } - *value = static_cast(parsed); - return TRUE; -} - -static void log_compact_agenda_geometry(GtkWindow* window, - gboolean has_position, - gint x, - gint y, - gint width, - gint height, - const gchar* native_move_status, - const gchar* phase) { - GdkDisplay* display = gtk_widget_get_display(GTK_WIDGET(window)); - const gchar* backend = - display != nullptr ? G_OBJECT_TYPE_NAME(display) : ""; - const gchar* session = g_getenv("XDG_SESSION_TYPE"); - if (session == nullptr || strlen(session) == 0) { - session = ""; - } - - GdkRectangle workarea = {-1, -1, -1, -1}; - if (display != nullptr) { - GdkMonitor* monitor = has_position - ? gdk_display_get_monitor_at_point(display, x, y) - : gdk_display_get_primary_monitor(display); - if (monitor != nullptr) { - gdk_monitor_get_workarea(monitor, &workarea); - } - } - - g_debug( - "BusyMax compact agenda positioning: phase=%s requested=%s " - "requested_x=%d requested_y=%d workarea=%d,%d,%dx%d final_size=%dx%d " - "backend=%s session=%s native_move_call_succeeded=%s", - phase, has_position ? "true" : "false", has_position ? x : -1, - has_position ? y : -1, workarea.x, workarea.y, workarea.width, - workarea.height, width, height, backend, session, native_move_status); -} - -static const gchar* move_compact_agenda_window_if_supported(GtkWindow* window, - gboolean has_position, - gint x, - gint y) { - if (!has_position) { - return "false"; - } - - GdkDisplay* display = gtk_widget_get_display(GTK_WIDGET(window)); -#ifdef GDK_WINDOWING_X11 - if (display != nullptr && GDK_IS_X11_DISPLAY(display)) { - gtk_window_move(window, x, y); - return "true"; - } -#endif - - return "skipped-non-x11"; -} - -static void apply_compact_agenda_geometry(GtkWindow* window, FlValue* args) { - const gint width = compact_agenda_dimension_arg( - args, "width", kCompactAgendaWindowWidth, kCompactAgendaWindowMinWidth, - kCompactAgendaWindowMaxWidth); - const gint height = compact_agenda_dimension_arg( - args, "height", kCompactAgendaWindowHeight, kCompactAgendaWindowMinHeight, - kCompactAgendaWindowMaxHeight); - - gtk_window_set_position(window, GTK_WIN_POS_NONE); - gtk_window_set_gravity(window, GDK_GRAVITY_NORTH_EAST); - gtk_window_set_default_size(window, width, height); - gtk_window_resize(window, width, height); - gtk_widget_set_size_request(GTK_WIDGET(window), width, height); - - GtkWidget* child = gtk_bin_get_child(GTK_BIN(window)); - if (child != nullptr) { - gtk_widget_set_size_request(child, width, height); - } - - gint x = 0; - gint y = 0; - const gboolean has_position = - compact_agenda_position_arg(args, "x", &x) && - compact_agenda_position_arg(args, "y", &y); - const gchar* native_move_status = - move_compact_agenda_window_if_supported(window, has_position, x, y); - log_compact_agenda_geometry(window, has_position, x, y, width, height, - native_move_status, "apply"); -} - -static void compact_agenda_window_method_call_cb(FlMethodChannel* channel, - FlMethodCall* method_call, - gpointer user_data) { - GtkWindow* window = GTK_WINDOW(user_data); - const gchar* method = fl_method_call_get_name(method_call); - FlValue* args = fl_method_call_get_args(method_call); - - if (strcmp(method, "setPlacement") == 0) { - apply_compact_agenda_geometry(window, args); - respond_bool(method_call, TRUE); - } else if (strcmp(method, "show") == 0) { - apply_compact_agenda_geometry(window, args); - gtk_widget_show(GTK_WIDGET(window)); - gtk_window_present(window); - apply_compact_agenda_geometry(window, args); - respond_bool(method_call, TRUE); - } else { - fl_method_call_respond_not_implemented(method_call, nullptr); - } -} - -static void register_compact_agenda_window_channel(FlView* view, - 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)), - kCompactAgendaWindowChannel, FL_METHOD_CODEC(codec)); - fl_method_channel_set_method_call_handler( - channel, compact_agenda_window_method_call_cb, g_object_ref(window), - g_object_unref); - g_object_set_data_full(G_OBJECT(window), "busymax-compact-agenda-channel", - channel, g_object_unref); -} - -static void configure_compact_agenda_subwindow(FlPluginRegistry* registry) { - // BusyMax creates desktop_multi_window subwindows for the compact tray - // Agenda. Configure the GTK shell before Dart/window_manager can show the - // plugin's default 1280x720 secondary window. - if (!FL_IS_VIEW(registry)) { - return; - } - - FlView* view = FL_VIEW(registry); - GtkWidget* toplevel = gtk_widget_get_toplevel(GTK_WIDGET(view)); - if (!GTK_IS_WINDOW(toplevel)) { - return; - } - - GtkWindow* window = GTK_WINDOW(toplevel); - GdkRGBA transparent = {0, 0, 0, 0}; - fl_view_set_background_color(view, &transparent); - - gtk_widget_set_name(GTK_WIDGET(window), "busymax-compact-agenda-window"); - install_compact_agenda_window_css(window); - GtkWidget* titlebar = gtk_window_get_titlebar(window); - if (titlebar != nullptr) { - gtk_widget_hide(titlebar); - } - gtk_window_set_decorated(window, FALSE); - gtk_window_set_type_hint(window, GDK_WINDOW_TYPE_HINT_UTILITY); - gtk_window_set_skip_taskbar_hint(window, TRUE); - gtk_window_set_skip_pager_hint(window, TRUE); - gtk_window_set_keep_above(window, TRUE); - gtk_window_set_gravity(window, GDK_GRAVITY_NORTH_EAST); - gtk_window_set_position(window, GTK_WIN_POS_NONE); - gtk_window_set_title(window, "BusyMax Agenda"); - gtk_window_set_resizable(window, FALSE); - gtk_window_set_default_size(window, kCompactAgendaWindowWidth, - kCompactAgendaWindowHeight); - gtk_window_resize(window, kCompactAgendaWindowWidth, - kCompactAgendaWindowHeight); - gtk_widget_set_size_request(GTK_WIDGET(window), kCompactAgendaWindowWidth, - kCompactAgendaWindowHeight); - gtk_widget_set_size_request(GTK_WIDGET(view), kCompactAgendaWindowWidth, - kCompactAgendaWindowHeight); - - GdkGeometry geometry = {}; - geometry.min_width = kCompactAgendaWindowMinWidth; - geometry.min_height = kCompactAgendaWindowMinHeight; - geometry.max_width = kCompactAgendaWindowMaxWidth; - geometry.max_height = kCompactAgendaWindowMaxHeight; - gtk_window_set_geometry_hints( - window, GTK_WIDGET(window), &geometry, - static_cast(GDK_HINT_MIN_SIZE | GDK_HINT_MAX_SIZE)); - - register_compact_agenda_window_channel(view, window); - register_compact_gtk_settings_channel(view, window); - register_native_date_time_picker_for_subwindow(view, window); - register_native_dialogs_for_subwindow(view, window); - GtkWidget* view_parent = gtk_widget_get_parent(GTK_WIDGET(view)); - if (!GTK_IS_BIN(view_parent) || - gtk_bin_get_child(GTK_BIN(view_parent)) != GTK_WIDGET(view)) { - g_warning("Unable to install the native menu host in the compact window"); - return; - } - g_object_ref(view); - gtk_container_remove(GTK_CONTAINER(view_parent), GTK_WIDGET(view)); - NativeMenuHostWidgets native_menu_host = create_native_menu_host(view); - gtk_container_add(GTK_CONTAINER(view_parent), native_menu_host.overlay); - g_object_unref(view); - register_native_menus_for_subwindow(view, window, native_menu_host); -} - // Called when first Flutter frame received. static void first_frame_cb(MyApplication* self, FlView* view) { gtk_widget_show(gtk_widget_get_toplevel(GTK_WIDGET(view))); @@ -5652,11 +5147,6 @@ static void my_application_activate(GApplication* application) { gtk_widget_realize(GTK_WIDGET(view)); fl_register_plugins(FL_PLUGIN_REGISTRY(view)); - desktop_multi_window_plugin_set_window_created_callback( - [](FlPluginRegistry* registry) { - configure_compact_agenda_subwindow(registry); - fl_register_plugins(registry); - }); register_native_date_time_picker(self, view, window); register_native_dialogs(self, view, window); register_native_menus(self, view, native_menu_host); diff --git a/test/app/keyboard_shortcuts_dialog_test.dart b/test/app/keyboard_shortcuts_dialog_test.dart index c817b31..ab1c95d 100644 --- a/test/app/keyboard_shortcuts_dialog_test.dart +++ b/test/app/keyboard_shortcuts_dialog_test.dart @@ -24,7 +24,7 @@ void main() { expect(find.text('View'), findsOneWidget); expect(find.text('Create and Edit'), findsOneWidget); expect(find.text('Task editing'), findsOneWidget); - expect(find.text('Compact agenda'), findsOneWidget); + expect(find.text('Compact agenda'), findsNothing); expect(find.text('Ctrl+Alt+K'), findsOneWidget); expect(find.text('Ctrl+Alt+S'), findsOneWidget); expect(find.text('Ctrl+F'), findsOneWidget); @@ -43,8 +43,8 @@ void main() { expect(find.text('0'), findsNothing); expect(find.text('Ctrl+S'), findsOneWidget); expect(find.text('Backspace / Delete'), findsOneWidget); - expect(find.text('Ctrl+R'), findsOneWidget); - expect(find.text('Esc'), findsNWidgets(2)); + expect(find.text('Ctrl+R'), findsNothing); + expect(find.text('Esc'), findsOneWidget); expect(find.byIcon(Icons.close), findsWidgets); expect(find.byType(YaruDialogTitleBar), findsOneWidget); expect(find.byType(YaruWindowControl), findsOneWidget); @@ -116,14 +116,14 @@ void main() { final closePosition = tester.getTopLeft(closeButton); await tester.scrollUntilVisible( - find.text('Compact agenda'), + find.text('Agenda view'), 400, scrollable: find.byType(Scrollable), ); await tester.pumpAndSettle(); expect(tester.takeException(), isNull); - expect(find.text('Compact agenda').hitTestable(), findsOneWidget); + expect(find.text('Agenda view').hitTestable(), findsOneWidget); expect(closeButton.hitTestable(), findsOneWidget); expect(tester.getTopLeft(closeButton), closePosition); }); @@ -172,7 +172,7 @@ void main() { ) .toList(); - expect(groupedMaterials, hasLength(6)); + expect(groupedMaterials, hasLength(5)); expect( groupedMaterials.every( (material) => @@ -186,7 +186,7 @@ void main() { ), isTrue, ); - expect(groupedCards, hasLength(6)); + expect(groupedCards, hasLength(5)); final dialog = tester.widget

(find.byType(Dialog)); final dialogShape = (dialog.shape ?? theme.dialogTheme.shape)! diff --git a/test/app/native_ui_audit_test.dart b/test/app/native_ui_audit_test.dart index 1fedad8..2a7e6ba 100644 --- a/test/app/native_ui_audit_test.dart +++ b/test/app/native_ui_audit_test.dart @@ -157,7 +157,7 @@ void main() { }); test( - 'Task Details, Settings, and Agenda use BusyMax Yaru row patterns', + 'Task Details, Settings, and main Agenda use BusyMax Yaru row patterns', () { final taskDetails = File( 'lib/src/features/tasks/presentation/task_details_editor.dart', @@ -181,9 +181,6 @@ void main() { final scheduleAgenda = File( 'lib/src/features/schedule/presentation/schedule_agenda_view.dart', ).readAsStringSync(); - final compactAgenda = File( - 'lib/src/features/schedule/presentation/compact_agenda_panel.dart', - ).readAsStringSync(); expect(design, contains('class BusyMaxClamp')); expect(design, contains('class BusyMaxEditorScrollBody')); @@ -269,12 +266,6 @@ void main() { expect(scheduleAgenda, isNot(contains('class _AgendaDayHeader'))); expect(scheduleAgenda, isNot(contains('class _AgendaPlainHeader'))); - expect(compactAgenda, contains('BusyMaxGroupedList')); - expect(compactAgenda, contains('BusyMaxActionRow')); - expect(compactAgenda, isNot(contains('scheduleAgendaRowBackground'))); - expect(compactAgenda, contains('ScheduleProjection.colorForItem')); - expect(compactAgenda, contains('leading: _CompactAgendaRowMarker')); - expect(dateTimeFields, contains('MiniCalendarGrid(')); expect(dateTimeFields, isNot(contains('ScheduleItem'))); expect( @@ -368,147 +359,31 @@ void main() { expect(portalStore, contains('Hkdf(hmac: Hmac.sha256()')); }); - test('compact agenda uses a separate desktop window', () { + test('tray Agenda action reuses the main application window', () { final pubspec = File('pubspec.yaml').readAsStringSync(); final runner = File('linux/runner/my_application.cc').readAsStringSync(); final linuxMain = File('linux/runner/main.cc').readAsStringSync(); - final tray = File( - 'lib/src/platform/busymax_tray_service.dart', - ).readAsStringSync(); - final router = File('lib/src/app/app_router.dart').readAsStringSync(); - final main = File('lib/main.dart').readAsStringSync(); - final compactApp = File( - 'lib/src/features/schedule/presentation/compact_agenda_app.dart', - ).readAsStringSync(); - final compactPanel = File( - 'lib/src/features/schedule/presentation/compact_agenda_panel.dart', + final app = File('lib/src/app/busymax_app.dart').readAsStringSync(); + final commands = File( + 'lib/src/schedule/schedule_commands.dart', ).readAsStringSync(); - final compactWindowService = File( - 'lib/src/platform/compact_agenda_window_service.dart', + final workspace = File( + 'lib/src/features/schedule/presentation/schedule_workspace.dart', ).readAsStringSync(); - expect(pubspec, contains('desktop_multi_window:')); - expect(pubspec, contains('window_manager:')); expect(linuxMain, contains('gdk_set_allowed_backends("wayland,x11")')); - expect( - runner, - contains('desktop_multi_window_plugin_set_window_created_callback'), - ); - expect(runner, contains('configure_compact_agenda_subwindow')); - expect(runner, contains('install_compact_agenda_window_css')); - expect(runner, contains('io.busystack.busymax/compact_agenda_window')); - expect(runner, contains('kCompactAgendaPanelWidth = 420')); - expect(runner, contains('kCompactAgendaWindowShadowMargin = 32')); - expect( - runner, - contains('gtk_window_resize(window, kCompactAgendaWindowWidth'), - ); - expect(runner, contains('move_compact_agenda_window_if_supported')); - expect(runner, contains('GDK_IS_X11_DISPLAY(display)')); - expect(runner, contains('gtk_window_move(window, x, y)')); - expect(runner, contains('"skipped-non-x11"')); - expect( - runner, - contains('gtk_window_set_gravity(window, GDK_GRAVITY_NORTH_EAST)'), - ); - expect(runner, contains('BusyMax compact agenda positioning: phase=%s')); - expect(runner, contains('apply_compact_agenda_geometry')); - expect( - runner, - contains( - 'gtk_window_set_type_hint(window, GDK_WINDOW_TYPE_HINT_UTILITY)', - ), - ); - expect( - runner, - contains('gtk_window_set_skip_taskbar_hint(window, TRUE)'), - ); - expect(runner, contains('gtk_window_set_skip_pager_hint(window, TRUE)')); - expect(runner, contains('gtk_window_set_keep_above(window, TRUE)')); - expect(runner, contains('register_compact_gtk_settings_channel')); - expect( - runner, - contains('register_native_date_time_picker_for_subwindow'), - ); - expect(runner, contains('register_native_dialogs_for_subwindow')); - expect(runner, contains('window#busymax-compact-agenda-window')); - expect(runner, contains('gtk_window_get_titlebar(window)')); - expect(runner, contains('gtk_widget_hide(titlebar)')); - expect(runner, contains('gtk_window_set_decorated(window, FALSE)')); - expect( - runner, - contains('gtk_widget_set_size_request(GTK_WIDGET(window)'), - ); - expect(runner, contains('gtk_widget_set_size_request(GTK_WIDGET(view)')); - expect( - runner, - isNot(contains('gtk_window_set_titlebar(window, nullptr)')), - ); - expect(tray, contains('return _onOpenAgenda();')); - expect(tray, isNot(contains('BusyMaxTrayAgendaMenu'))); - expect(tray, isNot(contains('BusyMaxTrayAgendaEntry'))); - expect(tray, isNot(contains('onOpenAgendaEntry'))); - expect(tray, contains('id: _busyMaxTrayAgendaMenuId')); - expect(router, isNot(contains('/tray-agenda'))); - expect(compactApp, isNot(contains('linux_header_bar_service.dart'))); - expect(compactApp, contains('gtk_font_service.dart')); - expect(compactApp, contains('gtkFontSettingsProvider')); - expect(compactApp, contains('gtkThemeColorsProvider')); - expect(compactApp, isNot(contains('syncSchedulerProvider'))); - expect(compactApp, isNot(contains('notificationSchedulerProvider'))); - expect(compactApp, isNot(contains('dueTodayNotificationProvider'))); - expect(compactApp, contains('const _compactAgendaPanelWidth = 420.0')); - expect(compactApp, contains('const _compactAgendaPanelHeight = 680.0')); - expect(compactApp, contains('BusyMaxShadow.windowMargin')); - expect( - compactApp, - contains('io.busystack.busymax/compact_agenda_window'), - ); - expect( - compactApp, - contains('_compactAgendaWindowChannel.invokeMethod'), - ); - expect(compactApp, contains('unawaited(_destroyWindow());')); - expect(compactApp, contains('Future _clearWindowMethodHandler()')); - expect(compactApp, contains('Compact agenda positioning: event=')); - expect( - compactApp, - contains('await windowManager.setSize(_compactAgendaWindowSize)'), - ); - expect(compactApp, contains('await windowManager.setBounds(')); - expect(compactApp, isNot(contains('windowManager.setPosition('))); - expect( - compactApp, - contains('final shownNatively = await _showNativeWindow(position);'), - ); - expect(compactApp, isNot(contains('void onWindowBlur()'))); - expect(compactApp, isNot(contains('_hideAfterBlurDelay'))); - expect(compactPanel, contains('ClipRRect')); - expect(compactPanel, contains('BusyMaxRadius.window')); - expect(compactPanel, contains('BusyMaxShadow.windowShadowsFor')); - expect(compactWindowService, contains('getPrimaryDisplay()')); - expect(compactWindowService, contains('getCursorScreenPoint()')); - expect(compactWindowService, contains('getAllDisplays()')); - expect(compactWindowService, contains('_compactAgendaWindowFrameWidth')); - expect(compactWindowService, contains('_compactAgendaWindowFrameHeight')); - expect(compactWindowService, contains('_clampWindowPositionToWorkArea')); - expect(compactWindowService, contains('raw_x=')); - expect(compactWindowService, contains('final_x=')); - expect( - compactWindowService, - contains( - 'workarea.right -\n' - ' _compactAgendaWindowFrameWidth -\n' - ' _compactAgendaPanelScreenGap', - ), - ); - expect( - compactWindowService, - isNot(contains('panelTop - _compactAgendaWindowShadowMargin')), - ); - expect(compactWindowService, isNot(contains('controller.show()'))); - expect(main, isNot(contains('waitUntilReadyToShow'))); - expect(main, isNot(contains('await windowManager.show();'))); + expect(pubspec, isNot(contains('desktop_multi_window:'))); + expect(pubspec, isNot(contains('screen_retriever:'))); + expect(pubspec, isNot(contains('window_manager:'))); + expect(runner, isNot(contains('desktop_multi_window'))); + expect(runner, isNot(contains('compact_agenda'))); + expect(app, contains('Future _openMainAgenda(')); + expect(app, contains('await windowService.showWindow();')); + expect(app, contains('ScheduleWorkspaceCommandKind.agenda')); + expect(app, contains("ref.read(appRouterProvider).go('/schedule')")); + expect(commands, contains('agenda,')); + expect(workspace, contains('case ScheduleWorkspaceCommandKind.agenda:')); + expect(workspace, contains('_setMode(ScheduleViewMode.agenda);')); }); test('native headerbar keeps sidebar branded with GTK-owned centering', () { @@ -1438,10 +1313,7 @@ void main() { expect(runner, isNot(contains('handle_native_confirmation'))); expect(runner, isNot(contains('strcmp(method, "confirm")'))); expect(runner, contains('register_native_dialogs(self, view, window)')); - expect( - runner, - contains('register_native_dialogs_for_subwindow(view, window)'), - ); + expect(runner, isNot(contains('register_native_dialogs_for_subwindow'))); expect(runner, contains('g_object_add_weak_pointer')); expect(runner, contains('native_dialog_handler_data_free')); expect( @@ -2365,9 +2237,6 @@ void main() { ).readAsStringSync(); final app = File('lib/src/app/busymax_app.dart').readAsStringSync(); final main = File('lib/main.dart').readAsStringSync(); - final compactApp = File( - 'lib/src/features/schedule/presentation/compact_agenda_app.dart', - ).readAsStringSync(); expect(source, contains('kGtkThemeColorsEventChannel')); expect(source, contains('io.busystack.busymax/gtk_theme_colors')); @@ -2473,12 +2342,6 @@ void main() { 'gtkThemeColors?.accent ?? ubuntuAccentColor ?? systemColor.accent', ), ); - expect( - compactApp, - contains( - 'gtkThemeColors?.accent ?? ubuntuAccentColor ?? systemColor.accent', - ), - ); expect(source, contains('fl_lookup_optional_bool_arg')); expect( source, diff --git a/test/app/theme_localization_test.dart b/test/app/theme_localization_test.dart index 150b76c..13cae42 100644 --- a/test/app/theme_localization_test.dart +++ b/test/app/theme_localization_test.dart @@ -21,7 +21,7 @@ import 'package:busymax/src/l10n/l10n.dart'; import 'package:busymax/src/platform/busymax_tray_service.dart'; import 'package:busymax/src/platform/gtk_font_service.dart'; import 'package:busymax/src/platform/linux_window_service.dart'; -import 'package:busymax/src/platform/main_window_command_bridge.dart'; +import 'package:busymax/src/schedule/schedule_commands.dart'; import 'package:busymax/src/schedule/schedule_view_mode.dart'; import '../test_localized_app.dart'; @@ -1828,10 +1828,14 @@ void main() { await tester.pumpAndSettle(); ColoredBox flutterSurface() { - final bridge = tester.widget( - find.byType(MainWindowCommandBridge), - ); - return bridge.child as ColoredBox; + final shortcuts = tester + .widgetList(find.byType(Shortcuts)) + .firstWhere( + (widget) => + widget.child is Actions && + (widget.child as Actions).child is ColoredBox, + ); + return (shortcuts.child as Actions).child as ColoredBox; } expect(flutterSurface().child, isNot(isA())); @@ -1869,7 +1873,6 @@ void main() { required windowService, required labels, required onOpenAgenda, - onBeforeQuit, }) => trayService, ), ), @@ -1897,6 +1900,81 @@ void main() { expect(windowService.hideWindowCalls, 1); }); + testWidgets('tray Agenda opens the main window in Agenda mode', ( + tester, + ) async { + final database = AppDatabase.memoryForTests(); + addTearDown(database.close); + final windowService = _RecordingWindowService(); + late _RecordingTrayService trayService; + + await tester.pumpWidget( + ProviderScope( + overrides: [ + buildConfigProvider.overrideWithValue(_missingConfig), + databaseProvider.overrideWithValue(database), + localSettingsStoreProvider.overrideWithValue(_MemorySettingsStore()), + linuxWindowServiceProvider.overrideWithValue(windowService), + ], + child: BusyMaxApp( + trayServiceFactory: + ({ + required windowService, + required labels, + required onOpenAgenda, + }) => trayService = _RecordingTrayService( + windowService, + openAgendaCallback: onOpenAgenda, + ), + ), + ), + ); + await tester.pump(); + await tester.pump(); + + await trayService.openAgenda(); + await tester.pump(); + + final container = ProviderScope.containerOf( + tester.element(find.byType(BusyMaxApp)), + ); + final command = container.read(scheduleWorkspaceCommandProvider); + expect(windowService.showWindowCalls, 1); + expect(command?.kind, ScheduleWorkspaceCommandKind.agenda); + }); + + testWidgets('demo mode retains the local tray entry', (tester) async { + final database = AppDatabase.memoryForTests(); + addTearDown(database.close); + final windowService = _RecordingWindowService(); + final trayService = _RecordingTrayService(windowService); + + await tester.pumpWidget( + ProviderScope( + overrides: [ + buildConfigProvider.overrideWithValue(_demoConfig), + databaseProvider.overrideWithValue(database), + localSettingsStoreProvider.overrideWithValue(_MemorySettingsStore()), + linuxWindowServiceProvider.overrideWithValue(windowService), + ], + child: BusyMaxApp( + trayServiceFactory: + ({ + required windowService, + required labels, + required onOpenAgenda, + }) => trayService, + ), + ), + ); + await tester.pump(); + await tester.pump(); + + expect(trayService.startCalls, 1); + expect(trayService.available, isTrue); + expect(windowService.hideWindowCalls, 0); + }); + test('production sources avoid forbidden hardcoded accent colors', () { final disallowedHue = String.fromCharCodes([111, 114, 97, 110, 103, 101]); final forbidden = [ @@ -2193,27 +2271,38 @@ class _DelayedSettingsStore implements LocalSettingsStore { class _RecordingWindowService extends LinuxWindowService { var hideWindowCalls = 0; + var showWindowCalls = 0; @override Future hideWindow() async { hideWindowCalls += 1; } + @override + Future showWindow() async { + showWindowCalls += 1; + } + @override Future setHideOnClose(bool enabled) async {} } class _RecordingTrayService extends BusyMaxTrayService { - _RecordingTrayService(LinuxWindowService windowService) - : super( - windowService: windowService, - labels: const BusyMaxTrayLabels( - openBusyMax: 'Open BusyMax', - agenda: 'Agenda', - quitBusyMax: 'Exit', - ), - onOpenAgenda: _noop, - ); + _RecordingTrayService( + LinuxWindowService windowService, { + Future Function() openAgendaCallback = _noop, + }) : _onOpenAgenda = openAgendaCallback, + super( + windowService: windowService, + labels: const BusyMaxTrayLabels( + openBusyMax: 'Open BusyMax', + agenda: 'Agenda', + quitBusyMax: 'Exit', + ), + onOpenAgenda: openAgendaCallback, + ); + + final Future Function() _onOpenAgenda; var startCalls = 0; var _available = false; @@ -2234,6 +2323,8 @@ class _RecordingTrayService extends BusyMaxTrayService { @override Future updateLabels(BusyMaxTrayLabels labels) async {} + + Future openAgenda() => _onOpenAgenda(); } Future _noop() async {} @@ -2246,3 +2337,12 @@ const _missingConfig = BuildConfig( oauthTokenEndpoint: 'https://oauth2.googleapis.com/token', oauthRevocationEndpoint: 'https://oauth2.googleapis.com/revoke', ); + +const _demoConfig = BuildConfig( + googleOAuthClientId: '', + googleOAuthClientSecret: '', + oauthAuthorizationEndpoint: 'https://example.test/authorize', + oauthTokenEndpoint: 'https://example.test/token', + oauthRevocationEndpoint: 'https://example.test/revoke', + useFakeProviderData: true, +); diff --git a/test/demo/demo_profile_test.dart b/test/demo/demo_profile_test.dart index 1926b51..2064b6a 100644 --- a/test/demo/demo_profile_test.dart +++ b/test/demo/demo_profile_test.dart @@ -4,20 +4,19 @@ import 'package:busymax/src/demo/demo_profile.dart'; import 'package:busymax/src/demo/demo_seed.dart'; import 'package:busymax/src/features/auth/data/auth_repository.dart'; import 'package:busymax/src/features/feedback/data/feedback_submission.dart'; -import 'package:busymax/src/features/schedule/application/compact_agenda_data.dart'; import 'package:busymax/src/features/sync/account_sync_operations.dart'; import 'package:busymax/src/google_tasks/oauth/oauth_token_store.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; void main() { - test('demo settings are isolated and disable background effects', () async { + test('demo settings keep tray and disable background effects', () async { final settings = busyMaxDemoSettings(BusyMaxDemoTheme.dark); final store = InMemoryLocalSettingsStore(settings.toJson()); expect(settings.themeModePreference, BusyMaxThemeModePreference.dark); expect(settings.runInBackgroundWhenClosed, isFalse); - expect(settings.showTrayIcon, isFalse); + expect(settings.showTrayIcon, isTrue); expect(settings.startMinimizedToTray, isFalse); expect(settings.notifySyncFailures, isFalse); expect(settings.notifyConflicts, isFalse); @@ -60,12 +59,10 @@ void main() { .then((account) => account.id), busyMaxDemoAccountId, ); - final compactAgenda = await container.read( - compactAgendaDataProvider.future, - ); + final seededTasks = await database.select(database.tasks).get(); expect( - compactAgenda.items.map((item) => item.title), - contains('Product planning'), + seededTasks.map((task) => task.title), + contains('Polish calendar prototype'), ); final authState = await container diff --git a/test/features/schedule/application/compact_agenda_data_test.dart b/test/features/schedule/application/compact_agenda_data_test.dart deleted file mode 100644 index 6ec5e58..0000000 --- a/test/features/schedule/application/compact_agenda_data_test.dart +++ /dev/null @@ -1,183 +0,0 @@ -import 'package:busymax/src/app/app_bootstrap.dart'; -import 'package:busymax/src/features/schedule/application/compact_agenda_data.dart'; -import 'package:busymax/src/features/schedule/application/compact_agenda_snapshot.dart'; -import 'package:busymax/src/schedule/schedule_item.dart'; -import 'package:busymax/src/schedule/schedule_range.dart'; -import 'package:busymax/src/task_providers/task_provider.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:flutter_test/flutter_test.dart'; - -void main() { - test('compact agenda bridge loader does not open the database', () async { - var loadedFromBridge = false; - final expected = _agendaData(); - final container = ProviderContainer( - overrides: [ - databaseProvider.overrideWith((ref) { - throw StateError('compact agenda opened databaseProvider'); - }), - compactAgendaDataLoaderProvider.overrideWithValue((ref, query) async { - loadedFromBridge = true; - expect(query, CompactAgendaQuery.initial); - return expected; - }), - ], - ); - addTearDown(container.dispose); - - final data = await container.read(compactAgendaDataProvider.future); - - expect(loadedFromBridge, isTrue); - expect(data.generatedAt, expected.generatedAt); - expect(data.items.single.title, 'Bridge event'); - }); - - test('compact agenda retries temporary SQLITE_BUSY failures', () async { - var attempts = 0; - final expected = _agendaData(); - final container = ProviderContainer(); - addTearDown(container.dispose); - final provider = FutureProvider((ref) { - return loadCompactAgendaDataWithRetry( - ref, - CompactAgendaQuery.initial, - retryDelays: const [Duration.zero, Duration.zero], - delay: (_) async {}, - loader: (ref, query) async { - attempts += 1; - if (attempts < 3) { - throw Exception('SQLite exception(5): database is locked'); - } - return expected; - }, - ); - }); - - final data = await container.read(provider.future); - - expect(attempts, 3); - expect(data.items.single.title, 'Bridge event'); - }); - - test('compact agenda snapshot round-trips schedule data', () { - final expected = _agendaData( - items: [ - _event( - 'Planning', - start: DateTime(2026, 6, 10, 6), - editorStart: DateTime(2026, 6, 10, 9), - ), - _task('Review notes', start: DateTime(2026, 6, 10, 11)), - ], - ); - - final decoded = decodeCompactAgendaData(encodeCompactAgendaData(expected)); - - expect(decoded.today, expected.today); - expect(decoded.range.end, expected.range.end); - expect(decoded.items, hasLength(2)); - expect(decoded.items[0], isA()); - expect(decoded.items[0].title, 'Planning'); - final event = decoded.items[0] as CalendarScheduleItem; - expect(event.providerRecurringEventId, 'series-master'); - expect(event.recurrence, ['RRULE:FREQ=WEEKLY']); - expect(event.attendees, [ - {'email': 'guest@example.com'}, - ]); - expect(event.start, DateTime(2026, 6, 10, 6)); - expect(event.end, DateTime(2026, 6, 10, 7)); - expect(event.editorStart, DateTime(2026, 6, 10, 9)); - expect(event.editorEnd, DateTime(2026, 6, 10, 10)); - expect(decoded.items[1], isA()); - expect(decoded.items[1].title, 'Review notes'); - expect(decoded.hasSignedInAccounts, isTrue); - expect(decoded.hasSources, isTrue); - }); - - test('compact agenda query snapshot uses stable primitive fields', () { - const query = CompactAgendaQuery( - futureDays: 60, - overdueLimit: 16, - noDateLimit: 24, - ); - - final decoded = decodeCompactAgendaQuery(encodeCompactAgendaQuery(query)); - - expect(decoded.futureDays, 60); - expect(decoded.overdueLimit, 16); - expect(decoded.noDateLimit, 24); - }); -} - -CompactAgendaData _agendaData({List? items}) { - final today = DateTime(2026, 6, 10); - return CompactAgendaData( - today: today, - range: ScheduleRange( - start: today, - end: today.add(const Duration(days: 30)), - ), - items: items ?? [_event('Bridge event', start: today)], - hasMoreOverdueTasks: false, - hasMoreNoDateTasks: false, - hasSignedInAccounts: true, - hasSources: true, - generatedAt: today.add(const Duration(minutes: 5)), - ); -} - -CalendarScheduleItem _event( - String title, { - required DateTime start, - DateTime? editorStart, -}) { - return CalendarScheduleItem( - id: title, - accountId: 'account', - provider: TaskProvider.google, - sourceId: 'calendar', - providerCalendarId: 'provider-calendar', - providerRecurringEventId: 'series-master', - title: title, - allDay: false, - start: start, - end: start.add(const Duration(hours: 1)), - editorStart: editorStart, - editorEnd: editorStart?.add(const Duration(hours: 1)), - startTimeZone: 'America/Vancouver', - endTimeZone: 'America/Vancouver', - location: 'Room 1', - description: 'Description', - descriptionContentType: 'text/plain', - descriptionHtml: '

Description

', - recurrence: const ['RRULE:FREQ=WEEKLY'], - attendees: const [ - {'email': 'guest@example.com'}, - ], - colorHex: '#4477aa', - categories: const ['Work'], - reminderMinutesBeforeStart: const [10], - sourceName: 'Work', - accountDisplayName: 'Account', - accountEmail: 'account@example.com', - ); -} - -TaskScheduleItem _task(String title, {DateTime? start}) { - return TaskScheduleItem( - id: title, - accountId: 'account', - provider: TaskProvider.microsoft, - sourceId: 'tasks', - title: title, - completed: false, - allDay: true, - start: start, - notes: 'Notes', - categories: const ['Blue'], - reminder: start?.subtract(const Duration(minutes: 30)), - sourceName: 'Tasks', - accountDisplayName: 'Account', - accountEmail: 'account@example.com', - ); -} diff --git a/test/features/schedule/application/compact_agenda_sections_test.dart b/test/features/schedule/application/compact_agenda_sections_test.dart deleted file mode 100644 index 36d8033..0000000 --- a/test/features/schedule/application/compact_agenda_sections_test.dart +++ /dev/null @@ -1,218 +0,0 @@ -import 'dart:io'; - -import 'package:busymax/src/features/schedule/application/compact_agenda_sections.dart'; -import 'package:busymax/src/schedule/schedule_item.dart'; -import 'package:busymax/src/task_providers/task_provider.dart'; -import 'package:flutter_test/flutter_test.dart'; - -void main() { - final today = DateTime(2026, 6, 10); - - test('overdue incomplete tasks appear in Overdue', () { - final sections = buildCompactAgendaSections( - today: today, - items: [_task('overdue', start: DateTime(2026, 6, 9))], - ); - - expect(sections, hasLength(1)); - expect(sections.single.kind, CompactAgendaSectionKind.overdue); - expect(sections.single.items.single.title, 'overdue'); - }); - - test('completed tasks are excluded before sectioning', () { - final sections = buildCompactAgendaSections( - today: today, - items: [_task('done', start: today, completed: true)], - ); - - expect(sections, isEmpty); - }); - - test('today items appear under Today day section', () { - final sections = buildCompactAgendaSections( - today: today, - items: [ - _event('standup', start: today.add(const Duration(hours: 9))), - _task('submit', start: today), - ], - ); - - expect(sections.single.kind, CompactAgendaSectionKind.day); - expect(sections.single.day, today); - expect(sections.single.items.map((item) => item.title), [ - 'submit', - 'standup', - ]); - }); - - test('tomorrow items appear under Tomorrow day section', () { - final tomorrow = today.add(const Duration(days: 1)); - final sections = buildCompactAgendaSections( - today: today, - items: [_task('tomorrow', start: tomorrow)], - ); - - expect(sections.single.day, tomorrow); - expect(sections.single.items.single.title, 'tomorrow'); - }); - - test('future items group by day', () { - final friday = today.add(const Duration(days: 2)); - final saturday = today.add(const Duration(days: 3)); - final sections = buildCompactAgendaSections( - today: today, - items: [ - _event('friday event', start: friday), - _task('saturday task', start: saturday), - ], - ); - - expect(sections.map((section) => section.day), [friday, saturday]); - expect( - sections.expand((section) => section.items).map((item) => item.title), - ['friday event', 'saturday task'], - ); - }); - - test('future items are not capped to seven days by sectioning', () { - final later = today.add(const Duration(days: 14)); - final sections = buildCompactAgendaSections( - today: today, - items: [_task('later', start: later)], - ); - - expect(sections.single.kind, CompactAgendaSectionKind.day); - expect(sections.single.day, later); - expect(sections.single.items.single.title, 'later'); - }); - - test('more than 8 overdue tasks are capped', () { - final sections = buildCompactAgendaSections( - today: today, - items: [ - for (var index = 0; index < 10; index += 1) - _task( - 'overdue $index', - start: today.subtract(Duration(days: index + 1)), - ), - ], - ); - - expect(sections.single.items, hasLength(8)); - expect(sections.single.hasMore, isTrue); - }); - - test('overdue limit can be expanded', () { - final sections = buildCompactAgendaSections( - today: today, - overdueLimit: 16, - items: [ - for (var index = 0; index < 10; index += 1) - _task( - 'overdue $index', - start: today.subtract(Duration(days: index + 1)), - ), - ], - ); - - expect(sections.single.items, hasLength(10)); - expect(sections.single.hasMore, isFalse); - }); - - test('no-date tasks are capped and can be expanded', () { - final cappedSections = buildCompactAgendaSections( - today: today, - items: [ - for (var index = 0; index < 10; index += 1) _task('someday $index'), - ], - ); - final expandedSections = buildCompactAgendaSections( - today: today, - noDateLimit: 16, - items: [ - for (var index = 0; index < 10; index += 1) _task('someday $index'), - ], - ); - - expect(cappedSections.single.items, hasLength(8)); - expect(cappedSections.single.hasMore, isTrue); - expect(expandedSections.single.items, hasLength(10)); - expect(expandedSections.single.hasMore, isFalse); - }); - - test('no-date tasks appear in No date section', () { - final sections = buildCompactAgendaSections( - today: today, - items: [_task('someday')], - ); - - expect(sections.single.kind, CompactAgendaSectionKind.noDate); - expect(sections.single.items.single.title, 'someday'); - }); - - test('no-date tasks appear after overdue and before dated sections', () { - final sections = buildCompactAgendaSections( - today: today, - items: [ - _task('dated', start: today), - _task('someday'), - _task('overdue', start: today.subtract(const Duration(days: 1))), - ], - ); - - expect(sections.map((section) => section.kind), [ - CompactAgendaSectionKind.overdue, - CompactAgendaSectionKind.noDate, - CompactAgendaSectionKind.day, - ]); - expect(sections[1].items.single.title, 'someday'); - }); - - test('compact agenda data includes no-date tasks without old events', () { - final source = File( - 'lib/src/features/schedule/application/compact_agenda_data.dart', - ).readAsStringSync(); - - expect(source, contains('showNoDateTasks: false')); - expect(source, contains('compactAgendaInitialDays = 30')); - expect(source, contains('compactAgendaPageDays = 30')); - expect(source, contains('compactAgendaDataForQueryProvider')); - expect(source, contains('repository.listOverdueTasks')); - expect(source, contains('repository.listNoDateTasks')); - expect(source, contains('limit: query.overdueLimit')); - expect(source, contains('limit: query.noDateLimit')); - }); -} - -TaskScheduleItem _task( - String title, { - DateTime? start, - bool completed = false, -}) { - return TaskScheduleItem( - id: title, - accountId: 'account', - provider: TaskProvider.google, - sourceId: 'tasks', - title: title, - completed: completed, - allDay: true, - start: start, - sourceName: 'Inbox', - ); -} - -CalendarScheduleItem _event(String title, {required DateTime start}) { - return CalendarScheduleItem( - id: title, - accountId: 'account', - provider: TaskProvider.google, - sourceId: 'calendar', - providerCalendarId: 'calendar', - title: title, - allDay: false, - start: start, - end: start.add(const Duration(hours: 1)), - sourceName: 'Work', - ); -} diff --git a/test/features/schedule/presentation/compact_agenda_panel_test.dart b/test/features/schedule/presentation/compact_agenda_panel_test.dart deleted file mode 100644 index c42ba01..0000000 --- a/test/features/schedule/presentation/compact_agenda_panel_test.dart +++ /dev/null @@ -1,489 +0,0 @@ -import 'dart:io'; - -import 'package:busymax/src/app/busymax_design.dart'; -import 'package:busymax/src/features/schedule/application/compact_agenda_data.dart'; -import 'package:busymax/src/features/schedule/presentation/compact_agenda_panel.dart'; -import 'package:busymax/src/schedule/schedule_item.dart'; -import 'package:busymax/src/schedule/schedule_range.dart'; -import 'package:busymax/src/task_providers/task_provider.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:flutter/services.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:yaru/yaru.dart'; - -import '../../../test_localized_app.dart'; - -void main() { - final today = DateTime(2026, 6, 10); - - testWidgets('signed-out state shows sign-in and open app message', ( - tester, - ) async { - await tester.pumpWidget( - _testPanel( - data: _data(today, hasSignedInAccounts: false, hasSources: false), - ), - ); - - expect(find.text('Sign in to show agenda.'), findsOneWidget); - expect(find.text('Open BusyMax'), findsWidgets); - }); - - testWidgets('no sources state shows no sources message', (tester) async { - await tester.pumpWidget(_testPanel(data: _data(today, hasSources: false))); - - expect(find.text('No visible calendars or task lists.'), findsOneWidget); - expect(find.text('Open BusyMax'), findsWidgets); - }); - - testWidgets('long error state scrolls without overflowing', (tester) async { - final message = List.filled(160, 'Failure details').join('\n'); - - await tester.pumpWidget( - _testPanel( - data: AsyncError(message, StackTrace.empty), - size: const Size(420, 520), - ), - ); - - expect(tester.takeException(), isNull); - expect(find.text('Agenda unavailable'), findsOneWidget); - }); - - testWidgets('empty state shows positive empty message', (tester) async { - await tester.pumpWidget(_testPanel(data: _data(today))); - - expect(find.text('Clear for now'), findsOneWidget); - expect(find.text('No events or tasks'), findsOneWidget); - }); - - test('compact agenda panel loads more days as the list is scrolled', () { - final source = File( - 'lib/src/features/schedule/presentation/compact_agenda_panel.dart', - ).readAsStringSync(); - - expect( - source, - contains('ref.watch(compactAgendaDataForQueryProvider(_query))'), - ); - expect(source, contains('_loadedDays += compactAgendaPageDays')); - expect(source, contains('metrics.extentAfter > 1')); - expect(source, contains('end: data.range.end')); - }); - - test('new task action stays inside the compact agenda window', () { - final source = File( - 'lib/src/features/schedule/presentation/compact_agenda_panel.dart', - ).readAsStringSync(); - - expect(source, contains('NewTaskEditorPanel')); - expect(source, isNot(contains('MainWindowCommandClient().newTask'))); - expect(source, contains('_creatingTask = true')); - expect(source, isNot(contains('useNativeDatePicker: false'))); - }); - - test('compact agenda uses in-window editors for task and event actions', () { - final source = File( - 'lib/src/features/schedule/presentation/compact_agenda_panel.dart', - ).readAsStringSync(); - - expect(source, contains('NewTaskEditorPanel')); - expect(source, contains('TaskDetailsPane')); - expect(source, contains('EventEditor(')); - expect(source, contains('_openTaskEditor(item)')); - expect(source, contains('_openEventEditor(item)')); - expect(source, contains('_creatingEvent = true')); - expect(source, contains('dialogBarrierColor: Colors.transparent')); - expect(source, isNot(contains('MainWindowCommandClient().newTask'))); - }); - - testWidgets('no-date tasks render in a No date section', (tester) async { - await tester.pumpWidget( - _testPanel(data: _data(today, items: [_task('Plan someday')])), - ); - - expect(find.text('No date'), findsOneWidget); - expect(find.text('Plan someday'), findsOneWidget); - }); - - testWidgets('compact shell has rounded corners', (tester) async { - await tester.pumpWidget(_testPanel(data: _data(today))); - - final clip = tester.widget(find.byType(ClipRRect).first); - - expect(clip.borderRadius, isA()); - }); - - testWidgets('header uses native close control and no open-app chrome', ( - tester, - ) async { - await tester.pumpWidget( - _testPanel( - data: _data(today, items: [_event('Team sync', start: today)]), - ), - ); - - expect(find.byType(YaruWindowControl), findsOneWidget); - expect(find.byIcon(Icons.open_in_full), findsNothing); - expect(find.text('Open BusyMax'), findsNothing); - expect(find.text('New task'), findsOneWidget); - }); - - testWidgets('more overdue row loads overdue tasks in place', (tester) async { - final overdueDay = today.subtract(const Duration(days: 1)); - final items = [ - for (var index = 0; index < 10; index += 1) - _task('Overdue task $index', start: overdueDay), - ]; - - await tester.pumpWidget( - _testPanel( - data: _data(today, items: items), - size: const Size(420, 680), - ), - ); - - expect(find.text('Load more overdue tasks'), findsOneWidget); - expect(find.text('Overdue task 8'), findsNothing); - - await tester.ensureVisible(find.text('Load more overdue tasks')); - await tester.pump(); - await tester.tap(find.text('Load more overdue tasks')); - await tester.pump(); - - expect(find.text('Load more overdue tasks'), findsNothing); - expect(find.text('Overdue task 8'), findsOneWidget); - expect(find.text('Overdue task 9'), findsOneWidget); - }); - - testWidgets('more no-date row loads no-date tasks in place', (tester) async { - final items = [ - for (var index = 0; index < 10; index += 1) _task('Someday task $index'), - ]; - - await tester.pumpWidget( - _testPanel( - data: _data(today, items: items), - size: const Size(420, 680), - ), - ); - - expect(find.text('Load more no-date tasks'), findsOneWidget); - expect(find.text('Someday task 8'), findsNothing); - - await tester.ensureVisible(find.text('Load more no-date tasks')); - await tester.pump(); - await tester.tap(find.text('Load more no-date tasks')); - await tester.pump(); - - expect(find.text('Load more no-date tasks'), findsNothing); - expect(find.text('Someday task 8'), findsOneWidget); - expect(find.text('Someday task 9'), findsOneWidget); - }); - - testWidgets('task row renders checkbox and calls completion callback', ( - tester, - ) async { - var completed = false; - final task = _task('Submit report', start: today); - - await tester.pumpWidget( - _testPanel( - data: _data(today, items: [task]), - onTaskCompletionChanged: (_, value) async { - completed = value; - }, - ), - ); - - expect(find.byType(YaruCheckbox), findsOneWidget); - await tester.tap(find.byType(YaruCheckbox)); - await tester.pump(); - - expect(completed, isTrue); - }); - - testWidgets('event row does not render checkbox', (tester) async { - await tester.pumpWidget( - _testPanel( - data: _data(today, items: [_event('Team sync', start: today)]), - ), - ); - - expect(find.text('Team sync'), findsOneWidget); - expect(find.byType(YaruCheckbox), findsNothing); - }); - - testWidgets('rows use native grouped action row pattern', (tester) async { - final event = _event('Team sync', start: today); - - await tester.pumpWidget(_testPanel(data: _data(today, items: [event]))); - - expect(find.byType(BusyMaxGroupedList), findsWidgets); - expect(find.byType(BusyMaxActionRow), findsWidgets); - expect(find.text('Team sync'), findsOneWidget); - }); - - testWidgets('chrome uses scroll shadows instead of static borders', ( - tester, - ) async { - final items = [ - for (var index = 0; index < 24; index += 1) - _event('Event $index', start: today.add(Duration(minutes: index))), - ]; - - await tester.pumpWidget( - _testPanel( - data: _data(today, items: items), - size: const Size(420, 520), - ), - ); - - final header = tester.widget( - find.byKey(const ValueKey('compactAgendaHeader')), - ); - final footer = tester.widget( - find.byKey(const ValueKey('compactAgendaFooter')), - ); - final headerDecoration = header.decoration! as BoxDecoration; - final footerDecoration = footer.decoration! as BoxDecoration; - - expect(headerDecoration.border, isNull); - expect(footerDecoration.border, isNull); - expect( - tester - .widget( - find.byKey(const ValueKey('compactAgendaTopScrollShadow')), - ) - .opacity, - 0, - ); - expect( - tester - .widget( - find.byKey(const ValueKey('compactAgendaBottomScrollShadow')), - ) - .opacity, - 0, - ); - - await tester.drag(find.byType(ListView), const Offset(0, -180)); - await tester.pumpAndSettle(); - - expect( - tester - .widget( - find.byKey(const ValueKey('compactAgendaTopScrollShadow')), - ) - .opacity, - 1, - ); - expect( - tester - .widget( - find.byKey(const ValueKey('compactAgendaBottomScrollShadow')), - ) - .opacity, - 1, - ); - }); - - testWidgets('row tap calls open-item callback', (tester) async { - ScheduleItem? opened; - final event = _event('Team sync', start: today); - - await tester.pumpWidget( - _testPanel( - data: _data(today, items: [event]), - onOpenItem: (item) async { - opened = item; - }, - ), - ); - - await tester.tap(find.text('Team sync')); - await tester.pump(); - - expect(opened, event); - }); - - testWidgets('default row tap shows details popover in compact window', ( - tester, - ) async { - final event = _event('Team sync', start: today); - - await tester.pumpWidget(_testPanel(data: _data(today, items: [event]))); - - await tester.tap(find.text('Team sync')); - await tester.pumpAndSettle(); - - expect(find.byIcon(YaruIcons.share), findsOneWidget); - expect(find.byIcon(Icons.edit_outlined), findsOneWidget); - expect(find.text('Work'), findsWidgets); - }); - - testWidgets('Escape closes an editor before hiding compact agenda', ( - tester, - ) async { - var hideCalls = 0; - await tester.pumpWidget( - _testPanel( - data: _data(today, canCreateEvents: true), - onHide: () async => hideCalls += 1, - ), - ); - - await tester.tap(find.text('New event')); - await tester.pump(); - expect(find.text('Agenda'), findsNothing); - - await tester.sendKeyEvent(LogicalKeyboardKey.escape); - await tester.pumpAndSettle(); - - expect(hideCalls, 0); - expect(find.text('Agenda'), findsOneWidget); - }); - - testWidgets('equal create actions use neutral standard buttons', ( - tester, - ) async { - await tester.pumpWidget( - _testPanel( - data: _data(today, canCreateEvents: true, canCreateTasks: true), - ), - ); - - final standardButtons = find.byWidgetPredicate( - (widget) => widget is FilledButton, - description: 'standard filled button', - ); - final suggestedButtons = find.byWidgetPredicate( - (widget) => widget is ElevatedButton, - description: 'suggested elevated button', - ); - - expect( - find.ancestor(of: find.text('New event'), matching: standardButtons), - findsOneWidget, - ); - expect( - find.ancestor(of: find.text('New task'), matching: standardButtons), - findsOneWidget, - ); - expect( - find.ancestor(of: find.text('New event'), matching: suggestedButtons), - findsNothing, - ); - expect( - find.ancestor(of: find.text('New task'), matching: suggestedButtons), - findsNothing, - ); - }); - - testWidgets('loading state renders progress and skeleton rows', ( - tester, - ) async { - await tester.pumpWidget( - _testPanel(data: const AsyncLoading()), - ); - - expect(find.byType(LinearProgressIndicator), findsOneWidget); - expect(find.byType(Container), findsWidgets); - }); - - testWidgets('startup one-pixel allocation does not overflow', (tester) async { - await tester.pumpWidget( - _testPanel(data: _data(today), size: const Size(1, 1)), - ); - - expect(tester.takeException(), isNull); - expect(find.text('Agenda'), findsNothing); - }); -} - -Widget _testPanel({ - required AsyncValue data, - Size size = const Size(420, 680), - Future Function(ScheduleItem item)? onOpenItem, - CompactAgendaTaskCompletionCallback? onTaskCompletionChanged, - Future Function()? onHide, -}) { - return ProviderScope( - child: localizedTestApp( - child: Scaffold( - body: SizedBox( - width: size.width, - height: size.height, - child: CompactAgendaPanel( - data: data, - onOpenBusyMax: () async {}, - onNewTask: () async {}, - onRefresh: () async {}, - onHide: onHide ?? () async {}, - onOpenItem: onOpenItem, - onTaskCompletionChanged: onTaskCompletionChanged, - ), - ), - ), - ), - ); -} - -AsyncValue _data( - DateTime today, { - List items = const [], - bool hasMoreOverdueTasks = false, - bool hasMoreNoDateTasks = false, - bool hasSignedInAccounts = true, - bool hasSources = true, - bool canCreateEvents = false, - bool canCreateTasks = false, -}) { - return AsyncData( - CompactAgendaData( - today: today, - range: ScheduleRange( - start: today, - end: today.add(const Duration(days: 30)), - ), - items: items, - hasMoreOverdueTasks: hasMoreOverdueTasks, - hasMoreNoDateTasks: hasMoreNoDateTasks, - hasSignedInAccounts: hasSignedInAccounts, - hasSources: hasSources, - generatedAt: today, - canCreateEvents: canCreateEvents, - canCreateTasks: canCreateTasks, - ), - ); -} - -TaskScheduleItem _task(String title, {DateTime? start}) { - return TaskScheduleItem( - id: title, - accountId: 'account', - provider: TaskProvider.google, - sourceId: 'tasks', - title: title, - completed: false, - allDay: true, - start: start, - sourceName: 'Inbox', - ); -} - -CalendarScheduleItem _event(String title, {required DateTime start}) { - return CalendarScheduleItem( - id: title, - accountId: 'account', - provider: TaskProvider.google, - sourceId: 'calendar', - providerCalendarId: 'calendar', - title: title, - allDay: false, - start: start, - end: start.add(const Duration(hours: 1)), - sourceName: 'Work', - ); -} diff --git a/test/features/schedule/presentation/schedule_views_test.dart b/test/features/schedule/presentation/schedule_views_test.dart index ec44a52..bd69cc5 100644 --- a/test/features/schedule/presentation/schedule_views_test.dart +++ b/test/features/schedule/presentation/schedule_views_test.dart @@ -2592,25 +2592,15 @@ void main() { final agenda = File( 'lib/src/features/schedule/presentation/schedule_agenda_view.dart', ).readAsStringSync(); - final compactAgenda = File( - 'lib/src/features/schedule/presentation/compact_agenda_panel.dart', - ).readAsStringSync(); final design = File('lib/src/app/busymax_design.dart').readAsStringSync(); expect(agenda, contains('BusyMaxGroupedList(')); - expect(compactAgenda, contains('BusyMaxGroupedList(')); expect(agenda, isNot(contains('surfaceColor:'))); - expect(compactAgenda, isNot(contains('surfaceColor:'))); expect(agenda, contains('ScheduleProjection.colorForItem')); - expect(compactAgenda, contains('ScheduleProjection.colorForItem')); expect( agenda, contains('BusyMaxSurfaceColors.of(context).mutedForeground'), ); - expect( - compactAgenda, - contains('BusyMaxSurfaceColors.of(context).mutedForeground'), - ); expect(design, isNot(contains('final Color? surfaceColor;'))); expect(design, isNot(contains('color: color ?? surfaceColors.control'))); expect(design, contains('CardTheme.of(context)')); @@ -3711,22 +3701,13 @@ void main() { final agenda = File( 'lib/src/features/schedule/presentation/schedule_agenda_view.dart', ).readAsStringSync(); - final compactAgenda = File( - 'lib/src/features/schedule/presentation/compact_agenda_panel.dart', - ).readAsStringSync(); expect(agenda, contains('isTask ? YaruIcons.task_list')); - expect(compactAgenda, contains('isTask ? YaruIcons.task_list')); expect(agenda, contains('YaruCheckbox(')); - expect(compactAgenda, contains('YaruCheckbox(')); expect(agenda, isNot(contains('selectedColor:'))); - expect(compactAgenda, isNot(contains('selectedColor:'))); expect(agenda, isNot(contains('checkmarkColor:'))); - expect(compactAgenda, isNot(contains('checkmarkColor:'))); expect(agenda, isNot(contains('YaruCheckboxTheme'))); - expect(compactAgenda, isNot(contains('YaruCheckboxTheme'))); expect(agenda, isNot(contains('YaruIcons.checkbox'))); - expect(compactAgenda, isNot(contains('YaruIcons.checkbox'))); }); test( diff --git a/test/platform/busymax_tray_service_test.dart b/test/platform/busymax_tray_service_test.dart index 558677a..e9b036a 100644 --- a/test/platform/busymax_tray_service_test.dart +++ b/test/platform/busymax_tray_service_test.dart @@ -154,7 +154,7 @@ void main() { ); }); - test('agenda action opens compact agenda without restoring main first', () { + test('agenda action delegates navigation to the app', () { final source = File( 'lib/src/platform/busymax_tray_service.dart', ).readAsStringSync(); @@ -187,7 +187,7 @@ void main() { ), ), ); - expect(source, contains('await _onBeforeQuit?.call();')); + expect(source, isNot(contains('onBeforeQuit'))); }); test('DBus menu exposes layout, properties, and routes stable IDs', () async { diff --git a/test/platform/busymax_window_args_test.dart b/test/platform/busymax_window_args_test.dart deleted file mode 100644 index 81ca72b..0000000 --- a/test/platform/busymax_window_args_test.dart +++ /dev/null @@ -1,58 +0,0 @@ -import 'package:busymax/src/platform/busymax_window_args.dart'; -import 'package:flutter_test/flutter_test.dart'; - -void main() { - test('empty string parses as main window', () { - expect(BusyMaxWindowArgs.parse('').kind, BusyMaxWindowKind.main); - }); - - test('malformed JSON parses as main window', () { - expect(BusyMaxWindowArgs.parse('{bad').kind, BusyMaxWindowKind.main); - }); - - test('compact agenda JSON parses as compact agenda window', () { - final args = BusyMaxWindowArgs.parse( - BusyMaxWindowArgs.compactAgenda.encode(), - ); - - expect(args.kind, BusyMaxWindowKind.compactAgenda); - expect(args.version, BusyMaxWindowArgs.currentVersion); - }); - - test('compact agenda JSON preserves requested position', () { - final args = BusyMaxWindowArgs.parse( - BusyMaxWindowArgs.compactAgendaAt(x: 123, y: 45).encode(), - ); - - expect(args.kind, BusyMaxWindowKind.compactAgenda); - expect(args.requestedPositionX, 123); - expect(args.requestedPositionY, 45); - }); - - test('compact agenda JSON ignores malformed requested position', () { - final args = BusyMaxWindowArgs.parse( - '{"app":"BusyMax","version":1,"kind":"compactAgenda",' - '"position":{"x":"bad","y":45}}', - ); - - expect(args.kind, BusyMaxWindowKind.compactAgenda); - expect(args.requestedPositionX, isNull); - expect(args.requestedPositionY, isNull); - }); - - test('unknown app parses as main window', () { - final args = BusyMaxWindowArgs.parse( - '{"app":"Other","version":1,"kind":"compactAgenda"}', - ); - - expect(args.kind, BusyMaxWindowKind.main); - }); - - test('unknown kind parses as main window', () { - final args = BusyMaxWindowArgs.parse( - '{"app":"BusyMax","version":1,"kind":"unknown"}', - ); - - expect(args.kind, BusyMaxWindowKind.main); - }); -} diff --git a/test/platform/compact_agenda_window_service_test.dart b/test/platform/compact_agenda_window_service_test.dart deleted file mode 100644 index 9195cab..0000000 --- a/test/platform/compact_agenda_window_service_test.dart +++ /dev/null @@ -1,25 +0,0 @@ -import 'package:busymax/src/platform/compact_agenda_window_service.dart'; -import 'package:flutter/widgets.dart'; -import 'package:flutter_test/flutter_test.dart'; - -void main() { - group('compact agenda placement', () { - test('top-right fallback keeps the full window frame on-screen', () { - final position = compactAgendaTopRightWorkAreaPositionForTest( - const Rect.fromLTWH(0, 0, 1920, 1080), - ); - - expect(position.dx, 1430); - expect(position.dy, 6); - }); - - test('top-right fallback clamps to the workarea minimum', () { - final position = compactAgendaTopRightWorkAreaPositionForTest( - const Rect.fromLTWH(0, 0, 320, 240), - ); - - expect(position.dx, 6); - expect(position.dy, 6); - }); - }); -} diff --git a/test/platform/main_window_command_bridge_test.dart b/test/platform/main_window_command_bridge_test.dart deleted file mode 100644 index bc26ea7..0000000 --- a/test/platform/main_window_command_bridge_test.dart +++ /dev/null @@ -1,94 +0,0 @@ -import 'package:busymax/src/features/schedule/application/compact_agenda_data.dart'; -import 'package:busymax/src/features/schedule/application/compact_agenda_snapshot.dart'; -import 'package:busymax/src/platform/main_window_command_bridge.dart'; -import 'package:busymax/src/schedule/schedule_item.dart'; -import 'package:busymax/src/schedule/schedule_range.dart'; -import 'package:busymax/src/task_providers/task_provider.dart'; -import 'package:flutter/widgets.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:flutter_test/flutter_test.dart'; - -void main() { - testWidgets('compact agenda snapshot refreshes a cached query', ( - tester, - ) async { - const query = CompactAgendaQuery.initial; - var current = _agendaData(); - var loadCount = 0; - final container = ProviderContainer( - overrides: [ - compactAgendaDataLoaderProvider.overrideWithValue((ref, query) async { - loadCount += 1; - return current; - }), - ], - ); - final provider = compactAgendaDataForQueryProvider(query); - // Keep the initial value cached so a plain read would reproduce the bug. - final subscription = container.listen(provider, (_, _) {}); - addTearDown(() { - subscription.close(); - container.dispose(); - }); - - final initial = await container.read(provider.future); - expect(initial.items, isEmpty); - expect(loadCount, 1); - - late WidgetRef widgetRef; - await tester.pumpWidget( - UncontrolledProviderScope( - container: container, - child: Consumer( - builder: (context, ref, child) { - widgetRef = ref; - return const SizedBox.shrink(); - }, - ), - ), - ); - - current = _agendaData(items: [_googleTask('New Google task')]); - final encoded = await loadFreshCompactAgendaSnapshot( - widgetRef, - encodeCompactAgendaQuery(query), - ); - final refreshed = decodeCompactAgendaData(encoded); - - expect(refreshed.items, hasLength(1)); - expect(refreshed.items.single.title, 'New Google task'); - expect(loadCount, 2); - - await tester.pumpWidget(const SizedBox.shrink()); - }); -} - -CompactAgendaData _agendaData({List items = const []}) { - final today = DateTime(2026, 7, 21); - return CompactAgendaData( - today: today, - range: ScheduleRange( - start: today, - end: today.add(const Duration(days: 30)), - ), - items: items, - hasMoreOverdueTasks: false, - hasMoreNoDateTasks: false, - hasSignedInAccounts: true, - hasSources: true, - generatedAt: today.add(Duration(minutes: items.length)), - ); -} - -TaskScheduleItem _googleTask(String title) { - return TaskScheduleItem( - id: 'new-google-task', - accountId: 'google-account', - provider: TaskProvider.google, - sourceId: 'google-list', - title: title, - completed: false, - allDay: true, - start: DateTime(2026, 7, 21), - ); -} From 3f94eb3eda279e6a0430c8834ad0168bec5199f4 Mon Sep 17 00:00:00 2001 From: albert Date: Sun, 2 Aug 2026 17:29:41 -0700 Subject: [PATCH 71/73] Refactor native menu handling to remove unused popover-related code and improve menu activation logic --- lib/src/app/busymax_app.dart | 7 - lib/src/app/busymax_design.dart | 1 - .../platform/linux_header_bar_service.dart | 15 - linux/runner/my_application.cc | 549 +++++------------- test/app/native_ui_audit_test.dart | 287 ++++----- test/app/theme_localization_test.dart | 5 +- ...r_bar_configuration_synchronizer_test.dart | 3 - .../linux_header_bar_service_test.dart | 23 +- 8 files changed, 276 insertions(+), 614 deletions(-) diff --git a/lib/src/app/busymax_app.dart b/lib/src/app/busymax_app.dart index b9016db..96199ee 100644 --- a/lib/src/app/busymax_app.dart +++ b/lib/src/app/busymax_app.dart @@ -44,13 +44,6 @@ BusyMaxHeaderBarTheme busyMaxHeaderBarThemeFor( sidebarBackgroundColor: colors.sidebar, foregroundColor: colors.foreground, sidebarBorderColor: colors.sidebarBorder, - popoverBackgroundColor: colors.popover, - menuHoverColor: colors.controlHover, - popoverShadowColor: theme.colorScheme.shadow.withValues( - alpha: - theme.colorScheme.shadow.a * - BusyMaxAlpha.nativeHeaderMenuShadowOpacity, - ), dialogBackgroundColor: colors.dialog, dialogOutlineColor: colors.dialogOutline, modalBarrierColor: colors.shade, diff --git a/lib/src/app/busymax_design.dart b/lib/src/app/busymax_design.dart index 11409d7..5411d1c 100644 --- a/lib/src/app/busymax_design.dart +++ b/lib/src/app/busymax_design.dart @@ -83,7 +83,6 @@ abstract final class BusyMaxAlpha { static const double calendarGridLight = 0.10; static const double calendarGridDark = 0.06; static const double groupedRowLightHoverStrength = 0.50; - static const double nativeHeaderMenuShadowOpacity = 0.30; static const double tooltipBackground = 0.80; static const double tooltipBorder = 0.10; } diff --git a/lib/src/platform/linux_header_bar_service.dart b/lib/src/platform/linux_header_bar_service.dart index 44e4f1c..cd909cb 100644 --- a/lib/src/platform/linux_header_bar_service.dart +++ b/lib/src/platform/linux_header_bar_service.dart @@ -328,9 +328,6 @@ class BusyMaxHeaderBarTheme { required this.sidebarBackgroundColor, required this.foregroundColor, required this.sidebarBorderColor, - required this.popoverBackgroundColor, - required this.menuHoverColor, - required this.popoverShadowColor, required this.dialogBackgroundColor, required this.dialogOutlineColor, required this.modalBarrierColor, @@ -344,9 +341,6 @@ class BusyMaxHeaderBarTheme { final Color sidebarBackgroundColor; final Color foregroundColor; final Color sidebarBorderColor; - final Color popoverBackgroundColor; - final Color menuHoverColor; - final Color popoverShadowColor; final Color dialogBackgroundColor; final Color dialogOutlineColor; final Color modalBarrierColor; @@ -361,9 +355,6 @@ class BusyMaxHeaderBarTheme { 'sidebarBackgroundColor': busyMaxCssColor(sidebarBackgroundColor), 'foregroundColor': busyMaxCssColor(foregroundColor), 'sidebarBorderColor': busyMaxCssColor(sidebarBorderColor), - 'popoverBackgroundColor': busyMaxCssColor(popoverBackgroundColor), - 'menuHoverColor': busyMaxCssColor(menuHoverColor), - 'popoverShadowColor': busyMaxCssColor(popoverShadowColor), 'dialogBackgroundColor': busyMaxCssColor(dialogBackgroundColor), 'dialogOutlineColor': busyMaxCssColor(dialogOutlineColor), 'modalBarrierColor': busyMaxCssColor(modalBarrierColor), @@ -382,9 +373,6 @@ class BusyMaxHeaderBarTheme { other.sidebarBackgroundColor == sidebarBackgroundColor && other.foregroundColor == foregroundColor && other.sidebarBorderColor == sidebarBorderColor && - other.popoverBackgroundColor == popoverBackgroundColor && - other.menuHoverColor == menuHoverColor && - other.popoverShadowColor == popoverShadowColor && other.dialogBackgroundColor == dialogBackgroundColor && other.dialogOutlineColor == dialogOutlineColor && other.modalBarrierColor == modalBarrierColor && @@ -400,9 +388,6 @@ class BusyMaxHeaderBarTheme { sidebarBackgroundColor, foregroundColor, sidebarBorderColor, - popoverBackgroundColor, - menuHoverColor, - popoverShadowColor, dialogBackgroundColor, dialogOutlineColor, modalBarrierColor, diff --git a/linux/runner/my_application.cc b/linux/runner/my_application.cc index ef7d5c3..6281a43 100644 --- a/linux/runner/my_application.cc +++ b/linux/runner/my_application.cc @@ -45,7 +45,6 @@ constexpr char kDefaultHeaderBarBackgroundColor[] = "#272727"; constexpr char kDefaultHeaderBarSidebarBackgroundColor[] = "#393939"; constexpr char kDefaultHeaderBarSidebarBorderColor[] = "rgba(16,16,16,0.35)"; -constexpr char kDefaultHeaderMenuShadowColor[] = "rgba(0,0,0,0.3)"; 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)"; @@ -64,10 +63,7 @@ constexpr gdouble kGtkTooltipContainerInset = 6.0; constexpr char kHeaderControlStyleClass[] = "busymax-header-control"; constexpr char kHeaderOnboardingTextButtonStyleClass[] = "busymax-onboarding-text-button"; -constexpr char kMenuShortcutAttribute[] = "x-busymax-shortcut"; -constexpr char kMenuIconAttribute[] = "x-busymax-icon"; -constexpr char kModelButtonShortcutKey[] = - "busymax-model-button-shortcut"; +constexpr char kMenuAccelAttribute[] = "accel"; constexpr char kLtrIsolateStart[] = "\xE2\x81\xA6"; constexpr char kBidiIsolateEnd[] = "\xE2\x81\xA9"; constexpr char kHeaderSearchEntryStyleClass[] = @@ -91,9 +87,6 @@ constexpr char kNativeTimeZoneRowStyleClass[] = "busymax-time-zone-row"; constexpr gint kNativeTimeZoneDialogWidth = 520; constexpr gint kNativeTimeZoneDialogContentHeight = 420; constexpr size_t kNativeTimeZoneResultLimit = 250; -constexpr char kNativePopoverStyleClass[] = "busymax-native-popover"; -constexpr char kHeaderMenuDepthStyleClass[] = "busymax-header-menu-depth"; -constexpr char kNativeMenuItemStyleClass[] = "busymax-native-menu-item"; struct _MyApplication { GtkApplication parent_instance; @@ -117,9 +110,6 @@ struct _MyApplication { gchar* header_bar_sidebar_background_color; gchar* header_bar_sidebar_border_color; gchar* header_bar_foreground_color; - gchar* header_bar_popover_background_color; - gchar* header_bar_popover_shadow_color; - gchar* header_bar_menu_hover_color; gchar* header_bar_dialog_background_color; gchar* header_bar_dialog_outline_color; gchar* header_bar_modal_barrier_color; @@ -217,110 +207,60 @@ 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 void style_native_popover(GtkWidget* popover) { - if (popover == nullptr || !GTK_IS_POPOVER(popover)) { - return; - } - gtk_style_context_add_class(gtk_widget_get_style_context(popover), - kNativePopoverStyleClass); -} - -static void style_header_menu_popover(GtkWidget* popover) { - style_native_popover(popover); - if (popover == nullptr || !GTK_IS_POPOVER(popover)) { - return; - } - gtk_style_context_add_class(gtk_widget_get_style_context(popover), - kHeaderMenuDepthStyleClass); -} - -static void add_model_button_presentation(GtkWidget* button, - const gchar* icon_name, - const gchar* shortcut) { - if (button == nullptr || !GTK_IS_MODEL_BUTTON(button) || - ((icon_name == nullptr || icon_name[0] == '\0') && - (shortcut == nullptr || shortcut[0] == '\0')) || - g_object_get_data(G_OBJECT(button), kModelButtonShortcutKey) != nullptr) { - return; - } - - GtkWidget* content = gtk_bin_get_child(GTK_BIN(button)); - if (content == nullptr || !GTK_IS_WIDGET(content)) { - return; - } - g_object_ref(content); - gtk_container_remove(GTK_CONTAINER(button), content); - - GtkWidget* row = - gtk_box_new(GTK_ORIENTATION_HORIZONTAL, kHeaderButtonSpacing); - if (icon_name != nullptr && icon_name[0] != '\0') { - GtkWidget* icon = - gtk_image_new_from_icon_name(icon_name, GTK_ICON_SIZE_MENU); - gtk_widget_set_valign(icon, GTK_ALIGN_CENTER); - gtk_box_pack_start(GTK_BOX(row), icon, FALSE, FALSE, 0); +static gchar* gtk_accelerator_from_shortcut_label(const gchar* shortcut) { + if (shortcut == nullptr || shortcut[0] == '\0') { + return nullptr; } - gtk_widget_set_hexpand(content, TRUE); - gtk_box_pack_start(GTK_BOX(row), content, TRUE, TRUE, 0); - if (shortcut != nullptr && shortcut[0] != '\0') { - GtkWidget* shortcut_label = gtk_label_new(shortcut); - gtk_widget_set_direction(shortcut_label, GTK_TEXT_DIR_LTR); - gtk_widget_set_halign(shortcut_label, GTK_ALIGN_END); - gtk_widget_set_valign(shortcut_label, GTK_ALIGN_CENTER); - gtk_label_set_xalign(GTK_LABEL(shortcut_label), 1.0); - gtk_style_context_add_class( - gtk_widget_get_style_context(shortcut_label), "dim-label"); - gtk_box_pack_end(GTK_BOX(row), shortcut_label, FALSE, FALSE, 0); - } - - gtk_container_add(GTK_CONTAINER(button), row); - gtk_widget_show_all(row); - g_object_unref(content); - g_object_set_data(G_OBJECT(button), kModelButtonShortcutKey, - GINT_TO_POINTER(1)); -} - -struct ModelMenuShortcutDecoration { - GMenuModel* model; - gint item_index; -}; - -static void decorate_model_menu_shortcuts_cb(GtkWidget* widget, - gpointer user_data) { - auto* decoration = static_cast(user_data); - if (GTK_IS_MODEL_BUTTON(widget)) { - if (decoration->item_index < - g_menu_model_get_n_items(decoration->model)) { - g_autoptr(GVariant) value = g_menu_model_get_item_attribute_value( - decoration->model, decoration->item_index, kMenuShortcutAttribute, - G_VARIANT_TYPE_STRING); - g_autoptr(GVariant) icon_value = - g_menu_model_get_item_attribute_value( - decoration->model, decoration->item_index, kMenuIconAttribute, - G_VARIANT_TYPE_STRING); - add_model_button_presentation( - widget, - icon_value != nullptr ? g_variant_get_string(icon_value, nullptr) - : nullptr, - value != nullptr ? g_variant_get_string(value, nullptr) : nullptr); + guint key = 0; + GdkModifierType modifiers = static_cast(0); + gtk_accelerator_parse(shortcut, &key, &modifiers); + if (key != 0) { + return g_strdup(shortcut); + } + + gchar** parts = g_strsplit(shortcut, "+", -1); + const gsize part_count = g_strv_length(parts); + GString* accelerator = g_string_new(nullptr); + gboolean valid = part_count > 0; + for (gsize index = 0; valid && index + 1 < part_count; index++) { + const gchar* part = g_strstrip(parts[index]); + if (g_strcmp0(part, "Ctrl") == 0 || + g_strcmp0(part, "Control") == 0) { + g_string_append(accelerator, ""); + } else if (g_strcmp0(part, "Alt") == 0) { + g_string_append(accelerator, ""); + } else if (g_strcmp0(part, "Shift") == 0) { + g_string_append(accelerator, ""); + } else if (g_strcmp0(part, "Super") == 0) { + g_string_append(accelerator, ""); + } else if (g_strcmp0(part, "Meta") == 0) { + g_string_append(accelerator, ""); + } else { + valid = FALSE; } - decoration->item_index++; - return; } - if (GTK_IS_CONTAINER(widget)) { - gtk_container_foreach(GTK_CONTAINER(widget), - decorate_model_menu_shortcuts_cb, user_data); + if (valid) { + const gchar* key_label = g_strstrip(parts[part_count - 1]); + g_string_append(accelerator, + g_strcmp0(key_label, "Esc") == 0 ? "Escape" : key_label); + key = 0; + modifiers = static_cast(0); + gtk_accelerator_parse(accelerator->str, &key, &modifiers); + valid = key != 0; } + + g_strfreev(parts); + return g_string_free(accelerator, !valid); } -static void decorate_model_menu_shortcuts(GtkWidget* popover, - GMenuModel* model) { - if (popover == nullptr || !GTK_IS_CONTAINER(popover) || model == nullptr) { - return; +static void set_menu_item_accelerator(GMenuItem* item, + const gchar* shortcut) { + g_autofree gchar* accelerator = + gtk_accelerator_from_shortcut_label(shortcut); + if (accelerator != nullptr) { + g_menu_item_set_attribute(item, kMenuAccelAttribute, "s", accelerator); } - ModelMenuShortcutDecoration decoration = {model, 0}; - gtk_container_foreach(GTK_CONTAINER(popover), - decorate_model_menu_shortcuts_cb, &decoration); } static GdkPixbuf* load_application_icon_at_size(gint size) { @@ -1427,32 +1367,20 @@ struct NativeMenuSession { NativeMenuHandlerData* owner; gint64 id; size_t entry_count; - GtkWidget* popover; + GtkWidget* menu; GMenu* model; GSimpleActionGroup* action_group; FlMethodCall* method_call; - GPtrArray* shortcut_labels; - GPtrArray* icon_names; - gulong closed_signal_id; + gulong deactivate_signal_id; guint cleanup_source_id; gint pending_selected_index; }; struct NativeMenuHandlerData { GtkWidget* view; - GtkWidget* input_layer; - GtkWidget* menu_layer; - GtkWidget* menu_button; NativeMenuSession* active; }; -struct NativeMenuHostWidgets { - GtkWidget* overlay; - GtkWidget* input_layer; - GtkWidget* menu_layer; - GtkWidget* menu_button; -}; - static void native_menu_session_respond(NativeMenuSession* session, gint selected_index) { if (session->method_call == nullptr) { @@ -1479,35 +1407,30 @@ static void native_menu_session_dispose(NativeMenuSession* session) { g_source_remove(session->cleanup_source_id); session->cleanup_source_id = 0; } - if (session->popover != nullptr) { - if (session->closed_signal_id != 0) { - g_signal_handler_disconnect(session->popover, - session->closed_signal_id); - session->closed_signal_id = 0; + if (session->menu != nullptr) { + if (session->deactivate_signal_id != 0) { + g_signal_handler_disconnect(session->menu, + session->deactivate_signal_id); + session->deactivate_signal_id = 0; } - if (gtk_widget_get_visible(session->popover)) { - gtk_widget_hide(session->popover); + if (gtk_widget_get_visible(session->menu)) { + gtk_menu_shell_deactivate(GTK_MENU_SHELL(session->menu)); } } - if (owner != nullptr && owner->menu_button != nullptr) { - gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(owner->menu_button), - FALSE); - gtk_menu_button_set_menu_model(GTK_MENU_BUTTON(owner->menu_button), - nullptr); - gtk_widget_insert_action_group(owner->menu_button, + if (owner != nullptr && owner->view != nullptr) { + gtk_widget_insert_action_group(owner->view, kNativeMenuActionNamespace, nullptr); } - g_clear_object(&session->popover); - if (owner != nullptr && owner->input_layer != nullptr) { - gtk_widget_hide(owner->input_layer); + if (session->menu != nullptr && GTK_IS_MENU(session->menu) && + gtk_menu_get_attach_widget(GTK_MENU(session->menu)) != nullptr) { + gtk_menu_detach(GTK_MENU(session->menu)); } + g_clear_object(&session->menu); if (owner != nullptr && owner->view != nullptr) { if (gtk_widget_get_realized(owner->view)) { gtk_widget_grab_focus(owner->view); } } - g_clear_pointer(&session->shortcut_labels, g_ptr_array_unref); - g_clear_pointer(&session->icon_names, g_ptr_array_unref); g_clear_object(&session->model); g_clear_object(&session->action_group); // Resolve the Dart future only after the native session is fully retired. @@ -1523,11 +1446,11 @@ static gboolean native_menu_cleanup_idle_cb(gpointer user_data) { return G_SOURCE_REMOVE; } -static void native_menu_closed_cb(GtkPopover*, gpointer user_data) { +static void native_menu_deactivate_cb(GtkMenuShell*, gpointer user_data) { auto* session = static_cast(user_data); if (session->cleanup_source_id == 0) { - // A button can activate immediately before the popover closes. Resolve the - // Dart result after GTK has released its pointer grab. + // GtkMenu deactivates before it invokes the selected GAction. Resolve the + // Dart result after GTK has released its grab and the action has run. session->cleanup_source_id = g_idle_add_full( G_PRIORITY_DEFAULT_IDLE, native_menu_cleanup_idle_cb, session, nullptr); } @@ -1541,9 +1464,6 @@ static void native_menu_action_activated_cb(GSimpleAction* action, GPOINTER_TO_INT( g_object_get_data(G_OBJECT(action), kNativeMenuActionIndexKey)) - 1; - if (session->popover != nullptr) { - gtk_popover_popdown(GTK_POPOVER(session->popover)); - } } static void native_menu_selection_activated_cb(GSimpleAction* action, @@ -1565,9 +1485,6 @@ static void native_menu_selection_activated_cb(GSimpleAction* action, g_simple_action_set_state(action, parameter); session->pending_selected_index = static_cast(parsed); - if (session->popover != nullptr) { - gtk_popover_popdown(GTK_POPOVER(session->popover)); - } } static gboolean native_menu_dismiss_active(NativeMenuHandlerData* data, @@ -1577,9 +1494,8 @@ static gboolean native_menu_dismiss_active(NativeMenuHandlerData* data, return FALSE; } - if (session->popover != nullptr && - gtk_widget_get_visible(session->popover)) { - gtk_popover_popdown(GTK_POPOVER(session->popover)); + if (session->menu != nullptr && gtk_widget_get_visible(session->menu)) { + gtk_menu_shell_deactivate(GTK_MENU_SHELL(session->menu)); } else { native_menu_session_dispose(session); } @@ -1696,58 +1612,11 @@ static gboolean parse_native_menu_anchor(FlValue* args, return TRUE; } -struct NativeMenuShortcutDecoration { - GPtrArray* labels; - GPtrArray* icon_names; - guint index; -}; - -static void decorate_native_menu_shortcuts_cb(GtkWidget* widget, - gpointer user_data) { - auto* decoration = static_cast(user_data); - if (GTK_IS_MODEL_BUTTON(widget)) { - gtk_style_context_add_class(gtk_widget_get_style_context(widget), - kNativeMenuItemStyleClass); - if (decoration->index < decoration->labels->len) { - add_model_button_presentation( - widget, - static_cast( - g_ptr_array_index(decoration->icon_names, decoration->index)), - static_cast( - g_ptr_array_index(decoration->labels, decoration->index))); - } - decoration->index++; - return; - } - if (GTK_IS_CONTAINER(widget)) { - gtk_container_foreach(GTK_CONTAINER(widget), - decorate_native_menu_shortcuts_cb, user_data); - } -} - -static void decorate_native_menu_shortcuts(GtkWidget* popover, - GPtrArray* labels, - GPtrArray* icon_names) { - if (popover == nullptr || !GTK_IS_CONTAINER(popover) || labels == nullptr || - icon_names == nullptr) { - return; - } - NativeMenuShortcutDecoration decoration = {labels, icon_names, 0}; - gtk_container_foreach(GTK_CONTAINER(popover), - decorate_native_menu_shortcuts_cb, &decoration); -} - static void show_native_menu(NativeMenuHandlerData* data, FlMethodCall* method_call, FlValue* args) { - if (data->view == nullptr || data->input_layer == nullptr || - data->menu_layer == nullptr || data->menu_button == nullptr || - !gtk_widget_get_realized(data->view) || - !GTK_IS_FIXED(data->menu_layer) || - gtk_widget_get_parent(data->menu_button) != data->menu_layer || - gtk_widget_get_parent(data->menu_layer) != data->input_layer || - !GTK_IS_EVENT_BOX(data->input_layer) || - !GTK_IS_OVERLAY(gtk_widget_get_parent(data->input_layer))) { + if (data->view == nullptr || !gtk_widget_get_realized(data->view) || + gtk_widget_get_window(data->view) == nullptr) { fl_method_call_respond_error(method_call, "unavailable", "The native menu host is unavailable.", nullptr, nullptr); @@ -1765,6 +1634,21 @@ static void show_native_menu(NativeMenuHandlerData* data, return; } + GtkWidget* toplevel = gtk_widget_get_toplevel(data->view); + GdkWindow* rect_window = + GTK_IS_WINDOW(toplevel) ? gtk_widget_get_window(toplevel) : nullptr; + GdkRectangle window_anchor = anchor; + if (rect_window == nullptr || + !gtk_widget_translate_coordinates( + data->view, toplevel, anchor.x, anchor.y, &window_anchor.x, + &window_anchor.y)) { + fl_method_call_respond_error( + method_call, "unavailable", + "GTK could not translate the menu anchor into window coordinates.", + nullptr, nullptr); + return; + } + FlValue* entries = fl_value_lookup_string(args, "entries"); GtkPositionType preferred_position = GTK_POS_BOTTOM; const gchar* preferred_position_arg = @@ -1838,8 +1722,6 @@ static void show_native_menu(NativeMenuHandlerData* data, FL_METHOD_CALL(g_object_ref(G_OBJECT(method_call))); session->action_group = g_simple_action_group_new(); session->model = g_menu_new(); - session->shortcut_labels = g_ptr_array_new_with_free_func(g_free); - session->icon_names = g_ptr_array_new_with_free_func(g_free); data->active = session; GSimpleAction* selection_action = nullptr; @@ -1899,33 +1781,24 @@ static void show_native_menu(NativeMenuHandlerData* data, 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(session->model, item); - g_ptr_array_add(session->shortcut_labels, - g_strdup(shortcut != nullptr ? shortcut : "")); - g_ptr_array_add(session->icon_names, - g_strdup(icon_name != nullptr ? icon_name : "")); } if (selection_action != nullptr) { g_object_unref(selection_action); } - gtk_fixed_move(GTK_FIXED(data->menu_layer), data->menu_button, anchor.x, - anchor.y); - gtk_widget_set_size_request(data->menu_button, anchor.width, anchor.height); - gtk_widget_show(data->input_layer); - - gtk_menu_button_set_use_popover(GTK_MENU_BUTTON(data->menu_button), TRUE); - gtk_menu_button_set_direction( - GTK_MENU_BUTTON(data->menu_button), - preferred_position == GTK_POS_TOP ? GTK_ARROW_UP : GTK_ARROW_DOWN); + // GTK 3 maps GtkPopover as a Wayland subsurface. Mutter can leave that + // surface's frame callback pending while Flutter's parent surface is idle, + // freezing GDK redraws after hover state changes. GtkMenu is GTK's native + // menu backend and maps as an independent xdg_popup instead. gtk_widget_insert_action_group( - data->menu_button, kNativeMenuActionNamespace, + data->view, kNativeMenuActionNamespace, G_ACTION_GROUP(session->action_group)); - gtk_menu_button_set_menu_model(GTK_MENU_BUTTON(data->menu_button), - G_MENU_MODEL(session->model)); - session->popover = - GTK_WIDGET(gtk_menu_button_get_popover(GTK_MENU_BUTTON(data->menu_button))); - if (session->popover == nullptr) { + session->menu = gtk_menu_new_from_model(G_MENU_MODEL(session->model)); + if (session->menu == nullptr || !GTK_IS_MENU(session->menu)) { fl_method_call_respond_error(method_call, "unavailable", "GTK could not create the native menu.", nullptr, nullptr); @@ -1933,22 +1806,34 @@ static void show_native_menu(NativeMenuHandlerData* data, native_menu_session_dispose(session); return; } - g_object_ref(session->popover); - style_native_popover(session->popover); - gtk_popover_set_position(GTK_POPOVER(session->popover), preferred_position); - gtk_popover_set_constrain_to(GTK_POPOVER(session->popover), - GTK_POPOVER_CONSTRAINT_WINDOW); - gtk_popover_set_modal(GTK_POPOVER(session->popover), TRUE); - decorate_native_menu_shortcuts(session->popover, - session->shortcut_labels, - session->icon_names); - session->closed_signal_id = g_signal_connect( - session->popover, "closed", G_CALLBACK(native_menu_closed_cb), session); - // This is the proven Local History path: let a mapped GtkMenuButton own the - // popup and its Wayland input lifecycle, just like the header-bar menus. - gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(data->menu_button), TRUE); + g_object_ref_sink(session->menu); + gtk_menu_attach_to_widget(GTK_MENU(session->menu), data->view, nullptr); + gtk_widget_show_all(session->menu); + session->deactivate_signal_id = g_signal_connect( + session->menu, "deactivate", G_CALLBACK(native_menu_deactivate_cb), + session); + + const gboolean open_above = preferred_position == GTK_POS_TOP; + g_object_set(session->menu, "anchor-hints", + GDK_ANCHOR_FLIP_Y | GDK_ANCHOR_SLIDE | GDK_ANCHOR_RESIZE, + nullptr); + if (!open_above) { + g_object_set(session->menu, "menu-type-hint", + GDK_WINDOW_TYPE_HINT_DROPDOWN_MENU, nullptr); + } + // Flutter reports view-local coordinates, while FlView is a no-window + // widget whose GdkWindow belongs to the toplevel. Anchor to the translated + // rectangle directly: moving a hidden proxy widget would only queue a later + // size allocation, so an immediate popup would still see its old position. + gtk_menu_popup_at_rect( + GTK_MENU(session->menu), rect_window, &window_anchor, + open_above ? GDK_GRAVITY_NORTH_WEST : GDK_GRAVITY_SOUTH_WEST, + open_above ? GDK_GRAVITY_SOUTH_WEST : GDK_GRAVITY_NORTH_WEST, nullptr); if (focus_first) { - gtk_widget_child_focus(session->popover, GTK_DIR_TAB_FORWARD); + gtk_menu_shell_select_first(GTK_MENU_SHELL(session->menu), TRUE); + } else { + // A pointer-opened menu should not start with a keyboard-selected row. + gtk_menu_shell_deselect(GTK_MENU_SHELL(session->menu)); } } @@ -1962,21 +1847,6 @@ static void native_menu_handler_data_free(gpointer user_data) { G_OBJECT(data->view), reinterpret_cast(&data->view)); } - if (data->input_layer != nullptr) { - g_object_remove_weak_pointer( - G_OBJECT(data->input_layer), - reinterpret_cast(&data->input_layer)); - } - if (data->menu_layer != nullptr) { - g_object_remove_weak_pointer( - G_OBJECT(data->menu_layer), - reinterpret_cast(&data->menu_layer)); - } - if (data->menu_button != nullptr) { - g_object_remove_weak_pointer( - G_OBJECT(data->menu_button), - reinterpret_cast(&data->menu_button)); - } g_free(data); } @@ -2002,71 +1872,23 @@ static void native_menu_method_call_cb(FlMethodChannel*, } } -static NativeMenuHostWidgets create_native_menu_host(FlView* view) { - NativeMenuHostWidgets host = {}; - host.overlay = gtk_overlay_new(); - gtk_container_add(GTK_CONTAINER(host.overlay), GTK_WIDGET(view)); - - host.input_layer = gtk_event_box_new(); - gtk_event_box_set_above_child(GTK_EVENT_BOX(host.input_layer), TRUE); - gtk_event_box_set_visible_window(GTK_EVENT_BOX(host.input_layer), FALSE); - gtk_widget_set_halign(host.input_layer, GTK_ALIGN_FILL); - gtk_widget_set_valign(host.input_layer, GTK_ALIGN_FILL); - gtk_overlay_add_overlay(GTK_OVERLAY(host.overlay), host.input_layer); - - host.menu_layer = gtk_fixed_new(); - gtk_container_add(GTK_CONTAINER(host.input_layer), host.menu_layer); - - host.menu_button = gtk_menu_button_new(); - gtk_widget_set_opacity(host.menu_button, 0); - gtk_widget_set_can_focus(host.menu_button, FALSE); - gtk_widget_set_focus_on_click(host.menu_button, FALSE); - gtk_widget_set_size_request(host.menu_button, 1, 1); - gtk_fixed_put(GTK_FIXED(host.menu_layer), host.menu_button, 0, 0); - - gtk_widget_show(host.menu_button); - gtk_widget_show(host.menu_layer); - // Keep the native input layer unmapped except while a modal menu is open so - // Flutter remains the content input owner at every other time. - gtk_widget_set_no_show_all(host.input_layer, TRUE); - gtk_widget_hide(host.input_layer); - gtk_widget_show(host.overlay); - return host; -} - -static FlMethodChannel* create_native_menu_channel( - FlView* view, - const NativeMenuHostWidgets& host) { +static FlMethodChannel* create_native_menu_channel(FlView* view) { 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)), kNativeMenuChannel, FL_METHOD_CODEC(codec)); auto* data = g_new0(NativeMenuHandlerData, 1); data->view = GTK_WIDGET(view); - data->input_layer = host.input_layer; - data->menu_layer = host.menu_layer; - data->menu_button = host.menu_button; g_object_add_weak_pointer(G_OBJECT(data->view), reinterpret_cast(&data->view)); - g_object_add_weak_pointer( - G_OBJECT(data->input_layer), - reinterpret_cast(&data->input_layer)); - g_object_add_weak_pointer( - G_OBJECT(data->menu_layer), - reinterpret_cast(&data->menu_layer)); - g_object_add_weak_pointer( - G_OBJECT(data->menu_button), - reinterpret_cast(&data->menu_button)); fl_method_channel_set_method_call_handler( channel, native_menu_method_call_cb, data, native_menu_handler_data_free); return channel; } -static void register_native_menus(MyApplication* self, - FlView* view, - const NativeMenuHostWidgets& host) { - self->native_menu_channel = create_native_menu_channel(view, host); +static void register_native_menus(MyApplication* self, FlView* view) { + self->native_menu_channel = create_native_menu_channel(view); } static void respond_success(FlMethodCall* method_call) { @@ -2239,17 +2061,6 @@ static void refresh_header_bar_css(MyApplication* self) { 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_popover_css = - is_css_color_token(self->header_bar_popover_background_color) - ? g_strdup_printf( - "popover.background.%s," - "popover.background.%s:backdrop {" - "background-color: %s;" - "background-image: none;" - "}", - kNativePopoverStyleClass, kNativePopoverStyleClass, - self->header_bar_popover_background_color) - : g_strdup(""); g_autofree gchar* native_dialog_css = g_strdup_printf( ".%s,.%s:backdrop {" "background-color: %s;" @@ -2332,12 +2143,6 @@ static void refresh_header_bar_css(MyApplication* self) { const gboolean use_legacy_yaru_compatibility = !self->header_bar_high_contrast && current_gtk_theme_uses_legacy_yaru_shadow(); - g_autofree gchar* native_menu_geometry_css = g_strdup_printf( - "popover.background.%s .%s {" - "font-size: 0.92em;" - "padding: 2px 6px;" - "}", - kNativePopoverStyleClass, kNativeMenuItemStyleClass); g_autofree gchar* native_search_geometry_css = use_legacy_yaru_compatibility ? g_strdup_printf( @@ -2346,51 +2151,6 @@ static void refresh_header_bar_css(MyApplication* self) { "}", kHeaderSearchEntryStyleClass) : g_strdup(""); - g_autofree gchar* native_menu_state_css = - !self->header_bar_high_contrast && - is_css_color_token(self->header_bar_menu_hover_color) - ? g_strdup_printf( - "popover.background.%s " - "modelbutton:hover:not(:disabled) {" - "background-color: %s;" - "background-image: none;" - "}" - "popover.background.%s " - "row:hover:not(:disabled) {" - "background-color: %s;" - "background-image: none;" - "}" - "popover.background.%s " - ".%s:hover:not(:disabled)," - "popover.background.%s " - ".%s:focus:not(:disabled) {" - "background-color: %s;" - "background-image: none;" - "border-color: transparent;" - "outline-width: 0;" - "}", - kNativePopoverStyleClass, - self->header_bar_menu_hover_color, - kNativePopoverStyleClass, - self->header_bar_menu_hover_color, - kNativePopoverStyleClass, - kNativeMenuItemStyleClass, - kNativePopoverStyleClass, - kNativeMenuItemStyleClass, - self->header_bar_menu_hover_color) - : g_strdup(""); - g_autofree gchar* header_menu_shadow_css = - use_legacy_yaru_compatibility - ? g_strdup_printf( - "popover.background.%s.%s:not(:backdrop) {" - // Preserve Yaru's semantic shadow strength and native - // one-pixel offset, softening only its legacy two-pixel blur. - "box-shadow: 0 1px 3px %s;" - "}", - kNativePopoverStyleClass, kHeaderMenuDepthStyleClass, - css_color_or(self->header_bar_popover_shadow_color, - kDefaultHeaderMenuShadowColor)) - : g_strdup(""); const gchar* tooltip_background = css_color_or( self->header_bar_tooltip_background_color, kDefaultTooltipBackground); const gchar* tooltip_foreground = css_color_or( @@ -2701,10 +2461,6 @@ static void refresh_header_bar_css(MyApplication* self) { "border-color: transparent;" "box-shadow: none;" "}" - "%s" - "%s" - "%s" - "%s" ".busymax-titlebar .%s," ".busymax-titlebar .%s:backdrop {" "background-color: %s;" @@ -2724,8 +2480,6 @@ static void refresh_header_bar_css(MyApplication* self) { kHeaderModalOpenStyleClass, kHeaderModalOpenStyleClass, kHeaderModalOpenStyleClass, kHeaderModalOpenStyleClass, kHeaderModalOpenStyleClass, kHeaderModalOpenStyleClass, - native_popover_css, native_menu_geometry_css, native_menu_state_css, - header_menu_shadow_css, kHeaderModalBarrierStyleClass, kHeaderModalBarrierStyleClass, modal_barrier_color); @@ -2779,12 +2533,6 @@ static void set_header_bar_theme(MyApplication* self, FlValue* args) { 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_popover_background_color, - fl_lookup_string_arg(args, "popoverBackgroundColor")); - set_css_color_field(&self->header_bar_popover_shadow_color, - fl_lookup_string_arg(args, "popoverShadowColor")); - set_css_color_field(&self->header_bar_menu_hover_color, - fl_lookup_string_arg(args, "menuHoverColor")); 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, @@ -3135,9 +2883,10 @@ static void close_header_menu_button(GtkWidget* menu_button) { if (menu_button == nullptr || !GTK_IS_MENU_BUTTON(menu_button)) { return; } - GtkPopover* popover = gtk_menu_button_get_popover(GTK_MENU_BUTTON(menu_button)); - if (popover != nullptr && GTK_IS_POPOVER(popover)) { - gtk_popover_popdown(popover); + 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); @@ -3234,7 +2983,7 @@ static void update_header_view_mode_presentation(MyApplication* self) { static void set_header_menu_button_model(GtkWidget* button, GMenuModel* model, - GtkWidget** tracked_popover) { + GtkWidget** tracked_menu) { if (button == nullptr || !GTK_IS_MENU_BUTTON(button) || model == nullptr) { return; } @@ -3242,19 +2991,18 @@ static void set_header_menu_button_model(GtkWidget* 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_popover != nullptr) { - clear_widget_pointer(tracked_popover); + if (*tracked_menu != nullptr) { + clear_widget_pointer(tracked_menu); } - gtk_menu_button_set_use_popover(GTK_MENU_BUTTON(button), TRUE); + // 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); - GtkPopover* popover = gtk_menu_button_get_popover(GTK_MENU_BUTTON(button)); - if (popover == nullptr || !GTK_IS_POPOVER(popover)) { + GtkMenu* menu = gtk_menu_button_get_popup(GTK_MENU_BUTTON(button)); + if (menu == nullptr || !GTK_IS_MENU(menu)) { return; } - track_widget_pointer(tracked_popover, GTK_WIDGET(popover)); - style_header_menu_popover(GTK_WIDGET(popover)); - gtk_popover_set_position(popover, GTK_POS_BOTTOM); - decorate_model_menu_shortcuts(GTK_WIDGET(popover), model); + track_widget_pointer(tracked_menu, GTK_WIDGET(menu)); } static void append_header_action_item(GMenu* menu, @@ -3266,10 +3014,9 @@ static void append_header_action_item(GMenu* menu, 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); - g_menu_item_set_attribute(item, kMenuIconAttribute, "s", icon_name); } if (shortcut != nullptr && shortcut[0] != '\0') { - g_menu_item_set_attribute(item, kMenuShortcutAttribute, "s", shortcut); + set_menu_item_accelerator(item, shortcut); } g_menu_append_item(menu, item); } @@ -3284,10 +3031,9 @@ static void append_header_view_mode_item(GMenu* menu, 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); - g_menu_item_set_attribute(item, kMenuIconAttribute, "s", icon_name); } if (shortcut != nullptr && shortcut[0] != '\0') { - g_menu_item_set_attribute(item, kMenuShortcutAttribute, "s", shortcut); + set_menu_item_accelerator(item, shortcut); } g_menu_append_item(menu, item); } @@ -4825,10 +4571,6 @@ static void apply_gtk_theme_to_bootstrap_chrome(MyApplication* self) { 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_popover_background_color, - fl_lookup_string_arg(colors, "popover")); - set_css_color_field(&self->header_bar_menu_hover_color, - fl_lookup_string_arg(colors, "controlHover")); set_css_color_field(&self->header_bar_dialog_background_color, fl_lookup_string_arg(colors, "dialog")); } @@ -5130,13 +4872,10 @@ static void my_application_activate(GApplication* application) { set_main_flutter_view_background(self); gtk_widget_show(GTK_WIDGET(view)); - NativeMenuHostWidgets native_menu_host = create_native_menu_host(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), native_menu_host.overlay, TRUE, - TRUE, 0); + gtk_box_pack_start(GTK_BOX(window_content), GTK_WIDGET(view), TRUE, TRUE, 0); gtk_widget_show(window_content); gtk_container_add(GTK_CONTAINER(window), window_content); @@ -5149,7 +4888,7 @@ static void my_application_activate(GApplication* application) { fl_register_plugins(FL_PLUGIN_REGISTRY(view)); register_native_date_time_picker(self, view, window); register_native_dialogs(self, view, window); - register_native_menus(self, view, native_menu_host); + register_native_menus(self, view); register_window_channel(self, view); register_header_bar_channel(self, view); register_gtk_settings_channel(self, view); @@ -5266,9 +5005,6 @@ static void my_application_dispose(GObject* object) { 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_popover_background_color, g_free); - g_clear_pointer(&self->header_bar_popover_shadow_color, g_free); - g_clear_pointer(&self->header_bar_menu_hover_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); @@ -5343,9 +5079,6 @@ static void my_application_init(MyApplication* self) { g_strdup(kDefaultHeaderBarSidebarBackgroundColor); self->header_bar_sidebar_border_color = nullptr; self->header_bar_foreground_color = nullptr; - self->header_bar_popover_background_color = nullptr; - self->header_bar_popover_shadow_color = nullptr; - self->header_bar_menu_hover_color = nullptr; self->header_bar_dialog_background_color = nullptr; self->header_bar_dialog_outline_color = g_strdup(kDefaultDialogOutlineColor); diff --git a/test/app/native_ui_audit_test.dart b/test/app/native_ui_audit_test.dart index 2a7e6ba..a1de802 100644 --- a/test/app/native_ui_audit_test.dart +++ b/test/app/native_ui_audit_test.dart @@ -709,12 +709,20 @@ void main() { ), ); expect(source, isNot(contains('transition: none;'))); - expect(source, contains('gtk_popover_set_position')); + expect(source, isNot(contains('gtk_popover_set_position'))); expect(source, contains('GTK_POS_BOTTOM')); - expect(source, contains('gtk_popover_popdown')); + expect(source, isNot(contains('gtk_popover_popdown'))); expect(source, contains('gtk_menu_button_new()')); - expect(source, contains('gtk_menu_button_set_use_popover')); + 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()')); @@ -737,15 +745,15 @@ void main() { 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, contains('"busymax-native-popover"')); - expect(source, contains('"busymax-header-menu-depth"')); - expect(source, contains('header_bar_popover_background_color')); - expect(source, contains('header_bar_popover_shadow_color')); - expect(source, contains('header_bar_menu_hover_color')); + 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, contains('"popoverBackgroundColor"')); - expect(source, contains('"menuHoverColor"')); - expect(source, contains('"popoverShadowColor"')); + 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"'))); @@ -913,7 +921,7 @@ void main() { 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, contains('header_bar_popover_background_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')); @@ -966,7 +974,7 @@ void main() { ); 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, 2); + 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'))); @@ -1149,11 +1157,13 @@ void main() { 'append_header_action_item(menu, self->header_create_task_label,', ), ); - expect(source, contains('kMenuShortcutAttribute')); - expect(source, contains('kMenuIconAttribute')); + 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('gtk_image_new_from_icon_name(icon_name')); - expect(source, contains('decorate_model_menu_shortcuts')); + 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"'))); @@ -1172,7 +1182,7 @@ void main() { expect(source, isNot(contains('"openMenu"'))); }); - test('Linux content menus use native GTK model buttons on mapped host', () { + 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', @@ -1195,33 +1205,52 @@ void main() { final dispose = nativeMenu.substring(disposeStart, disposeEnd); expect(runner, contains('"busymax/native_menus"')); - expect(nativeMenu, contains('struct NativeMenuHostWidgets')); - expect(nativeMenu, contains('gtk_event_box_set_above_child(')); - expect(nativeMenu, contains('gtk_widget_show(data->input_layer)')); + 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('GTK_IS_MODEL_BUTTON(widget)')); - expect(nativeMenu, contains('gtk_menu_button_set_menu_model(')); - expect(nativeMenu, contains('gtk_menu_button_get_popover(')); + 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->menu_button, kNativeMenuActionNamespace', + ' data->view, kNativeMenuActionNamespace', ), ); - expect(nativeMenu, isNot(contains('gtk_popover_new(data->view)'))); expect( nativeMenu, contains( - 'gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(data->menu_button), TRUE)', + 'gtk_widget_translate_coordinates(\n' + ' data->view, toplevel, anchor.x, anchor.y', ), ); - expect(nativeMenu, contains('style_native_popover(session->popover)')); - expect(nativeMenu, contains('kNativeMenuItemStyleClass')); - expect(nativeMenu, contains('add_model_button_presentation(')); + 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()'))); @@ -1233,48 +1262,52 @@ void main() { expect(nativeMenu, isNot(contains('"object-select-symbolic"'))); expect(nativeMenu, isNot(contains('"radio-symbolic"'))); expect(nativeMenu, isNot(contains('"radio-checked-symbolic"'))); - expect(nativeMenu, contains('gtk_popover_set_modal(')); - expect(nativeMenu, isNot(contains('gtk_popover_popup('))); expect( nativeMenu, contains( - 'gtk_widget_child_focus(session->popover, GTK_DIR_TAB_FORWARD)', + 'gtk_menu_shell_select_first(GTK_MENU_SHELL(session->menu), TRUE)', ), ); - expect(nativeMenu, contains('gtk_overlay_add_overlay(')); expect( nativeMenu, - isNot(contains('gtk_overlay_set_overlay_pass_through(')), + contains('gtk_menu_shell_deselect(GTK_MENU_SHELL(session->menu))'), ); - expect(nativeMenu, contains('gtk_menu_button_new()')); - expect(nativeMenu, isNot(contains('gtk_menu_new_from_model('))); - expect(nativeMenu, isNot(contains('gtk_menu_popup_at_rect('))); + 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( - nativeMenu, - isNot(contains('gtk_widget_show_all(session->popover)')), + dispose, + contains('gtk_menu_shell_deactivate(GTK_MENU_SHELL(session->menu))'), ); - expect(dispose, contains('gtk_widget_hide(session->popover)')); - expect(dispose, contains('gtk_widget_hide(owner->input_layer)')); - expect(dispose, contains('gtk_menu_button_set_menu_model(')); + expect(dispose, contains('gtk_menu_detach(GTK_MENU(session->menu))')); expect(dispose, contains('kNativeMenuActionNamespace, nullptr')); - expect(dispose, isNot(contains('gtk_widget_destroy(session->popover)'))); - expect(dispose, contains('g_clear_object(&session->popover)')); - final hideIndex = dispose.indexOf('gtk_widget_hide(session->popover)'); - final detachIndex = dispose.indexOf('gtk_menu_button_set_menu_model('); + 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(hideIndex, isNonNegative); + expect(deactivateIndex, isNonNegative); expect(detachIndex, isNonNegative); expect(respondIndex, isNonNegative); expect(freeIndex, isNonNegative); - expect(hideIndex, lessThan(detachIndex)); + expect(deactivateIndex, lessThan(detachIndex)); expect(detachIndex, lessThan(respondIndex)); expect(respondIndex, lessThan(freeIndex)); expect( @@ -1283,7 +1316,7 @@ void main() { ); expect( nativeMenu, - contains('"closed", G_CALLBACK(native_menu_closed_cb)'), + contains('g_idle_add_full(\n G_PRIORITY_DEFAULT_IDLE'), ); expect(nativeMenu, isNot(contains('"unmap"'))); expect(nativeMenu, isNot(contains('gtk_dialog_run('))); @@ -1669,14 +1702,9 @@ void main() { expect(headerCssStart, isNonNegative); expect(headerCssEnd, isNonNegative); final headerCss = source.substring(headerCssStart, headerCssEnd); - final nativePopoverCssStart = source.indexOf( - 'g_autofree gchar* native_popover_css =', - ); - final nativePopoverCssEnd = source.indexOf( + final nativeDialogCssStart = source.indexOf( 'g_autofree gchar* native_dialog_css =', - nativePopoverCssStart, ); - final nativeDialogCssStart = nativePopoverCssEnd; final nativeDialogCssEnd = source.indexOf( 'g_autofree gchar* native_time_zone_dialog_css =', nativeDialogCssStart, @@ -1686,16 +1714,10 @@ void main() { 'g_autofree gchar* modal_barrier_color', nativeTimeZoneDialogCssStart, ); - expect(nativePopoverCssStart, isNonNegative); - expect(nativePopoverCssEnd, isNonNegative); expect(nativeDialogCssStart, isNonNegative); expect(nativeDialogCssEnd, isNonNegative); expect(nativeTimeZoneDialogCssStart, isNonNegative); expect(nativeTimeZoneDialogCssEnd, isNonNegative); - final nativePopoverCss = source.substring( - nativePopoverCssStart, - nativePopoverCssEnd, - ); final nativeDialogCss = source.substring( nativeDialogCssStart, nativeDialogCssEnd, @@ -1721,7 +1743,7 @@ void main() { 'g_autofree gchar* native_search_geometry_css =', ); final nativeSearchGeometryCssEnd = source.indexOf( - 'g_autofree gchar* native_menu_state_css =', + 'const gchar* tooltip_background =', nativeSearchGeometryCssStart, ); expect(nativeSearchGeometryCssStart, isNonNegative); @@ -1733,31 +1755,7 @@ void main() { nativeSearchGeometryCssStart, nativeSearchGeometryCssEnd, ); - final nativeMenuStateCssStart = nativeSearchGeometryCssEnd; - final nativeMenuStateCssEnd = source.indexOf( - 'g_autofree gchar* header_menu_shadow_css =', - nativeMenuStateCssStart, - ); - expect(nativeMenuStateCssStart, isNonNegative); - expect(nativeMenuStateCssEnd, greaterThan(nativeMenuStateCssStart)); - final nativeMenuStateCss = source.substring( - nativeMenuStateCssStart, - nativeMenuStateCssEnd, - ); - final headerMenuShadowCssStart = source.indexOf( - 'g_autofree gchar* header_menu_shadow_css =', - ); - final headerMenuShadowCssEnd = source.indexOf( - 'const gchar* tooltip_background =', - headerMenuShadowCssStart, - ); - expect(headerMenuShadowCssStart, isNonNegative); - expect(headerMenuShadowCssEnd, isNonNegative); - final headerMenuShadowCss = source.substring( - headerMenuShadowCssStart, - headerMenuShadowCssEnd, - ); - final tooltipCssStart = headerMenuShadowCssEnd; + final tooltipCssStart = nativeSearchGeometryCssEnd; final tooltipCssEnd = source.indexOf( 'g_autofree gchar* header_focus_css =', tooltipCssStart, @@ -1851,9 +1849,9 @@ void main() { expect(source, contains('"sidebarBackgroundColor"')); expect(source, contains('"sidebarBorderColor"')); expect(source, contains('"foregroundColor"')); - expect(source, contains('"popoverBackgroundColor"')); - expect(source, contains('"menuHoverColor"')); - expect(source, contains('"popoverShadowColor"')); + 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"')); @@ -1873,12 +1871,15 @@ void main() { ); expect( source, - contains('fl_lookup_string_arg(args, "popoverBackgroundColor")'), + isNot(contains('fl_lookup_string_arg(args, "popoverBackgroundColor")')), ); - expect(source, contains('fl_lookup_string_arg(args, "menuHoverColor")')); expect( source, - contains('fl_lookup_string_arg(args, "popoverShadowColor")'), + isNot(contains('fl_lookup_string_arg(args, "menuHoverColor")')), + ); + expect( + source, + isNot(contains('fl_lookup_string_arg(args, "popoverShadowColor")')), ); expect( source, @@ -1896,22 +1897,11 @@ void main() { source, contains('fl_lookup_optional_bool_arg(args, "highContrast"'), ); - expect(source, contains('"popover.background.%s,"')); expect(source, contains('"background-color: %s;"')); - expect(nativePopoverCss, contains('kNativePopoverStyleClass')); - expect(nativePopoverCss, contains('"background-color: %s;"')); - expect( - nativePopoverCss, - isNot(contains('g_strdup_printf("border-color: %s;"')), - ); - expect(nativePopoverCss, isNot(contains('g_strdup("border: none;")'))); - expect(nativePopoverCss, isNot(contains('box-shadow'))); - expect(source, isNot(contains('kNativePopoverShadowCss'))); - expect(nativePopoverCss, isNot(contains('border-radius'))); - expect(nativePopoverCss, isNot(contains('padding'))); - expect(nativePopoverCss, isNot(contains('outline'))); - expect(nativePopoverCss, isNot(contains('modelbutton'))); - expect(nativePopoverCss, isNot(contains('#'))); + 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'), @@ -1927,69 +1917,21 @@ void main() { expect(nativeSearchGeometryCss, isNot(contains('min-height'))); expect(nativeSearchGeometryCss, isNot(contains('#'))); expect(nativeSearchGeometryCss, isNot(contains('rgba('))); - expect(nativeMenuStateCss, contains('!self->header_bar_high_contrast')); - expect( - nativeMenuStateCss, - isNot(contains('use_legacy_yaru_compatibility')), - ); - expect( - nativeMenuStateCss, - contains('is_css_color_token(self->header_bar_menu_hover_color)'), - ); - expect( - nativeMenuStateCss, - contains( - '"popover.background.%s "\n' - ' ' - '"modelbutton:hover:not(:disabled) {"', - ), - ); - expect(nativeMenuStateCss, isNot(contains(':not(:backdrop)'))); - expect(nativeMenuStateCss, isNot(contains('modelbutton.flat'))); - expect(nativeMenuStateCss, contains('"background-color: %s;"')); - expect(nativeMenuStateCss, contains('"background-image: none;"')); - expect(nativeMenuStateCss, contains('".%s:hover:not(:disabled),"')); - expect(nativeMenuStateCss, contains('".%s:focus:not(:disabled) {"')); - expect(nativeMenuStateCss, contains('"border-color: transparent;"')); - expect(nativeMenuStateCss, contains('"outline-width: 0;"')); - expect(nativeMenuStateCss, isNot(contains('"outline-style: none;"'))); - expect(nativeMenuStateCss, contains('self->header_bar_menu_hover_color')); - expect(nativeMenuStateCss, contains('kNativePopoverStyleClass')); - expect(nativeMenuStateCss, isNot(contains('border-radius'))); - expect(nativeMenuStateCss, isNot(contains('"border:'))); - expect(nativeMenuStateCss, isNot(contains('box-shadow'))); - expect(nativeMenuStateCss, isNot(contains('padding'))); - expect(nativeMenuStateCss, isNot(contains('margin'))); - expect(nativeMenuStateCss, isNot(contains('min-height'))); - expect(nativeMenuStateCss, isNot(contains('#'))); - expect(nativeMenuStateCss, isNot(contains('rgba('))); expect(source, isNot(contains('kNativeMenuContentPadding'))); - expect(source, contains('g_autofree gchar* native_menu_geometry_css')); - expect(source, contains('"popover.background.%s .%s {"')); - expect(source, contains('"font-size: 0.92em;"')); - expect(source, contains('"padding: 2px 6px;"')); - expect(source, isNot(contains('kNativeMenuRadioLtrStyleClass'))); - expect(source, isNot(contains('kNativeMenuRadioRtlStyleClass'))); expect( source, - contains( - 'native_popover_css, native_menu_geometry_css, native_menu_state_css,', - ), + isNot(contains('g_autofree gchar* native_menu_geometry_css')), ); expect( - headerMenuShadowCss, - contains('"popover.background.%s.%s:not(:backdrop) {"'), + source, + isNot(contains('g_autofree gchar* native_menu_state_css')), ); - expect(headerMenuShadowCss, contains('"box-shadow: 0 1px 3px %s;"')); expect( - headerMenuShadowCss, - contains('self->header_bar_popover_shadow_color'), + source, + isNot(contains('g_autofree gchar* header_menu_shadow_css')), ); - expect(headerMenuShadowCss, contains('kDefaultHeaderMenuShadowColor')); - expect(headerMenuShadowCss, contains('kNativePopoverStyleClass')); - expect(headerMenuShadowCss, contains('kHeaderMenuDepthStyleClass')); - expect(headerMenuShadowCss, isNot(contains('"border:'))); - expect(headerMenuShadowCss, isNot(contains('border-radius'))); + 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 {"')); @@ -2136,13 +2078,17 @@ void main() { 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, contains('style_native_popover(session->popover)')); + expect(source, isNot(contains('style_native_popover'))); expect(source, isNot(contains('activate_native_menu_host('))); expect( source, - contains('style_header_menu_popover(GTK_WIDGET(popover))'), + contains( + 'gtk_menu_button_set_use_popover(GTK_MENU_BUTTON(button), FALSE)', + ), ); - expect(source, contains('style_native_popover(popover)')); + 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'))); @@ -2289,8 +2235,11 @@ void main() { expect(headerBarService, contains('required this.preferDark')); expect(headerBarService, contains("'preferDark': preferDark")); expect(app, contains('preferDark: theme.brightness == Brightness.dark')); - expect(app, contains('popoverShadowColor: theme.colorScheme.shadow')); - expect(app, contains('BusyMaxAlpha.nativeHeaderMenuShadowOpacity')); + expect(app, isNot(contains('popoverShadowColor:'))); + expect( + app, + isNot(contains('BusyMaxAlpha.nativeHeaderMenuShadowOpacity')), + ); expect(source, contains('static void set_gtk_theme_preference')); expect( source, diff --git a/test/app/theme_localization_test.dart b/test/app/theme_localization_test.dart index 13cae42..cdf1666 100644 --- a/test/app/theme_localization_test.dart +++ b/test/app/theme_localization_test.dart @@ -1731,8 +1731,9 @@ void main() { expect(source, contains('sidebarBackgroundColor: colors.sidebar')); expect(source, contains('foregroundColor: colors.foreground')); expect(source, contains('sidebarBorderColor: colors.sidebarBorder')); - expect(source, contains('popoverBackgroundColor: colors.popover')); - expect(source, contains('menuHoverColor: colors.controlHover')); + 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')); diff --git a/test/platform/linux_header_bar_configuration_synchronizer_test.dart b/test/platform/linux_header_bar_configuration_synchronizer_test.dart index 18dabf8..0e94a97 100644 --- a/test/platform/linux_header_bar_configuration_synchronizer_test.dart +++ b/test/platform/linux_header_bar_configuration_synchronizer_test.dart @@ -133,9 +133,6 @@ BusyMaxHeaderBarConfiguration _configuration({required bool dark}) { sidebarBackgroundColor: dark ? Colors.black : Colors.white, foregroundColor: dark ? Colors.white : Colors.black, sidebarBorderColor: Colors.grey, - popoverBackgroundColor: dark ? Colors.black : Colors.white, - menuHoverColor: dark ? Colors.white12 : Colors.black12, - popoverShadowColor: Colors.black38, dialogBackgroundColor: dark ? Colors.black : Colors.white, dialogOutlineColor: dark ? Colors.white : Colors.white10, modalBarrierColor: Colors.black54, diff --git a/test/platform/linux_header_bar_service_test.dart b/test/platform/linux_header_bar_service_test.dart index 7599781..a118b2d 100644 --- a/test/platform/linux_header_bar_service_test.dart +++ b/test/platform/linux_header_bar_service_test.dart @@ -111,9 +111,6 @@ void main() { sidebarBackgroundColor: Color(0xFF2E2E32), foregroundColor: Color(0xFFFFFFFF), sidebarBorderColor: Color.fromRGBO(0, 0, 6, 0.75), - popoverBackgroundColor: Color(0xFF36363A), - menuHoverColor: Color.fromRGBO(255, 255, 255, 0.14), - popoverShadowColor: Color.fromRGBO(0, 0, 0, 0.3), dialogBackgroundColor: Color(0xFF36363A), dialogOutlineColor: Color.fromRGBO(255, 255, 255, 0.07), modalBarrierColor: Color.fromRGBO(0, 0, 0, 0.32), @@ -200,9 +197,6 @@ void main() { 'sidebarBackgroundColor': '#2E2E32', 'foregroundColor': '#FFFFFF', 'sidebarBorderColor': 'rgba(0,0,6,0.75)', - 'popoverBackgroundColor': '#36363A', - 'menuHoverColor': 'rgba(255,255,255,0.14)', - 'popoverShadowColor': 'rgba(0,0,0,0.30)', 'dialogBackgroundColor': '#36363A', 'dialogOutlineColor': 'rgba(255,255,255,0.07)', 'modalBarrierColor': 'rgba(0,0,0,0.32)', @@ -649,7 +643,7 @@ void main() { 'g_autofree gchar* native_search_geometry_css =', ); final geometryCssEnd = source.indexOf( - 'g_autofree gchar* native_menu_state_css =', + 'const gchar* tooltip_background =', geometryCssStart, ); @@ -790,8 +784,19 @@ void main() { expect(source, isNot(contains('outline-style: none'))); expect(source, isNot(contains('transition: none'))); expect(source, isNot(contains('popover.busymax-header-popover'))); - expect(source, contains('kNativePopoverStyleClass')); - expect(source, contains('style_header_menu_popover(GTK_WIDGET(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( From 272185945bd8b561add01c0e484476b2d89ab4f4 Mon Sep 17 00:00:00 2001 From: albert Date: Mon, 3 Aug 2026 14:04:51 -0700 Subject: [PATCH 72/73] Update snapcraft.yaml to clarify Snap Store listing translations management --- snap/snapcraft.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/snap/snapcraft.yaml b/snap/snapcraft.yaml index 82126bf..e2d0db3 100644 --- a/snap/snapcraft.yaml +++ b/snap/snapcraft.yaml @@ -7,6 +7,7 @@ description: | It supports Google Calendar, Google Tasks, Microsoft Calendar, and Microsoft To Do. +# Snap Store listing translations are managed outside Snapcraft, via the Snap Store web UI. license: Apache-2.0 base: core24 From 42f5118477a326892951cdb91121fe9406393748 Mon Sep 17 00:00:00 2001 From: albert Date: Mon, 3 Aug 2026 14:56:00 -0700 Subject: [PATCH 73/73] Refactor schedule_anchored_popover.dart for improved code readability and formatting --- .../presentation/schedule_anchored_popover.dart | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/lib/src/features/schedule/presentation/schedule_anchored_popover.dart b/lib/src/features/schedule/presentation/schedule_anchored_popover.dart index ad16883..a698fd6 100644 --- a/lib/src/features/schedule/presentation/schedule_anchored_popover.dart +++ b/lib/src/features/schedule/presentation/schedule_anchored_popover.dart @@ -295,10 +295,9 @@ class _SchedulePopoverLayout { 1.0, safeViewportWidth - safeHorizontalMargin * 2, ); - final width = math.min(preferredWidth, availableWidth).clamp( - 1.0, - availableWidth, - ); + final width = math + .min(preferredWidth, availableWidth) + .clamp(1.0, availableWidth); final maximumLeft = math.max( safeHorizontalMargin, safeViewportWidth - width - safeHorizontalMargin, @@ -335,8 +334,10 @@ class _SchedulePopoverLayout { final arrowAlignment = width <= 0 ? 0.5 : ((anchor.center.dx - left) / width).clamp(0.08, 0.92).toDouble(); - final resolvedMaximumHeight = - math.max(1.0, showBelow ? spaceBelow : spaceAbove); + final resolvedMaximumHeight = math.max( + 1.0, + showBelow ? spaceBelow : spaceAbove, + ); return _SchedulePopoverLayout( anchor: anchor, left: left,

;1W?FN~`8zhbWz#!ruAxXP&YuWWC() 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 Q1ONa407*qoM6N<$f+NtV+yDRo diff --git a/docs/screenshots/agenda_window_agenda_event_details.png b/docs/screenshots/agenda_window_agenda_event_details.png deleted file mode 100644 index bb5afac35173bd2486882833354330837321f016..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 264325 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�zLQ}_o(PShr90`Y|A!+~bW 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 lzv-bQ8H&niQAJu!S*<8HxE`e&ejX`5$ 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