diff --git a/src/Languages/lang_en.json b/src/Languages/lang_en.json index a6f885972..232ab1df4 100644 --- a/src/Languages/lang_en.json +++ b/src/Languages/lang_en.json @@ -850,5 +850,9 @@ "Log in failed: ": "Log in failed: ", "Log out failed: ": "Log out failed: ", "Package backup settings": "Package backup settings", - "Show the release notes after UniGetUI is updated": "Show the release notes after UniGetUI is updated" + "Show the release notes after UniGetUI is updated": "Show the release notes after UniGetUI is updated", + "Operations completed": "Operations completed", + "Operations finished with errors": "Operations finished with errors", + "{0} of {1} succeeded, {2} failed": "{0} of {1} succeeded, {2} failed", + "Show an in-app summary toast when a batch of operations finishes": "Show an in-app summary toast when a batch of operations finishes" } diff --git a/src/SharedAssets/Assets/Symbols/success_round.svg b/src/SharedAssets/Assets/Symbols/success_round.svg new file mode 100644 index 000000000..1b60f794d --- /dev/null +++ b/src/SharedAssets/Assets/Symbols/success_round.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/UniGetUI.Avalonia/Assets/Styles/Styles.Common.axaml b/src/UniGetUI.Avalonia/Assets/Styles/Styles.Common.axaml index c4835b2d4..1582038c8 100644 --- a/src/UniGetUI.Avalonia/Assets/Styles/Styles.Common.axaml +++ b/src/UniGetUI.Avalonia/Assets/Styles/Styles.Common.axaml @@ -85,6 +85,9 @@ + + + 0 4 16 0 #33000000 @@ -164,6 +167,9 @@ + + + 0 4 18 0 #80000000 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..93a70c373 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; @@ -63,12 +64,24 @@ 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(); + private bool _batchSummaryShown; + + public bool HasFailedOperations => Operations.Any(o => o.Operation.Status is OperationStatus.Failed); + + public OperationViewModel? FirstFailedOperation => + Operations.FirstOrDefault(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 { @@ -91,6 +104,7 @@ private void AddToBatch(AbstractOperation op) foreach (var old in _operationBatch) old.StatusChanged -= OnOperationStatusChanged; _operationBatch.Clear(); + _batchSummaryShown = false; } if (!_operationBatch.Contains(op)) @@ -101,7 +115,42 @@ 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)); + MaybeShowBatchSummaryToast(); + }); + + // Opt-in: one toast summarizing the batch once every operation in it has finished. + private void MaybeShowBatchSummaryToast() + { + if (_batchSummaryShown || _operationBatch.Count == 0) return; + if (!Settings.Get(Settings.K.ShowOperationSummaryNotifications)) return; + if (_operationBatch.Any(o => o.Status is OperationStatus.InQueue or OperationStatus.Running)) return; + + _batchSummaryShown = true; + + int total = _operationBatch.Count; + int failed = _operationBatch.Count(o => o.Status is OperationStatus.Failed); + int succeeded = _operationBatch.Count(o => o.Status is OperationStatus.Succeeded); + + var toast = new InfoBarViewModel + { + Title = failed > 0 + ? CoreTools.Translate("Operations finished with errors") + : CoreTools.Translate("Operations completed"), + Message = failed > 0 + ? CoreTools.Translate("{0} of {1} succeeded, {2} failed", succeeded, total, failed) + : CoreTools.Translate("{0} of {1} operations completed", succeeded, total), + Severity = failed > 0 ? InfoBarSeverity.Error : InfoBarSeverity.Success, + IsClosable = true, + IsOpen = true, + }; + toast.OnClosed = () => DismissToast(toast); + ShowToast(toast); + } [RelayCommand] private void ToggleOperationsPanel() => OperationsPanelExpanded = !OperationsPanelExpanded; @@ -169,12 +218,126 @@ 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(); + private const int MaxVisibleToasts = 5; + + // 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); + AnnounceToast(toast); + } + TrimToastStack(keep: toast); + ArmToastTimer(toast); + } + + // Keep the stack from overflowing the screen: drop the oldest auto-dismissing toasts, + // but never a sticky (action-button) one or the toast just shown. + private void TrimToastStack(InfoBarViewModel keep) + { + while (Toasts.Count > MaxVisibleToasts) + { + var oldest = Toasts.FirstOrDefault(t => t != keep && !IsSticky(t)); + if (oldest is null) break; + DismissToast(oldest); + } + } + + // Surface the toast to screen readers via the live region (assertive for errors so it + // interrupts, polite otherwise) — the toast's own automation name isn't announced on its own. + private static void AnnounceToast(InfoBarViewModel toast) + { + string message = string.IsNullOrEmpty(toast.Message) + ? toast.Title + : $"{toast.Title}. {toast.Message}"; + AccessibilityAnnouncementService.Announce( + message, + toast.Severity is InfoBarSeverity.Error + ? AutomationLiveSetting.Assertive + : AutomationLiveSetting.Polite); + } + + 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 +346,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(); @@ -264,6 +432,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/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..1ae4be569 100644 --- a/src/UniGetUI.Avalonia/Views/Controls/InfoBar.axaml +++ b/src/UniGetUI.Avalonia/Views/Controls/InfoBar.axaml @@ -23,6 +23,23 @@ + + - + - + VerticalAlignment="Top"> + @@ -83,8 +100,8 @@ IsVisible="{Binding IsClosable}" Command="{Binding CloseCommand}" automation:AutomationProperties.Name="Close" - VerticalAlignment="Center" - Margin="4,0,8,0" + VerticalAlignment="Top" + Margin="4,6,6,0" Padding="6" Background="Transparent" BorderThickness="0" diff --git a/src/UniGetUI.Avalonia/Views/Controls/InfoBar.axaml.cs b/src/UniGetUI.Avalonia/Views/Controls/InfoBar.axaml.cs index 6602b1996..9191e6dab 100644 --- a/src/UniGetUI.Avalonia/Views/Controls/InfoBar.axaml.cs +++ b/src/UniGetUI.Avalonia/Views/Controls/InfoBar.axaml.cs @@ -1,22 +1,28 @@ using Avalonia; using Avalonia.Controls; using Avalonia.Media; +using UniGetUI.Avalonia.Infrastructure; using UniGetUI.Avalonia.ViewModels; namespace UniGetUI.Avalonia.Views.Controls; public partial class InfoBar : UserControl { - // Icon path data for each severity - private const string InfoPath = "M12,2A10,10,0,1,0,22,12,10,10,0,0,0,12,2Zm1,15H11V11h2Zm0-8H11V7h2Z"; - private const string WarningPath = "M12,2,1,21H23Zm1,14H11V14h2Zm0-4H11V9h2Z"; - private const string ErrorPath = "M12,2A10,10,0,1,0,22,12,10,10,0,0,0,12,2Zm1,13H11V13h2Zm0-6H11V7h2Z"; - private const string SuccessPath = "M12,2A10,10,0,1,0,22,12,10,10,0,0,0,12,2ZM10,17,5,12l1.41-1.41L10,14.17l7.59-7.59L19,8Z"; + // Severity glyphs reuse the app's shared round symbols (same SvgIcon-by-path convention + // as the rest of the app), tinted with the severity colour. + private const string InfoIcon = "avares://UniGetUI/Assets/Symbols/info_round.svg"; + private const string WarningIcon = "avares://UniGetUI/Assets/Symbols/warning_round.svg"; + private const string ErrorIcon = "avares://UniGetUI/Assets/Symbols/close_round.svg"; + private const string SuccessIcon = "avares://UniGetUI/Assets/Symbols/success_round.svg"; public InfoBar() { InitializeComponent(); DataContextChanged += OnDataContextChanged; + + // Play the slide-in entrance only when the OS isn't set to minimize motion. + if (!MotionPreference.ReducedMotion) + BodyBorder.Classes.Add("animate-in"); } private InfoBarViewModel? _vm; @@ -57,16 +63,14 @@ private void ApplySeverity(InfoBarSeverity severity) }; SeverityStrip.Background = new SolidColorBrush(stripColor); - // Icon shape - SeverityIcon.Data = Geometry.Parse(severity switch + // Icon (shared SVG asset, tinted with the severity colour) + SeverityIcon.Path = severity switch { - InfoBarSeverity.Warning => WarningPath, - InfoBarSeverity.Error => ErrorPath, - InfoBarSeverity.Success => SuccessPath, - _ => InfoPath, - }); - - // Icon foreground (solid, not theme-sensitive) + InfoBarSeverity.Warning => WarningIcon, + InfoBarSeverity.Error => ErrorIcon, + InfoBarSeverity.Success => SuccessIcon, + _ => InfoIcon, + }; SeverityIcon.Foreground = new SolidColorBrush(stripColor); } } diff --git a/src/UniGetUI.Avalonia/Views/MainWindow.axaml b/src/UniGetUI.Avalonia/Views/MainWindow.axaml index 137c60fc9..a3c019a99 100644 --- a/src/UniGetUI.Avalonia/Views/MainWindow.axaml +++ b/src/UniGetUI.Avalonia/Views/MainWindow.axaml @@ -104,33 +104,19 @@ - - - - - - - - - - - - - - - - - - - - + + + + + + + + + @@ -210,7 +204,8 @@ IsVisible="{Binding OperationsPanelExpanded}" HorizontalScrollBarVisibility="Disabled" VerticalScrollBarVisibility="Auto"> - @@ -602,5 +597,28 @@ + + + + + + + + + + + + + + diff --git a/src/UniGetUI.Avalonia/Views/MainWindow.axaml.cs b/src/UniGetUI.Avalonia/Views/MainWindow.axaml.cs index 8d80005f7..c13f1b5cf 100644 --- a/src/UniGetUI.Avalonia/Views/MainWindow.axaml.cs +++ b/src/UniGetUI.Avalonia/Views/MainWindow.axaml.cs @@ -281,6 +281,25 @@ private void OnViewModelPropertyChanged(object? sender, System.ComponentModel.Pr if (e.PropertyName is nameof(MainWindowViewModel.OperationsPanelExpanded) or nameof(MainWindowViewModel.OperationsPanelVisible)) UpdateOperationsPanelRow(); + + // Expanding the panel while an operation has failed jumps to that op (the failure + // badge on the chevron is what drew the user here). + if (e.PropertyName is nameof(MainWindowViewModel.OperationsPanelExpanded) + && ViewModel.OperationsPanelExpanded) + ScrollToFirstFailedOperation(); + } + + private void ScrollToFirstFailedOperation() + { + if (ViewModel.FirstFailedOperation is not { } failed) + return; + + // Defer until the just-shown list has laid out its containers. + Dispatcher.UIThread.Post(() => + { + if (OperationsList.ContainerFromItem(failed) is Control container) + container.BringIntoView(); + }, DispatcherPriority.Background); } // Drive the operations-panel grid row: a resizable pixel height while the panel is @@ -1254,6 +1273,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 +1284,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..86d75283c 100644 --- a/src/UniGetUI.Avalonia/Views/Pages/SettingsPages/Notifications.axaml +++ b/src/UniGetUI.Avalonia/Views/Pages/SettingsPages/Notifications.axaml @@ -30,36 +30,46 @@ - + + - + + - + - + - + + + + + + diff --git a/src/UniGetUI.Core.Settings/SettingsEngine_Names.cs b/src/UniGetUI.Core.Settings/SettingsEngine_Names.cs index ecdf77673..3e8e4c365 100644 --- a/src/UniGetUI.Core.Settings/SettingsEngine_Names.cs +++ b/src/UniGetUI.Core.Settings/SettingsEngine_Names.cs @@ -77,6 +77,7 @@ public enum K DisableErrorNotifications, DisableSuccessNotifications, DisableProgressNotifications, + ShowOperationSummaryNotifications, KillProcessesThatRefuseToDie, ManagerPaths, GitHubUserLogin, @@ -187,6 +188,7 @@ public static string ResolveKey(K key) K.DisableErrorNotifications => "DisableErrorNotifications", K.DisableSuccessNotifications => "DisableSuccessNotifications", K.DisableProgressNotifications => "DisableProgressNotifications", + K.ShowOperationSummaryNotifications => "ShowOperationSummaryNotifications", K.KillProcessesThatRefuseToDie => "KillProcessesThatRefuseToDie", K.ManagerPaths => "ManagerPaths", K.GitHubUserLogin => "GitHubUserLogin",