diff --git a/src/NoteBookmark.Domain/SyncProgressEventArgs.cs b/src/NoteBookmark.Domain/SyncProgressEventArgs.cs new file mode 100644 index 0000000..fc7399a --- /dev/null +++ b/src/NoteBookmark.Domain/SyncProgressEventArgs.cs @@ -0,0 +1,18 @@ +using System; + +namespace NoteBookmark.Domain; + +public class SyncProgressEventArgs : EventArgs +{ + public int Current { get; } + public int Total { get; } + public string Status { get; } + public double Percentage => Total > 0 ? (double)Current / Total * 100 : 0; + + public SyncProgressEventArgs(int current, int total, string status) + { + Current = current; + Total = total; + Status = status; + } +} diff --git a/src/NoteBookmark.MauiApp.Tests/SyncServiceTests.cs b/src/NoteBookmark.MauiApp.Tests/SyncServiceTests.cs index b4eb5e6..6cc0536 100644 --- a/src/NoteBookmark.MauiApp.Tests/SyncServiceTests.cs +++ b/src/NoteBookmark.MauiApp.Tests/SyncServiceTests.cs @@ -444,5 +444,30 @@ public async Task PushPhase_ShouldSyncNoteDirectlyAndClearCreatedOfflineFlag_Whe _localDataServiceMock.Verify(c => c.SaveNoteAsync(It.Is(n => !n.CreatedOffline), false), Times.Once); _localDataServiceMock.Verify(c => c.MarkSyncedAsync("note1", false), Times.Once); } + + [Fact] + public async Task SyncAsync_ShouldRaiseSyncProgressChanged_WhenDownloadingPostHtml() + { + var post1 = new Post { Id = "post1", RowKey = "post1", PartitionKey = "pk", Title = "Post 1", is_read = false }; + var post2 = new Post { Id = "post2", RowKey = "post2", PartitionKey = "pk", Title = "Post 2", is_read = false }; + + _localDataServiceMock.Setup(c => c.GetPendingSyncNotesAsync()).ReturnsAsync(new List()); + _localDataServiceMock.Setup(c => c.GetPostsAsync()).ReturnsAsync(new List { post1, post2 }); + _apiClientMock.Setup(c => c.GetPostsModifiedAfter(It.IsAny())).ReturnsAsync(new List()); + _apiClientMock.Setup(c => c.GetNotesModifiedAfter(It.IsAny())).ReturnsAsync(new List()); + _apiClientMock.Setup(c => c.GetPostHtmlAsync(It.IsAny())).ReturnsAsync("Post content"); + + _localHtmlStorageServiceMock.Setup(s => s.GetCachedPostIds()).Returns(new List()); + _localHtmlStorageServiceMock.Setup(s => s.IsPostHtmlCached(It.IsAny())).Returns(false); + + var progressEvents = new List(); + _sut.SyncProgressChanged += (sender, args) => progressEvents.Add(args); + + await _sut.SyncAsync(); + + progressEvents.Should().NotBeEmpty(); + progressEvents.Should().Contain(e => e.Status.Contains("Downloading offline text")); + progressEvents.Last().Status.Should().Be("Synchronization complete!"); + } } diff --git a/src/NoteBookmark.MauiApp/Data/OfflineDataService.cs b/src/NoteBookmark.MauiApp/Data/OfflineDataService.cs index 115efe8..1136a47 100644 --- a/src/NoteBookmark.MauiApp/Data/OfflineDataService.cs +++ b/src/NoteBookmark.MauiApp/Data/OfflineDataService.cs @@ -279,6 +279,11 @@ public async Task DeletePost(string id) => localHtmlStorageService.GetPostHtmlAsync(postId); public Task SyncAsync() => syncService.SyncAsync(); + public event EventHandler? SyncProgressChanged + { + add => syncService.SyncProgressChanged += value; + remove => syncService.SyncProgressChanged -= value; + } public bool IsOffline => connectivity.NetworkAccess != NetworkAccess.Internet; public bool CanSync => true; diff --git a/src/NoteBookmark.MauiApp/Data/SyncService.cs b/src/NoteBookmark.MauiApp/Data/SyncService.cs index 7f5821c..4f52f3d 100644 --- a/src/NoteBookmark.MauiApp/Data/SyncService.cs +++ b/src/NoteBookmark.MauiApp/Data/SyncService.cs @@ -17,6 +17,7 @@ public interface ISyncService Task SyncAsync(); bool IsSyncing { get; } event EventHandler? ConflictDetected; + event EventHandler? SyncProgressChanged; } public class SyncService( @@ -30,6 +31,7 @@ public class SyncService( public bool IsSyncing => _isSyncing; public event EventHandler? ConflictDetected; + public event EventHandler? SyncProgressChanged; public async Task SyncAsync() { @@ -38,6 +40,7 @@ public async Task SyncAsync() _isSyncing = true; try { + SyncProgressChanged?.Invoke(this, new SyncProgressEventArgs(0, 0, "Starting synchronization...")); var lastSyncStr = await GetPreferenceAsync(LastSyncTimestampKey); DateTime? lastSync = null; if (!string.IsNullOrEmpty(lastSyncStr) && DateTime.TryParse(lastSyncStr, out var parsed)) @@ -45,11 +48,16 @@ public async Task SyncAsync() lastSync = parsed.ToUniversalTime(); } + SyncProgressChanged?.Invoke(this, new SyncProgressEventArgs(0, 0, "Pushing local changes...")); await PushAsync(lastSync); + + SyncProgressChanged?.Invoke(this, new SyncProgressEventArgs(0, 0, "Pulling remote changes...")); await PullAsync(lastSync); + await SyncHtmlAsync(); await SetPreferenceAsync(LastSyncTimestampKey, DateTime.UtcNow.ToString("O")); + SyncProgressChanged?.Invoke(this, new SyncProgressEventArgs(0, 0, "Synchronization complete!")); } finally { @@ -245,22 +253,33 @@ private async Task SyncHtmlAsync() } // Download HTML for unread posts not yet cached - foreach (var post in posts.Where(p => p.is_read != true)) + var unreadToDownload = posts.Where(p => p.is_read != true && !localHtmlStorageService.IsPostHtmlCached(p.Id ?? p.RowKey)).ToList(); + int total = unreadToDownload.Count; + + if (total > 0) { - var id = post.Id ?? post.RowKey; - if (localHtmlStorageService.IsPostHtmlCached(id)) continue; + SyncProgressChanged?.Invoke(this, new SyncProgressEventArgs(0, total, $"Downloading offline text (0/{total})...")); - try + for (int i = 0; i < unreadToDownload.Count; i++) { - var html = await apiClient.GetPostHtmlAsync(id); - if (html != null) + var post = unreadToDownload[i]; + var id = post.Id ?? post.RowKey; + + try { - await localHtmlStorageService.SavePostHtmlAsync(id, html); + var html = await apiClient.GetPostHtmlAsync(id); + if (html != null) + { + await localHtmlStorageService.SavePostHtmlAsync(id, html); + } } - } - catch (Exception ex) - { - logger.LogWarning(ex, "Failed to download HTML for post {PostId}", id); + catch (Exception ex) + { + logger.LogWarning(ex, "Failed to download HTML for post {PostId}", id); + } + + int current = i + 1; + SyncProgressChanged?.Invoke(this, new SyncProgressEventArgs(current, total, $"Downloading offline text ({current}/{total})...")); } } } diff --git a/src/NoteBookmark.SharedUI/Components/Pages/Posts.razor b/src/NoteBookmark.SharedUI/Components/Pages/Posts.razor index 697b5b1..9d4ff46 100644 --- a/src/NoteBookmark.SharedUI/Components/Pages/Posts.razor +++ b/src/NoteBookmark.SharedUI/Components/Pages/Posts.razor @@ -9,6 +9,7 @@ @inject IDialogService DialogService @inject NavigationManager Navigation @inject ILocalHtmlCache localHtmlCache +@implements IDisposable Posts @@ -23,6 +24,16 @@ Sync } + @if (isSyncing || !string.IsNullOrEmpty(syncProgressStatus)) + { + + + @if (!string.IsNullOrEmpty(syncProgressStatus)) + { + @syncProgressStatus + } + + } Read Only UnRead Only @@ -84,9 +95,13 @@ private PaginationState pagination = new PaginationState { ItemsPerPage = 20 }; private string titleFilter = string.Empty; private bool showPublishedDate = false; + private int syncProgressCurrent = 0; + private int syncProgressTotal = 0; + private string syncProgressStatus = string.Empty; protected override async Task OnInitializedAsync() { + client.SyncProgressChanged += OnSyncProgressChanged; await LoadPosts(); _ = StartBackgroundSync(); } @@ -284,4 +299,20 @@ } private void ReadPost(string postId) => Navigation.NavigateTo($"postreader/{postId}"); + + private void OnSyncProgressChanged(object? sender, SyncProgressEventArgs e) + { + InvokeAsync(() => + { + syncProgressCurrent = e.Current; + syncProgressTotal = e.Total; + syncProgressStatus = e.Status; + StateHasChanged(); + }); + } + + public void Dispose() + { + client.SyncProgressChanged -= OnSyncProgressChanged; + } } diff --git a/src/NoteBookmark.SharedUI/IDataService.cs b/src/NoteBookmark.SharedUI/IDataService.cs index 13b7ad3..0de89de 100644 --- a/src/NoteBookmark.SharedUI/IDataService.cs +++ b/src/NoteBookmark.SharedUI/IDataService.cs @@ -25,6 +25,7 @@ public interface IDataService Task SaveReadingNotesMarkdown(string markdown, string number); Task GetPostHtmlAsync(string postId); Task SyncAsync(); + event System.EventHandler? SyncProgressChanged; bool IsOffline { get; } bool CanSync { get; } } diff --git a/src/NoteBookmark.SharedUI/PostNoteClient.cs b/src/NoteBookmark.SharedUI/PostNoteClient.cs index 7d0e215..4e3a493 100644 --- a/src/NoteBookmark.SharedUI/PostNoteClient.cs +++ b/src/NoteBookmark.SharedUI/PostNoteClient.cs @@ -209,6 +209,7 @@ public async Task> GetNotesModifiedAfter(DateTime modifiedAfter) } public Task SyncAsync() => Task.CompletedTask; + public event EventHandler? SyncProgressChanged { add { } remove { } } public bool IsOffline => false; public bool CanSync => false; }