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" />
-
-
+
+
+ IsIndeterminate="{x:Bind IsProgressIndeterminate, Mode=OneWay}" />
@@ -292,6 +299,15 @@
TextTrimming="CharacterEllipsis"
Visibility="{x:Bind IsSynchronizationProgressVisible, Mode=OneWay}" />
+
+
+
+ Visibility="{x:Bind IsSynchronizationProgressVisible, Mode=OneWay}"
+ Value="{x:Bind SynchronizationProgressValue, Mode=OneWay}" />
diff --git a/Wino.Messages/CommunicationMessagesContext.cs b/Wino.Messages/CommunicationMessagesContext.cs
index ed059f2f..f0cf3fb8 100644
--- a/Wino.Messages/CommunicationMessagesContext.cs
+++ b/Wino.Messages/CommunicationMessagesContext.cs
@@ -1,4 +1,5 @@
-using System.Text.Json.Serialization;
+using System.Text.Json.Serialization;
+using Wino.Core.Domain.Models.Synchronization;
using Wino.Messaging.Server;
using Wino.Messaging.UI;
@@ -21,6 +22,7 @@ namespace Wino.Messaging;
[JsonSerializable(typeof(AccountSynchronizationCompleted))]
[JsonSerializable(typeof(RefreshUnreadCountsMessage))]
[JsonSerializable(typeof(AccountSynchronizerStateChanged))]
+[JsonSerializable(typeof(AccountSynchronizationProgress))]
[JsonSerializable(typeof(AccountSynchronizationProgressUpdatedMessage))]
[JsonSerializable(typeof(AccountFolderConfigurationUpdated))]
[JsonSerializable(typeof(CopyAuthURLRequested))]
diff --git a/Wino.Messages/UI/AccountSynchronizationProgressUpdatedMessage.cs b/Wino.Messages/UI/AccountSynchronizationProgressUpdatedMessage.cs
index fdb6eea7..723e19e7 100644
--- a/Wino.Messages/UI/AccountSynchronizationProgressUpdatedMessage.cs
+++ b/Wino.Messages/UI/AccountSynchronizationProgressUpdatedMessage.cs
@@ -1,8 +1,9 @@
-using System;
+using Wino.Core.Domain.Models.Synchronization;
namespace Wino.Messaging.UI;
///
/// Reports back the account synchronization progress.
///
-public record AccountSynchronizationProgressUpdatedMessage(Guid AccountId, double Progress) : UIMessageBase;
+public record AccountSynchronizationProgressUpdatedMessage(AccountSynchronizationProgress Progress)
+ : UIMessageBase;
diff --git a/Wino.Messages/UI/AccountSynchronizerStateChanged.cs b/Wino.Messages/UI/AccountSynchronizerStateChanged.cs
index 3e215906..94f62643 100644
--- a/Wino.Messages/UI/AccountSynchronizerStateChanged.cs
+++ b/Wino.Messages/UI/AccountSynchronizerStateChanged.cs
@@ -11,9 +11,11 @@ namespace Wino.Messaging.UI;
/// Total items to sync (0 for indeterminate)
/// Remaining items to sync
/// Current synchronization status message
+/// Synchronization category that emitted the update
public record AccountSynchronizerStateChanged(
Guid AccountId,
AccountSynchronizerState NewState,
int TotalItemsToSync = 0,
int RemainingItemsToSync = 0,
- string SynchronizationStatus = "") : UIMessageBase;
+ string SynchronizationStatus = "",
+ SynchronizationProgressCategory ProgressCategory = SynchronizationProgressCategory.Mail) : UIMessageBase;