From 462d562ac8b70e8060ac819ca55a02c487a213df Mon Sep 17 00:00:00 2001 From: GabrielDuf Date: Thu, 9 Jul 2026 09:53:09 -0400 Subject: [PATCH 01/10] Replace top notification banners with bottom-right toasts --- .../AvaloniaOperationRegistry.cs | 45 ++------- .../WindowsAppNotificationBridge.cs | 20 ++-- .../ViewModels/MainWindowViewModel.cs | 93 ++++++++++++++++++- .../SettingsPages/NotificationsViewModel.cs | 5 + .../Views/Controls/InfoBar.axaml | 28 ++++-- src/UniGetUI.Avalonia/Views/MainWindow.axaml | 63 +++++++------ .../Views/MainWindow.axaml.cs | 31 +++++-- .../Pages/SettingsPages/Notifications.axaml | 50 +++++----- 8 files changed, 224 insertions(+), 111 deletions(-) diff --git a/src/UniGetUI.Avalonia/Infrastructure/AvaloniaOperationRegistry.cs b/src/UniGetUI.Avalonia/Infrastructure/AvaloniaOperationRegistry.cs index ae2452a0c..efd86355c 100644 --- a/src/UniGetUI.Avalonia/Infrastructure/AvaloniaOperationRegistry.cs +++ b/src/UniGetUI.Avalonia/Infrastructure/AvaloniaOperationRegistry.cs @@ -196,19 +196,8 @@ private static void ShowOperationProgressNotification(AbstractOperation op) $"{title}. {message}", AutomationLiveSetting.Polite); - if (OperatingSystem.IsWindows() && WindowsAppNotificationBridge.ShowProgress(op)) - return; - - if (OperatingSystem.IsMacOS() && MacOsNotificationBridge.ShowProgress(op)) - return; - - if (TryGetMainWindow() is not { } mainWindow) - return; - - mainWindow.ShowRuntimeNotification( - title, - message, - UniGetUI.Avalonia.Views.MainWindow.RuntimeNotificationLevel.Progress); + if (OperatingSystem.IsWindows()) WindowsAppNotificationBridge.ShowProgress(op); + else if (OperatingSystem.IsMacOS()) MacOsNotificationBridge.ShowProgress(op); } private static void ShowOperationSuccessNotification(AbstractOperation op) @@ -230,11 +219,8 @@ private static void ShowOperationSuccessNotification(AbstractOperation op) WindowsAppNotificationBridge.RemoveProgress(op); - if (OperatingSystem.IsWindows() && WindowsAppNotificationBridge.ShowSuccess(op)) - return; - - if (OperatingSystem.IsMacOS() && MacOsNotificationBridge.ShowSuccess(op)) - return; + if (OperatingSystem.IsWindows()) WindowsAppNotificationBridge.ShowSuccess(op); + else if (OperatingSystem.IsMacOS()) MacOsNotificationBridge.ShowSuccess(op); } private static void ShowOperationFailureNotification(AbstractOperation op) @@ -256,27 +242,8 @@ private static void ShowOperationFailureNotification(AbstractOperation op) WindowsAppNotificationBridge.RemoveProgress(op); - if (OperatingSystem.IsWindows() && WindowsAppNotificationBridge.ShowError(op)) - return; - - if (OperatingSystem.IsMacOS() && MacOsNotificationBridge.ShowError(op)) - return; - - if (TryGetMainWindow() is not { } mainWindow) - return; - - mainWindow.ShowRuntimeNotification( - title, - message, - UniGetUI.Avalonia.Views.MainWindow.RuntimeNotificationLevel.Error); - } - - private static UniGetUI.Avalonia.Views.MainWindow? TryGetMainWindow() - { - return Application.Current?.ApplicationLifetime - is IClassicDesktopStyleApplicationLifetime { MainWindow: UniGetUI.Avalonia.Views.MainWindow mw } - ? mw - : null; + if (OperatingSystem.IsWindows()) WindowsAppNotificationBridge.ShowError(op); + else if (OperatingSystem.IsMacOS()) MacOsNotificationBridge.ShowError(op); } private static void AppendOperationHistory(AbstractOperation op) diff --git a/src/UniGetUI.Avalonia/Infrastructure/WindowsAppNotificationBridge.cs b/src/UniGetUI.Avalonia/Infrastructure/WindowsAppNotificationBridge.cs index fb3fe991d..9bfe70a95 100644 --- a/src/UniGetUI.Avalonia/Infrastructure/WindowsAppNotificationBridge.cs +++ b/src/UniGetUI.Avalonia/Infrastructure/WindowsAppNotificationBridge.cs @@ -18,9 +18,10 @@ namespace UniGetUI.Avalonia.Infrastructure; /// single-instance handler foregrounds the window. The inline action buttons carried /// by the old WinRT toasts are intentionally dropped; activation is surfaced via /// only when the app is already running and the toast -/// is clicked, so the main-window view model keeps its handler. On non-Windows, or if -/// the toast COM surface is unavailable, every call falls back to the in-app Avalonia -/// banner via . +/// is clicked, so the main-window view model keeps its handler. When the toast COM surface +/// is unavailable, feature notifications fall back to the in-app Avalonia banner via +/// ; per-operation results do not (they +/// already appear in the operations panel — see the allowInAppFallback flag on Show). /// internal static class WindowsAppNotificationBridge { @@ -64,7 +65,7 @@ public static bool ShowProgress(AbstractOperation operation) ? operation.Metadata.Status : CoreTools.Translate("Please wait..."); - return Show(title, message, MainWindow.RuntimeNotificationLevel.Progress, launchAction: NotificationArguments.Show); + return Show(title, message, MainWindow.RuntimeNotificationLevel.Progress, launchAction: NotificationArguments.Show, allowInAppFallback: false); } public static bool ShowSuccess(AbstractOperation operation) @@ -77,7 +78,7 @@ public static bool ShowSuccess(AbstractOperation operation) ? operation.Metadata.SuccessMessage : CoreTools.Translate("Success!"); - return Show(title, message, MainWindow.RuntimeNotificationLevel.Success, launchAction: NotificationArguments.Show); + return Show(title, message, MainWindow.RuntimeNotificationLevel.Success, launchAction: NotificationArguments.Show, allowInAppFallback: false); } public static bool ShowError(AbstractOperation operation) @@ -90,7 +91,7 @@ public static bool ShowError(AbstractOperation operation) ? operation.Metadata.FailureMessage : CoreTools.Translate("An error occurred while processing this package"); - return Show(title, message, MainWindow.RuntimeNotificationLevel.Error, launchAction: NotificationArguments.Show); + return Show(title, message, MainWindow.RuntimeNotificationLevel.Error, launchAction: NotificationArguments.Show, allowInAppFallback: false); } public static void RemoveProgress(AbstractOperation operation) @@ -258,12 +259,15 @@ public static void ShowNewShortcutsNotification(IReadOnlyList shortcuts) /// argument so the single-instance handler can route the activation when the toast /// is clicked. Returns true once the notification has been surfaced (toast or /// banner), so callers skip their own fallback banner and avoid double-showing. + /// is false for per-operation results: + /// those already live in the operations panel, so we don't duplicate them as a toast. /// private static bool Show( string title, string message, MainWindow.RuntimeNotificationLevel level, - string launchAction) + string launchAction, + bool allowInAppFallback = true) { #if WINDOWS if (OperatingSystem.IsWindows() && Win32ToastNotifier.IsAvailable()) @@ -281,7 +285,7 @@ private static bool Show( } #endif - return ShowInAppBanner(title, message, level); + return allowInAppFallback && ShowInAppBanner(title, message, level); } private static bool ShowInAppBanner( diff --git a/src/UniGetUI.Avalonia/ViewModels/MainWindowViewModel.cs b/src/UniGetUI.Avalonia/ViewModels/MainWindowViewModel.cs index 6adf89033..3316780de 100644 --- a/src/UniGetUI.Avalonia/ViewModels/MainWindowViewModel.cs +++ b/src/UniGetUI.Avalonia/ViewModels/MainWindowViewModel.cs @@ -1,3 +1,4 @@ +using System.Collections.ObjectModel; using System.Collections.Specialized; using Avalonia; using Avalonia.Automation; @@ -169,12 +170,95 @@ private void OnPageViewModelPropertyChanged(object? sender, System.ComponentMode ? $"UniGetUI {CoreTools.Translate("version {0}", CoreData.VersionName)}" : "UniGetUI"; - // ─── Banners ───────────────────────────────────────────────────────────── + // ─── Notifications (bottom-right toasts) ─────────────────────────────────── + // Persistent banners kept as singletons; RegisterBannerToast mirrors their IsOpen into Toasts. public InfoBarViewModel UpdatesBanner { get; } = new() { Severity = InfoBarSeverity.Success }; - public InfoBarViewModel ErrorBanner { get; } = new() { Severity = InfoBarSeverity.Error }; public InfoBarViewModel WinGetWarningBanner { get; } = new() { Severity = InfoBarSeverity.Warning }; public InfoBarViewModel TelemetryWarner { get; } = new() { Severity = InfoBarSeverity.Informational }; + // Oldest first (rendered bottom-up so the newest sits nearest the corner). + public ObservableCollection Toasts { get; } = new(); + private readonly Dictionary _toastTimers = new(); + + // A toast with an action button stays until acted on; everything else auto-dismisses. + private static bool IsSticky(InfoBarViewModel t) => !string.IsNullOrEmpty(t.ActionButtonText); + private static TimeSpan ToastDuration(InfoBarViewModel t) + => t.Severity is InfoBarSeverity.Error or InfoBarSeverity.Warning + ? TimeSpan.FromSeconds(8) + : TimeSpan.FromSeconds(5); + + public void ShowToast(InfoBarViewModel toast) + { + if (!Dispatcher.UIThread.CheckAccess()) + { + Dispatcher.UIThread.Post(() => ShowToast(toast)); + return; + } + if (!Toasts.Contains(toast)) + Toasts.Add(toast); + ArmToastTimer(toast); + } + + public void DismissToast(InfoBarViewModel toast) + { + if (!Dispatcher.UIThread.CheckAccess()) + { + Dispatcher.UIThread.Post(() => DismissToast(toast)); + return; + } + DisposeToastTimer(toast); + Toasts.Remove(toast); + } + + // Pause the auto-dismiss countdown while the pointer hovers a toast; restart it on leave. + public void PauseToast(InfoBarViewModel toast) + { + if (_toastTimers.TryGetValue(toast, out var timer)) timer.Stop(); + } + + public void ResumeToast(InfoBarViewModel toast) + { + if (_toastTimers.TryGetValue(toast, out var timer)) { timer.Stop(); timer.Start(); } + } + + private void ArmToastTimer(InfoBarViewModel toast) + { + DisposeToastTimer(toast); + if (IsSticky(toast)) + return; + + var timer = new DispatcherTimer { Interval = ToastDuration(toast) }; + timer.Tick += (_, _) => toast.IsOpen = false; + _toastTimers[toast] = timer; + timer.Start(); + } + + private void DisposeToastTimer(InfoBarViewModel toast) + { + if (_toastTimers.Remove(toast, out var timer)) + timer.Stop(); + } + + // Mirrors a persistent banner's IsOpen into Toasts so code that just flips IsOpen keeps working. + private void RegisterBannerToast(InfoBarViewModel banner) + { + banner.PropertyChanged += (_, e) => + { + if (e.PropertyName == nameof(InfoBarViewModel.IsOpen)) + { + if (banner.IsOpen) ShowToast(banner); + else DismissToast(banner); + } + // Content can change in place while the banner stays open (the updater cycles + // statuses on the same banner, finally adding the "Update now" action); re-arm so + // stickiness/countdown track the new content instead of a stale timer dismissing it. + else if (banner.IsOpen && Toasts.Contains(banner)) + { + ArmToastTimer(banner); + } + }; + } + // ─── Constructor ───────────────────────────────────────────────────────── [RelayCommand] private void ToggleSidebar() => Sidebar.IsPaneOpen = !Sidebar.IsPaneOpen; @@ -183,6 +267,11 @@ public MainWindowViewModel() { AccessibilityAnnouncementService.AnnouncementRequested += OnAnnouncementRequested; + // Wire before the blocks below flip the banners' IsOpen flags. + RegisterBannerToast(UpdatesBanner); + RegisterBannerToast(WinGetWarningBanner); + RegisterBannerToast(TelemetryWarner); + DiscoverPage = new DiscoverSoftwarePage(); UpdatesPage = new SoftwareUpdatesPage(); InstalledPage = new InstalledPackagesPage(); diff --git a/src/UniGetUI.Avalonia/ViewModels/Pages/SettingsPages/NotificationsViewModel.cs b/src/UniGetUI.Avalonia/ViewModels/Pages/SettingsPages/NotificationsViewModel.cs index 4375b1d55..76dc13883 100644 --- a/src/UniGetUI.Avalonia/ViewModels/Pages/SettingsPages/NotificationsViewModel.cs +++ b/src/UniGetUI.Avalonia/ViewModels/Pages/SettingsPages/NotificationsViewModel.cs @@ -13,6 +13,11 @@ public partial class NotificationsViewModel : ViewModelBase /// True when the system-tray-disabled warning should be shown. public bool IsSystemTrayWarningVisible => !IsSystemTrayEnabled; + // Per-type notifications are delivered only by the Windows/macOS bridges; Linux has no + // delivery path, so the whole "Notification types" group is hidden there. + public bool AreNotificationTypesSupported { get; } = + OperatingSystem.IsWindows() || OperatingSystem.IsMacOS(); + public NotificationsViewModel() { _isSystemTrayEnabled = !CoreSettings.Get(CoreSettings.K.DisableSystemTray); diff --git a/src/UniGetUI.Avalonia/Views/Controls/InfoBar.axaml b/src/UniGetUI.Avalonia/Views/Controls/InfoBar.axaml index e068b8328..927e2e65c 100644 --- a/src/UniGetUI.Avalonia/Views/Controls/InfoBar.axaml +++ b/src/UniGetUI.Avalonia/Views/Controls/InfoBar.axaml @@ -23,6 +23,22 @@ + + - + + VerticalAlignment="Top"> - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + diff --git a/src/UniGetUI.Avalonia/Views/MainWindow.axaml.cs b/src/UniGetUI.Avalonia/Views/MainWindow.axaml.cs index 8d80005f7..e4409a967 100644 --- a/src/UniGetUI.Avalonia/Views/MainWindow.axaml.cs +++ b/src/UniGetUI.Avalonia/Views/MainWindow.axaml.cs @@ -1254,6 +1254,7 @@ public void FocusGlobalSearch(string prefill = "") } // ─── Public API (legacy compat) ─────────────────────────────────────────── + // Surfaces a fresh, auto-dismissing toast per call (name kept for pre-toast callers). public void ShowBanner(string title, string message, RuntimeNotificationLevel level) { if (level == RuntimeNotificationLevel.Progress) return; @@ -1264,12 +1265,30 @@ public void ShowBanner(string title, string message, RuntimeNotificationLevel le RuntimeNotificationLevel.Success => InfoBarSeverity.Success, _ => InfoBarSeverity.Informational, }; - ViewModel.ErrorBanner.ActionButtonText = ""; - ViewModel.ErrorBanner.ActionButtonCommand = null; - ViewModel.ErrorBanner.Title = title; - ViewModel.ErrorBanner.Message = message; - ViewModel.ErrorBanner.Severity = severity; - ViewModel.ErrorBanner.IsOpen = true; + + var toast = new InfoBarViewModel + { + Title = title, + Message = message, + Severity = severity, + IsClosable = true, + IsOpen = true, + }; + toast.OnClosed = () => ViewModel.DismissToast(toast); + ViewModel.ShowToast(toast); + } + + // Pause/resume a toast's auto-dismiss countdown while the pointer is over it. + private void Toast_PointerEntered(object? sender, PointerEventArgs e) + { + if ((sender as Control)?.DataContext is InfoBarViewModel vm) + ViewModel.PauseToast(vm); + } + + private void Toast_PointerExited(object? sender, PointerEventArgs e) + { + if ((sender as Control)?.DataContext is InfoBarViewModel vm) + ViewModel.ResumeToast(vm); } public void UpdateSystemTrayStatus() => _trayService?.UpdateStatus(); diff --git a/src/UniGetUI.Avalonia/Views/Pages/SettingsPages/Notifications.axaml b/src/UniGetUI.Avalonia/Views/Pages/SettingsPages/Notifications.axaml index 5bd97e2ac..57e08d246 100644 --- a/src/UniGetUI.Avalonia/Views/Pages/SettingsPages/Notifications.axaml +++ b/src/UniGetUI.Avalonia/Views/Pages/SettingsPages/Notifications.axaml @@ -33,33 +33,37 @@ CornerRadius="8" IsEnabled="{Binding IsSystemTrayWarningVisible, Converter={x:Static BoolConverters.Not}}"/> - + + + + - + - + - + - + + From a11ab48d63d8293b9dd469971472091dfd82a527 Mon Sep 17 00:00:00 2001 From: GabrielDuf Date: Thu, 9 Jul 2026 10:39:10 -0400 Subject: [PATCH 02/10] Badge the operations chevron when a collapsed panel has a failed op --- .../ViewModels/MainWindowViewModel.cs | 17 ++++++++++++++++- src/UniGetUI.Avalonia/Views/MainWindow.axaml | 8 ++++++++ 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/src/UniGetUI.Avalonia/ViewModels/MainWindowViewModel.cs b/src/UniGetUI.Avalonia/ViewModels/MainWindowViewModel.cs index 3316780de..9054484cc 100644 --- a/src/UniGetUI.Avalonia/ViewModels/MainWindowViewModel.cs +++ b/src/UniGetUI.Avalonia/ViewModels/MainWindowViewModel.cs @@ -64,13 +64,21 @@ public partial class MainWindowViewModel : ViewModelBase public AvaloniaList Operations => AvaloniaOperationRegistry.OperationViewModels; [ObservableProperty] + [NotifyPropertyChangedFor(nameof(ShowFailedOperationBadge))] private bool _operationsPanelVisible; [ObservableProperty] + [NotifyPropertyChangedFor(nameof(ShowFailedOperationBadge))] private bool _operationsPanelExpanded = true; private readonly List _operationBatch = new(); + public bool HasFailedOperations => Operations.Any(o => o.Operation.Status is OperationStatus.Failed); + + // Red badge on the chevron when the panel is collapsed and an operation failed + // (failures no longer pop a toast; expanded, the failure is already visible). + public bool ShowFailedOperationBadge => OperationsPanelVisible && !OperationsPanelExpanded && HasFailedOperations; + public string OperationsHeaderText { get @@ -102,7 +110,12 @@ private void AddToBatch(AbstractOperation op) } private void OnOperationStatusChanged(object? sender, OperationStatus e) - => Dispatcher.UIThread.Post(() => OnPropertyChanged(nameof(OperationsHeaderText))); + => Dispatcher.UIThread.Post(() => + { + OnPropertyChanged(nameof(OperationsHeaderText)); + OnPropertyChanged(nameof(HasFailedOperations)); + OnPropertyChanged(nameof(ShowFailedOperationBadge)); + }); [RelayCommand] private void ToggleOperationsPanel() => OperationsPanelExpanded = !OperationsPanelExpanded; @@ -353,6 +366,8 @@ public MainWindowViewModel() AddToBatch(vm.Operation); OperationsPanelVisible = Operations.Count > 0; OnPropertyChanged(nameof(OperationsHeaderText)); + OnPropertyChanged(nameof(HasFailedOperations)); + OnPropertyChanged(nameof(ShowFailedOperationBadge)); }; if (OperatingSystem.IsWindows() && CoreTools.IsAdministrator() && !Settings.Get(Settings.K.AlreadyWarnedAboutAdmin)) diff --git a/src/UniGetUI.Avalonia/Views/MainWindow.axaml b/src/UniGetUI.Avalonia/Views/MainWindow.axaml index cd0a405eb..e7d9b0b79 100644 --- a/src/UniGetUI.Avalonia/Views/MainWindow.axaml +++ b/src/UniGetUI.Avalonia/Views/MainWindow.axaml @@ -158,6 +158,14 @@ automation:AutomationProperties.AccessibilityView="Raw" IsVisible="{Binding !OperationsPanelExpanded}" Data="M0,6 L5,0 L10,6"/> + + From 031420d6a5b9bc98265048059071d67dc135e6a2 Mon Sep 17 00:00:00 2001 From: GabrielDuf Date: Thu, 9 Jul 2026 10:49:57 -0400 Subject: [PATCH 03/10] Honor reduced-motion for the toast entrance animation --- src/UniGetUI.Avalonia/Views/Controls/InfoBar.axaml | 7 ++++--- src/UniGetUI.Avalonia/Views/Controls/InfoBar.axaml.cs | 5 +++++ 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/src/UniGetUI.Avalonia/Views/Controls/InfoBar.axaml b/src/UniGetUI.Avalonia/Views/Controls/InfoBar.axaml index 927e2e65c..066b99ed1 100644 --- a/src/UniGetUI.Avalonia/Views/Controls/InfoBar.axaml +++ b/src/UniGetUI.Avalonia/Views/Controls/InfoBar.axaml @@ -23,9 +23,10 @@ - -