From 5cb49efeb4e0707923470e920ee7731200b420a0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Burak=20Kaan=20K=C3=B6se?= Date: Sat, 11 Apr 2026 12:57:51 +0200 Subject: [PATCH] Updated synchronization progress implementation. --- .../Data/GroupedAccountCalendarViewModel.cs | 76 ++++++++---- .../Enums/SynchronizationProgressCategory.cs | 7 ++ .../Interfaces/IAccountMenuItem.cs | 22 ++-- .../Interfaces/ISynchronizationManager.cs | 5 + Wino.Core.Domain/MenuItems/AccountMenuItem.cs | 51 ++++---- .../MenuItems/MergedAccountMenuItem.cs | 75 ++++++------ .../AccountSynchronizationProgress.cs | 30 +++++ .../Translations/en_US/resources.json | 5 + Wino.Core/Services/SynchronizationManager.cs | 114 +++++++++++++++++- Wino.Core/Synchronizers/BaseSynchronizer.cs | 11 +- Wino.Core/Synchronizers/GmailSynchronizer.cs | 16 ++- Wino.Core/Synchronizers/ImapSynchronizer.cs | 12 +- .../Synchronizers/OutlookSynchronizer.cs | 14 ++- Wino.Core/Synchronizers/WinoSynchronizer.cs | 4 + Wino.Mail.ViewModels/MailAppShellViewModel.cs | 47 ++++++-- .../Services/AccountCalendarStateService.cs | 31 +++-- Wino.Mail.WinUI/Views/WinoAppShell.xaml | 56 ++++++--- Wino.Messages/CommunicationMessagesContext.cs | 4 +- ...ntSynchronizationProgressUpdatedMessage.cs | 5 +- .../UI/AccountSynchronizerStateChanged.cs | 4 +- 20 files changed, 444 insertions(+), 145 deletions(-) create mode 100644 Wino.Core.Domain/Enums/SynchronizationProgressCategory.cs create mode 100644 Wino.Core.Domain/Models/Synchronization/AccountSynchronizationProgress.cs diff --git a/Wino.Calendar.ViewModels/Data/GroupedAccountCalendarViewModel.cs b/Wino.Calendar.ViewModels/Data/GroupedAccountCalendarViewModel.cs index 0994b8c0..95b8cb9e 100644 --- a/Wino.Calendar.ViewModels/Data/GroupedAccountCalendarViewModel.cs +++ b/Wino.Calendar.ViewModels/Data/GroupedAccountCalendarViewModel.cs @@ -1,10 +1,11 @@ -using System; +using System; using System.Collections.Generic; using System.Collections.ObjectModel; using System.Collections.Specialized; using System.Linq; using CommunityToolkit.Mvvm.ComponentModel; using Wino.Core.Domain.Entities.Shared; +using Wino.Core.Domain.Models.Synchronization; namespace Wino.Calendar.ViewModels.Data; @@ -32,7 +33,7 @@ public partial class GroupedAccountCalendarViewModel : ObservableObject AccountCalendars.CollectionChanged += CalendarListUpdated; } - private void CalendarListUpdated(object sender, System.Collections.Specialized.NotifyCollectionChangedEventArgs e) + private void CalendarListUpdated(object sender, NotifyCollectionChangedEventArgs e) { if (e.Action == NotifyCollectionChangedAction.Add) { @@ -59,13 +60,11 @@ public partial class GroupedAccountCalendarViewModel : ObservableObject private void CalendarPropertyChanged(object sender, System.ComponentModel.PropertyChangedEventArgs e) { - if (sender is AccountCalendarViewModel viewModel) + if (sender is AccountCalendarViewModel viewModel && + e.PropertyName == nameof(AccountCalendarViewModel.IsChecked)) { - if (e.PropertyName == nameof(AccountCalendarViewModel.IsChecked)) - { - ManageIsCheckedState(); - UpdateCalendarCheckedState(viewModel, viewModel.IsChecked, true); - } + ManageIsCheckedState(); + UpdateCalendarCheckedState(viewModel, viewModel.IsChecked, true); } } @@ -79,19 +78,54 @@ public partial class GroupedAccountCalendarViewModel : ObservableObject public partial string AccountColorHex { get; set; } = string.Empty; [ObservableProperty] + [NotifyPropertyChangedFor(nameof(CanSynchronize), nameof(IsSynchronizationProgressVisible), nameof(IsProgressIndeterminate))] public partial bool IsSynchronizationInProgress { get; set; } + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(SynchronizationProgress), nameof(SynchronizationProgressValue), nameof(IsProgressIndeterminate))] + public partial int TotalItemsToSync { get; set; } + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(SynchronizationProgress), nameof(SynchronizationProgressValue), nameof(IsProgressIndeterminate))] + public partial int RemainingItemsToSync { get; set; } + [ObservableProperty] public partial string SynchronizationStatus { get; set; } = string.Empty; public bool CanSynchronize => !IsSynchronizationInProgress; public bool IsSynchronizationProgressVisible => IsSynchronizationInProgress; + public bool IsProgressIndeterminate => IsSynchronizationInProgress && TotalItemsToSync <= 0; - private bool _isExternalPropChangeBlocked = false; + public double SynchronizationProgress + { + get + { + if (TotalItemsToSync <= 0) + return 0; + + return ((double)(TotalItemsToSync - RemainingItemsToSync) / TotalItemsToSync) * 100; + } + } + + public double SynchronizationProgressValue => SynchronizationProgress; + + private bool _isExternalPropChangeBlocked; + + public void ApplySynchronizationProgress(AccountSynchronizationProgress progress) + { + if (progress == null || progress.AccountId != Account.Id) + return; + + IsSynchronizationInProgress = progress.IsInProgress; + TotalItemsToSync = progress.TotalUnits; + RemainingItemsToSync = progress.RemainingUnits; + SynchronizationStatus = progress.Status ?? string.Empty; + } private void ManageIsCheckedState() { - if (_isExternalPropChangeBlocked) return; + if (_isExternalPropChangeBlocked) + return; _isExternalPropChangeBlocked = true; @@ -113,17 +147,13 @@ public partial class GroupedAccountCalendarViewModel : ObservableObject partial void OnIsCheckedStateChanged(bool? oldValue, bool? newValue) { - if (_isExternalPropChangeBlocked) return; - - // Update is triggered by user on the three-state checkbox. - // We should not report all changes one by one. + if (_isExternalPropChangeBlocked) + return; _isExternalPropChangeBlocked = true; if (newValue == null) { - // Only primary calendars must be checked. - foreach (var calendar in AccountCalendars) { UpdateCalendarCheckedState(calendar, calendar.IsPrimary); @@ -138,7 +168,6 @@ public partial class GroupedAccountCalendarViewModel : ObservableObject } _isExternalPropChangeBlocked = false; - CollectiveSelectionStateChanged?.Invoke(this, EventArgs.Empty); } @@ -146,22 +175,17 @@ public partial class GroupedAccountCalendarViewModel : ObservableObject { var currentValue = accountCalendarViewModel.IsChecked; - if (currentValue == newValue && !ignoreValueCheck) return; + if (currentValue == newValue && !ignoreValueCheck) + return; accountCalendarViewModel.IsChecked = newValue; - // No need to report. - if (_isExternalPropChangeBlocked == true) return; + if (_isExternalPropChangeBlocked) + return; CalendarSelectionStateChanged?.Invoke(this, accountCalendarViewModel); } - partial void OnIsSynchronizationInProgressChanged(bool value) - { - OnPropertyChanged(nameof(CanSynchronize)); - OnPropertyChanged(nameof(IsSynchronizationProgressVisible)); - } - public void UpdateAccount(MailAccount updatedAccount) { if (updatedAccount == null || updatedAccount.Id != Account.Id) diff --git a/Wino.Core.Domain/Enums/SynchronizationProgressCategory.cs b/Wino.Core.Domain/Enums/SynchronizationProgressCategory.cs new file mode 100644 index 00000000..6b80b76c --- /dev/null +++ b/Wino.Core.Domain/Enums/SynchronizationProgressCategory.cs @@ -0,0 +1,7 @@ +namespace Wino.Core.Domain.Enums; + +public enum SynchronizationProgressCategory +{ + Mail, + Calendar +} diff --git a/Wino.Core.Domain/Interfaces/IAccountMenuItem.cs b/Wino.Core.Domain/Interfaces/IAccountMenuItem.cs index b978978f..95ddcede 100644 --- a/Wino.Core.Domain/Interfaces/IAccountMenuItem.cs +++ b/Wino.Core.Domain/Interfaces/IAccountMenuItem.cs @@ -1,35 +1,43 @@ -using System.Collections.Generic; +using System.Collections.Generic; using Wino.Core.Domain.Entities.Mail; using Wino.Core.Domain.Entities.Shared; +using Wino.Core.Domain.Models.Synchronization; namespace Wino.Core.Domain.Interfaces; public interface IAccountMenuItem : IMenuItem { bool IsEnabled { get; set; } - + bool IsSynchronizationInProgress { get; set; } + /// - /// Calculated synchronization progress percentage (0-100). -1 for indeterminate. + /// Calculated synchronization progress percentage (0-100). /// double SynchronizationProgress { get; } - + + /// + /// Progress value clamped for XAML progress controls. + /// + double SynchronizationProgressValue { get; } + /// /// Total items to sync. 0 for indeterminate progress. /// int TotalItemsToSync { get; set; } - + /// /// Remaining items to sync. /// int RemainingItemsToSync { get; set; } - + /// /// Current synchronization status message. /// string SynchronizationStatus { get; set; } - + int UnreadItemCount { get; set; } IEnumerable HoldingAccounts { get; } + void ApplySynchronizationProgress(AccountSynchronizationProgress progress); void UpdateAccount(MailAccount account); } diff --git a/Wino.Core.Domain/Interfaces/ISynchronizationManager.cs b/Wino.Core.Domain/Interfaces/ISynchronizationManager.cs index bfacad73..7689e2bb 100644 --- a/Wino.Core.Domain/Interfaces/ISynchronizationManager.cs +++ b/Wino.Core.Domain/Interfaces/ISynchronizationManager.cs @@ -41,6 +41,11 @@ public interface ISynchronizationManager /// bool IsAccountSynchronizing(Guid accountId); + /// + /// Gets the latest centralized synchronization progress snapshot for the given account and category. + /// + AccountSynchronizationProgress GetSynchronizationProgress(Guid accountId, SynchronizationProgressCategory category); + /// /// Queues a mail action request to the corresponding account's synchronizer with optional synchronization triggering. /// diff --git a/Wino.Core.Domain/MenuItems/AccountMenuItem.cs b/Wino.Core.Domain/MenuItems/AccountMenuItem.cs index fc836dbe..974d8aaa 100644 --- a/Wino.Core.Domain/MenuItems/AccountMenuItem.cs +++ b/Wino.Core.Domain/MenuItems/AccountMenuItem.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Linq; using CommunityToolkit.Mvvm.ComponentModel; @@ -6,6 +6,7 @@ using Wino.Core.Domain.Entities.Shared; using Wino.Core.Domain.Enums; using Wino.Core.Domain.Interfaces; using Wino.Core.Domain.Models.Folders; +using Wino.Core.Domain.Models.Synchronization; namespace Wino.Core.Domain.MenuItems; @@ -14,18 +15,22 @@ public partial class AccountMenuItem : MenuItemBase /// Total items to sync. 0 means indeterminate progress. /// [ObservableProperty] - [NotifyPropertyChangedFor(nameof(IsSynchronizationProgressVisible), nameof(SynchronizationProgress), nameof(IsProgressIndeterminate))] + [NotifyPropertyChangedFor(nameof(SynchronizationProgress), nameof(SynchronizationProgressValue), nameof(IsProgressIndeterminate))] public partial int TotalItemsToSync { get; set; } /// /// Remaining items to sync. /// [ObservableProperty] - [NotifyPropertyChangedFor(nameof(SynchronizationProgress))] + [NotifyPropertyChangedFor(nameof(SynchronizationProgress), nameof(SynchronizationProgressValue), nameof(IsProgressIndeterminate))] public partial int RemainingItemsToSync { get; set; } /// @@ -39,31 +44,22 @@ public partial class AccountMenuItem : MenuItemBase AttentionReason != AccountAttentionReason.None; - /// - /// Calculates synchronization progress percentage (0-100). - /// Returns -1 for indeterminate progress when TotalItemsToSync is 0. - /// public double SynchronizationProgress { get { - if (TotalItemsToSync == 0 || RemainingItemsToSync == 0) - return -1; // Indeterminate + if (TotalItemsToSync <= 0) + return 0; - return ((double)(TotalItemsToSync - RemainingItemsToSync) / TotalItemsToSync) * 100; + return Math.Clamp(((double)(TotalItemsToSync - RemainingItemsToSync) / TotalItemsToSync) * 100, 0, 100); } } - /// - /// Whether synchronization progress should be visible. - /// Visible when there's active synchronization (TotalItemsToSync > 0 or RemainingItemsToSync > 0). - /// - public bool IsSynchronizationProgressVisible => TotalItemsToSync > 0 || RemainingItemsToSync > 0; + public double SynchronizationProgressValue => SynchronizationProgress; - /// - /// Whether progress should be indeterminate (when total is 0 but there's still synchronization happening). - /// - public bool IsProgressIndeterminate => TotalItemsToSync == 0 && RemainingItemsToSync == 0 && IsSynchronizationProgressVisible; + public bool IsSynchronizationProgressVisible => IsSynchronizationInProgress; + + public bool IsProgressIndeterminate => IsSynchronizationInProgress && TotalItemsToSync <= 0; public Guid AccountId => Parameter.Id; @@ -77,7 +73,6 @@ public partial class AccountMenuItem : MenuItemBase a is FixAccountIssuesMenuItem)) { - // Add fix issue item if not exists. SubMenuItems.Insert(0, new FixAccountIssuesMenuItem(Parameter, this)); } else { - // Remove existing if issue is resolved. var fixAccountIssueItem = SubMenuItems.FirstOrDefault(a => a is FixAccountIssuesMenuItem); if (fixAccountIssueItem != null) diff --git a/Wino.Core.Domain/MenuItems/MergedAccountMenuItem.cs b/Wino.Core.Domain/MenuItems/MergedAccountMenuItem.cs index 3e970d65..ceed60a1 100644 --- a/Wino.Core.Domain/MenuItems/MergedAccountMenuItem.cs +++ b/Wino.Core.Domain/MenuItems/MergedAccountMenuItem.cs @@ -1,9 +1,10 @@ -using System.Collections.Generic; +using System.Collections.Generic; using System.Linq; using CommunityToolkit.Mvvm.ComponentModel; using Wino.Core.Domain.Entities.Mail; using Wino.Core.Domain.Entities.Shared; using Wino.Core.Domain.Interfaces; +using Wino.Core.Domain.Models.Synchronization; namespace Wino.Core.Domain.MenuItems; @@ -16,50 +17,37 @@ public partial class MergedAccountMenuItem : MenuItemBase - /// Total items to sync across all merged accounts. - /// [ObservableProperty] - [NotifyPropertyChangedFor(nameof(SynchronizationProgress), nameof(IsSynchronizationProgressVisible), nameof(IsProgressIndeterminate))] + [NotifyPropertyChangedFor(nameof(IsSynchronizationProgressVisible), nameof(IsProgressIndeterminate), nameof(SynchronizationProgress), nameof(SynchronizationProgressValue))] + public partial bool IsSynchronizationInProgress { get; set; } + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(SynchronizationProgress), nameof(SynchronizationProgressValue), nameof(IsProgressIndeterminate))] public partial int TotalItemsToSync { get; set; } - /// - /// Remaining items to sync across all merged accounts. - /// [ObservableProperty] - [NotifyPropertyChangedFor(nameof(SynchronizationProgress), nameof(IsSynchronizationProgressVisible), nameof(IsProgressIndeterminate))] + [NotifyPropertyChangedFor(nameof(SynchronizationProgress), nameof(SynchronizationProgressValue), nameof(IsProgressIndeterminate))] public partial int RemainingItemsToSync { get; set; } - /// - /// Current synchronization status message. - /// [ObservableProperty] public partial string SynchronizationStatus { get; set; } = string.Empty; - /// - /// Calculated synchronization progress for merged accounts. - /// public double SynchronizationProgress { get { - if (TotalItemsToSync == 0 || RemainingItemsToSync == 0) - return -1; // Indeterminate + if (TotalItemsToSync <= 0) + return 0; return ((double)(TotalItemsToSync - RemainingItemsToSync) / TotalItemsToSync) * 100; } } - /// - /// Whether synchronization progress should be visible. - /// Visible when there's active synchronization (TotalItemsToSync > 0 or RemainingItemsToSync > 0). - /// - public bool IsSynchronizationProgressVisible => TotalItemsToSync > 0 || RemainingItemsToSync > 0; + public double SynchronizationProgressValue => SynchronizationProgress; - /// - /// Whether progress should be indeterminate. - /// - public bool IsProgressIndeterminate => TotalItemsToSync == 0 && IsSynchronizationProgressVisible; + public bool IsSynchronizationProgressVisible => IsSynchronizationInProgress; + + public bool IsProgressIndeterminate => IsSynchronizationInProgress && TotalItemsToSync <= 0; [ObservableProperty] private string mergedAccountName; @@ -77,23 +65,34 @@ public partial class MergedAccountMenuItem : MenuItemBase().Sum(a => a.UnreadItemCount); } - - /// - /// Aggregates synchronization progress from all child account menu items. - /// + public void RefreshSynchronizationProgress() { - var accountMenuItems = SubMenuItems.OfType().ToList(); - - TotalItemsToSync = accountMenuItems.Sum(a => a.TotalItemsToSync); - RemainingItemsToSync = accountMenuItems.Sum(a => a.RemainingItemsToSync); - - // Use first non-empty status message - SynchronizationStatus = accountMenuItems.FirstOrDefault(a => !string.IsNullOrEmpty(a.SynchronizationStatus))?.SynchronizationStatus ?? string.Empty; + var activeAccountMenuItems = SubMenuItems + .OfType() + .Where(a => a.IsSynchronizationInProgress) + .ToList(); + + IsSynchronizationInProgress = activeAccountMenuItems.Any(); + TotalItemsToSync = activeAccountMenuItems.Sum(a => a.TotalItemsToSync); + RemainingItemsToSync = activeAccountMenuItems.Sum(a => a.RemainingItemsToSync); + SynchronizationStatus = activeAccountMenuItems + .Select(a => a.SynchronizationStatus) + .FirstOrDefault(s => !string.IsNullOrWhiteSpace(s)) ?? string.Empty; + } + + public void ApplySynchronizationProgress(AccountSynchronizationProgress progress) + { + if (progress == null) + return; + + IsSynchronizationInProgress = progress.IsInProgress; + TotalItemsToSync = progress.TotalUnits; + RemainingItemsToSync = progress.RemainingUnits; + SynchronizationStatus = progress.Status ?? string.Empty; } public void UpdateAccount(MailAccount account) { - } } diff --git a/Wino.Core.Domain/Models/Synchronization/AccountSynchronizationProgress.cs b/Wino.Core.Domain/Models/Synchronization/AccountSynchronizationProgress.cs new file mode 100644 index 00000000..5ba4f045 --- /dev/null +++ b/Wino.Core.Domain/Models/Synchronization/AccountSynchronizationProgress.cs @@ -0,0 +1,30 @@ +using System; +using Wino.Core.Domain.Enums; + +namespace Wino.Core.Domain.Models.Synchronization; + +public record AccountSynchronizationProgress( + Guid AccountId, + SynchronizationProgressCategory Category, + bool IsInProgress, + bool IsIndeterminate, + double ProgressPercentage, + int TotalUnits, + int RemainingUnits, + string Status, + AccountSynchronizerState State) +{ + public int CompletedUnits => Math.Max(0, TotalUnits - RemainingUnits); + + public static AccountSynchronizationProgress Idle(Guid accountId, SynchronizationProgressCategory category) + => new( + accountId, + category, + false, + false, + 0, + 0, + 0, + string.Empty, + AccountSynchronizerState.Idle); +} diff --git a/Wino.Core.Domain/Translations/en_US/resources.json b/Wino.Core.Domain/Translations/en_US/resources.json index 8871c450..b610f74e 100644 --- a/Wino.Core.Domain/Translations/en_US/resources.json +++ b/Wino.Core.Domain/Translations/en_US/resources.json @@ -108,6 +108,11 @@ "SyncAction_SynchronizingCalendarData": "Synchronizing calendar data", "SyncAction_SynchronizingCalendarEvents": "Synchronizing calendar events", "SyncAction_SynchronizingCalendarMetadata": "Synchronizing calendar metadata", + "SynchronizationProgress_ApplyingChanges": "Applying changes", + "SynchronizationProgress_CalendarInProgress": "Calendar sync in progress", + "SynchronizationProgress_CalendarPercent": "Calendar sync {0}%", + "SynchronizationProgress_MailInProgress": "Mail sync in progress", + "SynchronizationProgress_MailPercent": "Mail sync {0}%", "SyncAction_Unarchiving": "Unarchiving {0} mail(s)", "CalendarAllDayEventSummary": "all-day events", "CalendarDisplayOptions_Color": "Color", diff --git a/Wino.Core/Services/SynchronizationManager.cs b/Wino.Core/Services/SynchronizationManager.cs index c9edc33b..af0c486e 100644 --- a/Wino.Core/Services/SynchronizationManager.cs +++ b/Wino.Core/Services/SynchronizationManager.cs @@ -23,7 +23,7 @@ namespace Wino.Core.Services; /// Singleton manager that handles synchronizer instances and operations for all accounts. /// Replaces the old WinoServerConnectionManager functionality. /// -public class SynchronizationManager : ISynchronizationManager +public class SynchronizationManager : ISynchronizationManager, IRecipient { private static readonly Lazy _instance = new(() => new SynchronizationManager()); public static SynchronizationManager Instance => _instance.Value; @@ -31,6 +31,8 @@ public class SynchronizationManager : ISynchronizationManager private readonly ConcurrentDictionary _synchronizerCache = new(); private readonly ConcurrentDictionary _accountSynchronizationCancellationSources = new(); private readonly ConcurrentDictionary _calendarSynchronizationLocks = new(); + private readonly ConcurrentDictionary _mailSynchronizationProgress = new(); + private readonly ConcurrentDictionary _calendarSynchronizationProgress = new(); private readonly SemaphoreSlim _initializationSemaphore = new(1, 1); private readonly ILogger _logger = Log.ForContext(); @@ -41,6 +43,7 @@ public class SynchronizationManager : ISynchronizationManager private INotificationBuilder _notificationBuilder; private bool _isInitialized = false; + private bool _isRegisteredForProgressMessages; private SynchronizationManager() { } @@ -73,6 +76,11 @@ public class SynchronizationManager : ISynchronizationManager // DO NOT create synchronizers here to avoid requiring window handles during initialization. // Synchronizers will be created lazily when first accessed via GetOrCreateSynchronizerAsync. + if (!_isRegisteredForProgressMessages) + { + WeakReferenceMessenger.Default.Register(this); + _isRegisteredForProgressMessages = true; + } _isInitialized = true; _logger.Information("SynchronizationManager dependencies initialized. Synchronizers will be created lazily."); @@ -219,6 +227,21 @@ public class SynchronizationManager : ISynchronizationManager return false; } + public AccountSynchronizationProgress GetSynchronizationProgress(Guid accountId, SynchronizationProgressCategory category) + { + EnsureInitialized(); + + return category switch + { + SynchronizationProgressCategory.Calendar => _calendarSynchronizationProgress.TryGetValue(accountId, out var calendarProgress) + ? calendarProgress + : AccountSynchronizationProgress.Idle(accountId, SynchronizationProgressCategory.Calendar), + _ => _mailSynchronizationProgress.TryGetValue(accountId, out var mailProgress) + ? mailProgress + : AccountSynchronizationProgress.Idle(accountId, SynchronizationProgressCategory.Mail) + }; + } + /// /// Queues a request to the corresponding account's synchronizer with optional synchronization triggering. /// Automatically determines whether to trigger mail or calendar synchronization based on the request type. @@ -651,6 +674,9 @@ public class SynchronizationManager : ISynchronizationManager _logger.Information("Canceled ongoing synchronizations for account {AccountId}", accountId); } + PublishSynchronizationProgress(AccountSynchronizationProgress.Idle(accountId, SynchronizationProgressCategory.Mail)); + PublishSynchronizationProgress(AccountSynchronizationProgress.Idle(accountId, SynchronizationProgressCategory.Calendar)); + return Task.CompletedTask; } @@ -679,6 +705,9 @@ public class SynchronizationManager : ISynchronizationManager _logger.Error(ex, "Failed to destroy synchronizer for account {AccountId}", accountId); } } + + PublishSynchronizationProgress(AccountSynchronizationProgress.Idle(accountId, SynchronizationProgressCategory.Mail)); + PublishSynchronizationProgress(AccountSynchronizationProgress.Idle(accountId, SynchronizationProgressCategory.Calendar)); } /// @@ -770,6 +799,33 @@ public class SynchronizationManager : ISynchronizationManager } } + public void Receive(AccountSynchronizerStateChanged message) + { + var totalUnits = Math.Max(0, message.TotalItemsToSync); + var remainingUnits = totalUnits > 0 + ? Math.Clamp(message.RemainingItemsToSync, 0, totalUnits) + : 0; + + var isInProgress = message.NewState != AccountSynchronizerState.Idle; + var isIndeterminate = isInProgress && totalUnits <= 0; + var progressPercentage = totalUnits > 0 + ? ((double)(totalUnits - remainingUnits) / totalUnits) * 100 + : 0; + + var progress = new AccountSynchronizationProgress( + message.AccountId, + message.ProgressCategory, + isInProgress, + isIndeterminate, + progressPercentage, + totalUnits, + remainingUnits, + BuildSynchronizationStatus(message.ProgressCategory, message.NewState, totalUnits, progressPercentage, message.SynchronizationStatus), + message.NewState); + + PublishSynchronizationProgress(progress); + } + private void EnsureInitialized() { if (!_isInitialized) @@ -804,17 +860,67 @@ public class SynchronizationManager : ISynchronizationManager return account?.AttentionReason == AccountAttentionReason.InvalidCredentials; } + private void PublishSynchronizationProgress(AccountSynchronizationProgress progress) + { + var normalized = progress.IsInProgress + ? progress + : AccountSynchronizationProgress.Idle(progress.AccountId, progress.Category); + + var cache = normalized.Category == SynchronizationProgressCategory.Calendar + ? _calendarSynchronizationProgress + : _mailSynchronizationProgress; + + cache.AddOrUpdate(normalized.AccountId, normalized, (_, _) => normalized); + + WeakReferenceMessenger.Default.Send(new AccountSynchronizationProgressUpdatedMessage(normalized)); + } + + private static string BuildSynchronizationStatus( + SynchronizationProgressCategory category, + AccountSynchronizerState state, + int totalUnits, + double progressPercentage, + string rawStatus) + { + if (state == AccountSynchronizerState.Idle) + return string.Empty; + + if (state == AccountSynchronizerState.ExecutingRequests) + return Translator.SynchronizationProgress_ApplyingChanges; + + if (totalUnits > 0) + { + var roundedProgress = (int)Math.Round(progressPercentage, MidpointRounding.AwayFromZero); + + return category == SynchronizationProgressCategory.Calendar + ? string.Format(Translator.SynchronizationProgress_CalendarPercent, roundedProgress) + : string.Format(Translator.SynchronizationProgress_MailPercent, roundedProgress); + } + + if (category == SynchronizationProgressCategory.Calendar && !string.IsNullOrWhiteSpace(rawStatus)) + return rawStatus; + + return category == SynchronizationProgressCategory.Calendar + ? Translator.SynchronizationProgress_CalendarInProgress + : Translator.SynchronizationProgress_MailInProgress; + } + private void PublishCalendarSynchronizationState( Guid accountId, CalendarSynchronizationType synchronizationType, bool isSynchronizationInProgress, string synchronizationStatus = "") { - WeakReferenceMessenger.Default.Send(new AccountCalendarSynchronizationStateChanged( + PublishSynchronizationProgress(new AccountSynchronizationProgress( accountId, - synchronizationType, + SynchronizationProgressCategory.Calendar, isSynchronizationInProgress, - synchronizationStatus)); + isSynchronizationInProgress, + 0, + 0, + 0, + synchronizationStatus, + isSynchronizationInProgress ? AccountSynchronizerState.Synchronizing : AccountSynchronizerState.Idle)); } private static string GetCalendarSynchronizationStatus(CalendarSynchronizationType synchronizationType) diff --git a/Wino.Core/Synchronizers/BaseSynchronizer.cs b/Wino.Core/Synchronizers/BaseSynchronizer.cs index 5f909044..9b06656f 100644 --- a/Wino.Core/Synchronizers/BaseSynchronizer.cs +++ b/Wino.Core/Synchronizers/BaseSynchronizer.cs @@ -27,6 +27,7 @@ public abstract partial class BaseSynchronizer : ObservableObject, private readonly ConcurrentDictionary _pendingCalendarOperationIds = new(); private readonly ConcurrentQueue _capturedSynchronizationIssues = new(); protected readonly IMessenger Messenger; + protected SynchronizationProgressCategory CurrentSynchronizationProgressCategory { get; set; } = SynchronizationProgressCategory.Mail; public MailAccount Account { get; } @@ -44,7 +45,8 @@ public abstract partial class BaseSynchronizer : ObservableObject, value, TotalItemsToSync, RemainingItemsToSync, - SynchronizationStatus)); + SynchronizationStatus, + CurrentSynchronizationProgressCategory)); } } @@ -75,8 +77,8 @@ public abstract partial class BaseSynchronizer : ObservableObject, { get { - if (TotalItemsToSync == 0 || RemainingItemsToSync == 0) - return -1; // Indeterminate + if (TotalItemsToSync <= 0) + return 0; return ((double)(TotalItemsToSync - RemainingItemsToSync) / TotalItemsToSync) * 100; } @@ -118,7 +120,8 @@ public abstract partial class BaseSynchronizer : ObservableObject, State, TotalItemsToSync, RemainingItemsToSync, - SynchronizationStatus)); + SynchronizationStatus, + CurrentSynchronizationProgressCategory)); } /// diff --git a/Wino.Core/Synchronizers/GmailSynchronizer.cs b/Wino.Core/Synchronizers/GmailSynchronizer.cs index c3f66638..f2d2ec7f 100644 --- a/Wino.Core/Synchronizers/GmailSynchronizer.cs +++ b/Wino.Core/Synchronizers/GmailSynchronizer.cs @@ -22,6 +22,7 @@ using Microsoft.IdentityModel.Tokens; using MimeKit; using MoreLinq; using Serilog; +using Wino.Core.Domain; using Wino.Core.Domain.Entities.Calendar; using Wino.Core.Domain.Entities.Mail; using Wino.Core.Domain.Entities.Shared; @@ -388,7 +389,7 @@ public class GmailSynchronizer : WinoSynchronizer c.IsSynchronizationEnabled) .ToList(); - foreach (var calendar in localCalendars) + var totalCalendars = localCalendars.Count; + if (totalCalendars > 0) { + UpdateSyncProgress(totalCalendars, totalCalendars, Translator.SyncAction_SynchronizingCalendarEvents); + } + + for (int i = 0; i < totalCalendars; i++) + { + var calendar = localCalendars[i]; + try { var request = _calendarService.Events.List(calendar.RemoteCalendarId); @@ -602,6 +611,7 @@ public class GmailSynchronizer : WinoSynchronizer 0) { + UpdateSyncProgress(totalCalendars, totalCalendars, Translator.SyncAction_SynchronizingCalendarEvents); + } + + for (int i = 0; i < totalCalendars; i++) + { + var localCalendar = localCalendars[i]; + cancellationToken.ThrowIfCancellationRequested(); if (!remoteCalendarsById.TryGetValue(localCalendar.RemoteCalendarId, out var remoteCalendar)) @@ -1265,6 +1274,7 @@ public class ImapSynchronizer : WinoSynchronizer 0) { + UpdateSyncProgress(totalCalendars, totalCalendars, Translator.SyncAction_SynchronizingCalendarEvents); + } + + for (int i = 0; i < totalCalendars; i++) + { + var calendar = localCalendars[i]; + try { bool isInitialSync = string.IsNullOrEmpty(calendar.SynchronizationDeltaToken); @@ -2440,6 +2448,8 @@ public class OutlookSynchronizer : WinoSynchronizer> nativeRequests = new(); @@ -264,6 +265,7 @@ public abstract class WinoSynchronizer> nativeRequests = new(); @@ -482,6 +485,7 @@ public abstract class WinoSynchronizer, IRecipient, IRecipient, - IRecipient, + IRecipient, IRecipient, IRecipient, IRecipient, @@ -157,6 +157,24 @@ public partial class MailAppShellViewModel : MailBaseViewModel, }); } + private static void ApplySynchronizationProgress(IAccountMenuItem accountMenuItem, SynchronizationProgressCategory category) + { + AccountSynchronizationProgress progress; + + try + { + progress = SynchronizationManager.Instance.GetSynchronizationProgress( + accountMenuItem.HoldingAccounts.First().Id, + category); + } + catch (InvalidOperationException) + { + return; + } + + accountMenuItem.ApplySynchronizationProgress(progress); + } + private async Task LoadAccountsAsync() { // First clear all account menu items. @@ -185,9 +203,13 @@ public partial class MailAppShellViewModel : MailBaseViewModel, foreach (var mergedAccount in mergedAccounts) { initializedAccountIds.Add(mergedAccount.Id); - mergedAccountMenuItem.SubMenuItems.Add(new AccountMenuItem(mergedAccount, mergedAccountMenuItem)); + var accountMenuItem = new AccountMenuItem(mergedAccount, mergedAccountMenuItem); + ApplySynchronizationProgress(accountMenuItem, SynchronizationProgressCategory.Mail); + mergedAccountMenuItem.SubMenuItems.Add(accountMenuItem); } + mergedAccountMenuItem.RefreshSynchronizationProgress(); + await ExecuteUIThread(() => { MenuItems.Add(mergedAccountMenuItem); @@ -196,9 +218,12 @@ public partial class MailAppShellViewModel : MailBaseViewModel, } else { + var accountMenuItem = new AccountMenuItem(account, null); + ApplySynchronizationProgress(accountMenuItem, SynchronizationProgressCategory.Mail); + await ExecuteUIThread(() => { - MenuItems.Add(new AccountMenuItem(account, null)); + MenuItems.Add(accountMenuItem); }); initializedAccountIds.Add(account.Id); @@ -1194,17 +1219,19 @@ public partial class MailAppShellViewModel : MailBaseViewModel, UpdateFolderCollection(mailItemFolder); } - public async void Receive(AccountSynchronizerStateChanged message) + public async void Receive(AccountSynchronizationProgressUpdatedMessage message) { - var accountMenuItem = MenuItems.GetSpecificAccountMenuItem(message.AccountId); + var progress = message.Progress; + if (progress.Category != SynchronizationProgressCategory.Mail) + return; + + var accountMenuItem = MenuItems.GetSpecificAccountMenuItem(progress.AccountId); if (accountMenuItem == null) return; await ExecuteUIThread(() => { - accountMenuItem.TotalItemsToSync = message.TotalItemsToSync; - accountMenuItem.RemainingItemsToSync = message.RemainingItemsToSync; - accountMenuItem.SynchronizationStatus = message.SynchronizationStatus; + accountMenuItem.ApplySynchronizationProgress(progress); // If this account is part of a merged inbox, update the merged inbox progress as well if (accountMenuItem.ParentMenuItem is MergedAccountMenuItem mergedAccountMenuItem) @@ -1231,7 +1258,7 @@ public partial class MailAppShellViewModel : MailBaseViewModel, Messenger.Register(this); Messenger.Register(this); Messenger.Register(this); - Messenger.Register(this); + Messenger.Register(this); Messenger.Register(this); Messenger.Register(this); } @@ -1248,7 +1275,7 @@ public partial class MailAppShellViewModel : MailBaseViewModel, Messenger.Unregister(this); Messenger.Unregister(this); Messenger.Unregister(this); - Messenger.Unregister(this); + Messenger.Unregister(this); Messenger.Unregister(this); Messenger.Unregister(this); } diff --git a/Wino.Mail.WinUI/Services/AccountCalendarStateService.cs b/Wino.Mail.WinUI/Services/AccountCalendarStateService.cs index 703a9e25..4450931f 100644 --- a/Wino.Mail.WinUI/Services/AccountCalendarStateService.cs +++ b/Wino.Mail.WinUI/Services/AccountCalendarStateService.cs @@ -8,7 +8,9 @@ using CommunityToolkit.Mvvm.Messaging; using Wino.Calendar.ViewModels.Data; using Wino.Calendar.ViewModels.Interfaces; using Wino.Core.Domain.Entities.Shared; +using Wino.Core.Domain.Enums; using Wino.Core.Domain.Interfaces; +using Wino.Core.Services; using Wino.Messaging.Client.Calendar; using Wino.Messaging.UI; @@ -25,7 +27,7 @@ public partial class AccountCalendarStateService : ObservableRecipient, IRecipient, IRecipient, IRecipient, - IRecipient + IRecipient { private readonly object _calendarStateLock = new(); @@ -90,7 +92,7 @@ public partial class AccountCalendarStateService : ObservableRecipient, Messenger.Register(this); Messenger.Register(this); Messenger.Register(this); - Messenger.Register(this); + Messenger.Register(this); } private void SingleGroupCalendarCollectiveStateChanged(object? sender, EventArgs e) @@ -105,6 +107,15 @@ public partial class AccountCalendarStateService : ObservableRecipient, { groupedAccountCalendar.CalendarSelectionStateChanged += SingleCalendarSelectionStateChanged; groupedAccountCalendar.CollectiveSelectionStateChanged += SingleGroupCalendarCollectiveStateChanged; + try + { + groupedAccountCalendar.ApplySynchronizationProgress(SynchronizationManager.Instance.GetSynchronizationProgress( + groupedAccountCalendar.Account.Id, + SynchronizationProgressCategory.Calendar)); + } + catch (InvalidOperationException) + { + } _internalGroupedAccountCalendars.Add(groupedAccountCalendar); @@ -364,15 +375,18 @@ public partial class AccountCalendarStateService : ObservableRecipient, } } - public async void Receive(AccountCalendarSynchronizationStateChanged message) + public async void Receive(AccountSynchronizationProgressUpdatedMessage message) { + if (message.Progress.Category != SynchronizationProgressCategory.Calendar) + return; + if (Dispatcher != null) { - await Dispatcher.ExecuteOnUIThread(() => UpdateCalendarSynchronizationState(message)); + await Dispatcher.ExecuteOnUIThread(() => UpdateCalendarSynchronizationState(message.Progress)); } else { - UpdateCalendarSynchronizationState(message); + UpdateCalendarSynchronizationState(message.Progress); } } @@ -387,19 +401,18 @@ public partial class AccountCalendarStateService : ObservableRecipient, groupedAccount?.UpdateAccount(updatedAccount); } - private void UpdateCalendarSynchronizationState(AccountCalendarSynchronizationStateChanged message) + private void UpdateCalendarSynchronizationState(Wino.Core.Domain.Models.Synchronization.AccountSynchronizationProgress progress) { GroupedAccountCalendarViewModel? groupedAccount; lock (_calendarStateLock) { - groupedAccount = _internalGroupedAccountCalendars.FirstOrDefault(a => a.Account.Id == message.AccountId); + groupedAccount = _internalGroupedAccountCalendars.FirstOrDefault(a => a.Account.Id == progress.AccountId); } if (groupedAccount == null) return; - groupedAccount.IsSynchronizationInProgress = message.IsSynchronizationInProgress; - groupedAccount.SynchronizationStatus = message.SynchronizationStatus; + groupedAccount.ApplySynchronizationProgress(progress); UpdateAggregateSynchronizationState(); } diff --git a/Wino.Mail.WinUI/Views/WinoAppShell.xaml b/Wino.Mail.WinUI/Views/WinoAppShell.xaml index 48aa5c4b..64fbc926 100644 --- a/Wino.Mail.WinUI/Views/WinoAppShell.xaml +++ b/Wino.Mail.WinUI/Views/WinoAppShell.xaml @@ -65,7 +65,13 @@ - + + + + + + + + TextTrimming="CharacterEllipsis" /> - - + +